Compare commits

..

136 Commits

Author SHA1 Message Date
Diego Imbert
e99db8ba79 save global css panel cursor position 2025-06-24 12:32:27 +02:00
Ruben Fiszel
e03246eadb chore(main): release 1.501.0 (#6034)
* chore(main): release 1.501.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-24 09:43:10 +02:00
claude[bot]
7f58a1cb47 nit(mcp): filter out scripts without main function from tools list (#6022)
* nit(mcp): filter out scripts without main function from tools list

- Add no_main_func field to ScriptInfo struct  
- Update SQL query to select no_main_func from database
- Filter scripts where no_main_func is true in list_tools function
- Hub scripts remain unaffected as requested

Resolves #6021

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* perf(mcp): optimize script filtering with SQL WHERE clause

Move the no_main_func filtering from Rust code to SQL WHERE clause for better performance:
- Add conditional WHERE clause in inner_get_items for scripts only
- Remove Rust-level filtering loop in list_tools function
- Reduces data transfer and improves query performance by filtering at database level
- Handles NULL values properly with (o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)

Co-authored-by: centdix <centdix@users.noreply.github.com>

* fix

* refactor(mcp): optimize filtering by removing no_main_func field selection

Remove no_main_func field from ScriptInfo struct and SQL selection while keeping WHERE clause filtering. This improves performance by filtering at the database level without transferring unnecessary data to the application layer.

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: centdix <centdix@users.noreply.github.com>
Co-authored-by: centdix <farhadg110@gmail.com>
2025-06-24 09:35:07 +02:00
HugoCasa
5722014651 feat: ai flow chat prompt and UX improvements (#5942)
* feat: ai flow chat prompt and UX improvements

* fix scroll + better stop button + snapshots base

* tmp

* feat: apply reject

* feat: warning modal + retry last + nits + use flow preview for diff/test/export

* Update frontend/src/lib/components/copilot/chat/flow/core.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* typo

* feat: step summary by AI

* nits

* reactivity nits flow builder

* nits

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-06-24 09:29:38 +02:00
Guilhem
3a1b43e8bc refactor using triggerableByAi using action (#6012)
* refactor using triggerableByAi using action

* fix update loop

* remove disabled option

* nit

# Conflicts:
#	frontend/src/lib/components/RunForm.svelte

* fix potential issue

---------

Co-authored-by: centdix <farhadg110@gmail.com>
2025-06-24 09:25:48 +02:00
Guilhem
a7bba4674b move test up to into test flwo dropdown (#6016) 2025-06-24 09:25:13 +02:00
Ruben Fiszel
d6a0c026d4 app nits 2025-06-24 09:02:32 +02:00
Ruben Fiszel
27e12a1527 fix: improve reactivity of apps 2025-06-24 08:41:46 +02:00
Ruben Fiszel
18cb8324ed add more quotas to prevent abuse on cloud 2025-06-24 06:42:42 +02:00
Ruben Fiszel
8ac16ca94b chore(main): release 1.500.3 (#6030)
* chore(main): release 1.500.3

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-23 18:06:49 +02:00
Ruben Fiszel
6f3cb5eabb fix: fix conditional wrappre 2025-06-23 18:00:21 +02:00
centdix
6ac004ece5 internal: add pr opened trigger for pr review 2025-06-23 15:32:30 +02:00
centdix
5b5a64e6c2 add csharp and java to aichat (#6027) 2025-06-23 15:05:34 +02:00
centdix
172af24ead internal: use pat for claude review (#6025)
* use pat

* check emoji when pr closed
2025-06-23 10:08:23 +02:00
Ruben Fiszel
607c23dcfd chore(main): release 1.500.2 (#6020)
* chore(main): release 1.500.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-20 21:11:57 +02:00
Ruben Fiszel
d1c33ab974 fix compile 2025-06-20 20:45:48 +02:00
centdix
bcac9f1844 close old bump prs (#6019) 2025-06-20 20:38:46 +02:00
Ruben Fiszel
9c2f6a757f fix: consistency of root job propagation fixing cases where runFlow in scripts would fail 2025-06-20 20:24:53 +02:00
Ruben Fiszel
1c85bbb05a chore(main): release 1.500.1 (#6018)
* chore(main): release 1.500.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-20 19:51:35 +02:00
HugoCasa
1b1bee5b53 fix: git repository resource picker effect loop (#6017) 2025-06-20 19:31:38 +02:00
Ruben Fiszel
f70b6f3052 update python paser 2025-06-20 18:24:17 +02:00
Ruben Fiszel
9466830810 chore(main): release 1.500.0 (#5998)
* chore(main): release 1.500.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-20 17:43:53 +02:00
pyranota
f32159f412 fix: do not split main.py on 'def main' (#5895)
doesn't work well in edge cases:

```
@schedule(
    bla=True
)
def main(x: str):
    return x
```

```
error_message = """
Please ensure you have defined main(username, password) correctly.
The function def main( should be at the end.
"""

def main(x: str):
    return x
```

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-20 16:40:30 +02:00
Guilhem
67e6bce9b2 feat(frontend): run steps from graph (#5915) 2025-06-20 16:39:24 +02:00
Guilhem
419defe05c improve filtering function (#6015) 2025-06-20 16:21:29 +02:00
Guilhem
f3ecbe1792 runs filter popover close on click outside (#6013) 2025-06-20 14:35:34 +02:00
Ruben Fiszel
99e18aedea nit typo 2025-06-20 14:08:44 +02:00
centdix
af6b724f0b internal: use git app for git actions (#6010)
* add flow to ask claude to review pr

* allow app for claude

* cleaning

* use app for change versions flow

* add owner

* fix

* rename
2025-06-20 13:32:33 +02:00
pyranota
88ab1a5136 Use regex to split input to python import parser (#6007) 2025-06-20 13:24:27 +02:00
claude[bot]
3e82282351 feat: add typescript client context to ai chat system prompt (#6004)
* feat: add typescript client context to ai chat system prompt

Add windmill client function signatures and descriptions to the TypeScript 
language context in the AI chat system. This provides users with inline 
documentation for key windmill client functions including resource operations,
state management, variables, script execution, S3 operations, and flow utilities.

The context is kept concise to avoid making the system prompt too large while
still providing the most useful function signatures for TypeScript scripts.

Resolves #6002

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* fix import

* feat: add python client context to ai chat system prompt

Add PYTHON_WINDMILL_CLIENT_CONTEXT with function signatures and descriptions for key windmill client functions in Python, including:
- Resource operations (get_resource, set_resource)
- State management (get_state, set_state, get_flow_user_state, set_flow_user_state)
- Variables (get_variable, set_variable)
- Script execution (run_script, run_script_async, wait_job)
- S3 file operations (load_s3_file, write_s3_file)
- Flow operations (run_flow_async, get_resume_urls)
- Utilities (whoami, get_job_status, set_progress)

The context is now included for Python language (python3) in the AI chat system prompt, providing users with helpful function signatures and descriptions when asking for coding assistance.

Co-authored-by: centdix <centdix@users.noreply.github.com>

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: centdix <farhadg110@gmail.com>
Co-authored-by: centdix <centdix@users.noreply.github.com>
2025-06-20 12:31:52 +02:00
Ruben Fiszel
b3b6c53430 remove WM_OIDC_JWT from context vars 2025-06-20 12:12:38 +02:00
Ruben Fiszel
934ae4fe57 rm WM_OIDC_JWT from ctx vars 2025-06-20 12:12:07 +02:00
Ruben Fiszel
86eb9074cc feat: blacklist remote agent worker token (#5985) 2025-06-20 12:07:33 +02:00
pyranota
06e61ee958 Add fake main function to fix Python AST parsing (#6006)
The changes ensure the Python AST parser can handle code snippets where the main
function was previously removed, by appending a dummy main function.
2025-06-20 09:28:30 +02:00
Diego Imbert
b9e668b489 Remove last SelectLegacy (#6003)
* Replace SelectLegacy in AppPicker

* Better DynSelect

* remove select legacy

* fix unknown values not displayed

* undo tsconfig change

* better dyn select search

* index js remove

* use bindable defaults
2025-06-19 17:49:53 +02:00
Guilhem
f2425362f9 display branchone default branch label (#6005) 2025-06-19 17:48:16 +02:00
claude[bot]
4ae5928788 fix: ai button in inline script editor to open AI chat in flow builder (#5989)
* fix: ai button in inline script editor to open AI chat in flow builder

- Add openAiChat prop to ScriptGen component to control AI button behavior
- When openAiChat=true, AI button opens AI chat manager in script mode
- When openAiChat=false (default), AI button shows direct generation popover
- Update flow builder (FlowModuleComponent) to use openAiChat=true for inline scripts
- App builder continues using direct generation (openAiChat defaults to false)
- Import AIMode enum properly to fix TypeScript error

Resolves request from @HugoCasa to make flow AI button open chat instead of direct generation while keeping app builder unchanged.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* refactor: extract repeated logic into reusable functions in ScriptGen.svelte

- Created determineModeFromEditor() to centralize mode setting logic
- Added callCopilot() to unify copilot function calls with proper typing
- Added handleAiButtonClick() as single handler for button click scenarios
- Created safeLocalStorageOperation() for generic localStorage error handling
- Added getPromptStorageKey() for centralized storage key generation

Reduces code duplication and improves maintainability.

Co-authored-by: HugoCasa <HugoCasa@users.noreply.github.com>

* refactor: extract duplicate determineModeFromEditor call in handleAiButtonClick

Remove code duplication by calling determineModeFromEditor() once at the 
beginning of handleAiButtonClick() instead of in both conditional branches.

Co-authored-by: HugoCasa <HugoCasa@users.noreply.github.com>

* nits

* better color

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: HugoCasa <HugoCasa@users.noreply.github.com>
Co-authored-by: HugoCasa <hugo@casademont.ch>
2025-06-19 16:15:35 +02:00
centdix
9462d56be7 internal: better instructions for claude (#5996)
* better instructions for claude

* remove file

* better rules

* better claude action

* add api routes prefixes

* typo

* typo

* fix

* fix

* add typegen explanations

* remove npm run format
2025-06-19 15:36:10 +02:00
claude[bot]
c4adaeeabd fix: improve piptar upload - sequential uploads via background task queue (#5994)
* feat: improve piptar upload with sequential background task queue

Replace simultaneous piptar uploads with sequential processing via background job queue while keeping dependency installation parallelized.

Key changes:
- Add PiptarUpload job kind and database migration  
- Queue piptar uploads instead of using tokio::spawn
- Implement job handler for sequential S3 uploads
- Maintain parallel dependency installation as requested

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* Remove PiptarUpload job kind migration files

Refactoring piptar uploads to use tokio channel instead of Windmill jobs.
Migration files are no longer needed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* Refactor piptar uploads from Windmill jobs to tokio channel

Replace the complex job queue infrastructure for piptar uploads with a simple 
tokio channel approach as requested. This maintains sequential upload behavior
while keeping dependency installation parallel.

Key changes:
- Add PIPTAR_UPLOAD_CHANNEL global channel for sequential processing
- Replace JobPayload::PiptarUpload with simple channel send
- Remove PiptarUpload from JobKind enum and all job handling code
- Remove job dispatcher case from worker.rs
- Simplify upload logic while maintaining same functionality

Benefits:
- Reduced complexity by removing unnecessary job infrastructure
- Sequential uploads without blocking dependency installation
- Better separation of concerns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* Update worker.rs

* Update python_executor.rs

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-19 15:32:21 +02:00
Diego Imbert
e4255e6276 fix: replace worker tags to listen multiselect (#5997)
* rename all multiselects to Legacy

* move Select

* Separate SelectDropdown

* fix click outside for select with portal

* multiselect v0

* multiselect clear btn

* move filterText logic to SelectDropdown

* ui nits

* console.log

* draggable

* Draggable multiselect

* multiselect search

* nit refacto

* autofocus multiselect input

* Replace in AppMultiSelectV2

* search icon

* app multi select nits

* arginput update multiselect

* fix autofocus scrolling up

* replace High priority tags multiselect

* autoscaling config editor multiselect replace

* fix clear btn not in border

* replace multiselect in cron input

* replace multiselect in savedinputs

* replace EventHandlerItem multiselect

* select dropdown shadow

* more multiselect migration

* hover opacity on drag

* TokensTable UI fixes + replace multiselect

* ai settings replace multiselect

* DefaultTags Multiselect replace

* prevent multiselect from opening on drag

* nit

* app multiselect css + simplify

* console log

* safeSelectItems cleanup

* Remove svelte-multiselect

* clip when wrap not allowed

* hide duplicate app components

* CSS works better  with multiselect in app editor

* allowOverflow

* allowClear

* replace tags to listen to with a multiselect

* fix custom createText messed up with search
2025-06-19 15:00:13 +02:00
Diego Imbert
fa8d1b47db fix: new MultiSelect component (#5979)
* rename all multiselects to Legacy

* move Select

* Separate SelectDropdown

* fix click outside for select with portal

* multiselect v0

* multiselect clear btn

* move filterText logic to SelectDropdown

* ui nits

* console.log

* draggable

* Draggable multiselect

* multiselect search

* nit refacto

* autofocus multiselect input

* Replace in AppMultiSelectV2

* search icon

* app multi select nits

* arginput update multiselect

* fix autofocus scrolling up

* replace High priority tags multiselect

* autoscaling config editor multiselect replace

* fix clear btn not in border

* replace multiselect in cron input

* replace multiselect in savedinputs

* replace EventHandlerItem multiselect

* select dropdown shadow

* more multiselect migration

* hover opacity on drag

* TokensTable UI fixes + replace multiselect

* ai settings replace multiselect

* DefaultTags Multiselect replace

* prevent multiselect from opening on drag

* nit

* app multiselect css + simplify

* console log

* safeSelectItems cleanup

* Remove svelte-multiselect

* clip when wrap not allowed

* hide duplicate app components

* CSS works better  with multiselect in app editor

* allowOverflow

* allowClear

* fix custom createText messed up with search
2025-06-19 14:37:17 +02:00
Ruben Fiszel
054f2c134a improve decision tree builder 2025-06-19 08:07:11 +02:00
Ruben Fiszel
530a72ba83 chore(main): release 1.499.0 (#5990)
* chore(main): release 1.499.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-19 00:58:56 +02:00
claude[bot]
2a334421e8 fix: prevent keypress events from bubbling in decision tree drawer (#5993)
Fixes #5991

- Add keydown event handlers with stopPropagation() to prevent SvelteFlow 
  from receiving keypress events from text inputs in decision tree config
- Applied at individual input level (summary field) and container level 
  (configuration panel) to catch all input types
- Prevents accidental node deletion when typing in configuration inputs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
2025-06-19 00:34:44 +02:00
Ruben Fiszel
c7c2efbbe5 nits 2025-06-18 23:46:21 +02:00
claude[bot]
b1c4f8b29d feat: devOps role can edit worker groups (#5984)
* feat: allow devOps role to edit worker groups config

- Updated backend permission checks in configs.rs to use require_devops_role() instead of require_super_admin()
- Updated frontend UI in workers page to show worker group management for devOps users
- Updated WorkerGroup component to allow devOps role access to all configuration features
- Updated AssignableTagsInner component to allow devOps users to manage tags

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* Update configs.rs

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-18 23:06:53 +02:00
Ruben Fiszel
14005fe4c1 nits debug 2025-06-18 22:41:05 +02:00
Ruben Fiszel
4326bb8dc9 improve decision tree preview 2025-06-18 22:33:02 +02:00
centdix
18d12525d2 fix typo (#5982) 2025-06-18 18:29:15 +02:00
Ruben Fiszel
47d1ef0f1c fix additional python paths for windows 2025-06-18 18:24:54 +02:00
claude[bot]
86b5fab4dc [Claude PR] Fix incorrect $props generic syntax in Svelte 5 components (#5975)
* fix: correct $props generic syntax in Svelte 5 components

Replace incorrect `$props<T>()` syntax with correct `let x: T = $props()` syntax
to ensure proper TypeScript typing instead of falling back to `any` types.

This affects 11 Svelte 5 components throughout the frontend codebase.

Fixes #5974

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>

* fix claude pr

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-authored-by: Diego Imbert <diego@windmill.dev>
Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com>
2025-06-18 18:21:20 +02:00
centdix
a58b0ffd06 internal: fix sqlx git action usage (#5980)
* create frontend build dir

* specify version

* add cache
2025-06-18 18:20:07 +02:00
Ruben Fiszel
38a050b0f4 simplify select (#5978)
* improve select

* improve
2025-06-18 17:01:57 +02:00
Ruben Fiszel
c088322159 fix input transform reactivity 2025-06-18 16:14:57 +02:00
claude[bot]
079af9292c [Claude PR] Add account_id update support for variables (#5953)
* write sqlx

* all

* update sqlx

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-18 15:25:36 +02:00
centdix
0f560bdc41 internal: fix claude tool usage (#5976)
* fix claude tool usage

* fix
2025-06-18 15:00:42 +02:00
dieriba
f97a61ecc8 fix openapi export duplicate issue and nits (#5971)
* nits and fix duplicate issue

* update .sqlx

* nits
2025-06-18 14:28:26 +02:00
Diego Imbert
be0c8ddae0 prevent datatable from spamming loadMore on scroll to end (#5973) 2025-06-18 14:25:55 +02:00
centdix
959280bf5a fix wrong typo (#5970) 2025-06-18 13:22:11 +02:00
centdix
38b06bf3a7 internal: add /updatesqlx as git command (#5969)
* add flow to update sqlx

* archive aider

* fix

* add comments
2025-06-18 13:08:59 +02:00
Ruben Fiszel
f5f2f8f344 chore(main): release 1.498.0 (#5957)
* chore(main): release 1.498.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-17 22:29:56 +02:00
dieriba
aba8c01d7f feat: windmill http triggers and webhooks to openapi spec (#5918)
* add sum and description to http routes

* add to openapi spec

* add subsection

* add collaspable and action button

* added path rendering,

* migrate logic to backend

* remover server and handle different format generation

* add filter for http route and document generated

* remove print

* handled webhook and integrated server component

* done

* add download and copy

* nits

* update .sqlx

* remove .vscode

* fix npm check

* add summary description and fix toggle

* added security handling and nits

* update .sqlx

* nits and rename key

* remove unused code

* update ref

* nits rename var

* nits

* nits

* nits

* nits

* nits

* add token generation for cURL command

* create token

* update label and remove section

* update repo ref

* clean

* let brower start download before cleaning up resource

* handle sync/async for webhook

* format fix

* nits

* nits

* reset sum and description
2025-06-17 19:49:00 +02:00
Diego Imbert
28a0209568 fix gcp trigger bind to undefined (#5967) 2025-06-17 19:47:40 +02:00
Diego Imbert
0cdff8acd1 Fix MQTT undefined binding bug (#5966) 2025-06-17 19:34:28 +02:00
centdix
e4534cabf5 ai chat textarea caret wrong positionning (#5964)
* fix textarea caret

* rename style
2025-06-17 18:44:41 +02:00
Diego Imbert
5bdbaf149b Fix unreactive navbar wizard (#5963)
* fix unreactive AppPicker

* migrate StaticInputEditor to svelte 5
2025-06-17 18:42:27 +02:00
Ruben Fiszel
51b3823f7b improve worker tooltip to get hostname, workerGroup 2025-06-17 18:24:30 +02:00
pyranota
74de2397ce docs: add Nix development guide (#5962)
* update readme

* change formatting

* link docker/dev.nu

Signed-off-by: pyranota <pyra@duck.com>

* move stuff a bit

* remove unrelated docs

* remove duplicates

Signed-off-by: pyranota <pyra@duck.com>

* add php to path

Signed-off-by: pyranota <pyra@duck.com>

---------

Signed-off-by: pyranota <pyra@duck.com>
2025-06-17 18:15:30 +02:00
centdix
2d0e65b7ca internal: give database schemas information to Claude (#5961)
* add schema

* give claude the schema

* use summarized schema

* add indexes

* add usage comments

* Remove backend/schema.sql from remote
2025-06-17 17:35:30 +02:00
Diego Imbert
84808e2694 fix conditional tabs reactivity (#5958)
* fix app conditional wrapper reactivity

* Convert GridCondition to Svelte 5

* Revert "fix app conditional wrapper reactivity"

This reverts commit 2b5910fde2.

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-17 11:29:51 +02:00
centdix
8407ac148b nit: fix typo in ai form filling text (#5959)
* differentiate flow from script

* fix typing
2025-06-17 11:24:50 +02:00
centdix
7490e883d7 feat: use provider api to list available AI models in workspace settings (#5947)
* use open router of model lists

* draft

* allow get in ai proxy

* add fetch available models function

* use func

* fix for anthropic

* fix

* fetch on mount

* fix ai settings

* fix

* handle azure
2025-06-17 08:48:47 +00:00
Ruben Fiszel
ad2de83354 chore(main): release 1.497.2 (#5956)
* chore(main): release 1.497.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-17 09:16:49 +02:00
Ruben Fiszel
8b7aefb3bc improve tag of new flow dependency job 2025-06-17 09:12:23 +02:00
Ruben Fiszel
0f63d03093 dind compile 2025-06-17 08:14:11 +02:00
Ruben Fiszel
38eb71bdf5 fix: always rm containers in docker mode 2025-06-17 02:32:49 +02:00
Ruben Fiszel
26bec054a3 fix: flow steps use their tags if any specific when used as subflow 2025-06-17 02:27:24 +02:00
Ruben Fiszel
d0ebb66d0d nits perf improvements 2025-06-17 02:06:58 +02:00
Ruben Fiszel
bc9893402b nit prevent default component list 2025-06-17 02:00:30 +02:00
Ruben Fiszel
13ac13e0b7 chore(main): release 1.497.1 (#5955)
* chore(main): release 1.497.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-17 01:53:14 +02:00
Ruben Fiszel
1c6a7c8cd0 fix: fix mcp server initialization 2025-06-17 01:44:04 +02:00
Ruben Fiszel
8e8e1a3129 chore(main): release 1.497.0 (#5912)
* chore(main): release 1.497.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-06-17 00:09:22 +02:00
Ruben Fiszel
babf046871 select click stop immediate propagation 2025-06-16 21:49:49 +02:00
Ruben Fiszel
02a4949fd8 stabilize input specs key 2025-06-16 21:27:30 +02:00
Ruben Fiszel
d2fa2e6464 nit scriptbuilder 2025-06-16 21:10:48 +02:00
Ruben Fiszel
d457bf5c80 nit showExpr 2025-06-16 19:03:02 +02:00
Ruben Fiszel
2bc06b72d2 nit showExpr + badge improvements 2025-06-16 18:29:04 +02:00
Diego Imbert
b106de5438 fix drawer subgrid not selecting (#5948) 2025-06-16 17:38:35 +02:00
Ruben Fiszel
ed6d018253 warn for raw scripts 2025-06-16 15:16:56 +02:00
Ruben Fiszel
6c1e7eed51 nit focus modal 2025-06-16 14:42:47 +02:00
Ruben Fiszel
f24894b1a5 fix tab selection on app builder 2025-06-16 14:36:26 +02:00
Diego Imbert
50d78a979b Fix select not scrolling correctly (#5946) 2025-06-16 12:08:59 +02:00
Ruben Fiszel
c75fd751b0 nits app builder 2025-06-15 23:57:55 +02:00
Ruben Fiszel
f0f720f490 partial app svelte 5 migration (#5945) 2025-06-15 23:25:34 +02:00
Ruben Fiszel
855571f359 fix history 2025-06-15 04:22:47 +02:00
Ruben Fiszel
41219e1f84 fix app editor reactivity 2025-06-15 03:59:37 +02:00
Ruben Fiszel
499fe7e7fe field.expr error message 2025-06-15 03:16:04 +02:00
JonasGruenwald
72613f3580 Fix wording in DeployOverrideConfirmationModal.svelte (#5944) 2025-06-14 17:07:19 +02:00
Ruben Fiszel
7d4ee825fa nits 2025-06-14 13:21:45 +02:00
Ruben Fiszel
1cd253405d nits 2025-06-14 12:33:37 +02:00
Ruben Fiszel
4dab80e24d fix schemaform default handling 2025-06-14 12:31:46 +02:00
Ruben Fiszel
38bb5e25cb nit 2025-06-14 12:02:48 +02:00
Ruben Fiszel
3678bf976e improve redirect behavior 2025-06-14 10:46:39 +02:00
pyranota
6623972ee9 build: correctly build WASM parsers with nix shell (#5943) 2025-06-14 09:03:45 +02:00
Diego Imbert
6a4391f616 Fix all structuredClones missing $state.snapshot (#5941) 2025-06-13 18:46:04 +02:00
centdix
f7a83c03c1 feat: add api tools to ai chat (#5921)
* add api tools to ai chat

* cleaning

* cleaning

* refine prompts

* tweaks

* add missing trigger

* cleaning

* cleaning

* fix
2025-06-13 16:29:18 +00:00
Ruben Fiszel
82f541565f fix reactivity in apps 2025-06-13 18:04:54 +02:00
Ruben Fiszel
44e1ed6c9c fix search by args 2025-06-13 17:42:06 +02:00
Ruben Fiszel
58ca3a6866 fix reactivity on app form 2025-06-13 17:37:39 +02:00
Ruben Fiszel
0b3a084c81 fix handle default values 2025-06-13 14:10:55 +02:00
HugoCasa
17c8c8a561 fix(frontend): use correct kind for flow insert module btn (#5938) 2025-06-13 11:43:09 +02:00
centdix
1a6283b42a fix: fix input with wrong height on first render (#5935)
* fix input with wrong height on first render

* better fix
2025-06-13 09:29:57 +00:00
Ruben Fiszel
126b610561 introduce QUIET_LOGS 2025-06-13 11:25:27 +02:00
pyranota
0b89260540 feat(go): local go.mod (#5929)
* feat(go): local go.mod

* readability refactor

* remove dbg!

* ignore module

* remove space

Signed-off-by: pyranota <pyra@duck.com>

* Update backend/windmill-worker/src/go_executor.rs

---------

Signed-off-by: pyranota <pyra@duck.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-13 10:17:31 +02:00
centdix
6c4818259c use new token for docs (#5937) 2025-06-13 10:08:26 +02:00
Ruben Fiszel
283167264e more svelte 5 migrations 2025-06-13 08:47:27 +02:00
Ruben Fiszel
b3e7a9cb79 more svelte 5 migration (#5936)
* all

* icons

* svelte2
2025-06-13 08:23:23 +02:00
Ruben Fiszel
c3a54ddd7c fix path bind 2025-06-13 06:47:05 +02:00
Alexander Petric
ae684d8608 improve teams startup time (#5931) 2025-06-12 21:40:18 +02:00
HugoCasa
f077849b8f fix: flow step missing input warnings (#5916)
* fix: flow step missing input warnings

* nit
2025-06-12 21:38:26 +02:00
HugoCasa
cf2d09e7a8 fix: audit logs for token refresh + consider refresh for active users (#5930)
* fix: audit logs for token refresh + consider refresh for active users

* tmp repo ref
2025-06-12 21:37:50 +02:00
Diego Imbert
bb354cfeb8 standardize select auto-complete inputs
* Replaced some Autocompletes

* more AutoComplete replace

* usePromise + replace resources with Select

* Select Chevron + changed operation to new Select

* simplified EditableSchemaWrapper

* replace autocompletes

* Replace last autocomplete

* Remove 'simple-svelte-autocomplete' dependency

* re-added some createText
2025-06-12 21:36:55 +02:00
Diego Imbert
bebfa76fc2 remove the 55 (#5933) 2025-06-12 21:30:56 +02:00
centdix
b0ac12052a chore(frontend): convert ai context components to svelte5 (#5928)
* convert contexttextarea to svelte5

* convert availablecontextlist

* force one line text and add max width to context badge

* cleaning
2025-06-12 16:01:03 +00:00
HugoCasa
f412ede6ed feat: multiple azure models support (#5920)
* feat: multiple azure models support

* all
2025-06-12 17:34:33 +02:00
Diego Imbert
f94651115a worker page ui fixes (#5927)
* worker page ui fixes

* Update +page.svelte

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-06-12 17:33:47 +02:00
centdix
6f907c79b4 fix: ai chat tooltip + user settings autocomplete issue (#5917)
* use form to avoid issues with chrome autocomplete

* fix tooltip overflowing to the right

* fix missclick on tooltip

* better fix

* dont show expiration on mcp token creation

* add zindexes to safelist

* add autocomplete
2025-06-12 17:00:52 +02:00
centdix
6e7ea4111c fix-app-drawer-offset (#5922) 2025-06-12 14:32:35 +00:00
Diego Imbert
ff9acb4717 remove unnecessary dispatchIfMounted (#5926) 2025-06-12 16:21:58 +02:00
centdix
a393c93085 nit: show disabled message when lang not supported (#5925)
* show disabled message when lang not supported

* remove open effect
2025-06-12 16:20:52 +02:00
Ruben Fiszel
23920aee84 feat: better graph layout algorithm + migrate to svelte 5 almost everywhere + xyflow 1.0 2025-06-12 15:44:35 +02:00
centdix
d47c078bb5 feat(backend): use streamable http in favor of sse for MCP (#5910)
* draft for http streamable usage

* good stuff

* add workspace_id to extensions

* fix shutdown

* cleaning

* fix

* adapt frontend

* Revert "adapt frontend"

This reverts commit 331dffaf98.

* dont use new path

* cleaning

* cleaner way of closing sessions
2025-06-12 08:49:22 +00:00
claude[bot]
b4a6a7e724 feat: fill runnable inputs with AI chat (#5887)
* feat: integrate TriggerableByAI with SchemaForm components

- Add currentValue and schema props to TriggerableByAI component
- Wrap all ArgInput fields with TriggerableByAI for AI chat integration
- Each input field now registers with AI chat manager including:
  - Current field value
  - Schema details (type, description, format, etc.)
  - Proper triggering mechanism for AI-driven value updates

This enables AI chat to interact with any input type generated by SchemaForm,
allowing intelligent form field modifications based on context and user intent.

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>

* add schema to description

* draft

* draft

* just use json inputs component

* good starting point

* add triggerable to scriptrow + shortcut

* fixes

* save prompt for ai in schema

* fix

* change visibility

* simplify

* cleaning

* fixes

* add tool to fetch resources

* fixes

* add try catch

* fix prompt

* cleaning

* use ask ai button

* fix

* no animation on form + fix empty summary

* add inputselectedbadge

* better action description + fix rows border

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: centdix <farhadg110@gmail.com>
2025-06-10 20:12:41 +00:00
pyranota
332f66e348 feat(rust): add rust sdk (#5909)
* upload client

* add gh workflow

* refactor build script

* remove dbg!

* fix async

* remove unused

* update dev.nu

* update CI

* fix ci

* fixin tests

* fixes + tests
2025-06-10 18:38:18 +02:00
Ruben Fiszel
d2dfd27b88 nit error message 2025-06-10 15:33:48 +02:00
Diego Imbert
b56a5c3e7a never exclude _ee.rs files in vscode (#5907) 2025-06-10 11:49:47 +02:00
758 changed files with 34423 additions and 20147 deletions

View File

@@ -45,7 +45,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.1.43
- uses: astral-sh/setup-uv@v6
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.6.2"
- uses: actions-rust-lang/setup-rust-toolchain@v1

View File

@@ -9,7 +9,14 @@ jobs:
runs-on: ubicloud
container: node:18
steps:
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app.outputs.token }}
- run: git config --system --add safe.directory /__w/windmill/windmill
- name: Change versions
run: ./.github/change-versions.sh "$(cat version.txt)"
@@ -21,3 +28,8 @@ jobs:
cd backend
cargo generate-lockfile
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_user_name: windmill-internal-app[bot]
commit_user_email: windmill-internal-app[bot]@users.noreply.github.com
env:
GITHUB_TOKEN: ${{ steps.app.outputs.token }}

View File

@@ -0,0 +1,60 @@
name: Check Organization Membership
on:
workflow_call:
inputs:
commenter:
required: true
type: string
description: 'The username to check for organization membership'
organization:
required: false
type: string
default: 'windmill-labs'
description: 'The organization to check membership for'
trusted_bot:
required: false
type: string
default: 'windmill-internal-app[bot]'
description: 'The trusted bot username to allow'
secrets:
access_token:
required: true
description: 'The access token to use for org membership check'
outputs:
is_member:
description: 'Whether the user is an organization member or trusted bot'
value: ${{ jobs.check-membership.outputs.is_member }}
jobs:
check-membership:
runs-on: ubicloud-standard-2
outputs:
is_member: ${{ steps.check-membership.outputs.is_member }}
steps:
- name: Check organization membership
id: check-membership
env:
ORG_ACCESS_TOKEN: ${{ secrets.access_token }}
COMMENTER: ${{ inputs.commenter }}
ORG: ${{ inputs.organization }}
TRUSTED_BOT: ${{ inputs.trusted_bot }}
run: |
# 1. Allow the trusted bot straight away
if [[ "$COMMENTER" == "$TRUSTED_BOT" ]]; then
echo "is_member=true" >> $GITHUB_OUTPUT
exit 0
fi
# 2. Otherwise fall back to the org-membership check
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token $ORG_ACCESS_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/$ORG/members/$COMMENTER")
if [ "$STATUS" -eq 204 ]; then
echo "is_member=true" >> $GITHUB_OUTPUT
else
echo "is_member=false" >> $GITHUB_OUTPUT
fi

View File

@@ -11,45 +11,40 @@ on:
types: [submitted]
jobs:
check-membership:
determine-commenter:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai') && !contains(github.event.comment.user.login, '[bot]')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai') && !contains(github.event.comment.user.login, '[bot]')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai') && !contains(github.event.review.user.login, '[bot]')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '/ai') && !contains(github.event.issue.user.login, '[bot]'))
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '/ai'))
runs-on: ubicloud-standard-2
outputs:
is_member: ${{ steps.check-membership.outputs.is_member }}
commenter: ${{ steps.determine-commenter.outputs.commenter }}
steps:
- name: Check organization membership
id: check-membership
env:
ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }}
- name: Determine commenter
id: determine-commenter
run: |
ORG="windmill-labs"
if [[ "${{ github.event_name }}" == "issue_comment" || "${{ github.event_name }}" == "pull_request_review_comment" ]]; then
# Work out who wrote the comment / review
if [[ "${{ github.event_name }}" == "issue_comment" || \
"${{ github.event_name }}" == "pull_request_review_comment" ]]; then
COMMENTER="${{ github.event.comment.user.login }}"
elif [[ "${{ github.event_name }}" == "pull_request_review" ]]; then
COMMENTER="${{ github.event.review.user.login }}"
else
COMMENTER="${{ github.event.issue.user.login }}"
fi
echo "commenter=$COMMENTER" >> $GITHUB_OUTPUT
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token $ORG_ACCESS_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/$ORG/members/$COMMENTER")
if [ "$STATUS" -eq 204 ]; then
echo "is_member=true" >> $GITHUB_OUTPUT
else
echo "is_member=false" >> $GITHUB_OUTPUT
fi
check-membership:
needs: determine-commenter
uses: ./.github/workflows/check-org-membership.yml
with:
commenter: ${{ needs.determine-commenter.outputs.commenter }}
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
claude-code-action:
needs: check-membership
needs: [determine-commenter, check-membership]
if: |
needs.check-membership.outputs.is_member == 'true'
runs-on: ubicloud-standard-8
@@ -64,22 +59,69 @@ jobs:
with:
fetch-depth: 1
- uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Run npm install and generate-backend-client
working-directory: ./frontend
run: |
# add a build directory for cargo check
mkdir -p build
npm install
npm run generate-backend-client
- name: install xmlsec1
run: |
sudo apt-get update
sudo apt-get install -y libxml2-dev libxmlsec1-dev
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.85.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: backend
- name: cargo check
working-directory: ./backend
timeout-minutes: 16
run: |
SQLX_OFFLINE=true cargo check --features $(./all_features_oss.sh)
- name: Run Claude PR Action
uses: anthropics/claude-code-action@beta
env:
SQLX_OFFLINE: true
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"
allowed_tools: "mcp__github__create_pull_request,Bash(npm run check),Bash(npm install),Bash(cargo check),Bash(curl https://sh.rustup.rs -sSf | sh -s -- -y)"
custom_instructions: "IMPORTANT INSTRUCTIONS:
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main, with the title starting with [Claude PR].
- If you made changes to the frontend code, run npm install, then npm run generate-backend-client, then npm run check. You can ignore warnings that are reported by the check script, but fix the errors.
- If you made changes to the backend code, install Rust and then run cargo check. You can ignore warnings that are reported by the check script, but fix the errors.
- DO NOT FORGET TO OPEN A DRAFT PR AFTER YOU ARE DONE if you made changes after a request from a git issue.
AVAILABLE TOOLS:
- mcp__github__create_pull_request: Create a PR from a branch to main
- Bash(npm run check): Run the check script. You should run this tool after making changes to the frontend code.
- Bash(npm install): Install dependencies. You need this to run npm run check.
- Bash(npm run generate-backend-client): Generate the backend client. You need this to run npm run check.
- Bash(cargo check): Run the cargo check script. You should run this tool after making changes to the backend code.
- Bash(curl https://sh.rustup.rs -sSf | sh -s -- -y): Install Rust. You need this to run cargo check."
allowed_tools: "mcp__github__create_pull_request,Bash"
custom_instructions: |
## IMPORTANT INSTRUCTIONS
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main.
## Code Quality Requirements
After making any code changes, you MUST run the appropriate validation commands:
**Frontend Changes:**
- Run: `npm run check` in the frontend directory
- Fix all warnings and errors before proceeding
**Backend Changes:**
- Run: `cargo check --features $(./all_features_oss.sh)` in the backend directory
- Fix all warnings and errors before proceeding
**Pull Request Creation:**
- DO NOT FORGET TO OPEN A DRAFT PR AFTER YOU ARE DONE if you made changes after a request from a git issue.
## Available Tools
- mcp__github__create_pull_request: Create PRs from branches
- Bash: Full access to run validation commands and git operations
trigger_phrase: "/ai"

View File

@@ -4,38 +4,36 @@ on:
jobs:
check-membership:
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && github.event.comment.user.type != 'Bot' }}
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') }}
uses: ./.github/workflows/check-org-membership.yml
with:
commenter: ${{ github.event.comment.user.login }}
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
generate-token:
needs: check-membership
if: ${{ needs.check-membership.outputs.is_member == 'true' }}
runs-on: ubicloud-standard-2
outputs:
is_member: ${{ steps.check-membership.outputs.is_member }}
app_token: ${{ steps.app.outputs.token }}
steps:
- name: Check organization membership
id: check-membership
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
ORG="windmill-labs"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/$ORG/members/$COMMENTER")
if [ "$STATUS" -eq 204 ]; then
echo "is_member=true" >> $GITHUB_OUTPUT
else
echo "is_member=false" >> $GITHUB_OUTPUT
fi
- name: Generate an installation token
id: app
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
owner: windmill-labs
trigger-docs:
needs: check-membership
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && needs.check-membership.outputs.is_member == 'true' }}
needs: [generate-token, check-membership]
if: ${{ needs.check-membership.outputs.is_member == 'true' }}
uses: windmill-labs/windmilldocs/.github/workflows/create-docs.yml@main
with:
pr_number: ${{ github.event.issue.number }}
repo: ${{ github.event.repository.name }}
comment_text: ${{ github.event.comment.body }}
secrets:
DOCS_TOKEN: ${{ secrets.DOCS_TOKEN }}
DOCS_TOKEN: ${{ needs.generate-token.outputs.app_token }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}

View File

@@ -24,7 +24,7 @@ jobs:
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}
merge_success_emoji:
if: github.event.pull_request.merged == true
if: github.event.action == 'closed'
uses: ./.github/workflows/shareable-discord-notification.yml
with:
PR_STATUS: "merged"

View File

@@ -9,11 +9,19 @@ jobs:
runs-on: ubicloud-standard-2
steps:
- name: Generate an installation token
id: app
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
owner: windmill-labs
- name: Checkout on helm repository
uses: actions/checkout@v3
with:
repository: windmill-labs/windmill-helm-charts
token: ${{ secrets.HELM_CHART_TOKEN }}
token: ${{ steps.app.outputs.token }}
- name: Get version
id: get_version
@@ -49,6 +57,23 @@ jobs:
APP_VERSION=${APP_VERSION%/}
sed -i "s/appVersion: .*/appVersion: $APP_VERSION/" ./charts/windmill/Chart.yaml
- name: Close existing bump-helm PRs
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
# List open PR numbers whose title starts with the prefix
prs=$(gh pr list \
--state open \
--search '"helm: bump version to" in:title' \
--json number \
-q '.[].number')
for pr in $prs; do
echo "Closing outdated bump PR #$pr"
gh pr close "$pr" \
--comment "Closed automatically superseded by a newer Helm-chart bump PR."
done
- name: Commit and push
run: |
git add .
@@ -57,7 +82,7 @@ jobs:
- name: Create PR
env:
GH_TOKEN: ${{ secrets.HELM_CHART_TOKEN }}
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
gh pr create \
--title "helm: bump version to ${{ env.VERSION }}" \

22
.github/workflows/pr-ready-review.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Auto Comment on PR Ready for Review
on:
pull_request:
types: [opened, ready_for_review]
jobs:
add-review-comment:
if: github.event.pull_request.draft == false
runs-on: ubicloud-standard-2
steps:
- name: Add review comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PUBLIC_REPO_TOKEN }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '/ai review this PR'
});

19
.github/workflows/rust_on_release.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Publish rust-client to crates.io on release
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build_rust_and_publish_to_crates_io:
runs-on: ubicloud-standard-8
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v20
with:
extra_nix_config: |
experimental-features = nix-command flakes
- run: cd rust-client && nix develop ../ --command ./dev.nu --check --publish
env:
CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}

105
.github/workflows/update-sqlx.yaml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: Update SQLx
on:
issue_comment:
types: [created]
jobs:
update-sqlx:
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/updatesqlx')
runs-on: ubicloud-standard-8
permissions:
contents: write
pull-requests: write
issues: write
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: windmill
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Comment on PR - Starting
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Starting sqlx update...'
})
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.event.issue.pull_request.head.ref }}
fetch-depth: 0
- name: Checkout windmill-ee-private
uses: actions/checkout@v3
with:
repository: windmill-labs/windmill-ee-private
path: windmill-ee-private
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
# Cache rust dependencies
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./backend -> target"
- name: Install xmlsec build-time deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxml2-dev libssl-dev \
xmlsec1 libxmlsec1-dev libxmlsec1-openssl
- name: Run update-sqlx script
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/windmill
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=${{ github.event.issue.number }}
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
echo "Checking out PR branch: $BRANCH_NAME"
git checkout $BRANCH_NAME
mkdir frontend/build
cd backend
cargo install sqlx-cli --version 0.8.5
sqlx migrate run
./update_sqlx.sh --dir ./windmill-ee-private
# Pass the branch name to the next step
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
- name: Commit changes if any
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add backend/.sqlx
git commit -m "Update SQLx metadata"
git push origin ${{ env.BRANCH_NAME }}
- name: Comment on PR - Completed
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Successfully ran sqlx update'
})

View File

@@ -1,5 +1,112 @@
# Changelog
## [1.501.0](https://github.com/windmill-labs/windmill/compare/v1.500.3...v1.501.0) (2025-06-24)
### Features
* ai flow chat prompt and UX improvements ([#5942](https://github.com/windmill-labs/windmill/issues/5942)) ([5722014](https://github.com/windmill-labs/windmill/commit/57220146513444436faff95f58c1b36481d1fa1d))
### Bug Fixes
* improve reactivity of apps ([27e12a1](https://github.com/windmill-labs/windmill/commit/27e12a1527c41ac801042038b707a94897e718f8))
## [1.500.3](https://github.com/windmill-labs/windmill/compare/v1.500.2...v1.500.3) (2025-06-23)
### Bug Fixes
* fix conditional wrappre ([6f3cb5e](https://github.com/windmill-labs/windmill/commit/6f3cb5eabb7b2224d04ec10f151f67c0955a5cfd))
## [1.500.2](https://github.com/windmill-labs/windmill/compare/v1.500.1...v1.500.2) (2025-06-20)
### Bug Fixes
* consistency of root job propagation fixing cases where runFlow in scripts would fail ([9c2f6a7](https://github.com/windmill-labs/windmill/commit/9c2f6a757fb168c7305c991c9fdbf78acd856a1c))
## [1.500.1](https://github.com/windmill-labs/windmill/compare/v1.500.0...v1.500.1) (2025-06-20)
### Bug Fixes
* git repository resource picker effect loop ([#6017](https://github.com/windmill-labs/windmill/issues/6017)) ([1b1bee5](https://github.com/windmill-labs/windmill/commit/1b1bee5b53d78e4407b684b567d0fddd2b5283f3))
## [1.500.0](https://github.com/windmill-labs/windmill/compare/v1.499.0...v1.500.0) (2025-06-20)
### Features
* add typescript client context to ai chat system prompt ([#6004](https://github.com/windmill-labs/windmill/issues/6004)) ([3e82282](https://github.com/windmill-labs/windmill/commit/3e822823519d1d5c22e422e4bd1ad4d37b6428b6))
* blacklist remote agent worker token ([#5985](https://github.com/windmill-labs/windmill/issues/5985)) ([86eb907](https://github.com/windmill-labs/windmill/commit/86eb9074cc94f309f17ea72e9cecd0d502ffd2be))
* **frontend:** run steps from graph ([#5915](https://github.com/windmill-labs/windmill/issues/5915)) ([67e6bce](https://github.com/windmill-labs/windmill/commit/67e6bce9b2eba1653450921afab3eabbd41fc715))
### Bug Fixes
* ai button in inline script editor to open AI chat in flow builder ([#5989](https://github.com/windmill-labs/windmill/issues/5989)) ([4ae5928](https://github.com/windmill-labs/windmill/commit/4ae5928788831196672e212b32ca410afab640e0))
* improve piptar upload - sequential uploads via background task queue ([#5994](https://github.com/windmill-labs/windmill/issues/5994)) ([c4adaee](https://github.com/windmill-labs/windmill/commit/c4adaeeabd287ca1c4f3522bcd8bcea30b00fe6d))
* new MultiSelect component ([#5979](https://github.com/windmill-labs/windmill/issues/5979)) ([fa8d1b4](https://github.com/windmill-labs/windmill/commit/fa8d1b47db19e15fe854e01f9987c8f97cb45b44))
* replace worker tags to listen multiselect ([#5997](https://github.com/windmill-labs/windmill/issues/5997)) ([e4255e6](https://github.com/windmill-labs/windmill/commit/e4255e6276565c4a45b1f45a5d627bcfb5369270))
## [1.499.0](https://github.com/windmill-labs/windmill/compare/v1.498.0...v1.499.0) (2025-06-18)
### Features
* devOps role can edit worker groups ([#5984](https://github.com/windmill-labs/windmill/issues/5984)) ([b1c4f8b](https://github.com/windmill-labs/windmill/commit/b1c4f8b29d0fb4cad76853110b84a87892b54661))
### Bug Fixes
* prevent keypress events from bubbling in decision tree drawer ([#5993](https://github.com/windmill-labs/windmill/issues/5993)) ([2a33442](https://github.com/windmill-labs/windmill/commit/2a334421e85abf046784aab57522582439ef2901))
## [1.498.0](https://github.com/windmill-labs/windmill/compare/v1.497.2...v1.498.0) (2025-06-17)
### Features
* use provider api to list available AI models in workspace settings ([#5947](https://github.com/windmill-labs/windmill/issues/5947)) ([7490e88](https://github.com/windmill-labs/windmill/commit/7490e883d747a7f65b2fefd3ec14b1cfc3d9bbd4))
* windmill http triggers and webhooks to openapi spec ([#5918](https://github.com/windmill-labs/windmill/issues/5918)) ([aba8c01](https://github.com/windmill-labs/windmill/commit/aba8c01d7f44ba4be369a3c711be9e156d6bf215))
## [1.497.2](https://github.com/windmill-labs/windmill/compare/v1.497.1...v1.497.2) (2025-06-17)
### Bug Fixes
* always rm containers in docker mode ([38eb71b](https://github.com/windmill-labs/windmill/commit/38eb71bdf55ee2f606d1d2ad2e987d5af16d88c0))
* flow steps use their tags if any specific when used as subflow ([26bec05](https://github.com/windmill-labs/windmill/commit/26bec054a3447a91c5d5f56d8b98717c06496087))
## [1.497.1](https://github.com/windmill-labs/windmill/compare/v1.497.0...v1.497.1) (2025-06-16)
### Bug Fixes
* fix mcp server initialization ([1c6a7c8](https://github.com/windmill-labs/windmill/commit/1c6a7c8cd0bd8396f158e3cb0583b927ce957f12))
## [1.497.0](https://github.com/windmill-labs/windmill/compare/v1.496.3...v1.497.0) (2025-06-16)
### Features
* add api tools to ai chat ([#5921](https://github.com/windmill-labs/windmill/issues/5921)) ([f7a83c0](https://github.com/windmill-labs/windmill/commit/f7a83c03c12b8ae70179fb228e0e2391b6ea2858))
* **backend:** use streamable http in favor of sse for MCP ([#5910](https://github.com/windmill-labs/windmill/issues/5910)) ([d47c078](https://github.com/windmill-labs/windmill/commit/d47c078bb5ab86d82d9cbbce3c55c89c0c20d809))
* better graph layout algorithm + migrate to svelte 5 almost everywhere + xyflow 1.0 ([23920ae](https://github.com/windmill-labs/windmill/commit/23920aee84fdca4a557a34ff2d66a0bb7bdca605))
* fill runnable inputs with AI chat ([#5887](https://github.com/windmill-labs/windmill/issues/5887)) ([b4a6a7e](https://github.com/windmill-labs/windmill/commit/b4a6a7e72429617d420af85a9de35bb13adfc6fb))
* **go:** local go.mod ([#5929](https://github.com/windmill-labs/windmill/issues/5929)) ([0b89260](https://github.com/windmill-labs/windmill/commit/0b89260540b307c6d614ca4275dd038fbfdac33c))
* multiple azure models support ([#5920](https://github.com/windmill-labs/windmill/issues/5920)) ([f412ede](https://github.com/windmill-labs/windmill/commit/f412ede6ed48e9a492f39582ac70a5584477529e))
* **rust:** add rust sdk ([#5909](https://github.com/windmill-labs/windmill/issues/5909)) ([332f66e](https://github.com/windmill-labs/windmill/commit/332f66e3483abbeacd4e7c1b74c94c5265314882))
### Bug Fixes
* ai chat tooltip + user settings autocomplete issue ([#5917](https://github.com/windmill-labs/windmill/issues/5917)) ([6f907c7](https://github.com/windmill-labs/windmill/commit/6f907c79b4cf6279bd52e35a3ee96e0d021422f5))
* audit logs for token refresh + consider refresh for active users ([#5930](https://github.com/windmill-labs/windmill/issues/5930)) ([cf2d09e](https://github.com/windmill-labs/windmill/commit/cf2d09e7a8c5d2472af0d483689c3fcfa2976117))
* fix input with wrong height on first render ([#5935](https://github.com/windmill-labs/windmill/issues/5935)) ([1a6283b](https://github.com/windmill-labs/windmill/commit/1a6283b42a6a514ab2e05160855cdc0f70b61d0e))
* flow step missing input warnings ([#5916](https://github.com/windmill-labs/windmill/issues/5916)) ([f077849](https://github.com/windmill-labs/windmill/commit/f077849b8f7c1916fd420e85b4844a5c5e93a139))
* **frontend:** use correct kind for flow insert module btn ([#5938](https://github.com/windmill-labs/windmill/issues/5938)) ([17c8c8a](https://github.com/windmill-labs/windmill/commit/17c8c8a5616ab8656799cea3fc5bc7cfaedc4995))
## [1.496.3](https://github.com/windmill-labs/windmill/compare/v1.496.2...v1.496.3) (2025-06-09)

View File

@@ -1,3 +1,10 @@
To have an overview of what this app does, see @.cursor/rules/windmill-overview.mdc
For backend modifications, follow the rules mentioned here @.cursor/rules/rust-best-practices.mdc
For frontend modifications, follow the rules mentioned here @.cursor/rules/svelte5-best-practices.mdc
# Windmill Development Guide
## Overview
Windmill is an open-source developer platform for building internal tools, workflows, API integrations, background jobs, workflows, and user interfaces. See @windmill-overview.mdc for full platform details.
## Language-Specific Guides
- Backend (Rust): @backend/rust-best-practices.mdc + @backend/summarized_schema.txt
- Frontend (Svelte 5): @frontend/svelte5-best-practices.mdc

View File

@@ -367,10 +367,11 @@ you to have it being synced automatically everyday.
## Run a local dev setup
Using [Nix](./frontend/README_DEV.md#nix) (Recommended).
See the [./frontend/README_DEV.md](./frontend/README_DEV.md) file for all
running options.
Using [Nix](./frontend/README_DEV.md#nix).
### only Frontend

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id As \"id!\",\n flow_status->'restarted_from'->'flow_job_id' AS \"restarted_from: Json<Uuid>\"\n FROM v2_as_queue\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $1), $1) = id AND workspace_id = $2",
"query": "SELECT\n id As \"id!\",\n flow_status->'restarted_from'->'flow_job_id' AS \"restarted_from: Json<Uuid>\"\n FROM v2_job_status\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $1), $1) = id",
"describe": {
"columns": [
{
@@ -16,14 +16,13 @@
],
"parameters": {
"Left": [
"Uuid",
"Text"
"Uuid"
]
},
"nullable": [
true,
false,
null
]
},
"hash": "3c0b2a840102b12864c5d721b8e0142602ab37f3e1a95d39b3c7cbd7ff34d0b2"
"hash": "019100d178129340a7c35d60ab61f983c8a9cb810db4369554bf26c6b0d6003d"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM app WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "08c827d9b2de0b77ce0ea2653760751615112c501b35e931ed817dbefd7c6bdb"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, expires_at, blacklisted_at, blacklisted_by \n FROM agent_token_blacklist \n ORDER BY blacklisted_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamp"
},
{
"ordinal": 2,
"name": "blacklisted_at",
"type_info": "Timestamp"
},
{
"ordinal": 3,
"name": "blacklisted_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "1c5d3556fc8436ddd294f39c5431e1f501a821d6143c5d8aece20814237a6b86"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM agent_token_blacklist WHERE token = $1 AND expires_at > $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Timestamp"
]
},
"nullable": [
null
]
},
"hash": "2bf99d540365c228e1776ee5d2ba01ebe289183526afab19c1390bbf5082f019"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM token WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "2f30274b0fe89aa1579b252b990876e5035ca5b31a68fcf08701102a6457e5c4"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n path,\n summary,\n description\n FROM\n flow\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2 AND\n archived is FALSE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "33367c42e87e78ae987c0966dc4d445c5eff75b2e2843ffd7a46b03cbaea9ae8"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n script_path, \n is_flow, \n http_method as \"http_method: _\", \n edited_by, \n email, \n edited_at, \n extra_perms, \n is_async, \n authentication_method as \"authentication_method: _\", \n static_asset_config as \"static_asset_config: _\", \n is_static_website,\n authentication_resource_path,\n wrap_body,\n raw_string\n FROM \n http_trigger\n WHERE \n workspace_id = $1 AND \n path = $2\n ",
"query": "\n SELECT \n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n script_path, \n summary,\n description,\n is_flow, \n http_method as \"http_method: _\", \n edited_by, \n email, \n edited_at, \n extra_perms, \n is_async, \n authentication_method as \"authentication_method: _\", \n static_asset_config as \"static_asset_config: _\", \n is_static_website,\n authentication_resource_path,\n wrap_body,\n raw_string\n FROM \n http_trigger\n WHERE \n workspace_id = $1 AND \n path = $2\n ",
"describe": {
"columns": [
{
@@ -35,11 +35,21 @@
},
{
"ordinal": 6,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 7,
"ordinal": 9,
"name": "http_method: _",
"type_info": {
"Custom": {
@@ -57,32 +67,32 @@
}
},
{
"ordinal": 8,
"ordinal": 10,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 9,
"ordinal": 11,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "authentication_method: _",
"type_info": {
"Custom": {
@@ -101,27 +111,27 @@
}
},
{
"ordinal": 14,
"ordinal": 16,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 15,
"ordinal": 17,
"name": "is_static_website",
"type_info": "Bool"
},
{
"ordinal": 16,
"ordinal": 18,
"name": "authentication_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 17,
"ordinal": 19,
"name": "wrap_body",
"type_info": "Bool"
},
{
"ordinal": 18,
"ordinal": 20,
"name": "raw_string",
"type_info": "Bool"
}
@@ -139,6 +149,8 @@
false,
false,
false,
true,
true,
false,
false,
false,
@@ -154,5 +166,5 @@
false
]
},
"hash": "144e4eccfd1c1e729e3c864bd5dc3316248719dfa8a6c9e1d15a7931638e86db"
"hash": "39401cb0db8d367b5beb2be0c13aa7595adae0eac4e4e3a888cb12b972d1a7ce"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM raw_app WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "3b5295a7c4b99aefa52c9a8ae1e0dd12bf4a0be1bf755caf7a1fa863e7950562"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n http_trigger \n SET \n route_path = $1, \n route_path_key = $2, \n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n authentication_resource_path = $6,\n script_path = $7, \n path = $8, \n is_flow = $9, \n http_method = $10, \n static_asset_config = $11, \n edited_by = $12, \n email = $13, \n is_async = $14, \n authentication_method = $15, \n edited_at = now(), \n is_static_website = $16\n WHERE \n workspace_id = $17 AND \n path = $18\n ",
"query": "\n UPDATE \n http_trigger \n SET \n route_path = $1, \n route_path_key = $2, \n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n authentication_resource_path = $6,\n script_path = $7, \n path = $8, \n is_flow = $9, \n http_method = $10, \n static_asset_config = $11, \n edited_by = $12, \n email = $13, \n is_async = $14, \n authentication_method = $15, \n summary = $16,\n description = $17,\n edited_at = now(), \n is_static_website = $18\n WHERE \n workspace_id = $19 AND \n path = $20\n ",
"describe": {
"columns": [],
"parameters": {
@@ -47,6 +47,8 @@
}
}
},
"Varchar",
"Text",
"Bool",
"Text",
"Text"
@@ -54,5 +56,5 @@
},
"nullable": []
},
"hash": "187e8f85a71dea958e89fdfdf96c913a19eef8678dc7890c2f0e1ef8758ec43b"
"hash": "3f05e6186050a7ce6d8efb41067d3c5282319fe7e041f114e02fb22b91716637"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n authentication_resource_path,\n script_path, \n is_flow, \n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n authentication_method AS \"authentication_method: _\", \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string\n FROM http_trigger\n WHERE workspace_id = $1\n ",
"query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n authentication_resource_path,\n script_path, \n is_flow, \n summary,\n description,\n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n authentication_method AS \"authentication_method: _\", \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string\n FROM http_trigger\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -45,31 +45,41 @@
},
{
"ordinal": 8,
"name": "edited_by",
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "edited_at",
"type_info": "Timestamptz"
"name": "description",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "email",
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 12,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "authentication_method: _",
"type_info": {
"Custom": {
@@ -88,7 +98,7 @@
}
},
{
"ordinal": 14,
"ordinal": 16,
"name": "http_method: _",
"type_info": {
"Custom": {
@@ -106,22 +116,22 @@
}
},
{
"ordinal": 15,
"ordinal": 17,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"ordinal": 18,
"name": "is_static_website",
"type_info": "Bool"
},
{
"ordinal": 17,
"ordinal": 19,
"name": "wrap_body",
"type_info": "Bool"
},
{
"ordinal": 18,
"ordinal": 20,
"name": "raw_string",
"type_info": "Bool"
}
@@ -140,6 +150,8 @@
true,
false,
false,
true,
true,
false,
false,
false,
@@ -153,5 +165,5 @@
false
]
},
"hash": "56c2522a12f91515e38290e4680a55a4727195125cd49a2f92f89bcdf74dc364"
"hash": "4228b098883408323bd8413ee094454b95962047458a6927d19ac0d3e7b3f0fa"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM flow WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "52032730f2eeaaeab55305f72bea5481d1c50c2eaa92a97a078239430f0d6c13"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users AS (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2",
"query": "WITH active_users AS (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
@@ -67,5 +67,5 @@
true
]
},
"hash": "3895cee539a24b4c6ea89fa7a835fc62bc93b0530efba09fc3c32a8f93eaabb1"
"hash": "5430f7728c1e9b539cc8aad29ca9e6733943278998d3df62a9486607827e59ec"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM agent_token_blacklist WHERE token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "54fee31b61d62598c89cf7d0729079ac1721fe7bd1844f339236379211defc78"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM variable WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "5a31b32659a0ac6a6ad0e122a4d475787240d6714ddadf16296d2b7bd5fdcb52"
}

View File

@@ -0,0 +1,93 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n route_path,\n http_method AS \"http_method: _\",\n is_async,\n workspaced_route,\n summary,\n description,\n authentication_method AS \"authentication_method: _\",\n authentication_resource_path\n FROM\n http_trigger\n WHERE\n path ~ ANY($1) AND\n route_path ~ ANY($2) AND\n workspace_id = $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "http_method: _",
"type_info": {
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
}
},
{
"ordinal": 2,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "workspaced_route",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "authentication_method: _",
"type_info": {
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
}
},
{
"ordinal": 7,
"name": "authentication_resource_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"TextArray",
"TextArray",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
true
]
},
"hash": "714fb0f66ceb536aee8cb9ae0144757b999d25870fda37fe904e09dd5c742015"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE account = $1 AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": [
null
]
},
"hash": "7aa589db3199d7f727cc69e63e1281b7ed329ff0c9d1617747f4ccd6014720cf"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM resource WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "7c765f50c67b0ef751bafc1bf9279c4cb8a851dfab406ba7611f77773663e9f3"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT distinct(path) FROM script WHERE workspace_id = $1 AND archived = true",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "8373b2649ab46310860adbdd7b717261771ac61d46d82d42d085ffebeb18be06"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "995b194da28092d5aa053df936e7a9ee4b80cf3ade038a032c57ecff8fa3c6cf"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace WHERE owner = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9d488c5ba4b9f5203692721d76ec831f5954861a5576e0d8c1c42a9eca90927f"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT leaf_jobs->$1::text AS \"leaf_jobs: Json<Box<RawValue>>\", parent_job\n FROM v2_as_queue\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $2), $2) = id AND workspace_id = $3",
"query": "SELECT flow_leaf_jobs->$1::text AS \"leaf_jobs: Json<Box<RawValue>>\", v2_job.parent_job\n FROM v2_job_status\n LEFT JOIN v2_job ON v2_job.id = v2_job_status.id AND v2_job.workspace_id = $3\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $2), $2) = v2_job_status.id",
"describe": {
"columns": [
{
@@ -26,5 +26,5 @@
true
]
},
"hash": "cf12a70e7b75ae471a0944de34502384be156cf25129f9c52bda34b240cf469a"
"hash": "b46a0fbebdc8e5e9852a06444b0aeaa4eaf67959e68b69eb2f0896ebe9244691"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,\n created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,\n script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,\n flow_innermost_root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,\n cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $21, $22, $23, $24, $25, $26,\n CASE WHEN $14::VARCHAR IS NOT NULL THEN 'schedule'::job_trigger_kind END,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id) \n values ($1, $32, $33, $34, $35, $36, $37, $2) \n ON CONFLICT (job_id) DO UPDATE SET email = $32, username = $33, is_admin = $34, is_operator = $35, folders = $36, groups = $37, workspace_id = $2\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 THEN now() END, $30, $31)",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,\n created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,\n script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,\n flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,\n cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26,\n CASE WHEN $14::VARCHAR IS NOT NULL THEN 'schedule'::job_trigger_kind END,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id) \n values ($1, $32, $33, $34, $35, $36, $37, $2) \n ON CONFLICT (job_id) DO UPDATE SET email = $32, username = $33, is_admin = $34, is_operator = $35, folders = $36, groups = $37, workspace_id = $2\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 THEN now() END, $30, $31)",
"describe": {
"columns": [],
"parameters": {
@@ -97,10 +97,11 @@
"Bool",
"Bool",
"JsonbArray",
"TextArray"
"TextArray",
"Uuid"
]
},
"nullable": []
},
"hash": "cccdcb7fe7968eadfc04d8957a8e98b2f2d92a6d7f687a9dd5a70edb3d5a63e6"
"hash": "b7c3a66c3831eb5d145ff00807badae57bef81be051f150df754fd1444d7356d"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT account FROM variable WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "account",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "c925264b7b0fd44ea7ab01c9af1514b9a9f2200e5a5db0a741697b28cd8b505f"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO agent_token_blacklist (token, expires_at, blacklisted_by) \n VALUES ($1, $2, $3) \n ON CONFLICT (token) DO UPDATE SET \n expires_at = EXCLUDED.expires_at,\n blacklisted_at = NOW(),\n blacklisted_by = EXCLUDED.blacklisted_by",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Timestamp",
"Varchar"
]
},
"nullable": []
},
"hash": "c9c040ec228a8fe4fda08439420141bee63339d4e3d5e2d68aabb12009f691c6"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users as (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "authors",
"type_info": "VarcharArray"
},
{
"ordinal": 1,
"name": "operators",
"type_info": "VarcharArray"
},
{
"ordinal": 2,
"name": "author_count",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "operator_count",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "cb3862634f18160207ee2621ddfca43f00456a27fda32583846497116f92f96c"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM script WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "cb8bde4d92a020278cbae79c5c01a766c198392aceb38fb27e57b73de8f7f279"
}

View File

@@ -1,38 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users as (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "authors",
"type_info": "VarcharArray"
},
{
"ordinal": 1,
"name": "operators",
"type_info": "VarcharArray"
},
{
"ordinal": 2,
"name": "author_count",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "operator_count",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "cce991f582bc9d2ba28a5b2b41c679366bb07bc6a100727721a787160ac6910c"
}

View File

@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, expires_at, blacklisted_at, blacklisted_by \n FROM agent_token_blacklist \n WHERE expires_at > $1 \n ORDER BY blacklisted_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamp"
},
{
"ordinal": 2,
"name": "blacklisted_at",
"type_info": "Timestamp"
},
{
"ordinal": 3,
"name": "blacklisted_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Timestamp"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "d56722c25877222af9affd5da5bb83b28fa8cbb528a2cfc90684cb10a69e4375"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n path,\n summary,\n description\n FROM\n script\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2 AND\n archived is FALSE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "dc36b46b9eb80cb7c92fa72519d117eda99a6f482a073ccd36a6431ef689a3fd"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO http_trigger (\n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n script_path, \n is_flow, \n is_async, \n authentication_method, \n http_method, \n static_asset_config, \n edited_by, \n email, \n edited_at, \n is_static_website\n ) \n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, now(), $17\n )\n ",
"query": "\n INSERT INTO http_trigger (\n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n script_path, \n summary,\n description,\n is_flow, \n is_async, \n authentication_method, \n http_method, \n static_asset_config, \n edited_by, \n email, \n edited_at, \n is_static_website\n ) \n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), $19\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -14,6 +14,8 @@
"Bool",
"Bool",
"Varchar",
"Varchar",
"Text",
"Bool",
"Bool",
{
@@ -53,5 +55,5 @@
},
"nullable": []
},
"hash": "8c30e91c2486f7511563621e7e805d0588a9ec8bbea9db10e95783e27e35bc12"
"hash": "ed99d4d088d0fd0c01f29803b12e99ae0a53d0b1feaa67737da409c51c1b6751"
}

View File

@@ -11,5 +11,7 @@
"remote.autoForwardPorts": true,
"conventionalCommits.scopes": [
"restructring triggers, decoding trigger message on work"
]
],
"files.exclude": { "**/*ee.rs": false },
"search.exclude": { "**/*ee.rs": false }
}

12
backend/CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# Backend Development (Rust)
## Core Principles
- Follow @rust-best-practices.mdc for detailed guidelines
- Database schema reference: @summarized_schema.txt
- The API routes prefixes are all listed in windmill-api/src/lib.rs
## Adding New Features
1. Update database schema with migration if necessary
2. Update backend/windmill-api/openapi.yaml after modifying API endpoints

662
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.496.3"
version = "1.501.0"
authors.workspace = true
edition.workspace = true
@@ -32,7 +32,7 @@ members = [
]
[workspace.package]
version = "1.496.3"
version = "1.501.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -130,6 +130,7 @@ prometheus = { workspace = true, optional = true }
uuid.workspace = true
gethostname.workspace = true
serde_json.workspace = true
serde_yml.workspace = true
serde.workspace = true
deno_core = { workspace = true, optional = true }
object_store = { workspace = true, optional = true }
@@ -201,6 +202,7 @@ tower-http = { version = "^0.6", features = ["trace", "cors"] }
tower-cookies = "^0.10"
serde = "^1"
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
serde_yml = "0.0.12"
uuid = { version = "^1", features = ["serde", "v4"] }
thiserror = "^2"
anyhow = "^1"
@@ -230,7 +232,7 @@ php-parser-rs = { git = "https://github.com/php-rust-tools/parser", rev = "ec4cb
cron = "^0"
mail-send = { version = "0.4.0", features = ["builder"], default-features=false }
urlencoding = "^2"
url = "^2"
url = { version = "^2" , features = ["serde"]}
async-oauth2 = "^0"
reqwest = { version = "^0.12", features = ["json", "stream", "gzip"] }
time = "^0"

View File

@@ -1 +1 @@
ab4f856708a056973faea19f10eda5efff546176
835a91c7c31ea749759cd8af0922ad837049ea2a

View File

@@ -4,4 +4,4 @@ DROP TYPE http_method;
ALTER TABLE script DROP COLUMN has_preprocessor;
DROP FUNCTION prevent_route_path_change();
DROP FUNCTION prevent_route_path_change();

View File

@@ -0,0 +1,4 @@
-- Add down migration script here
ALTER TABLE http_trigger
DROP COLUMN summary,
DROP COLUMN description;

View File

@@ -0,0 +1,5 @@
-- Add up migration script here
ALTER TABLE http_trigger
ADD COLUMN summary VARCHAR(512) NULL,
ADD COLUMN description TEXT NULL;

View File

@@ -0,0 +1,2 @@
-- Remove agent token blacklist table
DROP TABLE IF EXISTS agent_token_blacklist;

View File

@@ -0,0 +1,14 @@
-- Add agent token blacklist table
CREATE TABLE agent_token_blacklist (
token VARCHAR PRIMARY KEY,
expires_at TIMESTAMP NOT NULL,
blacklisted_at TIMESTAMP NOT NULL DEFAULT NOW(),
blacklisted_by VARCHAR NOT NULL
);
-- Add index for efficient expiry cleanup
CREATE INDEX idx_agent_token_blacklist_expires_at ON agent_token_blacklist(expires_at);
-- Grant permissions to windmill users
GRANT ALL ON agent_token_blacklist TO windmill_user;
GRANT ALL ON agent_token_blacklist TO windmill_admin;

View File

@@ -30,8 +30,6 @@ use windmill_common::{
worker::PythonAnnotations,
};
const DEF_MAIN: &str = "def main(";
fn replace_import(x: String) -> String {
SHORT_IMPORTS_MAP
.get(&x)
@@ -48,6 +46,9 @@ lazy_static! {
static ref RE: Regex = Regex::new(r"^\#\s?(\S+)\s*$").unwrap();
static ref PIN_RE: Regex = Regex::new(r"(?:\s*#\s*(pin|repin):\s*)(\S*)").unwrap();
static ref PKG_RE: Regex = Regex::new(r"^([^!=<>]+)(?:[!=<>]|$)").unwrap();
// Regex to properly match main function definition at line start,
// capturing both sync and async variants
static ref DEF_MAIN_RE: Regex = Regex::new(r"(?m)^(async\s+)?def\s+main\s*\(").unwrap();
}
fn process_import(module: Option<String>, path: &str, level: usize) -> Vec<NImport> {
@@ -143,7 +144,12 @@ struct ImportPin {
}
fn parse_code_for_imports(code: &str, path: &str) -> error::Result<Vec<NImport>> {
let mut code = code.split(DEF_MAIN).next().unwrap_or("").to_string();
// Use regex to safely find the main function definition
let mut code = DEF_MAIN_RE
.split(code)
.next()
.unwrap_or_default()
.to_string();
// remove main function decorator from end of file if it exists
if code
@@ -160,10 +166,17 @@ fn parse_code_for_imports(code: &str, path: &str) -> error::Result<Vec<NImport>>
+ "\n";
}
let ast = Suite::parse(&code, "main.py").map_err(|e| {
// Add a fake main function to ensure the parser can process the code correctly
// This is needed because we've split off the real main function above
let code_with_fake_main = format!("{}\n\ndef main(): pass", code);
let ast = Suite::parse(&code_with_fake_main, "main.py").map_err(|e| {
error::Error::ExecutionErr(format!("Error parsing code for imports: {}", e.to_string()))
})?;
// Note: We're still using the original code for finding pins,
// as the TextRange values from the parsed AST would be based on code_with_fake_main
// but we want to match against the original code
let find_pin = |range: TextRange, key: String| {
let hs = code
.chars()
@@ -378,7 +391,7 @@ async fn parse_python_imports_inner(
})
.join("\n")
.parse::<toml::Table>()
.map_err(to_anyhow)?;
.map_err(to_anyhow)?;
{
if let Some(v) = metadata.get("requires-python").and_then(|v| v.as_str()) {

View File

@@ -232,7 +232,14 @@ fn parse_typ(id: &str) -> Typ {
x @ _ if x.starts_with("DynSelect_") => {
Typ::DynSelect(x.strip_prefix("DynSelect_").unwrap().to_string())
}
_ => Typ::Resource(id.to_string()),
_ => Typ::Resource(map_resource_name(id)),
}
}
fn map_resource_name(x: &str) -> String {
match x {
"S3Object" => "s3_object".to_string(),
_ => x.to_string(),
}
}
@@ -468,7 +475,7 @@ def main(test1: str,
Arg {
otyp: None,
name: "s3o".to_string(),
typ: Typ::Resource("S3Object".to_string()),
typ: Typ::Resource("s3_object".to_string()),
default: None,
has_default: false,
oidx: None

View File

@@ -92,6 +92,12 @@ mod test {
fn test_snake_case() {
assert_eq!("s3", to_snake_case("S3"));
assert_eq!("s3", to_snake_case("s3"));
assert_eq!("s3_object", to_snake_case("S3Object"));
assert_eq!("s3_object", to_snake_case("S3object"));
assert_eq!("s3_object", to_snake_case("s3object"));
assert_eq!("abc", to_snake_case("ABC"));
assert_eq!("aa_bc", to_snake_case("AaBC"));
assert_eq!("a_b_c", to_snake_case("A_B_C"));
assert_eq!("s_3", to_snake_case("S_3"));
assert_eq!("type_name_here", to_snake_case("typeNameHere"));
}

View File

@@ -159,6 +159,7 @@ lazy_static::lazy_static! {
.ok()
.and_then(|x| x.parse::<u64>().ok())
.unwrap_or(3600 * 12);
}
pub fn main() -> anyhow::Result<()> {
@@ -1065,7 +1066,9 @@ Windmill Community Edition {GIT_VERSION}
}
if server_mode {
tracing::info!("monitor task started");
if !*windmill_common::QUIET_LOGS {
tracing::info!("monitor task started");
}
}
monitor_db(
&conn,
@@ -1077,7 +1080,9 @@ Windmill Community Edition {GIT_VERSION}
)
.await;
if server_mode {
tracing::info!("monitor task finished");
if !*windmill_common::QUIET_LOGS {
tracing::info!("monitor task finished");
}
}
},
}
@@ -1097,6 +1102,9 @@ Windmill Community Edition {GIT_VERSION}
_ = tokio::time::sleep(Duration::from_secs(12 * 60 * 60)) => {
tracing::info!("Reloading config after 12 hours");
initial_load(&conn, tx.clone(), worker_mode, server_mode, #[cfg(feature = "parquet")] disable_s3_store).await;
if let Err(e) = reload_license_key(&conn).await {
tracing::error!("Failed to reload license key on agent: {e:#}");
}
#[cfg(feature = "enterprise")]
ee_oss::verify_license_key().await;
}

View File

@@ -840,6 +840,24 @@ pub async fn delete_expired_items(db: &DB) -> () {
tracing::error!("Error deleting audit log on CE: {:?}", e);
}
match sqlx::query_scalar!(
"DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
)
.fetch_all(db)
.await
{
Ok(deleted_tokens) => {
if deleted_tokens.len() > 0 {
tracing::info!(
"deleted {} expired blacklisted agent tokens: {:?}",
deleted_tokens.len(),
deleted_tokens
);
}
}
Err(e) => tracing::error!("Error deleting expired blacklisted agent tokens: {:?}", e),
}
let job_retention_secs = *JOB_RETENTION_SECS.read().await;
if job_retention_secs > 0 {
match db.begin().await {
@@ -1324,7 +1342,6 @@ pub async fn monitor_db(
initial_load: bool,
_killpill_tx: KillpillSender,
) {
tracing::info!("Starting periodic monitor task");
let zombie_jobs_f = async {
if server_mode && !initial_load && !*DISABLE_ZOMBIE_JOBS_MONITORING {
if let Some(db) = conn.as_sql() {
@@ -1422,7 +1439,6 @@ pub async fn monitor_db(
apply_autoscaling_f,
update_min_worker_version_f,
);
tracing::info!("Periodic monitor task completed");
}
pub async fn expose_queue_metrics(db: &Pool<Postgres>) {

154
backend/summarize_schema.py Normal file
View File

@@ -0,0 +1,154 @@
# This script is used to summarize the database schema.
# You can use pg_dump to dump the schema to a file.
# pg_dump --file "schema.sql" --host "localhost" --port "5432" --username "postgres" --no-password --format=c --large-objects --schema-only --no-owner --no-privileges --no-tablespaces --no-unlogged-table-data --no-comments --no-publications --no-subscriptions --no-security-labels --no-toast-compression --no-table-access-method --verbose --schema "public" "windmill"
# Then you can run python summarize_schema.py schema.sql to get the summarized schema.
import re
import sys
from collections import defaultdict
def summarize_schema(file_path):
"""
Parses a PostgreSQL dump file and extracts a summarized schema.
"""
tables = defaultdict(lambda: {'columns': [], 'pks': set(), 'fks': [], 'indexes': []})
enums = defaultdict(list)
# Use state variables to parse multi-line definitions
current_table = None
current_enum = None
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# --- State Resets ---
if line.startswith(');'):
current_table = None
current_enum = None
continue
# --- Parse ENUM definitions ---
match_enum = re.match(r"CREATE TYPE public\.(\w+) AS ENUM \($", line)
if match_enum:
current_enum = match_enum.group(1)
continue
if current_enum:
# Extract enum values, which are typically like 'value',
value = line.strip("',")
if value and not value.startswith('--'):
enums[current_enum].append(value)
continue
# --- Parse TABLE definitions ---
match_table = re.match(r"CREATE TABLE public\.(\w+) \($", line)
if match_table:
current_table = match_table.group(1)
continue
if current_table:
# Parse columns within a CREATE TABLE block
# e.g., "column_name type NOT NULL,"
# e.g., "id bigint NOT NULL,"
match_column = re.match(r'^"?(\w+)"?\s+([\w\d\.\[\]\(\)]+)', line)
if match_column:
col_name = match_column.group(1)
col_type = match_column.group(2)
tables[current_table]['columns'].append(f"{col_name} ({col_type})")
# Parse PRIMARY KEY defined inside the table
match_pk = re.search(r"CONSTRAINT \w+ PRIMARY KEY \((.+)\)", line)
if match_pk:
# Handle multiple PK columns: "col1, col2, col3"
pk_cols = [p.strip().strip('"') for p in match_pk.group(1).split(',')]
tables[current_table]['pks'].update(pk_cols)
continue
# --- Parse Foreign Keys (defined outside CREATE TABLE) ---
match_fk = re.match(r"ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \w+ FOREIGN KEY \(([\w,\s\"]+)\) REFERENCES public\.(\w+)\(([\w,\s\"]+)\);", line)
if match_fk:
from_table, from_cols, to_table, to_cols = match_fk.groups()
# Clean up column names
from_cols_clean = ', '.join([c.strip().strip('"') for c in from_cols.split(',')])
to_cols_clean = ', '.join([c.strip().strip('"') for c in to_cols.split(',')])
fk_string = f"({from_cols_clean}) -> {to_table}({to_cols_clean})"
tables[from_table]['fks'].append(fk_string)
# --- Parse Index definitions ---
match_index = re.match(r"CREATE (UNIQUE )?INDEX (\w+) ON public\.(\w+) USING (\w+) \((.+)\);", line)
if match_index:
is_unique = match_index.group(1) is not None
index_name = match_index.group(2)
table_name = match_index.group(3)
index_type = match_index.group(4)
columns = match_index.group(5)
# Clean up column expressions
columns_clean = columns.replace('"', '')
unique_str = "UNIQUE " if is_unique else ""
index_string = f"{unique_str}INDEX {index_name} ({index_type}) ON ({columns_clean})"
tables[table_name]['indexes'].append(index_string)
return enums, tables
def format_output(enums, tables):
"""
Formats the parsed schema data into a clean, readable string.
"""
output = []
output.append("### Simplified Database Schema ###")
output.append("\n--- Custom Data Types (ENUMs) ---\n")
if not enums:
output.append("No custom ENUM types found.")
else:
for name, values in sorted(enums.items()):
output.append(f"{name}:")
for v in values:
output.append(f" - {v}")
output.append("")
output.append("\n--- Tables and Relationships ---\n")
if not tables:
output.append("No tables found.")
else:
for name, data in sorted(tables.items()):
output.append(f"TABLE: {name}")
for col in data['columns']:
col_name = col.split(' ')[0]
marker = " (PK)" if col_name in data['pks'] else ""
output.append(f" - {col}{marker}")
if data['fks']:
output.append(" Relationships:")
for fk in data['fks']:
output.append(f" - {fk}")
if data['indexes']:
output.append(" Indexes:")
for idx in data['indexes']:
output.append(f" - {idx}")
output.append("-" * 20)
return "\n".join(output)
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} <path_to_dump.sql>")
sys.exit(1)
input_file = sys.argv[1]
try:
enums_data, tables_data = summarize_schema(input_file)
formatted_summary = format_output(enums_data, tables_data)
print(formatted_summary)
except FileNotFoundError:
print(f"Error: The file '{input_file}' was not found.")
sys.exit(1)
except Exception as e:
print(f"An unexpected error occurred: {e}")
sys.exit(1)

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,18 @@
./substitute_ee_code.sh --dir ../windmill-ee-private
#!/bin/bash
# Default directory
EE_DIR="../windmill-ee-private"
# Parse arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--dir) EE_DIR="$2"; shift ;;
*) echo "Unknown parameter: $1"; exit 1 ;;
esac
shift
done
./substitute_ee_code.sh --dir "$EE_DIR"
# Check if running on macOS
if [[ "$(uname)" == "Darwin" ]]; then
@@ -18,4 +32,4 @@ if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' 's/^#samael = { version="0.0.14", features = \["xmlsec"\] }/samael = { version="0.0.14", features = ["xmlsec"] }/' Cargo.toml
# Comment out the git-based samael dependency
sed -i '' 's/^\(samael = { git="https:\/\/github.com\/njaremko\/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = \["xmlsec"\] }\)/# \1/' Cargo.toml
fi
fi

View File

@@ -17,7 +17,7 @@ agent_worker_server = []
enterprise_saml = ["dep:samael", "dep:libxml"]
benchmark = []
embedding = ["dep:tinyvector", "dep:hf-hub", "dep:tokenizers", "dep:candle-core", "dep:candle-transformers", "dep:candle-nn"]
parquet = ["dep:datafusion", "dep:object_store", "dep:url", "windmill-common/parquet", "windmill-worker/parquet"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker/parquet"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect"]
tantivy = ["dep:windmill-indexer"]
@@ -40,7 +40,7 @@ mcp = ["dep:rmcp"]
python = []
[dependencies]
rmcp = { git = "https://github.com/windmill-labs/rust-sdk", features = ["transport-sse-server"], optional = true }
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", features=["transport-streamable-http-server", "transport-streamable-http-server-session", "transport-worker"], optional = true }
windmill-queue.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-audit.workspace = true
@@ -76,6 +76,7 @@ hex.workspace = true
base64.workspace = true
base32.workspace = true
serde_urlencoded.workspace = true
serde_yml.workspace = true
cron.workspace = true
mime_guess.workspace = true
rust-embed = { workspace = true, optional = true }
@@ -102,6 +103,7 @@ prometheus = { workspace = true, optional = true }
async_zip = { workspace = true, optional = true }
regex.workspace = true
bytes.workspace = true
url.workspace = true
samael = { workspace = true, optional = true }
libxml = { workspace = true, optional = true }
async-recursion.workspace = true
@@ -116,7 +118,6 @@ candle-nn = { workspace = true, optional = true}
datafusion = { workspace = true, optional = true}
object_store = { workspace = true, optional = true}
openidconnect = { workspace = true, optional = true}
url = { workspace = true, optional = true}
jsonwebtoken = { workspace = true }
matchit = { workspace = true, optional = true }
tokio-tungstenite = { workspace = true, optional = true}
@@ -126,6 +127,7 @@ nkeys = { workspace = true, optional = true }
const_format.workspace = true
pin-project.workspace = true
http.workspace = true
indexmap.workspace = true
async-stream.workspace = true
ulid.workspace = true
rust-postgres = { workspace = true, optional = true }

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.496.3
version: 1.501.0
title: Windmill API
contact:
@@ -8451,6 +8451,51 @@ paths:
"201":
description: default error handler set
/w/{workspace}/openapi/generate:
post:
summary: generate openapi spec from http routes/webhook
operationId: generateOpenapiSpec
tags:
- openapi
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: openapi spec info and url
content:
application/json:
schema:
$ref: "#/components/schemas/GenerateOpenapiSpec"
responses:
"200":
description: openapi spec
content:
text/plain:
schema:
type: string
/w/{workspace}/openapi/download:
post:
summary: Download the OpenAPI v3.1 spec as a file
operationId: DownloadOpenapiSpec
tags:
- openapi
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: openapi spec info and url
content:
application/json:
schema:
$ref: "#/components/schemas/GenerateOpenapiSpec"
responses:
"200":
description: Downloaded OpenAPI spec
content:
application/octet-stream:
schema:
type: string
format: binary
/w/{workspace}/http_triggers/create_many:
post:
summary: create many HTTP triggers
@@ -11195,6 +11240,98 @@ paths:
schema:
type: string
/agent_workers/blacklist_token:
post:
summary: blacklist agent token (requires super admin)
operationId: blacklistAgentToken
tags:
- agent_workers
requestBody:
description: token to blacklist
required: true
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: The agent token to blacklist
expires_at:
type: string
format: date-time
description: Optional expiration date for the blacklist entry
required:
- token
responses:
"200":
description: token blacklisted successfully
/agent_workers/remove_blacklist_token:
post:
summary: remove agent token from blacklist (requires super admin)
operationId: removeBlacklistAgentToken
tags:
- agent_workers
requestBody:
description: token to remove from blacklist
required: true
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: The agent token to remove from blacklist
required:
- token
responses:
"200":
description: token removed from blacklist successfully
/agent_workers/list_blacklisted_tokens:
get:
summary: list blacklisted agent tokens (requires super admin)
operationId: listBlacklistedAgentTokens
tags:
- agent_workers
parameters:
- name: include_expired
in: query
description: Whether to include expired blacklisted tokens
schema:
type: boolean
default: false
responses:
"200":
description: list of blacklisted tokens
content:
application/json:
schema:
type: array
items:
type: object
properties:
token:
type: string
description: The blacklisted token (without prefix)
expires_at:
type: string
format: date-time
description: When the blacklist entry expires
blacklisted_at:
type: string
format: date-time
description: When the token was blacklisted
blacklisted_by:
type: string
description: Email of the user who blacklisted the token
required:
- token
- expires_at
- blacklisted_at
- blacklisted_by
/w/{workspace}/acls/get/{kind}/{path}:
get:
@@ -14754,6 +14891,106 @@ components:
- custom_script
- signature
RunnableKind:
type: string
enum:
- script
- flow
OpenapiSpecFormat:
type: string
enum:
- yaml
- json
OpenapiHttpRouteFilters:
type: object
properties:
folder_regex:
type: string
path_regex:
type: string
route_path_regex:
type: string
required:
- folder_regex
- path_regex
- route_path_regex
WebhookFilters:
type: object
properties:
user_or_folder_regex:
type: string
enum:
- "*"
- u
- f
user_or_folder_regex_value:
type: string
path:
type: string
runnable_kind:
$ref: "#/components/schemas/RunnableKind"
required:
- user_or_folder_regex
- user_or_folder_regex_value
- path
- runnable_kind
OpenapiV3Info:
type: object
properties:
title:
type: string
version:
type: string
description:
type: string
terms_of_service:
type: string
contact:
type: object
properties:
name:
type: string
url:
type: string
email:
type: string
license:
type: object
properties:
name:
type: string
identifier:
type: string
url:
type: string
required:
- name
required:
- title
- version
GenerateOpenapiSpec:
type: object
properties:
info:
$ref: "#/components/schemas/OpenapiV3Info"
url:
type: string
openapi_spec_format:
$ref: "#/components/schemas/OpenapiSpecFormat"
http_route_filters:
type: array
items:
$ref: "#/components/schemas/OpenapiHttpRouteFilters"
webhook_filters:
type: array
items:
$ref: "#/components/schemas/WebhookFilters"
HttpMethod:
type: string
enum:
@@ -14785,6 +15022,10 @@ components:
$ref: "#/components/schemas/HttpMethod"
authentication_resource_path:
type: string
summary:
type: string
description:
type: string
is_async:
type: boolean
authentication_method:
@@ -14819,6 +15060,10 @@ components:
type: string
workspaced_route:
type: boolean
summary:
type: string
description:
type: string
static_asset_config:
type: object
properties:
@@ -14866,6 +15111,10 @@ components:
type: string
route_path:
type: string
summary:
type: string
description:
type: string
workspaced_route:
type: boolean
static_asset_config:

View File

@@ -16,9 +16,6 @@ use crate::db::DB;
#[cfg(not(feature = "private"))]
use axum::Router;
#[cfg(not(feature = "private"))]
use serde::{Deserialize, Serialize};
#[cfg(not(feature = "private"))]
pub fn global_service() -> Router {
Router::new()
@@ -44,15 +41,6 @@ pub fn workspaced_service(
(router, vec![], Some(job_completed_tx))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg(not(feature = "private"))]
pub struct AgentAuth {
pub worker_group: String,
pub suffix: Option<String>,
pub tags: Vec<String>,
pub exp: Option<usize>,
}
#[cfg(not(feature = "private"))]
pub struct AgentCache {}

View File

@@ -4,7 +4,7 @@ use crate::{
};
use axum::{body::Bytes, extract::Path, response::IntoResponse, routing::post, Extension, Router};
use http::HeaderMap;
use http::{HeaderMap, Method};
use quick_cache::sync::Cache;
use reqwest::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
@@ -24,7 +24,7 @@ lazy_static::lazy_static! {
pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringAIRequestConfig> = Cache::new(500);
}
const AZURE_API_VERSION: &str = "2024-10-21";
const AZURE_API_VERSION: &str = "2025-04-01-preview";
const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
#[derive(Deserialize, Debug)]
@@ -141,23 +141,49 @@ impl AIRequestConfig {
self,
provider: &AIProvider,
path: &str,
method: Method,
headers: HeaderMap,
body: Bytes,
) -> Result<RequestBuilder> {
let url = format!("{}/{}", self.base_url, path);
let body = if let Some(user) = self.user {
Self::add_user_to_body(body, user)?
} else {
body
};
let is_azure = matches!(provider, AIProvider::OpenAI) && self.base_url != OPENAI_BASE_URL
let base_url = self.base_url.trim_end_matches('/');
let is_azure = matches!(provider, AIProvider::OpenAI) && base_url != OPENAI_BASE_URL
|| matches!(provider, AIProvider::AzureOpenAI);
let is_anthropic = matches!(provider, AIProvider::Anthropic);
let url = if is_azure && method != Method::GET {
if base_url.ends_with("/deployments") {
let model = Self::get_azure_model(&body)?;
format!("{}/{}/{}", base_url, model, path)
} else if base_url.ends_with("/openai") {
let model = Self::get_azure_model(&body)?;
format!("{}/deployments/{}/{}", base_url, model, path)
} else {
format!("{}/{}", base_url, path)
}
} else {
format!("{}/{}", base_url, path)
};
tracing::debug!("AI request URL: {}", url);
let mut request = HTTP_CLIENT
.post(url)
.header("content-type", "application/json")
.body(body);
.request(method, url)
.header("content-type", "application/json");
for (header_name, header_value) in headers.iter() {
if header_name.to_string().starts_with("anthropic-") {
request = request.header(header_name, header_value);
}
}
request = request.body(body);
if is_azure {
request = request.query(&[("api-version", AZURE_API_VERSION)])
@@ -165,9 +191,12 @@ impl AIRequestConfig {
if let Some(api_key) = self.api_key {
if is_azure {
request = request.header("api-key", api_key)
request = request.header("api-key", api_key.clone())
} else {
request = request.header("authorization", format!("Bearer {}", api_key))
request = request.header("authorization", format!("Bearer {}", api_key.clone()))
}
if is_anthropic {
request = request.header("X-API-Key", api_key);
}
}
@@ -199,6 +228,18 @@ impl AIRequestConfig {
.map_err(|e| Error::internal_err(format!("Failed to reserialize request body: {}", e)))?
.into())
}
fn get_azure_model(body: &Bytes) -> Result<String> {
#[derive(Deserialize, Debug)]
struct AzureModel {
model: String,
}
let azure_model: AzureModel = serde_json::from_slice(body)
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
Ok(azure_model.model)
}
}
#[derive(Clone, Debug)]
@@ -311,17 +352,18 @@ pub struct AIConfig {
}
pub fn global_service() -> Router {
Router::new().route("/proxy/*ai", post(global_proxy))
Router::new().route("/proxy/*ai", post(global_proxy).get(global_proxy))
}
pub fn workspaced_service() -> Router {
Router::new().route("/proxy/*ai", post(proxy))
Router::new().route("/proxy/*ai", post(proxy).get(proxy))
}
async fn global_proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(ai_path): Path<String>,
method: Method,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
@@ -346,7 +388,7 @@ async fn global_proxy(
let url = format!("{}/{}", base_url, ai_path);
let request = HTTP_CLIENT
.post(url)
.request(method, url)
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
.body(body);
@@ -382,6 +424,7 @@ async fn proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, ai_path)): Path<(String, String)>,
method: Method,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
@@ -464,7 +507,7 @@ async fn proxy(
}
};
let request = request_config.prepare_request(&provider, &ai_path, body)?;
let request = request_config.prepare_request(&provider, &ai_path, method, headers, body)?;
let response = request.send().await.map_err(to_anyhow)?;

View File

@@ -12,7 +12,7 @@ use crate::{
db::{ApiAuthed, DB},
resources::get_resource_value_interpolated_internal,
users::{require_owner_of_path, OptAuthed},
utils::{RunnableKind, WithStarredInfoQuery},
utils::WithStarredInfoQuery,
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
};
@@ -59,7 +59,7 @@ use windmill_common::{
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
Pagination, StripPath,
Pagination, RunnableKind, StripPath,
},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
@@ -917,6 +917,23 @@ async fn create_app_internal<'a>(
raw_app: bool,
mut app: CreateApp,
) -> Result<(sqlx::Transaction<'a, sqlx::Postgres>, String, i64)> {
if *CLOUD_HOSTED {
let nb_apps =
sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id)
.fetch_one(&db)
.await?;
if nb_apps.unwrap_or(0) >= 1000 {
return Err(Error::BadRequest(
"You have reached the maximum number of apps (1000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
if app.summary.len() > 300 {
return Err(Error::BadRequest(
"Summary must be less than 300 characters on cloud".to_string(),
));
}
}
let mut tx = user_db.clone().begin(&authed).await?;
app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
app.policy.on_behalf_of_email = Some(authed.email.clone());
@@ -2011,7 +2028,7 @@ async fn upload_s3_file_from_app(
if !has_unnamed_policy {
return Err(Error::BadRequest(
"no policy found for unnamed s3 file uplooad".to_string(),
"no policy found for unnamed s3 file upload".to_string(),
));
}

View File

@@ -34,10 +34,11 @@ lazy_static::lazy_static! {
// Global function to invalidate a specific token from cache
pub fn invalidate_token_from_cache(token: &str) {
// Remove all cache entries for this token (across all workspaces)
AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| {
cached_token != token
});
tracing::info!("Invalidated token from auth cache: {}...", &token[..token.len().min(8)]);
AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| cached_token != token);
tracing::info!(
"Invalidated token from auth cache: {}...",
&token[..token.len().min(8)]
);
}
#[derive(Clone)]
@@ -482,6 +483,27 @@ where
}
}
fn maybe_get_workspace_id_from_path(path_vec: &[&str]) -> Option<String> {
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[1] == "api"
&& path_vec[2] == "mcp"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_owned())
} else {
if path_vec.len() >= 5 && path_vec[0] == "" && path_vec[2] == "srch" && path_vec[3] == "w" {
Some(path_vec[4].to_owned())
} else {
None
}
};
workspace_id
}
#[async_trait]
impl<S> FromRequestParts<S> for ApiAuthed
where
@@ -494,85 +516,62 @@ where
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(ApiAuthed {
email: "".to_owned(),
username: "".to_owned(),
is_admin: false,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
});
return Ok(ApiAuthed::default());
};
let already_authed = parts.extensions.get::<ApiAuthed>();
if let Some(authed) = already_authed {
Ok(authed.clone())
} else {
let already_tokened = parts.extensions.get::<Tokened>();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(parts, state).await
};
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[1] == "api"
&& path_vec[2] == "mcp"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_string())
} else {
if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[2] == "srch"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_string())
} else {
None
}
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
{
if let Some(authed) = cache.get_authed(workspace_id.clone(), &token).await {
parts.extensions.insert(authed.clone());
if authed.scopes.as_ref().is_some_and(|scopes| {
scopes
.iter()
.any(|s| s.starts_with("jobs:") || s.starts_with("run:"))
}) && (path_vec.len() < 3
|| (path_vec[4] != "jobs" && path_vec[4] != "jobs_u"))
{
BRUTE_FORCE_COUNTER.increment().await;
return Err((
StatusCode::UNAUTHORIZED,
format!("Unauthorized scoped token: {:?}", authed.scopes),
));
}
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
return Ok(authed);
if let Some(authed) = already_authed {
return Ok(authed.clone());
}
let already_tokened = parts.extensions.get::<Tokened>();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(parts, state).await
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
{
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = maybe_get_workspace_id_from_path(&path_vec);
if let Some(authed) = cache.get_authed(workspace_id.clone(), &token).await {
if authed.scopes.as_ref().is_some_and(|scopes| {
scopes
.iter()
.any(|s| s.starts_with("jobs:") || s.starts_with("run:"))
}) && (path_vec.len() < 3
|| (path_vec[4] != "jobs" && path_vec[4] != "jobs_u"))
{
BRUTE_FORCE_COUNTER.increment().await;
return Err((
StatusCode::UNAUTHORIZED,
format!("Unauthorized scoped token: {:?}", authed.scopes),
));
}
parts.extensions.insert(authed.clone());
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
return Ok(authed);
}
}
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
}

View File

@@ -64,7 +64,6 @@ use crate::{
args::RawWebhookArgs,
db::{ApiAuthed, DB},
users::fetch_api_authed,
utils::RunnableKind,
};
use axum::{
@@ -82,7 +81,7 @@ use windmill_common::{
db::UserDB,
error::{JsonResult, Result},
triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind},
utils::{not_found_if_none, paginate, Pagination, StripPath},
utils::{not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
worker::{to_raw_value, CLOUD_HOSTED},
};

View File

@@ -21,7 +21,7 @@ use windmill_common::{
DB,
};
use crate::{db::ApiAuthed, utils::require_super_admin};
use crate::{db::ApiAuthed, utils::{require_devops_role}};
pub fn global_service() -> Router {
Router::new()
@@ -103,7 +103,7 @@ async fn get_config(
Path(name): Path<String>,
Extension(db): Extension<DB>,
) -> error::JsonResult<Option<serde_json::Value>> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let config = sqlx::query_as!(Config, "SELECT * FROM config WHERE name = $1", name)
.fetch_optional(&db)
@@ -119,7 +119,7 @@ async fn update_config(
authed: ApiAuthed,
Json(config): Json<serde_json::Value>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
#[cfg(not(feature = "enterprise"))]
if name.starts_with("worker__") {
@@ -157,7 +157,7 @@ async fn delete_config(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let mut tx = db.begin().await?;
@@ -232,7 +232,7 @@ async fn list_configs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> error::JsonResult<Vec<Config>> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let configs = sqlx::query_as!(Config, "SELECT name, config FROM config")
.fetch_all(&db)
.await?;

View File

@@ -815,7 +815,7 @@ async fn fix_job_completed_index(db: &DB) -> Result<(), Error> {
Ok(())
}
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
#[derive(Clone, Debug, Default, Hash, Eq, PartialEq)]
pub struct ApiAuthed {
pub email: String,
pub username: String,

View File

@@ -12,7 +12,7 @@ use crate::db::ApiAuthed;
use crate::triggers::{
get_triggers_count_internal, list_tokens_internal, TriggersCount, TruncatedTokenWithEmail,
};
use crate::utils::{RunnableKind, WithStarredInfoQuery};
use crate::utils::WithStarredInfoQuery;
use crate::{
db::DB,
schedule::clear_schedule,
@@ -34,7 +34,7 @@ use sqlx::{FromRow, Postgres, Transaction};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::utils::query_elems_from_hub;
use windmill_common::worker::to_raw_value;
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
use windmill_common::HUB_BASE_URL;
use windmill_common::{
db::UserDB,
@@ -43,7 +43,7 @@ use windmill_common::{
jobs::JobPayload,
schedule::Schedule,
scripts::Schema,
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, StripPath},
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_queue::{push, schedule::push_scheduled_job, PushIsolationLevel};
@@ -358,6 +358,32 @@ async fn create_flow(
Path(w_id): Path<String>,
Json(nf): Json<NewFlow>,
) -> Result<(StatusCode, String)> {
if *CLOUD_HOSTED {
let nb_flows =
sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id)
.fetch_one(&db)
.await?;
if nb_flows.unwrap_or(0) >= 1000 {
return Err(Error::BadRequest(
"You have reached the maximum number of flows (1000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
if nf.summary.len() > 300 {
return Err(Error::BadRequest(
"Summary must be less than 300 characters on cloud".to_string(),
));
}
if nf
.description
.as_ref()
.is_some_and(|desc| desc.len() > 3000)
{
return Err(Error::BadRequest(
"Description must be less than 3000 characters on cloud".to_string(),
));
}
}
#[cfg(not(feature = "enterprise"))]
if nf
.value
@@ -477,7 +503,7 @@ async fn create_flow(
false,
None,
true,
nf.tag,
None,
None,
None,
None,

View File

@@ -441,8 +441,8 @@ pub struct BasicAuthAuthentication {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiKeyAuthentication {
api_key_header: String,
api_key_secret: String,
pub api_key_header: String,
pub api_key_secret: String,
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, Deserialize)]

View File

@@ -44,7 +44,7 @@ use windmill_common::{
error::{self, JsonResult},
s3_helpers::S3Object,
triggers::TriggerKind,
utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath},
utils::{empty_as_none, not_found_if_none, paginate, require_admin, Pagination, StripPath},
worker::CLOUD_HOSTED,
};
use windmill_git_sync::handle_deployment_metadata;
@@ -114,6 +114,8 @@ struct NewTrigger {
static_asset_config: Option<sqlx::types::Json<S3Object>>,
http_method: HttpMethod,
workspaced_route: Option<bool>,
summary: Option<String>,
description: Option<String>,
is_static_website: bool,
wrap_body: Option<bool>,
raw_string: Option<bool>,
@@ -134,6 +136,8 @@ pub struct HttpTrigger {
pub is_async: bool,
pub authentication_method: AuthenticationMethod,
pub http_method: HttpMethod,
pub summary: Option<String>,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub static_asset_config: Option<sqlx::types::Json<S3Object>>,
pub is_static_website: bool,
@@ -153,6 +157,8 @@ struct EditTrigger {
authentication_method: AuthenticationMethod,
#[serde(deserialize_with = "non_empty_str")]
authentication_resource_path: Option<String>,
summary: Option<String>,
description: Option<String>,
http_method: HttpMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
workspaced_route: Option<bool>,
@@ -167,6 +173,7 @@ pub struct ListTriggerQuery {
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
#[serde(default, deserialize_with = "empty_as_none")]
pub path_start: Option<String>,
}
@@ -188,6 +195,8 @@ async fn list_triggers(
"wrap_body",
"raw_string",
"script_path",
"summary",
"description",
"is_flow",
"http_method",
"edited_by",
@@ -242,6 +251,8 @@ async fn get_trigger(
route_path_key,
workspaced_route,
script_path,
summary,
description,
is_flow,
http_method as "http_method: _",
edited_by,
@@ -317,6 +328,8 @@ async fn create_trigger_inner(
wrap_body,
raw_string,
script_path,
summary,
description,
is_flow,
is_async,
authentication_method,
@@ -328,7 +341,7 @@ async fn create_trigger_inner(
is_static_website
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, now(), $17
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), $19
)
"#,
w_id,
@@ -340,6 +353,8 @@ async fn create_trigger_inner(
new_http_trigger.wrap_body.unwrap_or(false),
new_http_trigger.raw_string.unwrap_or(false),
new_http_trigger.script_path,
new_http_trigger.summary,
new_http_trigger.description,
new_http_trigger.is_flow,
new_http_trigger.is_async,
new_http_trigger.authentication_method as _,
@@ -375,7 +390,11 @@ fn check_no_duplicates<'trigger>(
let mut seen = HashSet::with_capacity(new_http_triggers.len());
for (i, trigger) in new_http_triggers.iter().enumerate() {
if !seen.insert((&route_path_key[i], trigger.http_method, trigger.workspaced_route)) {
if !seen.insert((
&route_path_key[i],
trigger.http_method,
trigger.workspaced_route,
)) {
return Err(Error::BadRequest(format!(
"Duplicate HTTP route detected: '{}'. Each HTTP route must have a unique 'route_path'.",
&trigger.route_path
@@ -594,11 +613,13 @@ async fn update_trigger(
email = $13,
is_async = $14,
authentication_method = $15,
summary = $16,
description = $17,
edited_at = now(),
is_static_website = $16
is_static_website = $18
WHERE
workspace_id = $17 AND
path = $18
workspace_id = $19 AND
path = $20
"#,
route_path,
&route_path_key,
@@ -615,6 +636,8 @@ async fn update_trigger(
&authed.email,
ct.is_async,
ct.authentication_method as _,
ct.summary,
ct.description,
ct.is_static_website,
w_id,
path,
@@ -1147,8 +1170,8 @@ async fn route_job(
.map_err(|e| e.into_response())?;
if trigger.script_path.is_empty() && trigger.static_asset_config.is_none() {
return Err(Error::BadRequest(format!(
"Script path of HTTP route at path: {} must not be empty",
return Err(Error::NotFound(format!(
"Runnable path of HTTP route at path: {}",
trigger.path
))
.into_response());
@@ -1198,7 +1221,7 @@ async fn route_job(
let auth_method = try_get_resource_from_db_as::<
crate::http_trigger_auth::AuthenticationMethod,
>(
authed.clone(),
&authed,
Some(user_db.clone()),
&db,
&resource_path,

View File

@@ -3564,7 +3564,7 @@ pub async fn run_flow_by_path_inner(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -3657,7 +3657,7 @@ pub async fn restart_flow(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -3749,7 +3749,7 @@ pub async fn run_script_by_path_inner(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -4417,7 +4417,7 @@ pub async fn run_wait_result_job_by_path_get(
None,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -4557,7 +4557,7 @@ pub async fn run_wait_result_script_by_path_internal(
None,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -4670,7 +4670,7 @@ pub async fn run_wait_result_script_by_hash(
None,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -4784,7 +4784,7 @@ pub async fn run_wait_result_flow_by_path_internal(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -5625,7 +5625,7 @@ pub async fn run_job_by_hash_inner(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,

View File

@@ -19,14 +19,16 @@ use crate::oauth2_oss::SlackVerifier;
use crate::smtp_server_oss::SmtpServer;
#[cfg(feature = "mcp")]
use crate::mcp::{setup_mcp_server, Runner as McpRunner};
use crate::mcp::{extract_and_store_workspace_id, setup_mcp_server, shutdown_mcp_server};
#[cfg(feature = "mcp")]
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use crate::tracing_init::MyOnFailure;
use crate::{
tracing_init::{MyMakeSpan, MyOnResponse},
users::OptAuthed,
webhook_util::WebhookShared,
};
#[cfg(feature = "agent_worker_server")]
use agent_workers_oss::AgentCache;
@@ -101,6 +103,8 @@ mod integration;
#[cfg(feature = "postgres_trigger")]
mod postgres_triggers;
pub mod openapi;
mod approvals;
#[cfg(all(feature = "enterprise", feature = "private"))]
pub mod apps_ee;
@@ -520,21 +524,18 @@ pub async fn run_server(
// Setup MCP server
#[allow(unused_variables)]
let (mcp_router, mcp_main_ct, mcp_service_ct) = {
let (mcp_router, mcp_session_manager) = {
#[cfg(feature = "mcp")]
if server_mode || mcp_mode {
let (mcp_sse_server, mcp_router) = setup_mcp_server(addr, "/api/mcp/w/:workspace_id")?;
#[cfg(feature = "mcp")]
let mcp_main_ct = mcp_sse_server.config.ct.clone(); // Token to signal shutdown *to* MCP
#[cfg(feature = "mcp")]
let mcp_service_ct = mcp_sse_server.with_service(McpRunner::new); // Token to wait for MCP *service* shutdown
(mcp_router, Some(mcp_main_ct), Some(mcp_service_ct))
let (mcp_router, mcp_session_manager) = setup_mcp_server().await?;
let mcp_middleware = axum::middleware::from_fn(extract_and_store_workspace_id);
(mcp_router.layer(mcp_middleware), Some(mcp_session_manager))
} else {
(Router::new(), None, None)
(Router::new(), Option::<Arc<LocalSessionManager>>::None)
}
#[cfg(not(feature = "mcp"))]
(Router::new(), None::<()>, None::<()>)
(Router::new(), Option::<()>::None)
};
#[cfg(feature = "agent_worker_server")]
@@ -596,6 +597,7 @@ pub async fn run_server(
.nest("/variables", variables::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
.nest("/oidc", oidc_oss::workspaced_service())
.nest("/openapi", openapi::openapi_service())
.nest("/http_triggers", http_triggers_service)
.nest("/websocket_triggers", websocket_triggers_service)
.nest("/kafka_triggers", kafka_triggers_service)
@@ -660,7 +662,7 @@ pub async fn run_server(
.layer(from_extractor::<OptAuthed>())
.layer(cors.clone()),
)
.nest("/mcp/w/:workspace_id", mcp_router)
.nest("/mcp/w/:workspace_id/sse", mcp_router)
.layer(from_extractor::<OptAuthed>())
.nest("/agent_workers", {
#[cfg(feature = "agent_worker_server")]
@@ -819,16 +821,9 @@ pub async fn run_server(
tracing::info!("Graceful shutdown of server");
#[cfg(feature = "mcp")]
{
if let Some(mcp_main_ct) = mcp_main_ct {
tracing::info!("Received shutdown signal, cancelling MCP server...");
mcp_main_ct.cancel();
}
if let Some(mcp_service_ct) = mcp_service_ct {
tracing::info!("Waiting for MCP service cancellation...");
mcp_service_ct.cancelled().await;
tracing::info!("MCP service cancelled.");
}
if let Some(mcp_session_manager) = mcp_session_manager {
shutdown_mcp_server(mcp_session_manager).await;
tracing::info!("MCP server shutdown");
}
});

View File

@@ -1,11 +1,10 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::body::to_bytes;
use axum::Router;
use rmcp::transport::sse_server::{SseServer, SseServerConfig};
use axum::{extract::Path, http::Request, middleware::Next, response::Response};
use rmcp::{
handler::server::ServerHandler,
model::*,
@@ -17,7 +16,6 @@ use serde_json::Value;
use sql_builder::prelude::*;
use sqlx::FromRow;
use tokio::try_join;
use tokio_util::sync::CancellationToken;
use windmill_common::db::UserDB;
use windmill_common::worker::to_raw_value;
use windmill_common::{DB, HUB_BASE_URL};
@@ -29,6 +27,9 @@ use crate::jobs::{
run_wait_result_flow_by_path_internal, run_wait_result_script_by_path_internal, RunJobQuery,
};
use crate::HTTP_CLIENT;
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, SessionManager, StreamableHttpService,
};
use windmill_common::utils::{query_elems_from_hub, StripPath};
/// Transforms the path for workspace scripts/flows.
@@ -386,7 +387,8 @@ impl Runner {
item_type: &str,
) -> Result<Vec<T>, Error> {
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
sqlb.fields(&["o.path", "o.summary", "o.description", "o.schema"]);
let fields = vec!["o.path", "o.summary", "o.description", "o.schema"];
sqlb.fields(&fields);
if scope_type == "favorites" {
sqlb.join("favorite")
.on("favorite.favorite_kind = ? AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?".bind(&item_type)
@@ -394,16 +396,21 @@ impl Runner {
}
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id))
.and_where("o.archived = false")
.and_where("o.draft_only IS NOT TRUE")
.order_by(
if item_type == "flow" {
"o.edited_at"
} else {
"o.created_at"
},
false,
)
.limit(100);
.and_where("o.draft_only IS NOT TRUE");
if item_type == "script" {
sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)");
}
sqlb.order_by(
if item_type == "flow" {
"o.edited_at"
} else {
"o.created_at"
},
false,
)
.limit(100);
let sql = sqlb.sql().map_err(|_e| {
tracing::error!("failed to build sql: {}", _e);
Error::internal_error("failed to build sql", None)
@@ -856,28 +863,44 @@ impl ServerHandler for Runner {
})
};
let authed = context
.req_extensions
.get::<ApiAuthed>()
.ok_or_else(|| Error::internal_error("ApiAuthed not found", None))?;
let db = context
.req_extensions
.get::<DB>()
.ok_or_else(|| Error::internal_error("DB not found", None))?;
let user_db = context
.req_extensions
.get::<UserDB>()
.ok_or_else(|| Error::internal_error("UserDB not found", None))?;
let http_parts = context
.extensions
.get::<axum::http::request::Parts>()
.ok_or_else(|| {
tracing::error!("http::request::Parts not found");
Error::internal_error("http::request::Parts not found", None)
})?;
let authed = http_parts.extensions.get::<ApiAuthed>().ok_or_else(|| {
tracing::error!("ApiAuthed Axum extension not found");
Error::internal_error("ApiAuthed Axum extension not found", None)
})?;
let db = http_parts.extensions.get::<DB>().ok_or_else(|| {
tracing::error!("DB Axum extension not found");
Error::internal_error("DB Axum extension not found", None)
})?;
let user_db = http_parts.extensions.get::<UserDB>().ok_or_else(|| {
tracing::error!("UserDB Axum extension not found");
Error::internal_error("UserDB Axum extension not found", None)
})?;
let args = parse_args(request.arguments)?;
let workspace_id = http_parts
.extensions
.get::<WorkspaceId>()
.ok_or_else(|| {
tracing::error!("WorkspaceId not found");
Error::internal_error("WorkspaceId not found", None)
})
.map(|w_id| w_id.0.clone())?;
let (tool_type, path, is_hub) =
Runner::reverse_transform(&request.name).unwrap_or_default();
let item_schema = if is_hub {
Runner::get_hub_script_schema(&format!("hub/{}", path), db).await?
} else {
Runner::get_item_schema(&path, user_db, authed, &context.workspace_id, &tool_type)
.await?
Runner::get_item_schema(&path, user_db, authed, &workspace_id, &tool_type).await?
};
let schema_obj = if let Some(ref s) = item_schema {
@@ -906,8 +929,6 @@ impl ServerHandler for Runner {
} else {
windmill_queue::PushArgsOwned::default()
};
let w_id = context.workspace_id.clone();
let script_or_flow_path = if is_hub {
StripPath(format!("hub/{}", path))
} else {
@@ -922,7 +943,7 @@ impl ServerHandler for Runner {
script_or_flow_path,
authed.clone(),
user_db.clone(),
w_id.clone(),
workspace_id.clone(),
push_args,
)
.await
@@ -934,7 +955,7 @@ impl ServerHandler for Runner {
authed.clone(),
user_db.clone(),
push_args,
w_id.clone(),
workspace_id.clone(),
)
.await
};
@@ -978,19 +999,38 @@ impl ServerHandler for Runner {
_request: Option<PaginatedRequestParam>,
mut _context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, Error> {
let workspace_id = _context.workspace_id.clone();
let db = _context
.req_extensions
.get::<DB>()
.ok_or_else(|| Error::internal_error("DB not found", None))?;
let user_db = _context
.req_extensions
.get::<UserDB>()
.ok_or_else(|| Error::internal_error("UserDB not found", None))?;
let authed = _context
.req_extensions
.get::<ApiAuthed>()
.ok_or_else(|| Error::internal_error("ApiAuthed not found", None))?;
let http_parts = _context
.extensions
.get::<axum::http::request::Parts>()
.ok_or_else(|| {
tracing::error!("http::request::Parts not found");
Error::internal_error("http::request::Parts not found", None)
})?;
let db = http_parts.extensions.get::<DB>().ok_or_else(|| {
tracing::error!("DB Axum extension not found");
Error::internal_error("DB Axum extension not found", None)
})?;
let user_db = http_parts.extensions.get::<UserDB>().ok_or_else(|| {
tracing::error!("UserDB Axum extension not found");
Error::internal_error("UserDB Axum extension not found", None)
})?;
let authed = http_parts.extensions.get::<ApiAuthed>().ok_or_else(|| {
tracing::error!("ApiAuthed Axum extension not found");
Error::internal_error("ApiAuthed Axum extension not found", None)
})?;
let workspace_id = http_parts
.extensions
.get::<WorkspaceId>()
.ok_or_else(|| {
tracing::error!("WorkspaceId not found");
Error::internal_error("WorkspaceId not found", None)
})
.map(|w_id| w_id.0.clone())?;
let owned_scope = authed.scopes.as_ref().and_then(|scopes| {
scopes
.iter()
@@ -1127,15 +1167,54 @@ impl ServerHandler for Runner {
}
}
pub fn setup_mcp_server(addr: SocketAddr, path: &str) -> anyhow::Result<(SseServer, Router)> {
let config = SseServerConfig {
bind: addr,
sse_path: "/sse".to_string(),
post_path: "/message".to_string(),
full_message_path: path.to_string(),
ct: CancellationToken::new(),
sse_keep_alive: None,
#[derive(Clone, Debug)]
pub struct WorkspaceId(pub String);
pub async fn extract_and_store_workspace_id(
Path(params): Path<String>,
mut request: Request<axum::body::Body>,
next: Next,
) -> Response {
let workspace_id = params;
request.extensions_mut().insert(WorkspaceId(workspace_id));
next.run(request).await
}
pub async fn setup_mcp_server() -> anyhow::Result<(Router, Arc<LocalSessionManager>)> {
let session_manager = Arc::new(LocalSessionManager::default());
let service_config = Default::default();
let service = StreamableHttpService::new(
|| Ok(Runner::new()),
session_manager.clone(),
service_config,
);
let router = axum::Router::new().nest_service("/", service);
Ok((router, session_manager))
}
pub async fn shutdown_mcp_server(session_manager: Arc<LocalSessionManager>) {
let session_ids_to_close = {
let sessions_map = session_manager.sessions.read().await;
sessions_map.keys().cloned().collect::<Vec<_>>()
};
Ok(SseServer::new(config))
if !session_ids_to_close.is_empty() {
tracing::info!(
"Closing {} active MCP session(s)...",
session_ids_to_close.len()
);
let close_futures = session_ids_to_close
.iter()
.map(|session_id| {
let manager_clone = session_manager.clone();
async move {
if let Err(_) = manager_clone.close_session(session_id).await {
tracing::warn!("Error closing MCP session");
}
}
})
.collect::<Vec<_>>();
futures::future::join_all(close_futures).await;
}
}

View File

@@ -478,7 +478,7 @@ pub async fn test_mqtt_connection(
test_postgres;
let mqtt_resource = try_get_resource_from_db_as::<MqttResource>(
authed,
&authed,
Some(user_db),
&db,
&mqtt_resource_path,
@@ -1253,7 +1253,7 @@ impl MqttConfig {
}
}
let mqtt_resource = try_get_resource_from_db_as::<MqttResource>(
authed,
&authed,
Some(UserDB::new(db.clone())),
db,
mqtt_resource_path,

View File

@@ -0,0 +1,982 @@
use std::{
collections::{HashMap, HashSet},
fmt::Display,
};
use anyhow::anyhow;
use axum::{
body::Body, extract::Path, http, response::Response, routing::post, Extension, Json, Router,
};
use http::{header, HeaderValue, Method, StatusCode};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::{to_value, Map, Value};
use sqlx::PgConnection;
use url::Url;
use windmill_common::{
db::UserDB,
error::{Error, Result},
utils::{deserialize_url, empty_as_none, is_empty, RunnableKind},
DB,
};
use crate::db::ApiAuthed;
#[cfg(feature = "http_trigger")]
use {
crate::{
http_trigger_args::HttpMethod, http_trigger_auth::ApiKeyAuthentication,
http_triggers::AuthenticationMethod, resources::try_get_resource_from_db_as,
},
itertools::Itertools,
};
lazy_static::lazy_static! {
static ref DEFAULT_OPENAPI_INFO_OBJECT: Info = Info {
title: "Windmill API".to_string(),
version: "1.0.0".to_string(),
..Default::default()
};
}
const DEFAULT_OPENAPI_GENERATED_VERSION: &'static str = "3.1.0";
const JWT_SECURITY_SCHEME: &'static str = "JwtAuth";
const BASIC_HTTP_AUTH_SCHEME: &'static str = "BasicHttp";
const DEFAULT_REQUEST_KEY: &'static str = "defaultRequest";
const DEFAULT_ASYNC_RESPONSE_KEY: &'static str = "AsyncResponse";
const DEFAULT_SYNC_RESPONSE_KEY: &'static str = "SyncResponse";
const DEFAULT_PAYLOAD_PARAM_KEY: &'static str = "PayloadParam";
pub fn openapi_service() -> Router {
Router::new()
.route("/generate", post(generate_openapi_spec))
.route("/download", post(download_spec))
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum Format {
JSON,
YAML,
}
impl Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let format = match self {
Format::JSON => "json",
Format::YAML => "yaml",
};
write!(f, "{}", format)
}
}
impl Default for Format {
fn default() -> Self {
Self::YAML
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct Contact {
#[serde(skip_serializing_if = "is_empty")]
name: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_url",
skip_serializing_if = "Option::is_none"
)]
url: Option<Url>,
#[serde(skip_serializing_if = "is_empty")]
email: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct License {
name: String,
#[serde(skip_serializing_if = "is_empty")]
identifier: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_url",
skip_serializing_if = "Option::is_none"
)]
url: Option<Url>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Info {
title: String,
version: String,
#[serde(skip_serializing_if = "is_empty")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
contact: Option<Contact>,
#[serde(skip_serializing_if = "Option::is_none")]
license: Option<License>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Server {
url: String,
#[serde(skip_serializing_if = "is_empty")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
variables: Option<HashMap<String, Value>>,
}
#[derive(Debug)]
pub enum SecurityScheme {
BearerJwt,
BasicHttp,
ApiKey(String),
}
#[derive(Debug)]
pub struct WebhookConfig {
runnable_kind: RunnableKind,
}
impl WebhookConfig {
pub fn new(runnable_kind: RunnableKind) -> Self {
Self { runnable_kind }
}
}
#[derive(Debug)]
pub struct HttpRouteConfig {
method: Method,
}
impl HttpRouteConfig {
pub fn new(method: Method) -> Self {
Self { method }
}
}
#[derive(Debug)]
pub enum Kind {
Webhook(WebhookConfig),
HttpRoute(HttpRouteConfig),
}
#[derive(Debug)]
pub struct FuturePath {
route_path: String,
kind: Kind,
is_async: Option<bool>,
summary: Option<String>,
description: Option<String>,
security_scheme: Option<SecurityScheme>,
}
impl FuturePath {
pub fn new(
route_path: String,
kind: Kind,
is_async: Option<bool>,
summary: Option<String>,
description: Option<String>,
security_scheme: Option<SecurityScheme>,
) -> FuturePath {
FuturePath { route_path, kind, is_async, summary, description, security_scheme }
}
}
fn from_route_path_to_openapi_path(
route_path: &str,
kind: &Kind,
) -> Result<(Vec<String>, Option<Value>)> {
let mut openapi_path = String::new();
let mut parameters = Vec::new();
for segment in route_path.split('/') {
if segment.starts_with(':') {
let param_name = &segment[1..];
if param_name.is_empty() {
return Err(anyhow!("Empty parameter name in path: {}", route_path).into());
}
openapi_path.push_str(&format!("/{{{}}}", param_name));
parameters.push(serde_json::json!({
"name": param_name,
"in": "path",
"required": true,
"schema": { "type": "string" }
}));
} else if !segment.is_empty() {
openapi_path.push('/');
openapi_path.push_str(segment);
} else {
openapi_path.push('/');
}
}
let parameters_json = if parameters.is_empty() {
None
} else {
Some(Value::Array(parameters))
};
let prefix = match kind {
Kind::HttpRoute(_) => "",
Kind::Webhook(WebhookConfig { runnable_kind }) => match runnable_kind {
RunnableKind::Script => "p",
RunnableKind::Flow => "f",
},
};
let normalized_path = if openapi_path.starts_with('/') {
format!("{prefix}{openapi_path}")
} else {
format!("{}/{}", prefix, openapi_path)
};
let route_paths = if prefix.is_empty() {
vec![normalized_path]
} else {
vec![
format!("/run/{}", &normalized_path),
format!("/run_wait_result/{}", &normalized_path),
]
};
Ok((route_paths, parameters_json))
}
fn get_servers_component(url: &str, kind: &Kind) -> Server {
let url = url.trim_end_matches('/');
let server = match kind {
Kind::HttpRoute(_) => {
Server { url: format!("{}/api/r", url), description: None, variables: None }
}
Kind::Webhook(_) => Server {
url: format!("{}/api/w/{{workspace}}/jobs", url),
variables: Some(HashMap::from([(
"workspace".to_string(),
serde_json::json!({
"default": "test",
"description": "Workspace identifier"
}),
)])),
description: None,
},
};
server
}
fn generate_paths(
paths: Vec<FuturePath>,
url: Option<&Url>,
) -> Result<IndexMap<String, IndexMap<String, Value>>> {
let mut map: IndexMap<String, IndexMap<String, Value>> = IndexMap::new();
let generate_default_request = || {
serde_json::json!({
"$ref": format!("#/components/requestBodies/{DEFAULT_REQUEST_KEY}")
})
};
let generate_response = |is_async: bool| {
let responses = if is_async {
serde_json::json!({
"200": {
"$ref": format!("#/components/responses/{DEFAULT_ASYNC_RESPONSE_KEY}")
}
})
} else {
serde_json::json!(serde_json::json!({
"200": {
"$ref": format!("#/components/responses/{DEFAULT_SYNC_RESPONSE_KEY}")
}
}))
};
responses
};
let get_security_scheme = |security_scheme: Option<&SecurityScheme>| -> Vec<Value> {
if let Some(security_scheme) = security_scheme {
let scheme = match security_scheme {
SecurityScheme::ApiKey(api_key) => header_to_pascal_case(&api_key),
SecurityScheme::BearerJwt => JWT_SECURITY_SCHEME.to_owned(),
SecurityScheme::BasicHttp => BASIC_HTTP_AUTH_SCHEME.to_owned(),
};
vec![serde_json::json!({
scheme: []
})]
} else {
vec![]
}
};
let mut webhooks = HashSet::new();
for path in paths {
if let Kind::Webhook(WebhookConfig { runnable_kind }) = &path.kind {
if !webhooks.insert((path.route_path.clone(), runnable_kind.to_owned())) {
continue;
}
}
let (route_paths, parameters) =
from_route_path_to_openapi_path(&path.route_path, &path.kind)?;
for route_path in route_paths {
let path_object = map.entry(route_path.clone()).or_insert_with(|| {
let mut path_object = IndexMap::new();
if let Some(url) = url {
let servers = get_servers_component(url.as_str(), &path.kind);
path_object.insert("servers".to_string(), to_value(vec![servers]).unwrap());
}
if parameters.is_some() {
path_object.insert(
"parameters".to_string(),
to_value(parameters.clone()).unwrap(),
);
}
path_object
});
let is_async;
let (methods, is_webhook) = match &path.kind {
Kind::Webhook(_) => {
is_async = route_path.starts_with("/run/");
let methods = if is_async {
vec![Method::POST]
} else {
vec![Method::GET, Method::POST]
};
(methods, true)
}
Kind::HttpRoute(HttpRouteConfig { method }) => {
if path_object.get(&method.to_string()).is_some() {
return Err(anyhow!(
"Found duplicate {} method, for route at path: {}",
method,
path.route_path
)
.into());
}
is_async = path.is_async.unwrap_or(true);
(vec![method.to_owned()], false)
}
};
for method in methods {
let mut method_map = IndexMap::new();
if let Some(summary) = path.summary.as_ref().filter(|s| !s.is_empty()) {
method_map.insert("summary", Value::String(summary.to_owned()));
}
if let Some(description) = path.description.as_ref().filter(|s| !s.is_empty()) {
method_map.insert("description", Value::String(description.to_owned()));
}
method_map.insert(
"security",
to_value(get_security_scheme(path.security_scheme.as_ref()))?,
);
if method != Method::GET {
method_map.insert("requestBody", generate_default_request());
} else if is_webhook {
method_map.insert(
"parameters",
Value::Array(vec![serde_json::json!({
"$ref": format!("#/components/parameters/{DEFAULT_PAYLOAD_PARAM_KEY}")
})]),
);
}
method_map.insert("responses", generate_response(is_async));
path_object.insert(method.to_string().to_lowercase(), to_value(&method_map)?);
}
}
}
return Ok(map);
}
pub fn transform_to_minified_postgres_regex(glob: &str) -> String {
let mut regex = String::from("^");
for ch in glob.chars() {
match ch {
'*' => regex.push_str(".*"),
'.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '[' | ']' | '\\' => {
regex.push('\\');
regex.push(ch);
}
_ => regex.push(ch),
}
}
regex.push('$');
regex
}
#[derive(Debug, Default)]
pub struct ServerToSet {
pub http_route: bool,
pub webhook_flow: bool,
pub webhook_script: bool,
}
impl ServerToSet {
pub fn new(http_route: bool, webhook_flow: bool, webhook_script: bool) -> ServerToSet {
ServerToSet { http_route, webhook_flow, webhook_script }
}
}
fn header_to_pascal_case(header: &str) -> String {
header
.split(|c: char| c == '-' || c == '_' || c == ' ')
.filter(|s| !s.is_empty())
.map(|s| {
let mut chars = s.chars();
match chars.next() {
Some(first) => {
first.to_ascii_uppercase().to_string()
+ chars.as_str().to_ascii_lowercase().as_str()
}
None => String::new(),
}
})
.collect::<String>()
}
#[derive(Debug, Default)]
struct SecuritySchemeToAdd {
basic_http: bool,
bearer_jwt: bool,
api_keys: Vec<(String, Value)>,
}
fn generate_all_security_schemes(future_paths: &[FuturePath]) -> SecuritySchemeToAdd {
let mut to_add = SecuritySchemeToAdd::default();
let mut set = HashSet::new();
for future_path in future_paths {
if !to_add.basic_http
&& matches!(future_path.security_scheme, Some(SecurityScheme::BasicHttp))
{
to_add.basic_http = true
} else if !to_add.bearer_jwt
&& matches!(future_path.security_scheme, Some(SecurityScheme::BearerJwt))
{
to_add.bearer_jwt = true
} else if let Some(SecurityScheme::ApiKey(api_key)) = future_path.security_scheme.as_ref() {
let pascal_case_header = header_to_pascal_case(&api_key);
if !set.insert(pascal_case_header.clone()) {
continue;
}
let scheme = serde_json::json!({
"type": "apiKey",
"in": "header",
"name": api_key
});
to_add.api_keys.push((pascal_case_header, scheme));
}
}
to_add
}
fn generate_components(future_paths: &[FuturePath]) -> Map<String, Value> {
let mut components = Map::new();
if future_paths
.iter()
.any(|path| matches!(path.kind, Kind::Webhook(_)))
{
components.insert(
"parameters".to_owned(),
serde_json::json!({
"PayloadParam": {
"name": "payload",
"in": "query",
"required": true,
"description": "A URL-safe base64-encoded JSON string payload.",
"schema": {
"type": "string"
}
}
}),
);
}
{
let mut security_scheme = Map::new();
let SecuritySchemeToAdd { basic_http, bearer_jwt, api_keys } =
generate_all_security_schemes(future_paths);
if basic_http {
security_scheme.insert(
BASIC_HTTP_AUTH_SCHEME.to_owned(),
serde_json::json!({
"type": "http",
"scheme": "basic"
}),
);
}
if bearer_jwt {
security_scheme.insert(
JWT_SECURITY_SCHEME.to_owned(),
serde_json::json!({
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}),
);
}
for (key, value) in api_keys {
security_scheme.insert(key, value);
}
components.insert("securitySchemes".to_owned(), Value::Object(security_scheme));
}
components.insert("requestBodies".to_owned(), serde_json::json!({
DEFAULT_REQUEST_KEY: {
"description": "This route may or may not require a request body, but its structure and content type are unknown.",
"required": false,
"content": {
"application/json": {}
}
}
}));
components.insert("responses".to_owned(), serde_json::json!({
DEFAULT_ASYNC_RESPONSE_KEY: {
"description": "Returns a job ID as a UUID string.",
"content": {
"text/plain": {
"schema": {
"type": "string",
"format": "uuid",
"examples": [ "550e8400-e29b-41d4-a716-446655440000" ]
}
}
}
},
DEFAULT_SYNC_RESPONSE_KEY: {
"description": "This route may return a response, but its structure and content type are unknown.",
"content": {
"application/octet-stream": {}
}
},
}));
components
}
pub fn generate_openapi_document(
info: Option<&Info>,
url: Option<&Url>,
paths: Vec<FuturePath>,
format: Format,
) -> Result<String> {
let mut openapi_doc: IndexMap<&'static str, Value> = IndexMap::new();
openapi_doc.insert("openapi", to_value(&DEFAULT_OPENAPI_GENERATED_VERSION)?);
openapi_doc.insert(
"info",
to_value(info.unwrap_or(&DEFAULT_OPENAPI_INFO_OBJECT))?,
);
openapi_doc.insert("components", Value::Object(generate_components(&paths)));
openapi_doc.insert("paths", to_value(generate_paths(paths, url)?)?);
let openapi_document = match format {
Format::YAML => serde_yml::to_string(&openapi_doc).map_err(|err| {
anyhow!(
"Could not generate OpenAPI document in YAML format: {}",
err
)
})?,
Format::JSON => serde_json::to_string_pretty(&openapi_doc).map_err(|err| {
anyhow!(
"Could not generate OpenAPI document in JSON format: {}",
err
)
})?,
};
Ok(openapi_document)
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
struct HttpRouteFilter {
folder_regex: String,
path_regex: String,
route_path_regex: String,
}
#[derive(Debug, Deserialize)]
struct WebhookFilter {
user_or_folder_regex: String,
user_or_folder_regex_value: String,
path: String,
runnable_kind: RunnableKind,
}
#[derive(Debug, Deserialize)]
struct GenerateOpenAPI {
info: Option<Info>,
url: Option<Url>,
#[serde(default, deserialize_with = "empty_as_none")]
http_route_filters: Option<Vec<HttpRouteFilter>>,
#[serde(default, deserialize_with = "empty_as_none")]
webhook_filters: Option<Vec<WebhookFilter>>,
#[serde(default)]
openapi_spec_format: Format,
}
#[cfg(feature = "http_trigger")]
async fn http_routes_to_future_paths(
db: &DB,
user_db: UserDB,
authed: &ApiAuthed,
pg_pool: &mut PgConnection,
http_route_filters: Option<&[HttpRouteFilter]>,
w_id: &str,
) -> Result<Vec<FuturePath>> {
let mut http_routes = Vec::new();
if let Some(http_route_filters) = http_route_filters {
let path_regex = http_route_filters
.iter()
.map(|filter| {
transform_to_minified_postgres_regex(&format!(
"f/{}/{}",
filter.folder_regex, filter.path_regex
))
})
.collect_vec();
let route_path_regex = http_route_filters
.iter()
.map(|filter| transform_to_minified_postgres_regex(&filter.route_path_regex))
.collect_vec();
#[derive(Debug, Deserialize)]
struct MinifiedHttpTrigger {
route_path: String,
http_method: HttpMethod,
is_async: bool,
workspaced_route: bool,
summary: Option<String>,
description: Option<String>,
authentication_method: AuthenticationMethod,
authentication_resource_path: Option<String>,
}
http_routes = sqlx::query_as!(
MinifiedHttpTrigger,
r#"
SELECT
route_path,
http_method AS "http_method: _",
is_async,
workspaced_route,
summary,
description,
authentication_method AS "authentication_method: _",
authentication_resource_path
FROM
http_trigger
WHERE
path ~ ANY($1) AND
route_path ~ ANY($2) AND
workspace_id = $3
"#,
&path_regex,
&route_path_regex,
&w_id
)
.fetch_all(pg_pool)
.await?;
}
let mut openapi_future_paths = Vec::with_capacity(http_routes.len());
for http_route in http_routes {
let auth_method = match http_route.authentication_method {
AuthenticationMethod::BasicHttp => Some(SecurityScheme::BasicHttp),
AuthenticationMethod::Windmill => Some(SecurityScheme::BearerJwt),
AuthenticationMethod::ApiKey => {
let resource_path = match http_route.authentication_resource_path {
Some(resource_path) => resource_path,
None => {
return Err(Error::BadRequest(
"Missing authentication resource path".to_string(),
));
}
};
let api = try_get_resource_from_db_as::<ApiKeyAuthentication>(
authed,
Some(user_db.clone()),
db,
&resource_path,
w_id,
)
.await?;
Some(SecurityScheme::ApiKey(api.api_key_header))
}
_ => None,
};
let route_path = if http_route.workspaced_route {
format!("{}/{}", w_id, http_route.route_path.trim_start_matches('/'))
} else {
http_route.route_path.clone()
};
let method = match http_route.http_method {
HttpMethod::Get => Method::GET,
HttpMethod::Post => Method::POST,
HttpMethod::Put => Method::PUT,
HttpMethod::Patch => Method::PATCH,
HttpMethod::Delete => Method::DELETE,
};
let future_path = FuturePath::new(
route_path,
Kind::HttpRoute(HttpRouteConfig::new(method)),
Some(http_route.is_async),
http_route.summary,
http_route.description,
auth_method,
);
openapi_future_paths.push(future_path);
}
Ok(openapi_future_paths)
}
#[cfg(not(feature = "http_trigger"))]
async fn http_routes_to_future_paths(
_db: &DB,
_user_db: UserDB,
_authed: &ApiAuthed,
_pg_pool: &mut PgConnection,
_http_route_filters: Option<&[HttpRouteFilter]>,
_w_id: &str,
) -> Result<Vec<FuturePath>> {
Ok(Vec::new())
}
async fn webhook_to_future_paths(
pg_pool: &mut PgConnection,
webhook_filters: Option<&[WebhookFilter]>,
w_id: &str,
) -> Result<Vec<FuturePath>> {
let mut openapi_future_paths = Vec::new();
if let Some(webhook_filters) = webhook_filters {
let mut script_webhook_filter = Vec::new();
let mut flow_webhook_filter = Vec::new();
for webhook in webhook_filters {
let full_regex = transform_to_minified_postgres_regex(&format!(
"{}/{}/{}",
&webhook.user_or_folder_regex, &webhook.user_or_folder_regex_value, &webhook.path
));
match webhook.runnable_kind {
RunnableKind::Script => {
script_webhook_filter.push(full_regex);
}
RunnableKind::Flow => {
flow_webhook_filter.push(full_regex);
}
}
}
#[derive(Debug, Deserialize, Clone, Hash)]
struct MinifiedWebhook {
path: String,
description: Option<String>,
summary: Option<String>,
}
let webhook_scripts = sqlx::query_as!(
MinifiedWebhook,
r#"SELECT
path,
summary,
description
FROM
script
WHERE
path ~ ANY($1) AND
workspace_id = $2 AND
archived is FALSE
"#,
&script_webhook_filter,
&w_id
)
.fetch_all(&mut *pg_pool)
.await?;
let webhook_flows = sqlx::query_as!(
MinifiedWebhook,
r#"SELECT
path,
summary,
description
FROM
flow
WHERE
path ~ ANY($1) AND
workspace_id = $2 AND
archived is FALSE
"#,
&flow_webhook_filter,
&w_id
)
.fetch_all(&mut *pg_pool)
.await?;
openapi_future_paths.reserve_exact(webhook_scripts.len() + webhook_flows.len());
for webhook in webhook_scripts {
openapi_future_paths.push(FuturePath::new(
webhook.path,
Kind::Webhook(WebhookConfig::new(RunnableKind::Script)),
None,
webhook.summary,
webhook.description,
Some(SecurityScheme::BearerJwt),
));
}
for webhook in webhook_flows {
openapi_future_paths.push(FuturePath::new(
webhook.path,
Kind::Webhook(WebhookConfig::new(RunnableKind::Flow)),
None,
webhook.summary,
webhook.description,
Some(SecurityScheme::BearerJwt),
));
}
}
Ok(openapi_future_paths)
}
async fn generate_openapi_future_path(
db: &DB,
user_db: UserDB,
authed: &ApiAuthed,
http_route_filters: Option<&[HttpRouteFilter]>,
webhook_filters: Option<&[WebhookFilter]>,
w_id: &str,
) -> Result<Vec<FuturePath>> {
if http_route_filters.is_none() && webhook_filters.is_none() {
return Err(Error::BadRequest(
"Expected http route filter and/or webhook filters".to_string(),
));
}
let mut tx = user_db.clone().begin(authed).await?;
let mut openapi_future_paths =
http_routes_to_future_paths(db, user_db, authed, &mut tx, http_route_filters, w_id).await?;
openapi_future_paths
.append(&mut webhook_to_future_paths(&mut tx, webhook_filters, w_id).await?);
tx.commit().await?;
if openapi_future_paths.is_empty() {
return Err(Error::NotFound(
"No match for the current filter".to_string(),
));
}
Ok(openapi_future_paths)
}
async fn generate_openapi_spec(
Extension(authed): Extension<ApiAuthed>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(generate_openapi): Json<GenerateOpenAPI>,
) -> Result<String> {
let openapi_future_paths = generate_openapi_future_path(
&db,
user_db,
&authed,
generate_openapi.http_route_filters.as_deref(),
generate_openapi.webhook_filters.as_deref(),
&w_id,
)
.await?;
let openapi_document = generate_openapi_document(
generate_openapi.info.as_ref(),
generate_openapi.url.as_ref(),
openapi_future_paths,
generate_openapi.openapi_spec_format,
);
openapi_document
}
async fn download_spec(
Extension(authed): Extension<ApiAuthed>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(generate_openapi): Json<GenerateOpenAPI>,
) -> Result<Response> {
let openapi_future_paths = generate_openapi_future_path(
&db,
user_db,
&authed,
generate_openapi.http_route_filters.as_deref(),
generate_openapi.webhook_filters.as_deref(),
&w_id,
)
.await?;
let openapi_document = generate_openapi_document(
generate_openapi.info.as_ref(),
generate_openapi.url.as_ref(),
openapi_future_paths,
generate_openapi.openapi_spec_format,
)?;
let response = Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
)
.body(Body::from(openapi_document))
.unwrap();
Ok(response)
}

View File

@@ -160,7 +160,7 @@ pub async fn get_pg_connection(
logical_mode: bool,
) -> Result<Client> {
let database =
try_get_resource_from_db_as::<Postgres>(authed, user_db, db, postgres_resource_path, w_id)
try_get_resource_from_db_as::<Postgres>(&authed, user_db, db, postgres_resource_path, w_id)
.await?;
Ok(get_raw_postgres_connection(&database, logical_mode).await?)

View File

@@ -417,7 +417,7 @@ impl PostgresConfig {
};
let database = try_get_resource_from_db_as::<Postgres>(
authed,
&authed,
Some(UserDB::new(db.clone())),
&db,
postgres_resource_path,

View File

@@ -29,6 +29,7 @@ use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::{not_found_if_none, paginate, Pagination, StripPath},
worker::CLOUD_HOSTED,
};
pub fn workspaced_service() -> Router {
@@ -149,9 +150,29 @@ async fn create_app(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(app): Json<CreateApp>,
) -> Result<(StatusCode, String)> {
if *CLOUD_HOSTED {
let nb_apps = sqlx::query_scalar!(
"SELECT COUNT(*) FROM raw_app WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if nb_apps.unwrap_or(0) >= 1000 {
return Err(Error::BadRequest(
"You have reached the maximum number of apps (1000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
if app.summary.len() > 300 {
return Err(Error::BadRequest(
"Summary must be less than 300 characters on cloud".to_string(),
));
}
}
let mut tx = user_db.begin(&authed).await?;
if &app.path == "" {
return Err(Error::BadRequest("App path cannot be empty".to_string()));

View File

@@ -33,6 +33,7 @@ use windmill_common::{
error::{Error, JsonResult, Result},
utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath},
variables,
worker::CLOUD_HOSTED,
};
pub fn workspaced_service() -> Router {
@@ -583,7 +584,6 @@ pub async fn transform_json_value<'c>(
job.schedule_path.clone(),
job.flow_step_id.clone(),
job.root_job.map(|x| x.to_string()),
None,
Some(job.scheduled_for.clone()),
)
.await;
@@ -657,6 +657,20 @@ async fn create_resource(
Query(q): Query<CreateResourceQuery>,
Json(resource): Json<CreateResource>,
) -> Result<(StatusCode, String)> {
if *CLOUD_HOSTED {
let nb_resources = sqlx::query_scalar!(
"SELECT COUNT(*) FROM resource WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if nb_resources.unwrap_or(0) >= 10000 {
return Err(Error::BadRequest(
"You have reached the maximum number of resources (10000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
}
let authed = maybe_refresh_folders(&resource.path, &w_id, authed, &db).await;
let mut tx = user_db.begin(&authed).await?;
@@ -1217,7 +1231,7 @@ async fn update_resource_type(
)
))]
pub async fn try_get_resource_from_db_as<T>(
authed: ApiAuthed,
authed: &ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
resource_path: &str,

View File

@@ -42,7 +42,7 @@ use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_worker::process_relative_imports;
use windmill_common::error::to_anyhow;
use windmill_common::{error::to_anyhow, worker::CLOUD_HOSTED};
use windmill_common::{
db::UserDB,
@@ -520,6 +520,29 @@ async fn create_script_internal<'c>(
.to_string(),
));
}
if *CLOUD_HOSTED {
let nb_scripts =
sqlx::query_scalar!("SELECT COUNT(*) FROM script WHERE workspace_id = $1", &w_id)
.fetch_one(&db)
.await?;
if nb_scripts.unwrap_or(0) >= 5000 {
return Err(Error::BadRequest(
"You have reached the maximum number of scripts (5000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
if ns.summary.len() > 300 {
return Err(Error::BadRequest(
"Summary must be less than 300 characters on cloud".to_string(),
));
}
if ns.description.len() > 3000 {
return Err(Error::BadRequest(
"Description must be less than 3000 characters on cloud".to_string(),
));
}
}
let script_path = ns.path.clone();
let hash = ScriptHash(hash_script(&ns));
let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await;
@@ -1354,9 +1377,16 @@ async fn raw_script_by_path_internal(
)
.fetch_all(&db)
.await?;
tracing::info!(
"Script {path} does not exist in workspace {w_id} but these paths do: {:?}",
other_script_o.join(", ")
let other_script_archived = sqlx::query_scalar!(
"SELECT distinct(path) FROM script WHERE workspace_id = $1 AND archived = true",
w_id
)
.fetch_all(&db)
.await?;
tracing::warn!(
"Script {path} does not exist in workspace {w_id} but these paths do, non-archived: {:?} | archived: {:?}",
other_script_o.join(", "),
other_script_archived.join(", ")
)
}
}

View File

@@ -542,7 +542,7 @@ async fn list_users_as_super_admin(
let rows = if active_only.is_some_and(|x| x) {
sqlx::query_as!(
GlobalUserInfo,
"WITH active_users AS (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login')),
"WITH active_users AS (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username
FROM password
@@ -1750,7 +1750,22 @@ async fn refresh_token(
.await?
.unwrap_or(false);
let _ = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?;
let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?;
audit_log(
&mut *tx,
&AuditAuthor {
email: authed.email.to_string(),
username: authed.email.to_string(),
username_override: None,
},
"users.token.refresh",
ActionKind::Create,
&"global",
Some(&truncate_token(&new_token)),
None,
)
.await?;
tx.commit().await?;
Ok("token refreshed".to_string())
@@ -1838,6 +1853,18 @@ async fn create_token(
.fetch_optional(&mut *tx)
.await?
.unwrap_or(false);
if *CLOUD_HOSTED {
let nb_tokens =
sqlx::query_scalar!("SELECT COUNT(*) FROM token WHERE email = $1", &authed.email)
.fetch_one(&db)
.await?;
if nb_tokens.unwrap_or(0) >= 10000 {
return Err(Error::BadRequest(
"You have reached the maximum number of tokens (10000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
}
sqlx::query!(
"INSERT INTO token
(token, email, label, expiration, super_admin, scopes, workspace_id)

View File

@@ -6,8 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::fmt::Display;
use axum::{body::Body, response::Response};
use regex::Regex;
use serde::{Deserialize, Deserializer};
@@ -31,23 +29,6 @@ pub struct WithStarredInfoQuery {
pub with_starred_info: Option<bool>,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RunnableKind {
Script,
Flow,
}
impl Display for RunnableKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let runnable_kind = match self {
RunnableKind::Script => "script",
RunnableKind::Flow => "flow"
};
write!(f, "{}", runnable_kind)
}
}
pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
let is_admin = is_super_admin_email(db, email).await?;

View File

@@ -29,6 +29,7 @@ use windmill_common::{
variables::{
build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable,
},
worker::CLOUD_HOSTED,
};
use lazy_static::lazy_static;
@@ -74,8 +75,7 @@ async fn list_contextual_variables(
Some("u/user/triggering_flow_path".to_string()),
Some("c".to_string()),
Some("017e0ad5-f499-73b6-5488-92a61c5196dd".to_string()),
Some("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c".to_string()),
Some(chrono::offset::Utc::now())
Some(chrono::offset::Utc::now()),
)
.await
.to_vec(),
@@ -314,6 +314,20 @@ async fn create_variable(
Query(AlreadyEncrypted { already_encrypted }): Query<AlreadyEncrypted>,
Json(variable): Json<CreateVariable>,
) -> Result<(StatusCode, String)> {
if *CLOUD_HOSTED {
let nb_variables = sqlx::query_scalar!(
"SELECT COUNT(*) FROM variable WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if nb_variables.unwrap_or(0) >= 10000 {
return Err(Error::BadRequest(
"You have reached the maximum number of variables (10000) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
}
let authed = maybe_refresh_folders(&variable.path, &w_id, authed, &db).await;
check_path_conflict(&db, &w_id, &variable.path).await?;
@@ -450,6 +464,7 @@ struct EditVariable {
value: Option<String>,
is_secret: Option<bool>,
description: Option<String>,
account: Option<i32>,
}
#[derive(Deserialize)]
@@ -506,6 +521,10 @@ async fn update_variable(
sqlb.set_str("description", &desc);
}
if let Some(account_id) = ns.account {
sqlb.set_str("account", account_id);
}
if let Some(nbool) = ns.is_secret {
let old_secret = sqlx::query_scalar!(
"SELECT is_secret from variable WHERE path = $1 AND workspace_id = $2",
@@ -523,6 +542,21 @@ async fn update_variable(
sqlb.set_str("is_secret", nbool);
}
sqlb.returning("path");
// Get old account_id if we're updating the account field
let old_account_id = if ns.account.is_some() {
sqlx::query_scalar!(
"SELECT account FROM variable WHERE path = $1 AND workspace_id = $2",
&path,
&w_id
)
.fetch_optional(&db)
.await?
.flatten()
} else {
None
};
let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?;
if let Some(npath) = ns.path {
@@ -575,6 +609,33 @@ async fn update_variable(
None,
)
.await?;
// Clean up old account if it's no longer referenced and different from new account
if let Some(old_acc_id) = old_account_id {
if ns.account.is_some() && ns.account != Some(old_acc_id) {
// Check if old account is still referenced by other variables or resources
let account_still_used = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM variable WHERE account = $1 AND workspace_id = $2)",
old_acc_id,
&w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(true);
if !account_still_used {
// Delete the orphaned account
sqlx::query!(
"DELETE FROM account WHERE id = $1 AND workspace_id = $2",
old_acc_id,
&w_id
)
.execute(&mut *tx)
.await?;
}
}
}
tx.commit().await?;
handle_deployment_metadata(

View File

@@ -36,7 +36,7 @@ use windmill_common::db::UserDB;
use windmill_common::s3_helpers::LargeFileStorage;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{build_crypt, decrypt, encrypt};
use windmill_common::worker::to_raw_value;
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
#[cfg(feature = "enterprise")]
@@ -1454,6 +1454,21 @@ async fn create_workspace(
#[cfg(not(feature = "enterprise"))]
_check_nb_of_workspaces(&db).await?;
if *CLOUD_HOSTED {
let nb_workspaces = sqlx::query_scalar!(
"SELECT COUNT(*) FROM workspace WHERE owner = $1",
authed.email
)
.fetch_one(&db)
.await?;
if nb_workspaces.unwrap_or(0) >= 10 {
return Err(Error::BadRequest(
"You have reached the maximum number of workspaces (10) on cloud. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
}
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
check_w_id_conflict(&mut tx, &nw.id).await?;

View File

@@ -548,6 +548,8 @@ pub(crate) async fn tarball_workspace(
authentication_resource_path,
script_path,
is_flow,
summary,
description,
edited_by,
edited_at,
email,

View File

@@ -80,6 +80,7 @@ backon.workspace = true
openidconnect = { workspace = true, optional = true }
strum.workspace = true
strum_macros.workspace = true
url.workspace = true
semver.workspace = true
croner = "2.0.6"

View File

@@ -149,6 +149,8 @@ lazy_static::lazy_static! {
pub static ref DEPLOYED_SCRIPT_INFO_CACHE: Cache<(String, i64), ScriptHashInfo> = Cache::new(1000);
pub static ref FLOW_INFO_CACHE: Cache<(String, i64), FlowVersionInfo> = Cache::new(1000);
pub static ref QUIET_LOGS: bool = std::env::var("QUIET_LOGS").map(|s| s.parse::<bool>().unwrap_or(false)).unwrap_or(false);
}
const LATEST_VERSION_ID_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);

View File

@@ -22,11 +22,14 @@ use croner::Cron;
use rand::{distr::Alphanumeric, rng, Rng};
use reqwest::Client;
use semver::Version;
use serde::{Deserialize, Deserializer, Serialize};
use serde::{de::Error as SerdeDeserializerError, Deserialize, Deserializer, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{Pool, Postgres};
use std::borrow::Cow;
use std::fmt::Display;
use std::{fs::DirBuilder as SyncDirBuilder, str::FromStr};
use tokio::fs::DirBuilder as AsyncDirBuilder;
use url::Url;
pub const MAX_PER_PAGE: usize = 10000;
pub const DEFAULT_PER_PAGE: usize = 1000;
@@ -81,7 +84,7 @@ lazy_static::lazy_static! {
}
Mode::Worker
} else if &x == "agent" {
println!("Binary is in 'agent' mode");
println!("Binary is in 'agent' mode with BASE_INTERNAL_URL={}", std::env::var("BASE_INTERNAL_URL").unwrap_or_default());
if std::env::var("BASE_INTERNAL_URL").is_err() {
panic!("BASE_INTERNAL_URL is required in agent mode")
}
@@ -523,6 +526,18 @@ impl<T> IsEmpty for Vec<T> {
}
}
impl<T> IsEmpty for Option<T>
where
T: IsEmpty,
{
fn is_empty(&self) -> bool {
match self {
Some(v) => v.is_empty(),
None => true,
}
}
}
pub fn empty_as_none<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
@@ -532,6 +547,26 @@ where
Ok(option.filter(|s| !s.is_empty()))
}
pub fn is_empty<T>(value: &T) -> bool
where
T: IsEmpty,
{
value.is_empty()
}
pub fn deserialize_url<'de, D: Deserializer<'de>>(
de: D,
) -> std::result::Result<Option<Url>, D::Error> {
let intermediate = <Option<Cow<'de, str>>>::deserialize(de)?;
match intermediate.as_deref() {
None | Some("") => Ok(None),
Some(non_empty_string) => Url::parse(non_empty_string)
.map(Some)
.map_err(D::Error::custom),
}
}
pub async fn fetch_mute_workspace(_db: &DB, workspace_id: &str) -> Result<bool> {
match sqlx::query!(
"SELECT mute_critical_alerts FROM workspace_settings WHERE workspace_id = $1",
@@ -783,3 +818,20 @@ impl<F: Future> Future for WarnAfterFuture<F> {
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum RunnableKind {
Script,
Flow,
}
impl Display for RunnableKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let runnable_kind = match self {
RunnableKind::Script => "script",
RunnableKind::Flow => "flow",
};
write!(f, "{}", runnable_kind)
}
}

View File

@@ -180,7 +180,6 @@ pub async fn get_reserved_variables(
schedule_path: Option<String>,
step_id: Option<String>,
root_flow_id: Option<String>,
jwt_token: Option<String>,
scheduled_for: Option<chrono::DateTime<Utc>>,
) -> Vec<ContextualVariable> {
let state_path = {
@@ -334,12 +333,6 @@ pub async fn get_reserved_variables(
description: "Script or flow step execution unique path, useful for storing results in an external service".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_OIDC_JWT".to_string(),
value: jwt_token.unwrap_or_else(|| "".to_string()),
description: "OIDC JWT token (EE only)".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_WORKER_GROUP".to_string(),
value: WORKER_GROUP.clone(),

View File

@@ -2716,15 +2716,14 @@ pub async fn get_result_by_id(
.await
{
Ok(res) => Ok(res),
Err(_) => {
Err(e) => {
let root = sqlx::query!(
"SELECT
id As \"id!\",
flow_status->'restarted_from'->'flow_job_id' AS \"restarted_from: Json<Uuid>\"
FROM v2_as_queue
WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $1), $1) = id AND workspace_id = $2",
flow_id,
&w_id
FROM v2_job_status
WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $1), $1) = id",
flow_id
)
.fetch_optional(&db)
.await?;
@@ -2733,7 +2732,7 @@ pub async fn get_result_by_id(
let restarted_from_id = not_found_if_none(
root.restarted_from,
"Id not found in the result's mapping of the root job and root job had no restarted from information",
format!("parent: {}, root: {}, id: {}", flow_id, root.id, node_id),
format!("parent: {}, root: {}, id: {}, error: {e:#}", flow_id, root.id, node_id),
)?;
get_result_by_id_from_original_flow(
@@ -2863,9 +2862,10 @@ pub async fn get_result_by_id_from_running_flow_inner(
node_id: &str,
) -> error::Result<JobResult> {
let flow_job_result = sqlx::query!(
"SELECT leaf_jobs->$1::text AS \"leaf_jobs: Json<Box<RawValue>>\", parent_job
FROM v2_as_queue
WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $2), $2) = id AND workspace_id = $3",
"SELECT flow_leaf_jobs->$1::text AS \"leaf_jobs: Json<Box<RawValue>>\", v2_job.parent_job
FROM v2_job_status
LEFT JOIN v2_job ON v2_job.id = v2_job_status.id AND v2_job.workspace_id = $3
WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $2), $2) = v2_job_status.id",
node_id,
flow_id,
w_id,
@@ -2873,11 +2873,13 @@ pub async fn get_result_by_id_from_running_flow_inner(
.fetch_optional(db)
.await?;
// tracing::error!("flow_job_result: {:?} {:?}", flow_job_result, flow_id);
let flow_job_result = windmill_common::utils::not_found_if_none(
flow_job_result,
"Root job of parent runnnig flow",
format!("parent: {}, id: {}", flow_id, node_id),
)?;
// tracing::error!("flow_job_result: {:?}, {:?}", flow_job_result.leaf_jobs, flow_job_result.parent_job);
let job_result = flow_job_result
.leaf_jobs
@@ -4028,6 +4030,7 @@ pub async fn push<'c, 'd>(
),
};
let final_priority: Option<i16>;
#[cfg(not(feature = "enterprise"))]
{
@@ -4229,10 +4232,10 @@ pub async fn push<'c, 'd>(
INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,
created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,
script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,
flow_innermost_root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,
flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,
cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20, $21, $22, $23, $24, $25, $26,
$19, $20, $38, $21, $22, $23, $24, $25, $26,
CASE WHEN $14::VARCHAR IS NOT NULL THEN 'schedule'::job_trigger_kind END,
($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)
),
@@ -4288,6 +4291,7 @@ pub async fn push<'c, 'd>(
job_authed.is_operator,
folders.as_slice(),
job_authed.groups.as_slice(),
root_job.or(parent_job)
)
.execute(&mut *tx)
.warn_after_seconds(1)

View File

@@ -43,12 +43,11 @@ use crate::{
OccupancyMetrics,
},
handle_child::handle_child,
DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV,
POWERSHELL_CACHE_DIR, POWERSHELL_PATH, PROXY_ENVS, TZ_ENV,
DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, POWERSHELL_CACHE_DIR,
POWERSHELL_PATH, PROXY_ENVS, TZ_ENV,
};
use windmill_common::client::AuthedClient;
#[cfg(windows)]
use crate::SYSTEM_ROOT;
@@ -275,6 +274,19 @@ exit $exit_status
)))
}
#[cfg(feature = "dind")]
async fn rm_container(client: &bollard::Docker, container_id: &str) {
if let Err(e) = client
.remove_container(
container_id,
Some(RemoveContainerOptions { force: true, ..Default::default() }),
)
.await
{
tracing::error!("Error removing container: {:?}", e);
}
}
#[cfg(feature = "dind")]
async fn handle_docker_job(
job_id: Uuid,
@@ -446,19 +458,12 @@ async fn handle_docker_job(
}
}
}
rm_container(&client, &container_id).await;
return Err(e);
}
if let Err(e) = client
.remove_container(
&container_id,
Some(RemoveContainerOptions { force: true, ..Default::default() }),
)
.await
{
tracing::error!("Error removing container: {:?}", e);
}
rm_container(&client, &container_id).await;
let result = result.unwrap();

View File

@@ -1566,7 +1566,6 @@ pub async fn start_worker(
None,
None,
None,
None,
)
.await;
let context_envs = build_envs_map(context.to_vec()).await;

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