Compare commits

..

23 Commits

Author SHA1 Message Date
Ruben Fiszel
9a5b8e8344 all 2025-12-15 18:09:29 +00:00
hugocasa
ebc82dbe58 feat(backend): stop schedules and cancel jobs when archiving a workspace (#7377) 2025-12-15 18:03:28 +00:00
Devdatta Talele
b3603d8720 fix: add history directly viewable in folder/group viewer (#7365) 2025-12-15 18:01:07 +00:00
centdix
61a3c81d5d chore(appchat): improve prompt and tools (#7376)
* nit flow

* better prompt

* remove files from user message

* truncated files

* nit

* f
2025-12-15 17:29:06 +00:00
centdix
d229d469a1 chore(appchat): add tests pipeline (#7374)
* draft test app

* gitignore

* add app test pipeline

* add lot of tests

* add variant

* remove unrelated changes

* fix

* fix
2025-12-15 16:53:00 +00:00
Ruben Fiszel
2f5fdd6b3f fix(rawapp): make popup work with runnables 2025-12-14 22:32:22 +00:00
centdix
efe43ca3a8 add claude config (#7366) 2025-12-14 22:27:29 +00:00
centdix
37394d6d53 fix(rawapp): schema for openai (#7364) 2025-12-14 22:27:03 +00:00
Ruben Fiszel
f353b91407 history nits 2025-12-13 13:38:32 +00:00
Ruben Fiszel
431074d249 fix: add history to raw app builder (#7362)
* appHistory

* appHistory

* all

* all

* all

* all

* all

* all

* all

* all

* improvements
2025-12-13 13:32:52 +00:00
Ruben Fiszel
8d2ddad9e6 latest ee ref 2025-12-12 22:53:55 +00:00
Alexander Petric
c2a9ce46be improve github app error handling (#7357)
* improve github app error handling

* npm check

* sqlx

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-12-12 22:53:21 +00:00
wendrul
9d06c152ee feat: workspace forks merge UI (#7333)
* feat: Add workspace diff viewer and deployment UI for forked workspaces

- Add backend endpoint for comparing two workspaces
- Implement comparison logic for scripts, flows, apps, resources, variables
- Create ForkWorkspaceBanner component to detect and display fork status
- Build WorkspaceComparisonDrawer for detailed diff viewing and deployment
- Add DiffViewer component for line-by-line comparisons
- Support bidirectional deployment (fork to parent or parent to fork)
- Add conflict detection for items that are both ahead and behind
- Include delete fork option when no changes remain

Note: Backend implementation requires sqlx prepare to be run for full functionality

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* Fix banner and use wworkspace_diff table

* satisfactory UI WIP

* UI complete

* Deploy button

* Comaprison and reset tally

* compare all types of items

* Show summaries

* Disable buttons during deployment

* Auto select all on entering page

* Change migration to have 'exists_in' cols

* Show new and deleted items

* frontend fixes

* Block delpoyment if changes don't match (new chagnes detected)

* Message to block whe changes are behind

* Skip workspaces pre-migration

* Remove unused code

* Fix apps comparison

* Only return changes where user has visibility

* No deploy button if no access to all changes

* Prepare sqlx

* Remove redundant message

* CI: update ee repo ref

* eereporef bis

* Small tweaks

* Remove unused struct

* Remove unused refactor component

* Fix npm run check

* Remove unused component

* chore: update ee-repo-ref to bbf406edc222199ca2e6076da12c376fb4ff28c5

This commit updates the EE repository reference after PR #359 was merged in windmill-ee-private.

Previous ee-repo-ref: 6aae845c5629ae32da43dbfbdc4566e5bf90fb1e

New ee-repo-ref: bbf406edc222199ca2e6076da12c376fb4ff28c5

Automated by sync-ee-ref workflow.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-12-12 22:52:26 +00:00
Ruben Fiszel
9ba4b071c8 display git URL info for disabled git sync resources (#7345)
* feat: display git URL info for disabled git sync resources

When a git repo resource is already selected and saved in workspace
settings git sync, the resource picker becomes disabled. This made it
impossible to see what git URL the resource is attached to.

This commit adds:
- Automatic loading of resource info when a saved resource is selected
- Display of git URL alongside the disabled resource picker
- Password/token masking in URLs for security
- Loading state and error handling

Fixes #7338

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

Co-Authored-By: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* feat: fetch and display variable secrets for git URLs

- Detect :X pattern in git repository resource URLs
- Fetch variable value with decryptSecret for display
- Hide URL display if variable cannot be fetched (permissions/not found)
- Maintain password masking for security

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

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
2025-12-12 18:27:57 +00:00
Ruben Fiszel
734b6c0587 chore(main): release 1.592.1 (#7360)
* chore(main): release 1.592.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-12-12 18:20:06 +00:00
centdix
490114d133 fix(ai): improve share system prompts 2025-12-12 18:09:09 +00:00
Ruben Fiszel
af1b85f7d0 chore(main): release 1.592.0 (#7356)
* chore(main): release 1.592.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-12-12 17:47:18 +00:00
Alexander Petric
8f1343e155 fix: clear datetime input in schedule sets input to null (#7358) 2025-12-12 17:39:50 +00:00
centdix
31e002ad41 feat(ai): standardize and improve system prompts (#7346)
* init

* test in frontend

* copy files

* use in cli

* better

* add desc to sdks

* better

* fix ts parsing

* add docs to ts client

* add docs to python client

* use script prompt in frontend

* regen

* use in flow

* rm

* use in cli, create AGENTS.md instead of cursor rules

* remove apply

* better

* better

* simplify cli

* more docs

* cleaning

* update readme

* generate cli file

* better folder names

* fix ts

* fix multiline
2025-12-12 17:26:19 +00:00
Pyra
68596701f1 fix(bun): deployment error on workspace dependencies (#7355)
Signed-off-by: pyranota <pyra@duck.com>
2025-12-12 17:11:57 +00:00
Ruben Fiszel
1a438e9751 warn after secs bun install 2025-12-12 13:22:16 +00:00
Ruben Fiszel
e2953862af chore(main): release 1.591.4 (#7354)
* chore(main): release 1.591.4

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-12-12 12:27:45 +00:00
Ruben Fiszel
857adf5b63 fix(app): fix raw scripts forbidden by policy if no args 2025-12-12 12:22:35 +00:00
231 changed files with 19795 additions and 2344 deletions

62
.claude/settings.json Normal file
View File

@@ -0,0 +1,62 @@
{
"permissions": {
"allow": [
"Read(**/*.rs)",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(less:*)",
"Bash(more:*)",
"Bash(find:*)",
"Bash(wc:*)",
"Bash(diff:*)",
"Bash(file:*)",
"Bash(stat:*)",
"Bash(tree:*)",
"Bash(pwd)",
"Bash(which:*)",
"Bash(whereis:*)",
"Bash(echo:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git branch:*)",
"Bash(git show:*)",
"Bash(git blame:*)"
],
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/secrets/**)",
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(**/credentials.json)",
"Read(**/*secret*)",
"Edit(.env)",
"Edit(.env.*)",
"Edit(**/.env)",
"Edit(**/.env.*)"
],
"ask": [
"Bash(rm:*)",
"Bash(rmdir:*)",
"Bash(mv:*)",
"Bash(chmod:*)",
"Bash(chown:*)",
"Bash(truncate:*)",
"Bash(shred:*)",
"Bash(unlink:*)",
"Bash(git push:*)",
"Bash(git reset:*)",
"Bash(git revert:*)",
"Bash(git checkout:*)",
"Bash(git merge:*)",
"Bash(git rebase:*)"
]
},
"enableAllProjectMcpServers": true
}

View File

@@ -1,5 +1,32 @@
# Changelog
## [1.592.1](https://github.com/windmill-labs/windmill/compare/v1.592.0...v1.592.1) (2025-12-12)
### Bug Fixes
* **ai:** improve share system prompts ([490114d](https://github.com/windmill-labs/windmill/commit/490114d133a08ef7f61ed216796b01fbec32a677))
## [1.592.0](https://github.com/windmill-labs/windmill/compare/v1.591.4...v1.592.0) (2025-12-12)
### Features
* **ai:** standardize and improve system prompts ([#7346](https://github.com/windmill-labs/windmill/issues/7346)) ([31e002a](https://github.com/windmill-labs/windmill/commit/31e002ad411bfbf08c933700bf5ae12b253ac0b8))
### Bug Fixes
* **bun:** deployment error on workspace dependencies ([#7355](https://github.com/windmill-labs/windmill/issues/7355)) ([6859670](https://github.com/windmill-labs/windmill/commit/68596701f1e9da460d7bd3246dc797fca1e66a62))
* clear datetime input in schedule sets input to null ([#7358](https://github.com/windmill-labs/windmill/issues/7358)) ([8f1343e](https://github.com/windmill-labs/windmill/commit/8f1343e155620b047e746da40653ec627f97a1a3))
## [1.591.4](https://github.com/windmill-labs/windmill/compare/v1.591.3...v1.591.4) (2025-12-12)
### Bug Fixes
* **app:** fix raw scripts forbidden by policy if no args ([857adf5](https://github.com/windmill-labs/windmill/commit/857adf5b63fe243736366c38a7e573678552a99b))
## [1.591.3](https://github.com/windmill-labs/windmill/compare/v1.591.2...v1.591.3) (2025-12-11)

View File

@@ -44,6 +44,7 @@ RUN mkdir /backend
COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
COPY /system_prompts/auto-generated /system_prompts/auto-generated
RUN cd /backend/windmill-api && . ./build_openapi.sh
COPY /backend/parsers/windmill-parser-wasm/pkg/ /backend/parsers/windmill-parser-wasm/pkg/

View File

@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6\n WHERE path = $3 AND kind = $4 AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "03669873e4e3b22c737d5170821f677925474aad885bf1c0780bdb978225517e"
}

View File

@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]

View File

@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "kind",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "ahead",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "behind",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "has_changes",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "exists_in_source",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "exists_in_fork",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf"
}

View File

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

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, is_secret, description\n FROM variable\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_secret",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM workspace WHERE parent_workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM app\n WHERE workspace_id = $1 AND path = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM resource\n WHERE workspace_id = $1 AND path = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "policy",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "value",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, description, resource_type\n FROM resource\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "resource_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
true,
false
]
},
"hash": "819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n SELECT $1, unnest($2::varchar[]), $3, $4, 0, 1, NULL\n ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)\n DO UPDATE SET\n behind = workspace_diff.behind + 1,\n has_changes = NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"VarcharArray",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 RETURNING name",
"query": "UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 AND $1 = ANY(owners) RETURNING name",
"describe": {
"columns": [
{
@@ -11,7 +11,7 @@
],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Text"
]
@@ -20,5 +20,5 @@
false
]
},
"hash": "e500a422fa986faf5c612c08f4a139e10b25a3106551e7c92f43dcf9758a5ec7"
"hash": "a3ca0af5d84acba93776828c090a79bf6554180b9db4951b69f7070f769b2962"
}

View File

@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT hash, created_at, content, summary, description, lock, schema\n FROM script\n WHERE workspace_id = $1 AND path = $2 AND archived = false\n ORDER BY created_at DESC\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hash",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "lock",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "schema",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ($1, $2, $3, $4, 1, 0, NULL)\n ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)\n DO UPDATE SET\n ahead = workspace_diff.ahead + 1,\n has_changes = NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_settings\n SET git_app_installations = (\n SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)\n FROM jsonb_array_elements(git_app_installations) AS elem\n WHERE (elem->>'installation_id')::bigint != $1\n )\n WHERE workspace_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "ad288f9f242f930fc571d04d5ba217f653437f60c890b6b73059fdaa7eb45302"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_diff WHERE has_changes = false AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973"
}

View File

@@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, changed_by, changed_at, change_type, member_affected\n FROM group_permission_history\n WHERE workspace_id = $1 AND group_name = $2\n ORDER BY id DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "changed_by",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "changed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "change_type",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "member_affected",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "b11034489786626184e2f6b7f99469a945a4bbfe0e6a33c5a3e009dadd56d52c"
}

View File

@@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, changed_by, changed_at, change_type, affected\n FROM folder_permission_history\n WHERE workspace_id = $1 AND folder_name = $2\n ORDER BY id DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "changed_by",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "changed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "change_type",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "affected",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "b24eeafc2fc26664ef38d15d3cb47ca19549bcd9372d7a61ec2e28d82897bacf"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM v2_job_queue WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "b9c520fdc29b17977bf1c0b30311c536ebea8a76b75cda188ec91fcc0fc274a7"
}

View File

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

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, summary, description, schema\n FROM flow\n WHERE workspace_id = $1 AND path = $2 AND archived = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "schema",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder_permission_history\n (workspace_id, folder_name, changed_by, change_type, affected)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "f1206b00c4b81e59943385646d653330efba9cd1e731621f7efc91f04567841f"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO group_permission_history\n (workspace_id, group_name, changed_by, change_type, member_affected)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "f95358255e55d68dd453d173e23ab3cb1f1c2ba5b1cfc78f706b0b014f045477"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM variable\n WHERE workspace_id = $1 AND path = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09"
}

60
backend/Cargo.lock generated
View File

@@ -2166,9 +2166,9 @@ dependencies = [
[[package]]
name = "cmake"
version = "0.1.54"
version = "0.1.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0"
checksum = "d49d74c227b6cc9f3c51a2c7c667a05b6453f7f0f952a5f8e4493bb9e731d68e"
dependencies = [
"cc",
]
@@ -14147,7 +14147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3beec919fbdf99d719de8eda6adae3281f8a5b71ae40431f44dc7423053d34"
dependencies = [
"loki-api",
"reqwest 0.12.24",
"reqwest 0.11.27",
"serde",
"serde_json",
"snap",
@@ -15165,7 +15165,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"aws-sdk-config",
@@ -15227,7 +15227,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"argon2",
@@ -15348,7 +15348,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -15363,7 +15363,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"chrono",
"lazy_static",
@@ -15377,7 +15377,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"axum",
@@ -15396,7 +15396,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15489,7 +15489,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"regex",
"serde",
@@ -15504,7 +15504,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"bytes",
@@ -15528,7 +15528,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15544,7 +15544,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15553,7 +15553,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15565,7 +15565,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15577,7 +15577,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"gosyn",
@@ -15589,7 +15589,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15601,7 +15601,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15613,7 +15613,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -15624,7 +15624,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15635,7 +15635,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15648,7 +15648,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15672,7 +15672,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15686,7 +15686,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15703,7 +15703,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15717,7 +15717,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15736,7 +15736,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"serde",
@@ -15747,7 +15747,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15784,7 +15784,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -15794,7 +15794,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.591.3"
version = "1.592.1"
dependencies = [
"anyhow",
"async-once-cell",

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.591.3"
version = "1.592.1"
authors.workspace = true
edition.workspace = true
@@ -33,7 +33,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.591.3"
version = "1.592.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"

View File

@@ -1 +1 @@
55e8296c3758b031e50d7ebdaef7c33666f02385
4897449185f1af0f0323c931df288b5942b38fb8

View File

@@ -0,0 +1,6 @@
-- Add down migration script here
DROP INDEX IF EXISTS idx_group_perm_history_workspace_group;
DROP TABLE IF EXISTS group_permission_history;
DROP INDEX IF EXISTS idx_folder_perm_history_workspace_folder;
DROP TABLE IF EXISTS folder_permission_history;

View File

@@ -0,0 +1,90 @@
-- Add up migration script here
-- Folder permission changes history
CREATE TABLE IF NOT EXISTS folder_permission_history (
id BIGSERIAL PRIMARY KEY,
workspace_id VARCHAR(50) NOT NULL,
folder_name VARCHAR(255) NOT NULL,
changed_by VARCHAR(50) NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
change_type VARCHAR(50) NOT NULL,
affected VARCHAR(100),
FOREIGN KEY (workspace_id, folder_name) REFERENCES folder(workspace_id, name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_folder_perm_history_workspace_folder
ON folder_permission_history(workspace_id, folder_name, id DESC);
-- Group permission changes history
CREATE TABLE IF NOT EXISTS group_permission_history (
id BIGSERIAL PRIMARY KEY,
workspace_id VARCHAR(50) NOT NULL,
group_name VARCHAR(255) NOT NULL,
changed_by VARCHAR(50) NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
change_type VARCHAR(50) NOT NULL,
member_affected VARCHAR(100),
FOREIGN KEY (workspace_id, group_name) REFERENCES group_(workspace_id, name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_group_perm_history_workspace_group
ON group_permission_history(workspace_id, group_name, id DESC);
GRANT ALL ON TABLE folder_permission_history TO windmill_user;
GRANT ALL ON TABLE group_permission_history TO windmill_user;
GRANT ALL ON TABLE folder_permission_history TO windmill_admin;
GRANT ALL ON TABLE group_permission_history TO windmill_admin;
-- Enable RLS on folder_permission_history
ALTER TABLE folder_permission_history ENABLE ROW LEVEL SECURITY;
-- Admin policies (windmill_admin can always do everything)
CREATE POLICY admin_all ON folder_permission_history FOR ALL TO windmill_admin USING (true) WITH CHECK (true);
CREATE POLICY admin_all ON group_permission_history FOR ALL TO windmill_admin USING (true) WITH CHECK (true);
-- Enable RLS on group_permission_history
ALTER TABLE group_permission_history ENABLE ROW LEVEL SECURITY;
-- RLS policies for folder_permission_history
-- Anyone can insert
CREATE POLICY allow_insert ON folder_permission_history FOR INSERT TO windmill_user WITH CHECK (true);
-- Select requires being in extra_perms (as user or via group)
CREATE POLICY see_extra_perms_user ON folder_permission_history FOR SELECT TO windmill_user
USING (EXISTS (
SELECT 1 FROM folder f
WHERE f.workspace_id = folder_permission_history.workspace_id
AND f.name = folder_permission_history.folder_name
AND f.extra_perms ? CONCAT('u/', current_setting('session.user'))
));
CREATE POLICY see_extra_perms_groups ON folder_permission_history FOR SELECT TO windmill_user
USING (EXISTS (
SELECT 1 FROM folder f
WHERE f.workspace_id = folder_permission_history.workspace_id
AND f.name = folder_permission_history.folder_name
AND f.extra_perms ?| regexp_split_to_array(current_setting('session.pgroups'), ',')::text[]
));
-- RLS policies for group_permission_history
-- Anyone can insert
CREATE POLICY allow_insert ON group_permission_history FOR INSERT TO windmill_user WITH CHECK (true);
-- Select requires being in extra_perms (as user or via group)
CREATE POLICY see_extra_perms_user ON group_permission_history FOR SELECT TO windmill_user
USING (EXISTS (
SELECT 1 FROM group_ g
WHERE g.workspace_id = group_permission_history.workspace_id
AND g.name = group_permission_history.group_name
AND (g.extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean
));
CREATE POLICY see_extra_perms_groups ON group_permission_history FOR SELECT TO windmill_user
USING (EXISTS (
SELECT 1 FROM group_ g, jsonb_each_text(g.extra_perms) f
WHERE g.workspace_id = group_permission_history.workspace_id
AND g.name = group_permission_history.group_name
AND SPLIT_PART(f.key, '/', 1) = 'g'
AND f.key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
AND f.value::boolean
));

View File

@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE IF EXISTS skip_workspace_diff_tally;
DROP TABLE IF EXISTS workspace_diff;

View File

@@ -0,0 +1,24 @@
-- Add up migration script here
CREATE TABLE workspace_diff (
source_workspace_id VARCHAR(50) NOT NULL,
fork_workspace_id VARCHAR(50) NOT NULL,
path VARCHAR(255) NOT NULL,
kind VARCHAR(50) NOT NULL,
ahead INTEGER NOT NULL DEFAULT 0,
behind INTEGER NOT NULL DEFAULT 0,
has_changes BOOLEAN DEFAULT NULL,
exists_in_source BOOLEAN DEFAULT NULL,
exists_in_fork BOOLEAN DEFAULT NULL,
PRIMARY KEY (source_workspace_id, fork_workspace_id, path, kind)
);
-- Create table to track workspaces that should be excluded from diff tallying
-- Old workspaces that are linked but have already diverged need to be skipped
CREATE TABLE skip_workspace_diff_tally (
workspace_id VARCHAR(50) PRIMARY KEY,
added_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Populate with all existing workspaces to exclude them from new tallying logic
INSERT INTO skip_workspace_diff_tally (workspace_id)
SELECT id FROM workspace;

View File

@@ -1 +1,2 @@
-- Add down migration script here
ALTER TABLE email_trigger DROP COLUMN enabled;

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.591.3
version: 1.592.1
title: Windmill API
contact:
@@ -2024,6 +2024,52 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/compare/{target_workspace_id}:
get:
operationId: compareWorkspaces
summary: Compare two workspaces
description: Compares the current workspace with a target workspace to find differences in scripts, flows, apps, resources, and variables. Returns information about items that are ahead, behind, or in conflict.
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: target_workspace_id
in: path
required: true
schema:
type: string
description: The ID of the workspace to compare with
responses:
"200":
description: Workspace comparison results
content:
application/json:
schema:
$ref: "#/components/schemas/WorkspaceComparison"
/w/{workspace}/workspaces/reset_diff_tally/{fork_workspace_id}:
post:
operationId: resetDiffTally
summary: Resets the ahead and behind deployement counter after a deployement
description: This endpoint should be called after a fork deployement
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: fork_workspace_id
in: path
required: true
schema:
type: string
description: The ID of the workspace to compare with
responses:
"200":
description: status
content:
application/json:
schema: {}
/users/exists/{email}:
get:
summary: exists email
@@ -12975,6 +13021,40 @@ paths:
schema:
type: string
/w/{workspace}/groups_history/get/{name}:
get:
summary: get group permission history
operationId: getGroupPermissionHistory
tags:
- group
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Name"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: group permission history
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
changed_by:
type: string
changed_at:
type: string
format: date-time
change_type:
type: string
member_affected:
type: string
nullable: true
/w/{workspace}/folders/list:
get:
summary: list folders
@@ -13238,6 +13318,40 @@ paths:
schema:
type: string
/w/{workspace}/folders_history/get/{name}:
get:
summary: get folder permission history
operationId: getFolderPermissionHistory
tags:
- folder
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Name"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: folder permission history
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
changed_by:
type: string
changed_at:
type: string
format: date-time
change_type:
type: string
affected:
type: string
nullable: true
/workers/list:
get:
summary: list workers
@@ -20927,6 +21041,108 @@ components:
type: boolean
description: Whether operators can view workers page
WorkspaceComparison:
type: object
required:
- all_ahead_items_visible
- all_behind_items_visible
- skipped_comparison
- diffs
- summary
properties:
all_ahead_items_visible:
type: boolean
description: All items with changes ahead are visible by the user of the request.
all_behind_items_visible:
type: boolean
description: All items with changes behind are visible by the user of the request.
skipped_comparison:
type: boolean
description: Whether the comparison was skipped. This happens with old forks that where not being kept track of
diffs:
type: array
description: List of differences found between workspaces
items:
$ref: "#/components/schemas/WorkspaceItemDiff"
summary:
$ref: "#/components/schemas/CompareSummary"
description: Summary statistics of the comparison
WorkspaceItemDiff:
type: object
required:
- kind
- path
- ahead
- behind
- has_changes
- exists_in_source
- exists_in_fork
properties:
kind:
type: string
enum: ["script", "flow", "app", "resource", "variable"]
description: Type of the item
path:
type: string
description: Path of the item in the workspace
ahead:
type: integer
description: Number of versions source is ahead of target
behind:
type: integer
description: Number of versions source is behind target
has_changes:
type: boolean
description: Whether the item has any differences
exists_in_source:
type: boolean
description: If the item exists in the source workspace
exists_in_fork:
type: boolean
description: If the item exists in the fork workspace
CompareSummary:
type: object
required:
- total_diffs
- total_ahead
- total_behind
- scripts_changed
- flows_changed
- apps_changed
- resources_changed
- variables_changed
- conflicts
properties:
total_diffs:
type: integer
description: Total number of items with differences
total_ahead:
type: integer
description: Total number of ahead changes
total_behind:
type: integer
description: Total number of behind changes
scripts_changed:
type: integer
description: Number of scripts with differences
flows_changed:
type: integer
description: Number of flows with differences
apps_changed:
type: integer
description: Number of apps with differences
resources_changed:
type: integer
description: Number of resources with differences
variables_changed:
type: integer
description: Number of variables with differences
conflicts:
type: integer
description: Number of items that are both ahead and behind (conflicts)
TeamInfo:
type: object
required:
@@ -21002,6 +21218,9 @@ components:
per_page:
type: number
description: Number of repositories loaded per page
error:
type: string
description: Error message if token retrieval failed
required:
- installation_id
- account_id

View File

@@ -0,0 +1,68 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::db::ApiAuthed;
use axum::{
extract::{Extension, Path, Query},
routing::get,
Router,
};
use windmill_common::{
db::UserDB,
error::JsonResult,
utils::{paginate, Pagination},
};
use serde::Serialize;
use sqlx::FromRow;
pub fn workspaced_service() -> Router {
Router::new().route("/get/:name", get(get_folder_permission_history))
}
#[derive(Serialize, FromRow)]
pub struct FolderPermissionChange {
pub id: i64,
pub changed_by: String,
pub changed_at: chrono::DateTime<chrono::Utc>,
pub change_type: String,
pub affected: Option<String>,
}
async fn get_folder_permission_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<FolderPermissionChange>> {
// Check if user is owner of the folder (before starting transaction for performance)
crate::folders::require_is_owner(&authed, &name)?;
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(pagination);
let history = sqlx::query_as!(
FolderPermissionChange,
"SELECT id, changed_by, changed_at, change_type, affected
FROM folder_permission_history
WHERE workspace_id = $1 AND folder_name = $2
ORDER BY id DESC
LIMIT $3 OFFSET $4",
w_id,
name,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(axum::Json(history))
}

View File

@@ -261,6 +261,17 @@ async fn create_folder(
None,
)
.await?;
log_folder_permission_change(
&mut *tx,
&w_id,
&ng.name,
&authed.username,
"create",
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
@@ -332,6 +343,10 @@ async fn update_folder(
sqlb.set("edited_at", "now()");
// Track whether permission-related fields are being updated
let owners_changed = ng.owners.is_some();
let extra_perms_changed = ng.extra_perms.is_some();
if !authed.is_admin {
let prefixed_username = format!("u/{}", authed.username);
if ng.owners.as_ref().is_some_and(|x| {
@@ -418,6 +433,31 @@ async fn update_folder(
None,
)
.await?;
// Log permission changes if owners or extra_perms were updated
if owners_changed {
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"update_owners",
None,
)
.await?;
}
if extra_perms_changed {
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"update_acl",
None,
)
.await?;
}
tx.commit().await?;
handle_deployment_metadata(
@@ -675,6 +715,17 @@ async fn add_owner(
Some([("owner", owner.as_str())].into()),
)
.await?;
log_folder_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"grant_admin",
Some(&owner),
)
.await?;
tx.commit().await?;
webhook.send_message(
@@ -697,8 +748,8 @@ async fn remove_owner(
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
require_is_owner(&authed, &name)?;
sqlx::query!(
"UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 RETURNING name",
let folder = sqlx::query!(
"UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 AND $1 = ANY(owners) RETURNING name",
owner,
&name,
&w_id,
@@ -706,18 +757,29 @@ async fn remove_owner(
.fetch_optional(&mut *tx)
.await?;
if folder.is_none() && write.is_none() {
return Ok(format!("Owner {} is already not a member of folder {}", owner, name));
}
if let Some(write) = write {
sqlx::query(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
))
let old_write = sqlx::query_scalar::<_, Option<bool>>(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) FROM (SELECT (extra_perms->>'{owner}')::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
WHERE name = $2 AND workspace_id = $3 RETURNING old.old_val"
))
.bind(write)
.bind(&name)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
.await?
.flatten();
if folder.is_none() && old_write.is_none_or(|ow| ow == write) {
return Ok(format!("Owner {} is already not a member of folder {} and write permission was already {}", owner, name, write));
}
}
audit_log(
&mut *tx,
&authed,
@@ -728,6 +790,14 @@ async fn remove_owner(
Some([("owner", owner.as_str())].into()),
)
.await?;
let change_type = match write {
Some(true) => "grant_writer_only",
Some(false) => "grant_viewer_only",
None => "revoke_all",
};
log_folder_permission_change(&mut *tx, &w_id, &name, &authed.username, change_type, Some(&owner)).await?;
tx.commit().await?;
webhook.send_message(
@@ -737,3 +807,26 @@ async fn remove_owner(
Ok(format!("Removed {} to folder {}", owner, name))
}
pub async fn log_folder_permission_change<'c, E: sqlx::Executor<'c, Database = Postgres>>(
db: E,
workspace_id: &str,
folder_name: &str,
changed_by: &str,
change_type: &str,
affected: Option<&str>,
) -> Result<()> {
sqlx::query!(
"INSERT INTO folder_permission_history
(workspace_id, folder_name, changed_by, change_type, affected)
VALUES ($1, $2, $3, $4, $5)",
workspace_id,
folder_name,
changed_by,
change_type,
affected
)
.execute(db)
.await?;
Ok(())
}

View File

@@ -116,7 +116,7 @@ async fn add_granular_acl(
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \
true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms"
))
.bind(vec![owner])
.bind(vec![owner.clone()])
.bind(write.unwrap_or(false))
.bind(path)
.bind(&w_id)
@@ -124,6 +124,39 @@ async fn add_granular_acl(
.await?;
let _ = not_found_if_none(obj_o, &kind, &path)?;
if kind == "folder" {
let change_type = if write.unwrap_or(false) {
"grant_read"
} else {
"grant_write"
};
crate::folders::log_folder_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?;
} else if kind == "group_" {
let change_type = if write.unwrap_or(false) {
"grant_admin"
} else {
"grant_member_only"
};
crate::groups::log_group_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?;
}
tx.commit().await?;
match kind {
@@ -190,7 +223,7 @@ async fn remove_granular_acl(
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(GranularAcl { owner, write: _ }): Json<GranularAcl>,
Json(GranularAcl { owner, .. }): Json<GranularAcl>,
) -> Result<String> {
let path = path.to_path();
@@ -225,73 +258,104 @@ async fn remove_granular_acl(
require_owner_of_path(&authed, path)?;
}
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"UPDATE {kind} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND \
workspace_id = $3 RETURNING extra_perms"
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
"WITH old AS (
SELECT extra_perms->$1 as old_write FROM {kind}
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
)
UPDATE {kind} SET extra_perms = extra_perms - $1
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
RETURNING (SELECT old_write FROM old)::bool"
))
.bind(owner)
.bind(&owner)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let _ = not_found_if_none(obj_o, &kind, &path)?;
tx.commit().await?;
match kind {
"folder" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
// Only log if something was actually removed (obj_o is Some)
if let Some(write) = obj_o {
// Log permission changes for folders and groups
if kind == "folder" {
let change_type = if write { "revoke_write" } else { "revoke_read" };
crate::folders::log_folder_permission_change(
&mut *tx,
&w_id,
DeployedObject::Folder { path: format!("f/{}", path) },
Some(format!("Folder '{}' changed permissions", path)),
true,
path,
&authed.username,
change_type,
Some(&owner),
)
.await?
.await?;
} else if kind == "group_" {
crate::groups::log_group_permission_change(
&mut *tx,
&w_id,
path,
&authed.username,
"revoke_admin",
Some(&owner),
)
.await?;
}
tx.commit().await?;
match kind {
"folder" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}", path) },
Some(format!("Folder '{}' changed permissions", path)),
true,
)
.await?
}
// "app" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
// Some(format!("App '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "script" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Script {
// path: path.to_string(),
// parent_path: None,
// hash: ScriptHash(0),
// },
// Some(format!("Script '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "flow" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Flow { path: path.to_string(), parent_path: None },
// Some(format!("Flow '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
_ => (),
}
// "app" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
// Some(format!("App '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "script" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Script {
// path: path.to_string(),
// parent_path: None,
// hash: ScriptHash(0),
// },
// Some(format!("Script '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "flow" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Flow { path: path.to_string(), parent_path: None },
// Some(format!("Flow '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
_ => (),
}
Ok("Successfully removed granular acl".to_string())

View File

@@ -0,0 +1,66 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::db::ApiAuthed;
use axum::{
extract::{Extension, Path, Query},
routing::get,
Router,
};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult},
utils::{paginate, Pagination},
};
use serde::Serialize;
use sqlx::FromRow;
pub fn workspaced_service() -> Router {
Router::new().route("/get/:name", get(get_group_permission_history))
}
#[derive(Serialize, FromRow)]
pub struct GroupPermissionChange {
pub id: i64,
pub changed_by: String,
pub changed_at: chrono::DateTime<chrono::Utc>,
pub change_type: String,
pub member_affected: Option<String>,
}
async fn get_group_permission_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<GroupPermissionChange>> {
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(pagination);
let history = sqlx::query_as!(
GroupPermissionChange,
"SELECT id, changed_by, changed_at, change_type, member_affected
FROM group_permission_history
WHERE workspace_id = $1 AND group_name = $2
ORDER BY id DESC
LIMIT $3 OFFSET $4",
w_id,
name,
per_page as i64,
offset as i64
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(axum::Json(history))
}

View File

@@ -271,6 +271,16 @@ async fn create_group(
)
.await?;
log_group_permission_change(
&mut *tx,
&w_id,
&ng.name,
&authed.username,
"create",
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
@@ -542,6 +552,17 @@ async fn update_group(
None,
)
.await?;
log_group_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"update_summary",
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
@@ -572,7 +593,7 @@ async fn add_user(
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
sqlx::query!(
let result = sqlx::query!(
"INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
&w_id,
user_username,
@@ -581,6 +602,10 @@ async fn add_user(
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Ok(format!("{} is already a member of group {}", user_username, name));
}
audit_log(
&mut *tx,
&authed,
@@ -591,6 +616,17 @@ async fn add_user(
Some([("user", user_username.as_str())].into()),
)
.await?;
log_group_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"add_member",
Some(&user_username),
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
@@ -851,6 +887,16 @@ async fn remove_user(
)
.await?;
log_group_permission_change(
&mut *tx,
&w_id,
&name,
&authed.username,
"remove_member",
Some(&user_username),
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
@@ -981,3 +1027,26 @@ async fn overwrite_igroups() -> JsonResult<String> {
"This feature is only available in the enterprise version".to_string(),
))
}
pub async fn log_group_permission_change<'c, E: sqlx::Executor<'c, Database = Postgres>>(
db: E,
workspace_id: &str,
group_name: &str,
changed_by: &str,
change_type: &str,
member_affected: Option<&str>,
) -> Result<()> {
sqlx::query!(
"INSERT INTO group_permission_history
(workspace_id, group_name, changed_by, change_type, member_affected)
VALUES ($1, $2, $3, $4, $5)",
workspace_id,
group_name,
changed_by,
change_type,
member_affected
)
.execute(db)
.await?;
Ok(())
}

View File

@@ -94,8 +94,10 @@ mod favorite;
mod flow_conversations;
pub mod flows;
mod folders;
mod folder_history;
mod granular_acls;
mod groups;
mod group_history;
#[cfg(feature = "private")]
pub mod indexer_ee;
mod indexer_oss;
@@ -466,7 +468,9 @@ pub async fn run_server(
flow_conversations::workspaced_service(),
)
.nest("/folders", folders::workspaced_service())
.nest("/folders_history", folder_history::workspaced_service())
.nest("/groups", groups::workspaced_service())
.nest("/groups_history", group_history::workspaced_service())
.nest("/inputs", inputs::workspaced_service())
.nest("/job_metrics", job_metrics::workspaced_service())
.nest("/job_helpers", job_helpers_service)

View File

@@ -29,7 +29,7 @@ use regex::Regex;
use hex;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
@@ -172,11 +172,16 @@ pub fn workspaced_service() -> Router {
post(acknowledge_all_critical_alerts),
)
.route("/critical_alerts/mute", post(mute_critical_alerts))
.route("/operator_settings", post(update_operator_settings))
.route(
"/create_workspace_fork_branch",
post(create_workspace_fork_branch),
)
.route("/operator_settings", post(update_operator_settings));
.route(
"/reset_diff_tally/:fork_workspace_id",
post(reset_workspace_diffs),
)
.route("/compare/:target_workspace_id", get(compare_workspaces));
#[cfg(all(feature = "stripe", feature = "enterprise"))]
{
@@ -3322,11 +3327,73 @@ async fn archive_workspace(
authed: ApiAuthed,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
// Step 1: Disable all schedules and clear their queued jobs
let mut tx = db.begin().await?;
let disabled_schedules = sqlx::query_scalar!(
"UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path",
&w_id
)
.fetch_all(&mut *tx)
.await?;
let schedules_count = disabled_schedules.len();
tracing::info!(
"Disabled {} schedules in workspace {}",
schedules_count,
w_id
);
// Clear all schedule-related jobs using the existing clear_schedule function
for schedule_path in &disabled_schedules {
crate::schedule::clear_schedule(&mut tx, schedule_path, &w_id).await?;
}
tx.commit().await?;
// Step 2: Get all remaining queued jobs for this workspace (non-schedule jobs)
let jobs_to_cancel =
sqlx::query_scalar!("SELECT id FROM v2_job_queue WHERE workspace_id = $1", &w_id)
.fetch_all(&db)
.await?;
let jobs_count = jobs_to_cancel.len();
tracing::info!(
"Found {} remaining jobs to cancel in workspace {}",
jobs_count,
w_id
);
// Step 3: Cancel all remaining jobs using the existing cancel_jobs function
let canceled_count = if !jobs_to_cancel.is_empty() {
let axum::Json(canceled_jobs) = crate::jobs::cancel_jobs(
jobs_to_cancel,
&db,
&authed.username,
&w_id,
false, // force_cancel
)
.await?;
let count = canceled_jobs.len();
tracing::info!("Canceled {} jobs in workspace {}", count, w_id);
count
} else {
0
};
// Step 4: Archive the workspace
let mut tx = db.begin().await?;
sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", &w_id)
.execute(&mut *tx)
.await?;
let mut audit_params = HashMap::new();
audit_params.insert("disabled_schedules", schedules_count.to_string());
audit_params.insert("canceled_jobs", canceled_count.to_string());
let audit_params_refs: HashMap<&str, &str> =
audit_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
audit_log(
&mut *tx,
&authed,
@@ -3334,12 +3401,15 @@ async fn archive_workspace(
ActionKind::Update,
&w_id,
Some(&authed.email),
None,
Some(audit_params_refs),
)
.await?;
tx.commit().await?;
Ok(format!("Archived workspace {}", &w_id))
Ok(format!(
"Archived workspace {}, disabled {} schedules and canceled {} jobs",
&w_id, schedules_count, canceled_count
))
}
async fn leave_workspace(
@@ -4042,3 +4112,611 @@ async fn update_operator_settings(
Ok("Operator settings updated successfully".to_string())
}
#[derive(Serialize)]
pub struct WorkspaceComparison {
pub all_ahead_items_visible: bool,
pub all_behind_items_visible: bool,
pub skipped_comparison: bool,
pub diffs: Vec<WorkspaceDiffRow>,
pub summary: CompareSummary,
}
#[derive(Serialize, Default)]
pub struct CompareSummary {
pub total_diffs: usize,
pub total_ahead: usize,
pub total_behind: usize,
pub scripts_changed: usize,
pub flows_changed: usize,
pub apps_changed: usize,
pub resources_changed: usize,
pub variables_changed: usize,
pub conflicts: usize, // Items that are both ahead and behind
}
async fn reset_workspace_diffs(
authed: ApiAuthed,
Path((w_id, target_workspace_id)): Path<(String, String)>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<()> {
// Needed to compute the has_changes: Option<bool>. Otherwise it will be None, and the query will not hit the items
let _ = compare_workspaces(
authed,
Path((w_id.clone(), target_workspace_id.clone())),
Extension(db.clone()),
Extension(user_db),
)
.await?;
sqlx::query!(
"DELETE FROM workspace_diff WHERE has_changes = false AND (
(source_workspace_id = $1 AND fork_workspace_id = $2)
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
)",
target_workspace_id,
w_id,
)
.execute(&db)
.await?;
Ok(Json(()))
}
#[derive(Serialize, Debug, Clone, Default)]
pub struct WorkspaceDiffRow {
kind: String,
path: String,
ahead: i32,
behind: i32,
has_changes: Option<bool>,
exists_in_source: Option<bool>,
exists_in_fork: Option<bool>,
}
async fn compare_workspaces(
authed: ApiAuthed,
Path((source_workspace_id, fork_workspace_id)): Path<(String, String)>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<WorkspaceComparison> {
// require_admin(authed.is_admin, &authed.username)?;
let skipped_comparison: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM skip_workspace_diff_tally
WHERE workspace_id = $1
)",
)
.bind(&fork_workspace_id)
.fetch_one(&db)
.await?;
if skipped_comparison {
return Ok(Json(WorkspaceComparison {
all_ahead_items_visible: true,
all_behind_items_visible: true,
skipped_comparison,
diffs: vec![],
summary: Default::default(),
}));
}
let diff_items = sqlx::query_as!(
WorkspaceDiffRow,
"SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff
WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
source_workspace_id,
fork_workspace_id,
)
.fetch_all(&db)
.await?;
let mut confirmed_diffs = vec![];
for item in diff_items {
if let Some(has_changes) = item.has_changes {
if has_changes {
confirmed_diffs.push(item);
}
continue;
}
let item_comparison = match item.kind.as_str() {
"script" => Some(
compare_two_scripts(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
"flow" => Some(
compare_two_flows(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
"app" => Some(
compare_two_apps(&db, &source_workspace_id, &fork_workspace_id, &item.path).await?,
),
"resource" => Some(
compare_two_resources(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
"variable" => Some(
compare_two_variables(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
k => {
tracing::error!("Received unrecognized item kind `{k}` with path: `{}` while computing diff of {fork_workspace_id} and {source_workspace_id} workspaces. Skipping this item", item.path);
None
// Some(ItemComparison {
// has_changes: true,
// exists_in_source: true,
// exists_in_fork: true,
// })
}
};
if let Some(item_comparison) = item_comparison {
if item_comparison.has_changes {
sqlx::query!(
"UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6
WHERE path = $3 AND kind = $4 AND (
(source_workspace_id = $1 AND fork_workspace_id = $2)
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
)",
source_workspace_id,
fork_workspace_id,
item.path,
item.kind,
item_comparison.exists_in_source,
item_comparison.exists_in_fork,
)
.execute(&db)
.await?;
confirmed_diffs.push(WorkspaceDiffRow {
has_changes: Some(item_comparison.has_changes),
exists_in_source: Some(item_comparison.exists_in_source),
exists_in_fork: Some(item_comparison.exists_in_fork),
..item
});
} else {
sqlx::query!(
"DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND (
(source_workspace_id = $1 AND fork_workspace_id = $2)
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
)",
source_workspace_id,
fork_workspace_id,
item.path,
item.kind,
)
.execute(&db)
.await?;
}
}
}
let visible_diffs = filter_visible_diffs(
&confirmed_diffs,
&source_workspace_id,
&fork_workspace_id,
user_db.begin(&authed).await?,
)
.await?;
let summary = CompareSummary {
total_diffs: visible_diffs.len(),
total_ahead: visible_diffs
.iter()
.map(|s| s.ahead)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0)),
total_behind: visible_diffs
.iter()
.map(|s| s.behind)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0)),
scripts_changed: visible_diffs.iter().filter(|s| s.kind == "script").count(),
flows_changed: visible_diffs.iter().filter(|s| s.kind == "flow").count(),
apps_changed: visible_diffs.iter().filter(|s| s.kind == "app").count(),
resources_changed: visible_diffs
.iter()
.filter(|s| s.kind == "resource")
.count(),
variables_changed: visible_diffs
.iter()
.filter(|s| s.kind == "variable")
.count(),
conflicts: visible_diffs
.iter()
.filter(|s| s.ahead > 0 && s.behind > 0)
.count(),
};
let all_ahead_items_visible = summary.total_ahead
== confirmed_diffs
.iter()
.map(|s| s.ahead)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0));
let all_behind_items_visible = summary.total_behind
== confirmed_diffs
.iter()
.map(|s| s.behind)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0));
return Ok(Json(WorkspaceComparison {
all_ahead_items_visible,
all_behind_items_visible,
skipped_comparison: false,
diffs: visible_diffs,
summary,
}));
}
async fn filter_visible_diffs(
confirmed_diffs: &[WorkspaceDiffRow],
source_workspace_id: &str,
fork_workspace_id: &str,
mut tx: Transaction<'static, Postgres>,
) -> Result<Vec<WorkspaceDiffRow>> {
// Step 1: Group paths by (workspace, kind)
let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new();
let mut fork_items: HashMap<&str, Vec<&str>> = HashMap::new();
for diff in confirmed_diffs {
if diff.exists_in_source.unwrap_or(false) {
source_items.entry(&diff.kind).or_default().push(&diff.path);
}
if diff.exists_in_fork.unwrap_or(false) {
fork_items.entry(&diff.kind).or_default().push(&diff.path);
}
}
// Step 2: Batch query for each (workspace, kind) combination
let source_visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?;
let fork_visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?;
// Step 3: Filter diffs based on visibility
let visible_diffs: Vec<WorkspaceDiffRow> = confirmed_diffs
.iter()
.filter(|diff| {
let v = (diff.kind.to_string(), diff.path.to_string());
let source_ok = !diff.exists_in_source.unwrap_or(false) || source_visible.contains(&v);
let fork_ok = !diff.exists_in_fork.unwrap_or(false) || fork_visible.contains(&v);
source_ok && fork_ok
})
.cloned()
.collect();
Ok(visible_diffs)
}
async fn query_visible_items<'c>(
tx: &mut Transaction<'c, Postgres>,
workspace_id: &str,
items_by_kind: &HashMap<&str, Vec<&str>>,
) -> Result<HashSet<(String, String)>> {
let mut visible = HashSet::new();
for (kind, paths) in items_by_kind {
let paths_vec: Vec<String> = paths.iter().map(|s| s.to_string()).collect();
let results = match *kind {
"script" => {
sqlx::query_scalar!(
"SELECT path FROM script
WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
"flow" => {
sqlx::query_scalar!(
"SELECT path FROM flow
WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
"app" => {
sqlx::query_scalar!(
"SELECT path FROM app
WHERE workspace_id = $1 AND path = ANY($2)",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
"resource" => {
sqlx::query_scalar!(
"SELECT path FROM resource
WHERE workspace_id = $1 AND path = ANY($2)",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
"variable" => {
sqlx::query_scalar!(
"SELECT path FROM variable
WHERE workspace_id = $1 AND path = ANY($2)",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
_ => vec![], // Unknown kind
};
for path in results {
visible.insert((kind.to_string(), path));
}
}
Ok(visible)
}
#[derive(Debug)]
struct ItemComparison {
has_changes: bool,
exists_in_source: bool,
exists_in_fork: bool,
}
async fn compare_two_scripts(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get latest script from each workspace
let source_script = sqlx::query!(
"SELECT hash, created_at, content, summary, description, lock, schema
FROM script
WHERE workspace_id = $1 AND path = $2 AND archived = false
ORDER BY created_at DESC
LIMIT 1",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_script = sqlx::query!(
"SELECT hash, created_at, content, summary, description, lock, schema
FROM script
WHERE workspace_id = $1 AND path = $2 AND archived = false
ORDER BY created_at DESC
LIMIT 1",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata differences
if let (Some(source), Some(target)) = (&source_script, &target_script) {
if source.content != target.content
|| source.summary != target.summary
|| source.description != target.description
|| source.lock != target.lock
|| source.schema != target.schema
{
has_changes = true;
}
} else if source_script.is_some() || target_script.is_some() {
// The script exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_script.is_some(),
exists_in_fork: target_script.is_some(),
});
}
async fn compare_two_flows(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get latest flow from each workspace
let source_flow = sqlx::query!(
"SELECT value, summary, description, schema
FROM flow
WHERE workspace_id = $1 AND path = $2 AND archived = false",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_flow = sqlx::query!(
"SELECT value, summary, description, schema
FROM flow
WHERE workspace_id = $1 AND path = $2 AND archived = false",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata differences
if let (Some(source), Some(target)) = (&source_flow, &target_flow) {
if source.value != target.value
|| source.summary != target.summary
|| source.description != target.description
|| source.schema != target.schema
{
has_changes = true;
}
} else if source_flow.is_some() || target_flow.is_some() {
// The flow exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_flow.is_some(),
exists_in_fork: target_flow.is_some(),
});
}
async fn compare_two_apps(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get app with its latest version data from source workspace
let source_app = sqlx::query!(
"SELECT app.summary, app.policy, app_version.value
FROM app
JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_app = sqlx::query!(
"SELECT app.summary, app.policy, app_version.value
FROM app
JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata and content differences
if let (Some(source), Some(target)) = (&source_app, &target_app) {
if source.summary != target.summary
|| source.policy != target.policy
|| source.value != target.value
{
has_changes = true;
}
} else if source_app.is_some() || target_app.is_some() {
// The app exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_app.is_some(),
exists_in_fork: target_app.is_some(),
});
}
async fn compare_two_resources(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get resource from each workspace
let source_resource = sqlx::query!(
"SELECT value, description, resource_type
FROM resource
WHERE workspace_id = $1 AND path = $2",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_resource = sqlx::query!(
"SELECT value, description, resource_type
FROM resource
WHERE workspace_id = $1 AND path = $2",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata differences
if let (Some(source), Some(target)) = (&source_resource, &target_resource) {
if source.value != target.value
|| source.description != target.description
|| source.resource_type != target.resource_type
{
has_changes = true;
}
} else if source_resource.is_some() || target_resource.is_some() {
// The resource exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_resource.is_some(),
exists_in_fork: target_resource.is_some(),
});
}
async fn compare_two_variables(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get variable from each workspace
let source_variable = sqlx::query!(
"SELECT value, is_secret, description
FROM variable
WHERE workspace_id = $1 AND path = $2",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_variable = sqlx::query!(
"SELECT value, is_secret, description
FROM variable
WHERE workspace_id = $1 AND path = $2",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata differences
if let (Some(source), Some(target)) = (&source_variable, &target_variable) {
if source.is_secret != target.is_secret
|| source.value != target.value
|| source.description != target.description
{
has_changes = true;
}
} else if source_variable.is_some() || target_variable.is_some() {
// The variable exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_variable.is_some(),
exists_in_fork: target_variable.is_some(),
});
}

View File

@@ -28,7 +28,7 @@ lazy_static! {
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AgentAuth {
pub worker_group: String,
pub suffix: Option<String>,
pub suffix: Option<String>,
pub tags: Vec<String>,
pub exp: Option<usize>,
}

View File

@@ -102,4 +102,30 @@ impl DeployedObject {
DeployedObject::Key { .. } => None,
}
}
pub fn get_kind(&self) -> String {
match self {
DeployedObject::Script { .. } => "script",
DeployedObject::Flow { .. } => "flow",
DeployedObject::App { .. } => "app",
DeployedObject::Folder { .. } => "folder",
DeployedObject::Resource { .. } => "resource",
DeployedObject::Variable { .. } => "variable",
DeployedObject::Schedule { .. } => "schedule",
DeployedObject::ResourceType { .. } => "resource_type",
DeployedObject::User { .. } => "user",
DeployedObject::Group { .. } => "group",
DeployedObject::HttpTrigger { .. } => "http_trigger",
DeployedObject::WebsocketTrigger { .. } => "websocket_trigger",
DeployedObject::KafkaTrigger { .. } => "kafka_trigger",
DeployedObject::NatsTrigger { .. } => "nats_trigger",
DeployedObject::PostgresTrigger { .. } => "postgres_trigger",
DeployedObject::MqttTrigger { .. } => "mqtt_trigger",
DeployedObject::SqsTrigger { .. } => "sqs_trigger",
DeployedObject::GcpTrigger { .. } => "gcp_trigger",
DeployedObject::EmailTrigger { .. } => "email_trigger",
DeployedObject::Settings { .. } => "settings",
DeployedObject::Key { .. } => "key",
}.to_string()
}
}

View File

@@ -756,6 +756,7 @@ pub async fn add_completed_job_error(
false,
false,
)
.warn_after_seconds(10)
.await?;
Ok(result)
}
@@ -812,6 +813,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
has_stream,
from_cache,
)
.warn_after_seconds(10)
})
.retry(
ConstantBuilder::default()
@@ -872,7 +874,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
) -> windmill_common::error::Result<(Option<Uuid>, i64, bool)> {
// let start = std::time::Instant::now();
let mut tx = db.begin().await?;
let mut tx = db.begin().warn_after_seconds(10).await?;
let job_id = queued_job.id;
// tracing::error!("1 {:?}", start.elapsed());
@@ -927,6 +929,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
/* $10 */ result_columns as Option<&Vec<String>>,
)
.fetch_optional(&mut *tx)
.warn_after_seconds(10)
.await
.map_err(|e| Error::internal_err(format!("Could not add completed job {job_id}: {e:#}")))?;
@@ -938,6 +941,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
job_id
)
.fetch_one(&mut *tx)
.warn_after_seconds(10)
.await
.map_err(|e| Error::internal_err(format!("Could not add completed job {job_id}: {e:#}")))?
.unwrap_or(false);
@@ -963,6 +967,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
labels as Vec<String>
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
.map_err(|e| Error::InternalErr(format!("Could not update job labels: {e:#}")))?;
}
@@ -986,6 +991,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
parent_job
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
.inspect_err(|e| {
tracing::error!(
@@ -998,7 +1004,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
// tracing::error!("Added completed job {:#?}", queued_job);
let mut _skip_downstream_error_handlers = false;
tx = delete_job(tx, &job_id).await?;
tx = delete_job(tx, &job_id).warn_after_seconds(10).await?;
// tracing::error!("3 {:?}", start.elapsed());
if queued_job.is_flow_step() {
@@ -1019,13 +1025,14 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
&queued_job.workspace_id
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await?;
if flow_is_done {
let r = sqlx::query_scalar!(
"UPDATE parallel_monitor_lock SET last_ping = now() WHERE parent_flow_id = $1 and job_id = $2 RETURNING 1",
parent_job,
&queued_job.id
).fetch_optional(&mut *tx).await?;
).fetch_optional(&mut *tx).warn_after_seconds(10).await?;
if r.is_some() {
tracing::info!(
"parallel flow iteration is done, setting parallel monitor last ping lock for job {}",
@@ -1039,8 +1046,9 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
let schedule_path = queued_job.schedule_path().unwrap();
let script_path = queued_job.runnable_path.as_ref().unwrap();
let schedule =
get_schedule_opt(&mut *tx, &queued_job.workspace_id, &schedule_path).await?;
let schedule = get_schedule_opt(&mut *tx, &queued_job.workspace_id, &schedule_path)
.warn_after_seconds(10)
.await?;
if let Some(schedule) = schedule {
#[cfg(feature = "enterprise")]
@@ -1072,6 +1080,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
&queued_job.workspace_id
)
.fetch_optional(&mut *tx)
.warn_after_seconds(10)
.await?
.flatten()
.unwrap_or(false);
@@ -1084,6 +1093,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
&script_path,
&queued_job.workspace_id,
))
.warn_after_seconds(10)
.await
{
match err {
@@ -1106,6 +1116,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
queued_job.started_at.unwrap_or(chrono::Utc::now()),
queued_job.priority,
)
.warn_after_seconds(10)
.await
{
if !success {
@@ -1122,6 +1133,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
err
),
)
.warn_after_seconds(10)
.await;
}
} else {
@@ -1148,6 +1160,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
queued_job.id.hyphenated().to_string(),
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
.map_err(|e| {
Error::internal_err(format!(
@@ -1162,6 +1175,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
queued_job.id,
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
{
tracing::error!(
@@ -1174,15 +1188,17 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
sqlx::query!("DELETE FROM job_perms WHERE job_id = $1", job_id)
.execute(&mut *tx)
.warn_after_seconds(10)
.await?;
if !success || has_stream {
sqlx::query!("DELETE FROM job_result_stream_v2 WHERE job_id = $1", job_id)
.execute(&mut *tx)
.warn_after_seconds(10)
.await?;
}
tx.commit().await?;
tx.commit().warn_after_seconds(10).await?;
tracing::info!(
%job_id,

View File

@@ -27,6 +27,7 @@ use windmill_common::{
client::AuthedClient,
s3_helpers::BundleFormat,
scripts::{id_to_codebase_info, CodebaseInfo},
utils::WarnAfterExt,
workspace_dependencies::WorkspaceDependenciesPrefetched,
};
@@ -367,6 +368,7 @@ pub async fn install_bun_lockfile(
None,
None,
)
.warn_after_seconds(10)
.await?;
} else {
Box::into_pin(child_process.wait()).await?;

View File

@@ -161,6 +161,7 @@ async fn process_jc(
bench,
)
.instrument(span)
.warn_after_seconds(10)
.await;
if let Some(root_job) = root_job {
@@ -291,6 +292,7 @@ pub fn start_background_processor(
#[cfg(feature = "benchmark")]
&mut bench,
)
.warn_after_seconds(10)
.await;
if is_init_script_and_failure {
@@ -544,6 +546,7 @@ pub async fn handle_receive_completed_job(
#[cfg(feature = "benchmark")]
bench,
)
.warn_after_seconds(10)
.await;
match processed_completed_job {
@@ -807,6 +810,7 @@ pub async fn handle_job_error(
err_json.clone(),
worker_name,
)
.warn_after_seconds(10)
.await
};
@@ -850,7 +854,9 @@ pub async fn handle_job_error(
if let Err(err) = updated_flow {
if let Some(parent_job_id) = job.parent_job {
if let Ok(Some(parent_job)) =
get_mini_completed_job(&parent_job_id, &job.workspace_id, db).await
get_mini_completed_job(&parent_job_id, &job.workspace_id, db)
.warn_after_seconds(10)
.await
{
let e = json!({"message": err.to_string(), "name": "InternalErr"});
append_logs(
@@ -870,6 +876,7 @@ pub async fn handle_job_error(
false,
None,
)
.warn_after_seconds(10)
.await;
}
}

View File

@@ -2723,8 +2723,11 @@ async fn capture_dependency_job(
.await?
}
ScriptLang::Bun | ScriptLang::Bunnative => {
let wd_exist = workspace_dependencies.get_bun()?.is_some();
// TODO: move inside gen_bun_lockfile
write_file(job_dir, "main.ts", job_raw_code)?;
if !wd_exist {
write_file(job_dir, "main.ts", job_raw_code)?;
}
if let Some(lock) = gen_bun_lockfile(
mem_peak,
canceled_by,
@@ -2743,20 +2746,22 @@ async fn capture_dependency_job(
)
.await?
{
crate::bun_executor::prebundle_bun_script(
job_raw_code,
&lock,
script_path,
job_id,
w_id,
Some(&db),
&job_dir,
base_internal_url,
worker_name,
&token,
&mut Some(occupancy_metrics),
)
.await?;
if !wd_exist {
crate::bun_executor::prebundle_bun_script(
job_raw_code,
&lock,
script_path,
job_id,
w_id,
Some(&db),
&job_dir,
base_internal_url,
worker_name,
&token,
&mut Some(occupancy_metrics),
)
.await?;
}
lock
} else {

View File

@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.591.3";
export const VERSION = "v1.592.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({

4
cli/deno.lock generated
View File

@@ -65,6 +65,7 @@
"jsr:@windmill-labs/cliffy-table@1.0.0-rc.5": "1.0.0-rc.5",
"jsr:@windmill-labs/cliffy-table@^1.0.0-rc.5": "1.0.0-rc.5",
"jsr:@windmill-labs/shared-utils@1.0.10": "1.0.10",
"jsr:@windmill-labs/shared-utils@1.0.11": "1.0.11",
"jsr:@windmill-labs/shared-utils@1.0.3": "1.0.3",
"jsr:@windmill-labs/shared-utils@1.0.5": "1.0.5",
"jsr:@windmill-labs/shared-utils@1.0.6": "1.0.6",
@@ -363,6 +364,9 @@
},
"@windmill-labs/shared-utils@1.0.10": {
"integrity": "bd1993eb8d693c8ba49da1618f82ff4601eeb59011b2cac13e664291f7a299d8"
},
"@windmill-labs/shared-utils@1.0.11": {
"integrity": "4878a841480ad98213759495d72d40be1aebbbacc693f8aa9fc649127722580b"
}
},
"npm": {

View File

@@ -29,7 +29,7 @@ import {
loadRunnablesFromBackend,
writeRunnableToBackend,
} from "./raw_apps.ts";
import { replaceInlineScripts, AppFile as NormalAppFile } from "./apps.ts";
import { replaceInlineScripts, AppFile as NormalAppFile } from "./app.ts";
import {
newPathAssigner,
newRawAppPathAssigner,

View File

@@ -28,7 +28,7 @@ import * as wmill from "../../../gen/services.gen.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { requireLogin } from "../../core/auth.ts";
import { GLOBAL_CONFIG_OPT } from "../../core/conf.ts";
import { replaceInlineScripts } from "./apps.ts";
import { replaceInlineScripts } from "./app.ts";
import { Runnable } from "./metadata.ts";
import {
APP_BACKEND_FOLDER,

View File

@@ -15,7 +15,7 @@ import path from "node:path";
import { GlobalOptions, isSuperset } from "../../types.ts";
import { replaceInlineScripts, repopulateFields } from "./apps.ts";
import { replaceInlineScripts, repopulateFields } from "./app.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { writeIfChanged } from "../../utils/utils.ts";

View File

@@ -1,2 +1,60 @@
export { pullGitSyncSettings, pushGitSyncSettings } from "./index.ts";
export { default } from "./index.ts";
import { Command } from "../../../deps.ts";
import { pullGitSyncSettings } from "./pull.ts";
import { pushGitSyncSettings } from "./push.ts";
const command = new Command()
.description(
"Manage git-sync settings between local wmill.yaml and Windmill backend",
)
.command("pull")
.description(
"Pull git-sync settings from Windmill backend to local wmill.yaml",
)
.option(
"--repository <repo:string>",
"Specify repository path (e.g., u/user/repo)",
)
.option(
"--default",
"Write settings to top-level defaults instead of overrides",
)
.option("--replace", "Replace existing settings (non-interactive mode)")
.option(
"--override",
"Add branch-specific override (non-interactive mode)",
)
.option("--diff", "Show differences without applying changes")
.option("--json-output", "Output in JSON format")
.option(
"--with-backend-settings <json:string>",
"Use provided JSON settings instead of querying backend (for testing)",
)
.option("--yes", "Skip interactive prompts and use default behavior")
.option(
"--promotion <branch:string>",
"Use promotionOverrides from the specified branch instead of regular overrides"
)
.action(pullGitSyncSettings as any)
.command("push")
.description(
"Push git-sync settings from local wmill.yaml to Windmill backend",
)
.option(
"--repository <repo:string>",
"Specify repository path (e.g., u/user/repo)",
)
.option("--diff", "Show what would be pushed without applying changes")
.option("--json-output", "Output in JSON format")
.option(
"--with-backend-settings <json:string>",
"Use provided JSON settings instead of querying backend (for testing)",
)
.option("--yes", "Skip interactive prompts and use default behavior")
.option(
"--promotion <branch:string>",
"Use promotionOverrides from the specified branch instead of regular overrides"
)
.action(pushGitSyncSettings as any);
export { pullGitSyncSettings, pushGitSyncSettings };
export default command;

View File

@@ -1,60 +1,2 @@
import { Command } from "../../../deps.ts";
import { pullGitSyncSettings } from "./pull.ts";
import { pushGitSyncSettings } from "./push.ts";
const command = new Command()
.description(
"Manage git-sync settings between local wmill.yaml and Windmill backend",
)
.command("pull")
.description(
"Pull git-sync settings from Windmill backend to local wmill.yaml",
)
.option(
"--repository <repo:string>",
"Specify repository path (e.g., u/user/repo)",
)
.option(
"--default",
"Write settings to top-level defaults instead of overrides",
)
.option("--replace", "Replace existing settings (non-interactive mode)")
.option(
"--override",
"Add branch-specific override (non-interactive mode)",
)
.option("--diff", "Show differences without applying changes")
.option("--json-output", "Output in JSON format")
.option(
"--with-backend-settings <json:string>",
"Use provided JSON settings instead of querying backend (for testing)",
)
.option("--yes", "Skip interactive prompts and use default behavior")
.option(
"--promotion <branch:string>",
"Use promotionOverrides from the specified branch instead of regular overrides"
)
.action(pullGitSyncSettings as any)
.command("push")
.description(
"Push git-sync settings from local wmill.yaml to Windmill backend",
)
.option(
"--repository <repo:string>",
"Specify repository path (e.g., u/user/repo)",
)
.option("--diff", "Show what would be pushed without applying changes")
.option("--json-output", "Output in JSON format")
.option(
"--with-backend-settings <json:string>",
"Use provided JSON settings instead of querying backend (for testing)",
)
.option("--yes", "Skip interactive prompts and use default behavior")
.option(
"--promotion <branch:string>",
"Use promotionOverrides from the specified branch instead of regular overrides"
)
.action(pushGitSyncSettings as any);
export { pullGitSyncSettings, pushGitSyncSettings };
export default command;
export { pullGitSyncSettings, pushGitSyncSettings } from "./gitsync-settings.ts";
export { default } from "./gitsync-settings.ts";

View File

@@ -5,6 +5,7 @@ import { SCRIPT_GUIDANCE } from "../../guidance/script_guidance.ts";
import { FLOW_GUIDANCE } from "../../guidance/flow_guidance.ts";
import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts";
import { generateRTNamespace } from "../resource-type/resource-type.ts";
import { CLI_COMMANDS } from "../../guidance/prompts.ts";
export interface InitOptions {
useDefault?: boolean;
@@ -240,28 +241,12 @@ async function initAction(opts: InitOptions) {
try {
const scriptGuidanceContent = SCRIPT_GUIDANCE;
const flowGuidanceContent = FLOW_GUIDANCE;
const cliCommandsContent = CLI_COMMANDS;
// Create .cursor/rules directory
await Deno.mkdir(".cursor/rules", { recursive: true });
// Create windmill.mdc file
if (!(await Deno.stat(".cursor/rules/script.mdc").catch(() => null))) {
// Create AGENTS.md file
if (!(await Deno.stat("AGENTS.md").catch(() => null))) {
await Deno.writeTextFile(
".cursor/rules/script.mdc",
scriptGuidanceContent
);
log.info(colors.green("Created .cursor/rules/script.mdc"));
}
if (!(await Deno.stat(".cursor/rules/flow.mdc").catch(() => null))) {
await Deno.writeTextFile(".cursor/rules/flow.mdc", flowGuidanceContent);
log.info(colors.green("Created .cursor/rules/flow.mdc"));
}
// Create CLAUDE.md file
if (!(await Deno.stat("CLAUDE.md").catch(() => null))) {
await Deno.writeTextFile(
"CLAUDE.md",
"AGENTS.md",
`
You are a helpful assistant that can help with Windmill scripts and flows creation.
@@ -270,8 +255,17 @@ ${scriptGuidanceContent}
## Flow Guidance
${flowGuidanceContent}
`
## CLI Commands
${cliCommandsContent}
`
);
log.info(colors.green("Created AGENTS.md"));
}
// Create CLAUDE.md file, referencing AGENTS.md
if (!(await Deno.stat("CLAUDE.md").catch(() => null))) {
await Deno.writeTextFile("CLAUDE.md", "Instructions are in @AGENTS.md");
log.info(colors.green("Created CLAUDE.md"));
}
} catch (error) {

View File

@@ -75,7 +75,7 @@ import {
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { isExecutionModeAnonymous } from "../app/apps.ts";
import { isExecutionModeAnonymous } from "../app/app.ts";
import {
APP_BACKEND_FOLDER,
generateAppLocksInternal,

View File

@@ -5,7 +5,7 @@ import { AIConfig, Config, GlobalSetting } from "../../gen/types.gen.ts";
import { compareInstanceObjects, InstanceSyncOptions } from "../commands/instance/instance.ts";
import { isSuperset } from "../types.ts";
import { deepEqual } from "../utils/utils.ts";
import { removeWorkerPrefix } from "../commands/worker-groups/worker_groups.ts";
import { removeWorkerPrefix } from "../commands/worker-groups/worker-groups.ts";
import { decrypt, encrypt } from "../utils/local_encryption.ts";
export interface SimplifiedSettings {

View File

@@ -1,434 +1,17 @@
export const FLOW_GUIDANCE = `
---
alwaysApply: true
---
// CLI Flow Guidance - Uses centralized prompts from system_prompts/
import * as prompts from "./prompts.ts";
# System Prompt: OpenFlow Workflow Generator
You are an expert at creating OpenFlow YAML specifications for Windmill workflows.
// CLI-specific introduction
const CLI_INTRO = `You are an expert at creating OpenFlow YAML specifications for Windmill workflows.
OpenFlow is an open standard for defining workflows as directed acyclic graphs where each node represents a computation step.
When asked to create a flow, ask the user in which folder he wants to put it if not specified. Then create a new folder in the specified folder, that ends with \`.flow\`. It should contain a \`.yaml\` file that contains the flow definition.
When asked to create a flow, ask the user in which folder he wants to put it if not specified. Then create a new folder in the specified folder, that ends with \`.flow\`. It should contain a \`.yaml\` file that contains the flow definition.
For rawscript type module in the flow, the content key should start with "!inline" followed by the path of the script containing the code. It should be put in the same folder as the flow.
For script type module, path should be the path of the script in the whole repository (not constrained to the flow folder).
You do not need to create .lock and .yaml files manually. Instead, you should run \`wmill flow generate-locks --yes\` to create them.
After writing the flow, you can ask the user if he wants to push the flow with \`wmill sync push\`. Both should be run at the root of the repository.
You do not need to create .lock and .yaml files manually. Instead, you should run \`wmill flow generate-locks --yes\` to create them.`;
## OpenFlow Structure
// Assemble complete flow guidance
export const FLOW_GUIDANCE = `
${CLI_INTRO}
Every OpenFlow workflow must follow this root structure:
\`\`\`yaml
summary: "Brief one-line description"
description: "Optional detailed description"
value:
modules: [] # Array of workflow steps
# Optional properties:
failure_module: {} # Error handler
preprocessor_module: {} # Runs before first step
same_worker: false # Force same worker execution
concurrent_limit: 0 # Limit concurrent executions
concurrency_key: "string" # Custom concurrency grouping
concurrency_time_window_s: 0
custom_debounce_key: "key"
debounce_delay_s: 0
skip_expr: "javascript_expression" # Skip workflow condition
cache_ttl: 0 # Cache results duration
priority: 0 # Execution priority
early_return: "javascript_expression" # Early termination condition
schema: # JSON Schema for workflow inputs
type: object
properties: {}
required: []
\`\`\`
## Module Types
### 1. RawScript (Inline Code)
\`\`\`yaml
id: unique_step_id
value:
type: rawscript
content: '!inline inline_script_1.inline_script.ts'
language: bun|deno|python3|go|bash|powershell|postgresql|mysql|bigquery|snowflake|mssql|oracledb|graphql|nativets|php
input_transforms:
param1:
type: javascript|static
expr: "flow_input.name" # or for static: value: "fixed_value"
# Optional properties:
path: "optional/path"
lock: "dependency_lock_content"
tag: "version_tag"
concurrent_limit: 0
concurrency_time_window_s: 0
custom_concurrency_key: "key"
custom_debounce_key: "key"
debounce_delay_s: 0
is_trigger: false
assets: []
\`\`\`
### 2. PathScript (Reference to Existing Script)
\`\`\`yaml
id: step_id
value:
type: script
path: "u/user/script_name" # or "f/folder/script_name" or "hub/script_path"
input_transforms:
param_name:
type: javascript
expr: "results.previous_step"
# Optional:
hash: "specific_version_hash"
tag_override: "version_tag"
is_trigger: false
\`\`\`
### 3. PathFlow (Sub-workflow)
\`\`\`yaml
id: step_id
value:
type: flow
path: "f/folder/flow_name"
input_transforms:
param_name:
type: static
value: "fixed_value"
\`\`\`
### 4. ForLoop
\`\`\`yaml
id: loop_step
value:
type: forloopflow
iterator:
type: javascript
expr: "flow_input.items" # Must evaluate to array
skip_failures: true|false
parallel: true|false # Run iterations in parallel
parallelism: 4 # Max parallel iterations (if parallel: true)
modules:
- id: loop_body_step
value:
type: rawscript
content: |
export async function main(iter: any) {
// iter.value contains current item
// iter.index contains current index
return iter.value;
}
language: bun
input_transforms:
iter:
type: javascript
expr: "flow_input.iter"
\`\`\`
### 5. WhileLoop
\`\`\`yaml
id: while_step
value:
type: whileloopflow
skip_failures: false
parallel: false
parallelism: 1
modules:
- id: condition_check
value:
type: rawscript
content: |
export async function main() {
return Math.random() > 0.5; // Continue condition
}
language: bun
input_transforms: {}
\`\`\`
### 6. Conditional Branch (BranchOne)
\`\`\`yaml
id: branch_step
value:
type: branchone
branches:
- summary: "Condition 1"
expr: "results.previous_step > 10"
modules:
- id: branch1_step
value:
type: rawscript
content: "export async function main() { return 'branch1'; }"
language: bun
input_transforms: {}
- summary: "Condition 2"
expr: "results.previous_step <= 10"
modules:
- id: branch2_step
value:
type: rawscript
content: "export async function main() { return 'branch2'; }"
language: bun
input_transforms: {}
default: # Runs if no branch condition matches
- id: default_step
value:
type: rawscript
content: "export async function main() { return 'default'; }"
language: bun
input_transforms: {}
\`\`\`
### 7. Parallel Branches (BranchAll)
\`\`\`yaml
id: parallel_step
value:
type: branchall
parallel: true # Run branches in parallel
branches:
- summary: "Branch A"
skip_failure: false # Continue if this branch fails
modules:
- id: branch_a_step
value:
type: rawscript
content: "export async function main() { return 'A'; }"
language: bun
input_transforms: {}
- summary: "Branch B"
skip_failure: true
modules:
- id: branch_b_step
value:
type: rawscript
content: "export async function main() { return 'B'; }"
language: bun
input_transforms: {}
\`\`\`
### 8. Identity (Pass-through)
\`\`\`yaml
id: identity_step
value:
type: identity
flow: false # Set to true if this represents a sub-flow
\`\`\`
## Input Transforms & Data Flow
### JavaScript Expressions
Reference data using these variables in \`expr\` fields:
- \`flow_input.property_name\` - Access workflow inputs
- \`results.step_id\` - Access outputs from previous steps
- \`results.step_id.property\` - Access specific properties
- \`flow_input.iter.value\` - Current iteration value (in loops)
- \`flow_input.iter.index\` - Current iteration index (in loops)
### Static Values
\`\`\`yaml
input_transforms:
param_name:
type: static
value: "fixed_string" # Can be string, number, boolean, object, array
\`\`\`
### Resource References
\`\`\`yaml
input_transforms:
database:
type: static
value: "$res:f/folder/my_database" # Reference to stored resource
\`\`\`
## Advanced Module Properties
### Error Handling & Control Flow
\`\`\`yaml
id: step_id
value: # ... module definition
# Control flow options:
stop_after_if:
expr: "results.step_id.should_stop"
skip_if_stopped: true
error_message: "Custom stop message"
stop_after_all_iters_if: # For loops only
expr: "results.step_id.should_stop_loop"
skip_if_stopped: false
skip_if:
expr: "results.step_id.should_skip"
sleep:
type: javascript
expr: "flow_input.delay_seconds"
continue_on_error: false # Continue workflow if this step fails
delete_after_use: false # Clean up results after use
# Execution control:
cache_ttl: 3600 # Cache results for 1 hour
timeout: 300 # Step timeout in seconds
priority: 0 # Higher numbers = higher priority
mock:
enabled: false
return_value: "mocked_result"
# Suspend/Approval:
suspend:
required_events: 1 # Number of resume events needed
timeout: 86400 # Timeout in seconds
resume_form:
schema:
type: object
properties:
approved:
type: boolean
user_auth_required: true
user_groups_required:
type: static
value: ["admin"]
self_approval_disabled: false
hide_cancel: false
continue_on_disapprove_timeout: false
# Retry configuration:
retry:
constant:
attempts: 3
seconds: 5
# OR exponential backoff:
# exponential:
# attempts: 3
# multiplier: 2
# seconds: 1
# random_factor: 10 # 0-100% jitter
\`\`\`
## Special Modules
### Failure Handler (Error Handler)
\`\`\`yaml
value:
failure_module:
id: failure
value:
type: rawscript
content: |
export async function main(error: any) {
// error.message, error.step_id, error.name, error.stack
console.log("Flow failed:", error.message);
return error;
}
language: bun
input_transforms: {}
\`\`\`
### Preprocessor
\`\`\`yaml
value:
preprocessor_module:
id: preprocessor
value:
type: rawscript
content: |
export async function main() {
console.log("Flow starting...");
return "preprocessed";
}
language: bun
input_transforms: {}
\`\`\`
## Schema Definition
\`\`\`yaml
schema:
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
properties:
name:
type: string
description: "User name"
default: ""
email:
type: string
format: email
count:
type: integer
minimum: 1
maximum: 100
database:
type: object
format: "resource-postgresql" # Resource type reference
items:
type: array
items:
type: string
required: ["name", "email"]
order: ["name", "email", "count"] # UI field order
\`\`\`
## Best Practices
1. **Step IDs**: Use descriptive, unique identifiers (alphanumeric + underscores)
2. **Data Flow**: Chain steps using \`results.step_id\` references
3. **Error Handling**: Add failure_module for critical workflows
4. **Languages**: Use \`bun\` for TypeScript (fastest), \`python3\` for Python
5. **Resources**: Store credentials/configs as resources, reference with \`$res:path\`
6. **Loops**: Prefer \`parallel: true\` for independent iterations
7. **Branching**: Use \`branchone\` for if/else logic, \`branchall\` for parallel processing
8. **Schemas**: Always define input schemas for better UX and validation
## Example Complete Workflow
\`\`\`yaml
summary: "Process user data"
description: "Validates user input, processes data, and sends notifications"
value:
modules:
- id: validate_input
value:
type: rawscript
content: '!inline inline_script_0.inline_script.ts'
# script at path inline_script_0.inline_script.ts will contain
# export async function main(email: string, name: string) {
# if (!email.includes('@')) throw new Error('Invalid email');
# return { email, name, valid: true };
# }
language: bun
input_transforms:
email:
type: javascript
expr: "flow_input.email"
name:
type: javascript
expr: "flow_input.name"
- id: process_data
value:
type: script
path: "f/shared/data_processor"
input_transforms:
user_data:
type: javascript
expr: "results.validate_input"
- id: send_notification
value:
type: rawscript
content: '!inline inline_script_1.inline_script.ts'
# script at path inline_script_1.inline_script.ts will contain
# export async function main(processed_data: any) {
# console.log("Sending notification for:", processed_data.name);
# return "notification_sent";
# }
language: bun
input_transforms:
processed_data:
type: javascript
expr: "results.process_data"
schema:
type: object
properties:
email:
type: string
format: email
description: "User email address"
name:
type: string
description: "User full name"
required: ["email", "name"]
\`\`\`
When generating OpenFlow YAML, ensure proper indentation, valid YAML syntax, and logical step dependencies. Always include meaningful summaries and proper input transforms to connect workflow steps.
${prompts.FLOW_PROMPT}
`;

2624
cli/src/guidance/prompts.ts Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,439 +1,15 @@
export const SCRIPT_GUIDANCE = `
---
alwaysApply: true
---
// CLI Script Guidance - Uses centralized prompts from system_prompts/
import * as prompts from "./prompts.ts";
# Windmill Script Writing Guide - Universal System Prompt
You are a coding assistant for the Windmill platform. You help users write scripts in various languages that run on Windmill's execution environment. Each script should be placed in a folder. Ask the user in which folder he wants the script to be located at before starting coding.
// CLI-specific introduction
const CLI_INTRO = `Each script should be placed in a folder. Ask the user in which folder he wants the script to be located at before starting coding.
After writing a script, you do not need to create .lock and .yaml files manually. Instead, you can run \`wmill script generate-metadata\` bash command. This command takes no arguments. After writing the script, you can ask the user if he wants to push the script with \`wmill sync push\`. Both should be run at the root of the repository.
## General Principles
You can use \`wmill resource-type list --schema\` to list all resource types available. You should use that to know the type of the resource you need to use in your script. You can use grep if the output is too long.`;
On Windmill, scripts are executed in isolated environments with specific conventions:
// Assemble complete script guidance
export const SCRIPT_GUIDANCE = `
${CLI_INTRO}
- Scripts must export a main function
- Do not call the main function
- Libraries are installed automatically - do not show installation instructions
- Credentials and configuration are stored in resources and passed as parameters
- The windmill client (wmill) provides APIs for interacting with the platform
- You can use \`wmill resource-type list --schema\` to list all resource types available. You should use that to know the type of the resource you need to use in your script. You can use grep if the output is too long.
## Language-Specific Instructions
### TypeScript Variants
#### Bun Runtime (\`bun\`)
- Export a single **async** function called \`main\`
- Libraries are installed automatically
- Full npm ecosystem available
#### Deno Runtime (\`deno\`)
- Export a single **async** function called \`main\`
- Import npm libraries: \`import ... from "npm:{package}";\`
- Import deno libraries normally
- Libraries are installed automatically
#### TypeScript Resource Types & Windmill Client
**Resource Types:**
On Windmill, credentials and configuration are stored in resources and passed as parameters to main.
If you need credentials, add a parameter to \`main\` with the corresponding resource type inside the \`RT\` namespace: \`RT.Stripe\`.
Only use them if needed to satisfy instructions. Always use the RT namespace.
**Windmill Client (\`import * as wmill from "windmill-client"\`):**
\`\`\`typescript
// Resource operations
wmill.getResource(path?: string, undefinedIfEmpty?: boolean): Promise<any>
wmill.setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise<void>
// State management (persistent across executions)
wmill.getState(): Promise<any>
wmill.setState(state: any): Promise<void>
// Variables
wmill.getVariable(path: string): Promise<string>
wmill.setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise<void>
// Script execution
wmill.runScript(path?: string | null, hash_?: string | null, args?: Record<string, any> | null, verbose?: boolean): Promise<any>
wmill.runScriptAsync(path: string | null, hash_: string | null, args: Record<string, any> | null, scheduledInSeconds?: number | null): Promise<string>
wmill.waitJob(jobId: string, verbose?: boolean): Promise<any>
wmill.getResult(jobId: string): Promise<any>
wmill.getRootJobId(jobId?: string): Promise<string>
// S3 file operations (if S3 is configured)
wmill.loadS3File(s3object: S3Object, s3ResourcePath?: string | undefined): Promise<Uint8Array | undefined>
wmill.writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath?: string | undefined): Promise<S3Object>
// Flow operations
wmill.setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise<void>
wmill.getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise<any>
wmill.getResumeUrls(approver?: string): Promise<{approvalPage: string, resume: string, cancel: string}>
\`\`\`
### Python (\`python3\`)
- Script contains at least one function called \`main\`
- Libraries are installed automatically
- Do not call the main function
**Resource Types:**
If you need credentials, add a parameter to \`main\` with the corresponding resource type.
**Redefine** the type of needed resources before the main function as TypedDict (only include if actually needed).
Resource type name must be **IN LOWERCASE**.
If an import conflicts with a resource type name, **rename the imported object, not the type name**.
Import TypedDict from typing **if using it**.
**Windmill Client (\`import wmill\`):**
\`\`\`python
# Resource operations
wmill.get_resource(path: str, none_if_undefined: bool = False) -> dict | None
wmill.set_resource(path: str, value: Any, resource_type: str = "any") -> None
# State management
wmill.get_state() -> Any
wmill.set_state(value: Any) -> None
wmill.get_flow_user_state(key: str) -> Any
wmill.set_flow_user_state(key: str, value: Any) -> None
# Variables
wmill.get_variable(path: str) -> str
wmill.set_variable(path: str, value: str, is_secret: bool = False) -> None
# Script execution
wmill.run_script(path: str = None, hash_: str = None, args: dict = None, timeout = None, verbose: bool = False) -> Any
wmill.run_script_async(path: str = None, hash_: str = None, args: dict = None, scheduled_in_secs: int = None) -> str
wmill.wait_job(job_id: str, timeout = None, verbose: bool = False) -> Any
wmill.get_result(job_id: str) -> Any
# S3 operations
wmill.load_s3_file(s3object: S3Object | str, s3_resource_path: str | None = None) -> bytes
wmill.write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None = None) -> S3Object
# Utilities
wmill.get_workspace() -> str
wmill.whoami() -> dict
wmill.set_progress(value: int, job_id: Optional[str] = None) -> None
\`\`\`
### PHP (\`php\`)
- Script must start with \`<?php\`
- Contains at least one function called \`main\`
- **Redefine** resource types before main function (only if needed)
- Check if class exists using \`class_exists\` before defining types
- Resource type name must be exactly as specified
**Resource Types:**
If you need credentials, add a parameter to \`main\` with the corresponding resource type.
**Redefine** the type of needed resources before the main function.
Before defining each type, check if the class already exists using class_exists.
The resource type name has to be exactly as specified.
**Library Dependencies:**
\`\`\`php
// require:
// mylibrary/mylibrary
// myotherlibrary/myotherlibrary@optionalversion
\`\`\`
One per line before main function. Autoload already included.
### Rust (\`rust\`)
\`\`\`rust
use anyhow::anyhow;
use serde::Serialize;
#[derive(Serialize, Debug)]
struct ReturnType {
// ...
}
fn main(...) -> anyhow::Result<ReturnType>
\`\`\`
**Dependencies:**
\`\`\`\`rust
//! \`\`\`cargo
//! [dependencies]
//! anyhow = "1.0.86"
//! \`\`\`
\`\`\`\`
Serde already included. For async functions, keep main sync and create runtime inside.
### Go (\`go\`)
- File package must be "inner"
- Export single function called \`main\`
- Return type: \`({return_type}, error)\`
### Bash (\`bash\`)
- Do not include "#!/bin/bash"
- Arguments: \`var1="$1"\`, \`var2="$2"\`, etc.
### SQL Variants
#### PostgreSQL (\`postgresql\`)
- Arguments: \`$1::{type}\`, \`$2::{type}\`, etc.
- Name parameters: \`-- $1 name1\` or \`-- $2 name = default\`
#### MySQL (\`mysql\`)
- Arguments: \`?\` placeholders
- Name parameters: \`-- ? name1 ({type})\` or \`-- ? name2 ({type}) = default\`
#### BigQuery (\`bigquery\`)
- Arguments: \`@name1\`, \`@name2\`, etc.
- Name parameters: \`-- @name1 ({type})\` or \`-- @name2 ({type}) = default\`
#### Snowflake (\`snowflake\`)
- Arguments: \`?\` placeholders
- Name parameters: \`-- ? name1 ({type})\` or \`-- ? name2 ({type}) = default\`
#### Microsoft SQL Server (\`mssql\`)
- Arguments: \`@P1\`, \`@P2\`, etc.
- Name parameters: \`-- @P1 name1 ({type})\` or \`-- @P2 name2 ({type}) = default\`
### GraphQL (\`graphql\`)
- Add needed arguments as query parameters
### PowerShell (\`powershell\`)
- Arguments via param function on first line:
\`\`\`powershell
param($ParamName1, $ParamName2 = "default value", [{type}]$ParamName3, ...)
\`\`\`
### C# (\`csharp\`)
- Public static Main method inside a class
- NuGet packages: \`#r "nuget: PackageName, Version"\` at top
- Method signature: \`public static ReturnType Main(parameter types...)\`
### Java (\`java\`)
- Main public class with \`public static main()\` method
- Dependencies: \`//requirements://groupId:artifactId:version\` at top
- Method signature: \`public static Object main(parameter types...)\`
## Supported Languages
\`bunnative\`, \`nativets\`, \`bun\`, \`deno\`, \`python3\`, \`php\`, \`rust\`, \`go\`, \`bash\`, \`postgresql\`, \`mysql\`, \`bigquery\`, \`snowflake\`, \`mssql\`, \`graphql\`, \`powershell\`, \`csharp\`, \`java\`
Always follow the specific conventions for the language being used and include only necessary dependencies and resource types.
# Windmill CLI Commands Summary
## Core Commands
### \`wmill init\`
Bootstrap a new Windmill project with a \`wmill.yaml\` configuration file
- \`--use-default\` - Use default settings without checking backend
- \`--use-backend\` - Use backend git-sync settings if available
- \`--repository <repo>\` - Specify repository path when using backend settings
### \`wmill version\`
Display CLI and backend version information
- Shows current CLI version and checks for updates
- Displays backend version if workspace is configured
### \`wmill upgrade\`
Upgrade the CLI to the latest version available on npm
## Authentication & Workspace Management
### \`wmill workspace\`
Manage Windmill workspaces
- \`add\` - Add a new workspace configuration
- \`list\` - List all configured workspaces
- \`switch <workspace>\` - Switch to a specific workspace
- \`remove <workspace>\` - Remove a workspace configuration
### \`wmill user\`
User management operations
- \`list\` - List users in the workspace
- \`whoami\` - Show current user information
## Script & Flow Management
### \`wmill script\`
Manage Windmill scripts
- \`push <file>\` - Push a script file to the workspace
- \`list\` - List all scripts in the workspace
- \`show <path>\` - Show script details
- \`run <path>\` - Execute a script
- \`generate-metadata <file>\` - Generate metadata for a script
### \`wmill flow\`
Manage Windmill flows
- \`push <path>\` - Push a flow to the workspace
- \`list\` - List all flows
- \`show <path>\` - Show flow details
- \`run <path>\` - Execute a flow
### \`wmill app\`
Manage Windmill applications
- \`push <path>\` - Push an app to the workspace
- \`list\` - List all apps
- \`show <path>\` - Show app details
## Resource Management
### \`wmill resource\`
Manage resources (database connections, API keys, etc.)
- \`list\` - List all resources
- \`push <file>\` - Push a resource definition
- \`show <path>\` - Show resource details
### \`wmill resource-type\`
Manage custom resource types
- Operations for defining and managing custom resource schemas
### \`wmill variable\`
Manage workspace variables and secrets
- \`list\` - List all variables
- \`push <file>\` - Push a variable definition
- \`show <path>\` - Show variable details
## Scheduling & Automation
### \`wmill schedule\`
Manage scheduled jobs
- \`list\` - List all schedules
- \`push <file>\` - Push a schedule definition
- Operations for managing cron-based job scheduling
### \`wmill trigger\`
Manage event triggers
- Operations for managing webhooks and event-based triggers
## Synchronization
### \`wmill sync\`
Synchronize local files with Windmill workspace
- \`pull\` - Download resources from workspace to local files
- \`push\` - Upload local files to workspace
- Supports bidirectional sync with conflict resolution
- Works with \`wmill.yaml\` configuration
### \`wmill gitsync-settings\`
Manage git synchronization settings
- Configure automatic git sync for the workspace
- Pull/push git sync configurations
## Development Tools
### \`wmill dev\`
Start development mode with live reloading
- Watches local files for changes
- Automatically syncs changes to workspace
- Provides real-time feedback during development
### \`wmill hub\`
Interact with Windmill Hub
- \`pull\` - Pull resources from the public Windmill Hub
- Access community-shared scripts, flows, and resource types
## Infrastructure Management
### \`wmill instance\`
Manage Windmill instance settings (Enterprise)
- Configure instance-level settings
- Manage global configurations
### \`wmill worker-groups\`
Manage worker groups for job execution
- Configure and manage worker pool settings
### \`wmill workers\`
Manage individual workers
- Monitor and configure worker instances
### \`wmill queues\`
Manage job queues
- Monitor and configure job execution queues
## Utility Commands
### \`wmill folder\`
Manage workspace folders and organization
- Operations for organizing resources into folders
### \`wmill completions\`
Generate shell completion scripts
- Support for bash, zsh, fish, and PowerShell
## Global Options
All commands support these global options:
- \`--workspace <workspace>\` - Specify target workspace
- \`--token <token>\` - Specify API token
- \`--base-url <url>\` - Specify Windmill instance URL
- \`--config-dir <dir>\` - Custom configuration directory
- \`--debug/--verbose\` - Enable debug logging
- \`--show-diffs\` - Show detailed diff information during sync
The CLI uses a \`wmill.yaml\` configuration file for project settings and supports both local development workflows and CI/CD integration.
`;
${prompts.SCRIPT_PROMPT}
`;

View File

@@ -6,7 +6,7 @@ import {
log,
} from "../deps.ts";
import flow from "./commands/flow/flow.ts";
import app from "./commands/app/apps.ts";
import app from "./commands/app/app.ts";
import script from "./commands/script/script.ts";
import workspace, {
getActiveWorkspace,
@@ -22,7 +22,7 @@ import trigger from "./commands/trigger/trigger.ts";
import sync from "./commands/sync/sync.ts";
import gitsyncSettings from "./commands/gitsync-settings/gitsync-settings.ts";
import instance from "./commands/instance/instance.ts";
import workerGroups from "./commands/worker-groups/worker_groups.ts";
import workerGroups from "./commands/worker-groups/worker-groups.ts";
import dev from "./commands/dev/dev.ts";
import { GlobalOptions } from "./types.ts";
@@ -70,7 +70,7 @@ export {
// }
// });
export const VERSION = "1.591.3";
export const VERSION = "1.592.1";
export const WM_FORK_PREFIX = "wm-fork";

View File

@@ -9,7 +9,7 @@ import {
yamlParseContent,
yamlStringify,
} from "../deps.ts";
import { pushApp } from "./commands/app/apps.ts";
import { pushApp } from "./commands/app/app.ts";
import { pushFolder } from "./commands/folder/folder.ts";
import { pushFlow } from "./commands/flow/flow.ts";
import { pushResource } from "./commands/resource/resource.ts";

View File

@@ -27,29 +27,23 @@ export class LockfileGenerationError extends Error {
export async function generateAllMetadata() {}
export async function getRawWorkspaceDependencies(): Promise<
Record<string, string>
> {
export async function getRawWorkspaceDependencies(): Promise<Record<string, string>> {
const rawWorkspaceDeps: Record<string, string> = {};
try {
for await (const entry of Deno.readDir("dependencies")) {
if (entry.isDirectory) continue;
const filePath = `dependencies/${entry.name}`;
const content = await Deno.readTextFile(filePath);
// Find matching language
for (const lang of workspaceDependenciesLanguages) {
if (entry.name.endsWith(lang.filename)) {
// Check if out of sync
const contentHash = await generateHash(content + filePath);
const isUpToDate = await checkifMetadataUptodate(
filePath,
contentHash,
undefined
);
const isUpToDate = await checkifMetadataUptodate(filePath, contentHash, undefined);
if (!isUpToDate) {
rawWorkspaceDeps[filePath] = content;
}
@@ -60,24 +54,19 @@ export async function getRawWorkspaceDependencies(): Promise<
} catch {
// dependencies directory doesn't exist
}
return rawWorkspaceDeps;
return rawWorkspaceDeps;
}
export function workspaceDependenciesPathToLanguageAndFilename(
path: string
): { name: string | undefined; language: ScriptLanguage } | undefined {
const relativePath = path.replace("dependencies/", "");
for (const { filename, language } of workspaceDependenciesLanguages) {
if (relativePath.endsWith(filename)) {
return {
name:
relativePath === filename
? undefined
: relativePath.replace("." + filename, ""),
language,
};
export function workspaceDependenciesPathToLanguageAndFilename(path: string): { name: string | undefined, language: ScriptLanguage } | undefined {
const relativePath = path.replace("dependencies/", "");
for (const { filename, language } of workspaceDependenciesLanguages) {
if (relativePath.endsWith(filename)) {
return {
name: relativePath === filename ? undefined : relativePath.replace("." + filename, ""),
language
};
}
}
}
}
// on windows, when using powershell, blue is not readable
@@ -108,27 +97,24 @@ export async function generateScriptMetadataInternal(
// Filter workspace dependencies to only include those matching the script's language
const filteredRawWorkspaceDependencies: Record<string, string> = {};
for (const [depPath, depContent] of Object.entries(
rawWorkspaceDependencies
)) {
for (const [depPath, depContent] of Object.entries(rawWorkspaceDependencies)) {
const depInfo = workspaceDependenciesPathToLanguageAndFilename(depPath);
if (depInfo && depInfo.language === language) {
filteredRawWorkspaceDependencies[depPath] = depContent;
}
}
const metadataWithType = await parseMetadataFile(remotePath, undefined);
const metadataWithType = await parseMetadataFile(
remotePath,
undefined,
);
// read script content
const scriptContent = await Deno.readTextFile(scriptPath);
const metadataContent = await Deno.readTextFile(metadataWithType.path);
// Note: rawWorkspaceDependencies are now passed in as parameter instead of being searched hierarchically
let hash = await generateScriptHash(
filteredRawWorkspaceDependencies,
scriptContent,
metadataContent
);
let hash = await generateScriptHash(filteredRawWorkspaceDependencies, scriptContent, metadataContent);
if (await checkifMetadataUptodate(remotePath, hash, undefined)) {
if (!noStaleMessage) {
@@ -174,7 +160,7 @@ export async function generateScriptMetadataInternal(
} else {
metadataParsedContent.lock = "";
}
} else if (!opts.schemaOnly || metadataParsedContent.lock != "") {
} else {
metadataParsedContent.lock =
"!inline " + remotePath.replaceAll(SEP, "/") + ".script.lock";
}
@@ -244,13 +230,12 @@ async function updateScriptLock(
return;
}
if (Object.keys(rawWorkspaceDependencies).length > 0) {
const dependencyPaths = Object.keys(rawWorkspaceDependencies).join(", ");
log.info(
`Generating script lock for ${remotePath} with raw workspace dependencies: ${dependencyPaths}`
);
}
if (Object.keys(rawWorkspaceDependencies).length > 0) {
const dependencyPaths = Object.keys(rawWorkspaceDependencies).join(', ');
log.info(`Generating script lock for ${remotePath} with raw workspace dependencies: ${dependencyPaths}`);
}
// generate the script lock running a dependency job in Windmill and update it inplace
// TODO: update this once the client is released
const extraHeaders = getHeaders();
@@ -271,10 +256,8 @@ async function updateScriptLock(
script_path: remotePath,
},
],
raw_workspace_dependencies:
Object.keys(rawWorkspaceDependencies).length > 0
? rawWorkspaceDependencies
: null,
raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0
? rawWorkspaceDependencies : null,
entrypoint: remotePath,
}),
}
@@ -560,7 +543,7 @@ export async function parseMetadataFile(
workspaceRemote: Workspace;
schemaOnly?: boolean;
rawWorkspaceDependencies: Record<string, string>;
codebases: SyncCodebase[];
codebases: SyncCodebase[]
})
| undefined
): Promise<{ isJson: boolean; payload: any; path: string }> {
@@ -594,11 +577,7 @@ export async function parseMetadataFile(
metadataFilePath = scriptPath + ".script.yaml";
let scriptInitialMetadata = defaultScriptMetadata();
const lockPath = scriptPath + ".script.lock";
if (generateMetadataIfMissing && !generateMetadataIfMissing?.schemaOnly) {
log.info(`Using inline lock for ${metadataFilePath}`);
scriptInitialMetadata.lock = "!inline " + lockPath;
}
scriptInitialMetadata.lock = "!inline " + lockPath;
const scriptInitialMetadataYaml = yamlStringify(
scriptInitialMetadata as Record<string, any>,
yamlOptions
@@ -607,14 +586,11 @@ export async function parseMetadataFile(
await Deno.writeTextFile(metadataFilePath, scriptInitialMetadataYaml, {
createNew: true,
});
await Deno.writeTextFile(lockPath, "", {
createNew: true,
});
if (generateMetadataIfMissing) {
if (!generateMetadataIfMissing.schemaOnly) {
await Deno.writeTextFile(lockPath, "", {
createNew: true,
});
}
log.info(
(await blueColor())(
`Generating lockfile and schema for ${metadataFilePath}`
@@ -714,9 +690,7 @@ export async function generateScriptHash(
newMetadataContent: string
) {
return await generateHash(
JSON.stringify(rawWorkspaceDependencies) +
scriptContent +
newMetadataContent
JSON.stringify(rawWorkspaceDependencies) + scriptContent + newMetadataContent
);
}

View File

@@ -0,0 +1,48 @@
FROM debian:bookworm-slim AS nsjail
WORKDIR /nsjail
RUN apt-get -y update \
&& apt-get install -y \
bison=2:3.8.* \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
git=1:2.39.* \
libprotobuf-dev=3.21.* \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
pkg-config=1.8.* \
protobuf-compiler=3.21.*
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
RUN make
FROM alpine:3.14 AS oracledb-client
ARG TARGETPLATFORM
# Oracle DB Client for amd64
COPY --from=ghcr.io/oracle/oraclelinux9-instantclient:23 /usr/lib/oracle/23/client64/lib /opt/oracle/23/amd64/lib
# Oracle DB Client for arm64
RUN mkdir -p /opt/oracle/23/arm64 \
&& cd /opt/oracle/23/arm64 \
&& wget https://download.oracle.com/otn_software/linux/instantclient/instantclient-basiclite-linux-arm64.zip \
&& unzip instantclient-basiclite-linux-arm64.zip && rm instantclient-basiclite-linux-arm64.zip && mv instantclient* ./lib
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
mv /opt/oracle/23/arm64/lib /opt/oracle/23/lib; \
else \
mv /opt/oracle/23/amd64/lib /opt/oracle/23/lib; \
fi
FROM ghcr.io/windmill-labs/windmill-ee:dev
# iptables
RUN apt-get install -y iptables
COPY --from=nsjail /nsjail/nsjail /bin/nsjail

View File

@@ -0,0 +1,20 @@
FROM debian:bookworm-slim AS nsjail
WORKDIR /nsjail
RUN apt-get -y update \
&& apt-get install -y \
bison=2:3.8.* \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
git=1:2.39.* \
libprotobuf-dev=3.21.* \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
pkg-config=1.8.* \
protobuf-compiler=3.21.*
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
RUN make

3
frontend/.gitignore vendored
View File

@@ -14,4 +14,5 @@ static/tsdocs/
static/ui_builder/
ui_builder.tar.gz
ui_builder_serve/
src/lib/components/copilot/chat/flow/__tests__/eval/results/
src/lib/components/copilot/chat/__tests__/flow/results/
src/lib/components/copilot/chat/__tests__/app/results/

View File

@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.591.3",
"version": "1.592.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.591.3",
"version": "1.592.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.591.3",
"version": "1.592.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",

View File

@@ -20,7 +20,7 @@ console.log('Running postinstall for root project');
import { x } from 'tar'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-b4fcf00.tar.gz'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-6dcfccf.tar.gz'
const outputTarPath = path.join(process.cwd(), 'ui_builder.tar.gz')
const extractTo = path.join(process.cwd(), 'static/ui_builder/')

View File

@@ -1,8 +1,8 @@
node ./scripts/untar_ui_builder.js
# node ./scripts/untar_ui_builder.js
mkdir ui_builder_serve || true
cp -r static/ui_builder ui_builder_serve/ui_builder || true
rm -rf static/ui_builder || true
# mkdir ui_builder_serve || true
# cp -r static/ui_builder ui_builder_serve/ui_builder || true
# rm -rf static/ui_builder || true
python3 -c "
import os
os.chdir('ui_builder_serve')

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
import { format, isValid, parse } from 'date-fns'
import { sendUserToast } from '$lib/toast'
export let value: string | undefined = undefined
export let value: string | null | undefined = undefined
export let autofocus: boolean | null = false
export let minDate: string | undefined = undefined
export let maxDate: string | undefined = undefined
@@ -17,7 +17,7 @@
const dispatch = createEventDispatcher()
function computeDate(value: string | undefined) {
function computeDate(value: string | null | undefined) {
if (dateFormat === undefined) {
dateFormat = defaultDateFormat
}
@@ -83,6 +83,9 @@
on:change={() => {
if (date) {
updateValue(date)
} else {
value = null
dispatch('change', value)
}
}}
/>

View File

@@ -12,7 +12,7 @@
interface Props {
// import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
value?: string | undefined
value?: string | null | undefined
clearable?: boolean
autofocus?: boolean | null
useDropdown?: boolean
@@ -44,7 +44,7 @@
// let format: 'local' | 'utc' = 'local'
function parseValue(value: string | undefined = undefined) {
function parseValue(value: string | null | undefined = undefined) {
let dateFromValue: Date | undefined = value ? new Date(value) : undefined
if (!isValidDate(dateFromValue)) {
date = undefined
@@ -76,6 +76,13 @@
let initialTime = untrack(() => time)
function parseDateAndTime(date: string | undefined, time: string | undefined) {
// Handle cleared date - if date is empty string (user cleared) but value still has a value
if (date === '' && value) {
value = null
dispatchIfMounted('change', value)
return
}
if (date && time && (initialDate != date || initialTime != time)) {
let newDate = new Date(timezone === 'local' ? `${date}T${time}` : `${date}T${time}Z`)
if (newDate.toString() === 'Invalid Date') return
@@ -178,7 +185,7 @@
wrapperClasses="h-full"
{disabled}
on:click={() => {
value = undefined
value = null
dispatch('clear')
}}
>

View File

@@ -20,6 +20,7 @@
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import TextInput from './text_input/TextInput.svelte'
import PermissionHistory from './PermissionHistory.svelte'
interface Props {
name: string
@@ -90,6 +91,7 @@
role: getRole(x)
}
})
reloadHistory++
} catch (e) {
folderNotFound = true
}
@@ -144,6 +146,8 @@
})
}
})
let reloadHistory = $state(0)
</script>
<Drawer bind:this={newGroup}>
@@ -351,7 +355,7 @@
{/if}</td
>
<td class="flex items-center justify-end">
{#if can_write && (owner_name != 'u/' + $userStore?.username || $userStore?.is_admin)}
{#if (can_write && owner_name != 'u/' + $userStore?.username) || $userStore?.is_admin}
<Button
variant="subtle"
destructive
@@ -378,7 +382,7 @@
loadFolder()
}}
/>
{:else}
{:else if can_write && owner_name == 'u/' + $userStore?.username}
<span class="text-primary text-xs">cannot remove yourself</span>
{/if}</td
>
@@ -441,4 +445,20 @@
{/if}
</div>
</Label>
{#if reloadHistory > 0}
{#key reloadHistory}
<PermissionHistory
{name}
fetchHistory={async (workspace, folderName, page, perPage) => {
return await FolderService.getFolderPermissionHistory({
workspace,
name: folderName,
page,
perPage
})
}}
/>
{/key}
{/if}
</div>

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { workspaceStore, userWorkspaces } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import type { WorkspaceComparison } from '$lib/gen'
import { Button } from './common'
import { AlertTriangle, GitFork } from 'lucide-svelte'
import { goto } from '$app/navigation'
import { onMount, untrack } from 'svelte'
let loading = $state(false)
let comparison: WorkspaceComparison | undefined = $state(undefined)
let error: string | undefined = $state(undefined)
let isFork = $derived($workspaceStore?.startsWith('wm-fork-') ?? false)
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
$effect(() => {
;[$workspaceStore, parentWorkspaceId]
untrack(() => {
if (isFork && $workspaceStore) {
checkForChanges()
} else {
comparison = undefined
}
})
})
onMount(() => {
if (isFork && $workspaceStore) {
checkForChanges()
} else {
comparison = undefined
}
})
async function checkForChanges() {
if (!$workspaceStore || !parentWorkspaceId) {
return
}
loading = true
error = undefined
try {
// Compare with parent workspace
const result = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: $workspaceStore
})
comparison = result
} catch (e) {
console.error('Failed to compare workspaces:', e)
error = `Failed to check for changes: ${e}`
// Still show banner if there's an error, but with error message
} finally {
loading = false
}
}
function openComparisonDrawer() {
if (parentWorkspaceId && $workspaceStore) {
goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore), {
replaceState: true
})
}
}
function forkAheadBehindMessage(
changesAhead: number,
changesBehind: number,
) {
let msg: string[] = []
if (changesAhead > 0 || changesBehind > 0) {
msg.push('This fork is ')
if (changesAhead > 0)
msg.push(`${changesAhead} change${changesAhead > 1 ? 's' : ''} ahead of `)
if (changesAhead > 0 && changesBehind > 0) msg.push('and ')
if (changesBehind > 0)
msg.push(`${changesBehind} change${changesBehind > 1 ? 's' : ''} behind `)
}
return msg.join('')
}
</script>
{#if isFork}
<div class="w-full bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800">
<div class="px-4 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<GitFork class="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div class="text-sm">
<span class="font-medium text-blue-900 dark:text-blue-100">
Fork of <b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
</span>
</div>
{#if loading}
<span class="text-xs text-blue-600 dark:text-blue-400"> Checking for changes... </span>
{:else if error}
<span class="text-xs text-red-600 dark:text-red-400">
{error}
</span>
{:else if comparison}
<div class="flex items-center gap-4 text-xs">
{#if comparison.summary.total_diffs > 0}
<span>
{forkAheadBehindMessage(
comparison.summary.total_ahead,
comparison.summary.total_behind,
)}
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison.summary
.total_diffs} items:
</span>
<div class="flex items-center gap-2">
{#if comparison.summary.scripts_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.scripts_changed} script{comparison.summary
.scripts_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.flows_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.apps_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.resources_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.resources_changed} resource{comparison.summary
.resources_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.variables_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.variables_changed} variable{comparison.summary
.variables_changed !== 1
? 's'
: ''}
</span>
{/if}
</div>
{#if comparison.summary.conflicts > 0}
-
<div class="flex items-center gap-1 text-orange-600 dark:text-orange-400">
<AlertTriangle class="w-3 h-3" />
<span
>{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1
? 's'
: ''}</span
>
</div>
{/if}
{:else if comparison.skipped_comparison}
<span class="text-blue-600 dark:text-blue-400"> This fork was created before the addition of certain windmill features, and therefore the changes with its parent workspace cannot be displayed.</span>
{:else}
<span class="text-blue-600 dark:text-blue-400"> Everything is up to date </span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if comparison && comparison.summary.total_diffs > 0}
<Button size="xs" color="blue" on:click={openComparisonDrawer}>
{#if comparison.summary.total_ahead > 0}
Review & Deploy Changes
{:else}
Review & Update fork
{/if}
</Button>
{/if}
</div>
</div>
</div>
</div>
{/if}

View File

@@ -2,7 +2,7 @@
import { workspaceStore, enterpriseLicense, userStore } from '$lib/stores'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
import { Loader2, Github, RotateCw, Plus, Minus, Download } from 'lucide-svelte'
import { Loader2, Github, RotateCw, Plus, Minus, Download, AlertTriangle } from 'lucide-svelte'
import { onDestroy } from 'svelte'
import {
createGitHubAppState,
@@ -208,7 +208,9 @@
<select bind:value={githubState.selectedGHAppAccountId}>
<option value="" disabled>Select GitHub Account ID</option>
{#each githubState.workspaceGithubInstallations as installation (`select-${installation.installation_id}-${installation.workspace_id}`)}
<option value={installation.account_id}>{installation.account_id}</option>
<option value={installation.account_id} disabled={!!installation.error}>
{installation.account_id}{installation.error ? ' (token error)' : ''}
</option>
{/each}
</select>
</div>
@@ -292,13 +294,26 @@
<tbody>
{#each githubState.workspaceGithubInstallations as installation (`current-${installation.installation_id}-${installation.workspace_id}`)}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">{installation.account_id}</td>
<td class="py-2">
<div class="flex items-center gap-1">
{#if installation.error}
<span title={installation.error}>
<AlertTriangle class="w-4 h-4 text-yellow-500" />
</span>
{/if}
{installation.account_id}
</div>
</td>
<td class="py-2">
<span class="text-xs text-primary">{installation.workspace_id}</span
>
</td>
<td class="py-2 text-primary">
{installation.repositories.length} repos
{#if installation.error}
<span class="text-yellow-600 dark:text-yellow-400 text-xs" title={installation.error}>Token error</span>
{:else}
{installation.repositories.length} repos
{/if}
</td>
<td class="py-2 text-right">
<div class="flex justify-end gap-1">
@@ -350,13 +365,26 @@
<tbody>
{#each githubInstallationsNotInWorkspace as installation (`other-${installation.installation_id}-${installation.workspace_id}`)}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">{installation.account_id}</td>
<td class="py-2">
<div class="flex items-center gap-1">
{#if installation.error}
<span title={installation.error}>
<AlertTriangle class="w-4 h-4 text-yellow-500" />
</span>
{/if}
{installation.account_id}
</div>
</td>
<td class="py-2">
<span class="text-xs text-primary">{installation.workspace_id}</span
>
</td>
<td class="py-2 text-primary">
{installation.repositories.length} repos
{#if installation.error}
<span class="text-yellow-600 dark:text-yellow-400 text-xs" title={installation.error}>Token error</span>
{:else}
{installation.repositories.length} repos
{/if}
</td>
<td class="pl-8 py-2 text-right">
<Button

View File

@@ -20,6 +20,7 @@
import { safeSelectItems } from './select/utils.svelte'
import TextInput from './text_input/TextInput.svelte'
import { Trash } from 'lucide-svelte'
import PermissionHistory from './PermissionHistory.svelte'
interface Props {
name: string
@@ -81,6 +82,7 @@
}
})
summary = group.summary ?? ''
reloadHistory++
} catch (e) {
can_write = false
members = []
@@ -110,6 +112,7 @@
})
}
})
let reloadHistory = $state(0)
</script>
<div class="flex flex-col gap-6">
@@ -311,4 +314,20 @@
</div>
{/if}
</Label>
{#if reloadHistory > 0}
{#key reloadHistory}
<PermissionHistory
{name}
fetchHistory={async (workspace, groupName, page, perPage) => {
return await GroupService.getGroupPermissionHistory({
workspace,
name: groupName,
page,
perPage
})
}}
/>
{/key}
{/if}
</div>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
import TableCustom from './TableCustom.svelte'
import Skeleton from './common/skeleton/Skeleton.svelte'
import Label from './Label.svelte'
interface PermissionChange {
id?: number
changed_by?: string
changed_at?: string
change_type?: string
affected?: string | null
member_affected?: string | null
}
interface Props {
name: string
fetchHistory: (
workspace: string,
name: string,
page: number,
perPage: number
) => Promise<PermissionChange[]>
}
let { name, fetchHistory }: Props = $props()
let history: PermissionChange[] | undefined = $state(undefined)
let loading = $state(false)
let page = $state(1)
let perPage = $state(50)
async function loadHistory() {
if (!$workspaceStore) return
loading = true
try {
history = await fetchHistory($workspaceStore, name, page, perPage)
} catch (e) {
console.error('Failed to load permission history:', e)
history = []
} finally {
loading = false
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleString()
}
function formatChangeType(changeType: string): string {
return changeType
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
$effect.pre(() => {
if ($workspaceStore && name) {
untrack(() => {
loadHistory()
})
}
})
function removeUPrefix(username: string | undefined): string | undefined {
return username?.startsWith('u/') ? username.slice(2) : username
}
</script>
<Label label="History">
{#if loading || history === undefined}
<div class="flex flex-col gap-2">
{#each new Array(3) as _}
<Skeleton layout={[[4], 0.7]} />
{/each}
</div>
{:else if history.length === 0}
<p class="text-primary text-sm">No permission changes recorded yet</p>
{:else}
<TableCustom>
<tr slot="header-row">
<th>Changed By</th>
<th>Change Type</th>
<th>Affected</th>
<th>Date</th>
</tr>
{#snippet body()}
<tbody>
{#each history as change}
<tr>
<td>{change.changed_by ?? '-'}</td>
<td>{change.change_type ? formatChangeType(change.change_type) : '-'}</td>
<td>{change.affected ?? removeUPrefix(change.member_affected ?? '')}</td>
<td class="text-xs">{change.changed_at ? formatDate(change.changed_at) : '-'}</td>
</tr>
{/each}
</tbody>
{/snippet}
</TableCustom>
<div class="flex flex-row gap-2 justify-end">
{#if page > 1}
<button
class="text-primary text-xs"
onclick={() => {
page--
loadHistory()
}}>Previous</button
>
{/if}
{#if history.length === perPage}
<button
class="text-primary text-xs"
onclick={() => {
page++
loadHistory()
}}>Next</button
>
{/if}
</div>
{/if}
</Label>

View File

@@ -274,7 +274,7 @@
{/snippet}
</Select>
{#if value && hovering}
<div class="absolute right-2 z-20">
<div class="absolute {disabled ? 'right-2' : 'right-10'} z-20">
<Button
variant="subtle"
size="xs2"

View File

@@ -1,19 +1,26 @@
<script lang="ts">
import { getDbClockNow } from '$lib/forLater'
import { displayDate } from '$lib/utils'
import { onDestroy, onMount } from 'svelte'
import { onDestroy, onMount, untrack } from 'svelte'
export let date: string
export let agoOnlyIfRecent: boolean = false
export let noDate = false
export let isRecent: boolean = true
interface Props {
date: string
agoOnlyIfRecent?: boolean
noDate?: boolean
isRecent?: boolean
}
let computedTimeAgo: string | undefined = undefined
let {
date,
agoOnlyIfRecent = false,
noDate = false,
isRecent = $bindable(true)
}: Props = $props()
let computedTimeAgo: string | undefined = $state(undefined)
let interval
$: date && computeDate()
onMount(() => {
interval = setInterval(() => {
computeDate()
@@ -73,6 +80,9 @@
}
}
}
$effect(() => {
date && untrack(() => computeDate())
})
</script>
{#if computedTimeAgo && (!agoOnlyIfRecent || isRecent)}

View File

@@ -524,7 +524,7 @@
const requestBody: ExecuteComponentData['requestBody'] = {
args: nonStaticRunnableInputs,
component: id,
force_viewer_static_fields: !isEditor ? undefined : undefinedIfEmpty(staticRunnableInputs),
force_viewer_static_fields: !isEditor ? undefined : staticRunnableInputs,
force_viewer_one_of_fields: !isEditor ? undefined : undefinedIfEmpty(oneOfRunnableInputs),
force_viewer_allow_user_resources: !isEditor
? undefined

View File

@@ -12,74 +12,91 @@
interface Props {
marked: string | undefined
starred: boolean
selected?: boolean
disabled?: boolean
canFavorite?: boolean
isSelectable?: boolean
alignWithSelectable?: boolean
errorHandlerMuted?: boolean
aiId?: string | undefined
aiDescription?: string | undefined
kind?: 'script' | 'flow' | 'app' | 'raw_app'
kind?: 'script' | 'flow' | 'app' | 'raw_app' | 'resource' | 'variable'
summary?: string | undefined
path: string
href: string
href?: string
workspaceId: string
depth?: number
badges?: import('svelte').Snippet
actions?: import('svelte').Snippet
customSummary?: import('svelte').Snippet
onSelect?: (
e: Event & {
currentTarget: EventTarget & HTMLInputElement
}
) => void
}
let {
marked,
starred,
selected = false,
disabled = false,
canFavorite = true,
isSelectable = false,
alignWithSelectable = false,
errorHandlerMuted = false,
aiId = undefined,
aiDescription = undefined,
kind = 'script',
summary = undefined,
path,
href,
href = undefined,
workspaceId,
depth = 0,
badges,
actions
actions,
customSummary,
onSelect = () => {}
}: Props = $props()
let displayPath: string = (depth === 0 ? path : path?.split('/')?.slice(-1)?.[0]) ?? ''
</script>
<div
style="display: none"
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => {
goto(href)
}
}}
></div>
{#if href}
<div
style="display: none"
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => {
goto(href)
}
}}
></div>
{/if}
<div
class={twMerge(
'hover:bg-surface-hover w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0',
depth > 0 ? '!rounded-none' : ''
'w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0',
depth > 0 ? '!rounded-none' : '',
disabled ? 'opacity-25' : 'hover:bg-surface-hover',
selected ? 'bg-surface-accent-selected' : ''
)}
style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''}
>
<a {href} class="min-w-0 grow hover:underline decoration-gray-400 inline-flex items-center gap-4">
<div class="shrink">
<RowIcon {href} {kind} />
</div>
<div class="grow">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? displayPath : summary}
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
{path}
</div>
</div>
</a>
{#if isSelectable}
<input type="checkbox" checked={selected} onchange={onSelect} class="rounded max-w-4 w-full" />
{:else if alignWithSelectable}
<div class="rounded max-w-4 w-full"></div>
{/if}
{#if href}
<a {href} class="min-w-0 grow hover:underline decoration-gray-400 inline-flex items-center gap-4">
{@render rowContent()}
</a>
{:else}
{@render rowContent()}
{/if}
{#if errorHandlerMuted}
<BellOff class="w-8 opacity-60" size={12} fill="currentcolor" />
@@ -91,8 +108,8 @@
</div>
{/if}
{#if canFavorite}
<div class="center-center h-full text-xs font-semibold text-secondary">
{#if canFavorite && kind !== 'resource' && kind !== 'variable'}
<div class="center-center h-full text-xs font-semibold text-secondary w-9">
<Star
{kind}
{path}
@@ -111,3 +128,25 @@
{@render actions?.()}
</div>
</div>
{#snippet rowContent()}
<div class="shrink">
<RowIcon {kind} />
</div>
<div class="grow">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
{#if customSummary}
{@render customSummary?.()}
{:else}
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? displayPath : summary}
{/if}
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
{path}
</div>
</div>
{/snippet}

View File

@@ -1,17 +1,22 @@
<script lang="ts">
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { Code2, LayoutDashboard } from 'lucide-svelte'
import { Boxes, Code2, DollarSign, LayoutDashboard } from 'lucide-svelte'
export let kind: 'script' | 'flow' | 'app' | 'raw_app'
export let href: string = '#'
export let kind: 'script' | 'flow' | 'app' | 'raw_app' | 'resource' | 'variable'
</script>
<a {href} class="flex justify-center items-center">
<div class="flex justify-center items-center">
{#if kind === 'flow'}
<BarsStaggered size={16} class="text-teal-500" />
{:else if kind === 'app' || kind === 'raw_app'}
<LayoutDashboard size={16} class="text-orange-500" />
{:else if kind === 'script'}
<Code2 size={16} class="text-blue-500" />
{:else if kind === 'variable'}
<DollarSign size={16} class="text-gray-400" />
{:else if kind === 'resource'}
<Boxes size={16} class="text-gray-400" />
{:else}
<div class="w-[16px]"></div>
{/if}
</a>
</div>

View File

@@ -2,7 +2,7 @@
import { Button } from '$lib/components/common'
import { WandSparkles } from 'lucide-svelte'
import { aiChatManager } from './chat/AIChatManager.svelte'
import { flowAIBtnClasses } from './chat/flow/FlowAIButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
interface Props {
label?: string
initialInput?: string
@@ -29,7 +29,7 @@
icon: WandSparkles
}}
unifiedSize="md"
btnClasses={flowAIBtnClasses('default')}
btnClasses={AIBtnClasses('default')}
on:click={onClick}
>
{label}

View File

@@ -18,7 +18,7 @@
inputBorderClass,
inputSizeClasses
} from '../text_input/TextInput.svelte'
import { flowAIBtnClasses } from './chat/flow/FlowAIButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
type PromptConfig = {
system: string
@@ -294,7 +294,7 @@ Generate a tool name for the script below:
<span
class={twMerge(
'rounded-md px-1',
flowAIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'selected')
AIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'selected')
)}
>
<span class="px-0.5 py-0.5 rounded-md text-2xs text-bold flex flex-row items-center gap-1">

View File

@@ -19,7 +19,7 @@
import FlowCopilotInputsModal from './FlowCopilotInputsModal.svelte'
import { twMerge } from 'tailwind-merge'
import { copilotInfo } from '$lib/aiStore'
import { flowAIBtnClasses } from './chat/flow/FlowAIButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
let generatedContent = $state('')
let loading = $state(false)
@@ -232,7 +232,7 @@ Only return the expression without any wrapper.`
size="xs"
variant="default"
btnClasses={twMerge(
flowAIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'default'),
AIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'default'),
btnClass
)}
on:click={() => {

View File

@@ -19,7 +19,7 @@
import FlowCopilotInputsModal from './FlowCopilotInputsModal.svelte'
import type { Flow } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
import { flowAIBtnClasses } from './chat/flow/FlowAIButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
let loading = $state(false)
interface Props {
@@ -189,7 +189,7 @@ input_name2: expression2
variant="default"
btnClasses={twMerge(
!disabled &&
flowAIBtnClasses(
AIBtnClasses(
!loading && Object.keys($generatedExprs || {}).length > 0 ? 'green' : 'default'
)
)}
@@ -234,7 +234,7 @@ input_name2: expression2
<Button
size="xs"
variant="default"
btnClasses={flowAIBtnClasses('default')}
btnClasses={AIBtnClasses('default')}
nonCaptureEvent
startIcon={{
icon: Wand2

View File

@@ -1,17 +1,3 @@
<script module lang="ts">
export function flowAIBtnClasses(state: 'default' | 'selected' | 'green' = 'default') {
return twMerge(
['selected', 'default'].includes(state) ? 'text-ai !border-ai/20 hover:bg-ai/15' : '',
{
default: '',
selected: 'bg-ai/10',
green:
'bg-green-50 hover:bg-green-50 dark:bg-green-400/15 dark:hover:bg-green-400/15 text-green-800 border-green-200 dark:border-green-300/60 dark:text-green-400'
}[state]
)
}
</script>
<script lang="ts">
import { base } from '$lib/base'
import { copilotInfo } from '$lib/aiStore'
@@ -20,14 +6,13 @@
import { ExternalLink, WandSparkles } from 'lucide-svelte'
import { getModifierKey } from '$lib/utils'
import Button from '$lib/components/common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
let {
togglePanel,
selected = false
btnClasses
}: {
togglePanel: () => void
selected?: boolean
btnClasses?: string
} = $props()
</script>
@@ -72,7 +57,7 @@
onClick={onPress}
startIcon={{ icon: WandSparkles }}
iconOnly
btnClasses={flowAIBtnClasses(selected ? 'selected' : 'default')}
{btnClasses}
>
AI Panel
</Button>

View File

@@ -0,0 +1,13 @@
export function AIBtnClasses(state: 'default' | 'selected' | 'green' = 'default') {
return twMerge(
['selected', 'default'].includes(state) ? 'text-ai !border-ai/20 hover:bg-ai/15' : '',
{
default: '',
selected: 'bg-ai/10',
green:
'bg-green-50 hover:bg-green-50 dark:bg-green-400/15 dark:hover:bg-green-400/15 text-green-800 border-green-200 dark:border-green-300/60 dark:text-green-400'
}[state]
)
}
import { twMerge } from 'tailwind-merge'

View File

@@ -26,7 +26,7 @@ import type {
ChatCompletionUserMessageParam
} from 'openai/resources/chat/completions.mjs'
import {
INLINE_CHAT_SYSTEM_PROMPT,
prepareInlineChatSystemPrompt,
prepareScriptSystemMessage,
prepareScriptTools
} from './script/core'
@@ -67,10 +67,6 @@ export enum AIMode {
}
class AIChatManager {
NAVIGATION_SYSTEM_PROMPT = `
CONSIDERATIONS:
- You are provided with a tool to switch to navigation mode, only use it when you are sure that the user is asking you to navigate the application, help them find something or fetch data from the API. Do not use it otherwise.
`
contextManager = new ContextManager()
historyManager = new HistoryManager()
abortController: AbortController | undefined = undefined
@@ -221,10 +217,10 @@ class AIChatManager {
if (mode === AIMode.SCRIPT) {
const customPrompt = getCombinedCustomPrompt(mode)
const currentModel = getCurrentModel()
this.systemMessage = prepareScriptSystemMessage(currentModel, customPrompt)
this.systemMessage.content = this.NAVIGATION_SYSTEM_PROMPT + this.systemMessage.content
const context = this.contextManager.getSelectedContext()
const lang = this.scriptEditorOptions?.lang ?? 'bun'
const context = this.contextManager.getSelectedContext()
this.systemMessage = prepareScriptSystemMessage(currentModel, lang, {}, customPrompt)
this.systemMessage.content = this.systemMessage.content
this.tools = [...prepareScriptTools(currentModel, lang, context)]
this.helpers = {
getScriptOptions: () => {
@@ -248,7 +244,7 @@ class AIChatManager {
} else if (mode === AIMode.FLOW) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareFlowSystemMessage(customPrompt)
this.systemMessage.content = this.NAVIGATION_SYSTEM_PROMPT + this.systemMessage.content
this.systemMessage.content = this.systemMessage.content
this.tools = [...flowTools]
this.helpers = this.flowAiChatHelpers
} else if (mode === AIMode.NAVIGATOR) {
@@ -408,7 +404,6 @@ class AIChatManager {
if (this.mode === AIMode.SCRIPT) {
pendingUserMessage = prepareScriptUserMessage(
pendingPrompt,
this.scriptEditorOptions?.lang as ScriptLang | 'bunnative',
this.contextManager.getSelectedContext()
)
} else if (this.mode === AIMode.FLOW) {
@@ -508,15 +503,13 @@ class AIChatManager {
const systemMessage: ChatCompletionSystemMessageParam = {
role: 'system',
content: INLINE_CHAT_SYSTEM_PROMPT
content: prepareInlineChatSystemPrompt(lang)
}
let reply = ''
try {
const userMessage = prepareScriptUserMessage(instructions, lang, selectedContext, {
isPreprocessor: false
})
const userMessage = prepareScriptUserMessage(instructions, selectedContext)
const messages = [userMessage]
const params = {
@@ -606,10 +599,15 @@ class AIChatManager {
throw new Error('No flow helpers found')
}
let snapshot: ExtendedOpenFlow | undefined = undefined
let snapshot:
| { type: 'flow'; value: ExtendedOpenFlow }
| { type: 'app'; value: number }
| undefined = undefined
if (this.mode === AIMode.FLOW) {
snapshot = this.flowAiChatHelpers!.getFlowAndSelectedId().flow
this.flowAiChatHelpers!.setSnapshot(snapshot)
snapshot = { type: 'flow', value: this.flowAiChatHelpers!.getFlowAndSelectedId().flow }
this.flowAiChatHelpers!.setSnapshot(snapshot.value)
} else if (this.mode === AIMode.APP) {
snapshot = { type: 'app', value: this.appAiChatHelpers!.snapshot() }
}
this.displayMessages = [
@@ -632,10 +630,6 @@ class AIChatManager {
throw new Error('No script options passed')
}
const lang = this.scriptEditorOptions?.lang ?? options.lang ?? 'bun'
const isPreprocessor =
this.scriptEditorOptions?.path === 'preprocessor' || options.isPreprocessor
let userMessage: ChatCompletionMessageParam = {
role: 'user',
content: ''
@@ -655,9 +649,7 @@ class AIChatManager {
userMessage = prepareAskUserMessage(oldInstructions)
break
case AIMode.SCRIPT:
userMessage = prepareScriptUserMessage(oldInstructions, lang, oldSelectedContext, {
isPreprocessor
})
userMessage = prepareScriptUserMessage(oldInstructions, oldSelectedContext)
break
case AIMode.API:
userMessage = prepareApiUserMessage(oldInstructions)
@@ -665,7 +657,6 @@ class AIChatManager {
case AIMode.APP:
userMessage = prepareAppUserMessage(
oldInstructions,
this.appAiChatHelpers?.getFiles(),
this.appAiChatHelpers?.getSelectedContext()
)
break

View File

@@ -89,13 +89,17 @@
{/if}
{#if message.role === 'user' && message.snapshot}
<div class="mx-2 text-sm text-primary flex flex-row items-center justify-between gap-2 mt-2">
Saved a flow snapshot
Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot
<Button
size="xs2"
variant="default"
on:click={() => {
if (message.snapshot) {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot)
if (message.snapshot.type === 'flow') {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
} else if (message.snapshot.type === 'app') {
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
}
}
}}
title="Revert to snapshot"

View File

@@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import { runVariantComparison, writeAppComparisonResults } from './appEvalRunner'
import { BASELINE_VARIANT, STREAMLINED_VARIANT } from './variants'
import { loadAppFixtureForEval } from './appFixtureLoader'
// @ts-ignore - Node.js path
import { dirname, join } from 'path'
// @ts-ignore - Node.js url
import { fileURLToPath } from 'url'
// Get API key from environment - tests will be skipped if not set
// @ts-ignore
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
// Skip all tests if no API key is provided
const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o']
const VARIANTS = [
...MODELS.map((model) => ({
...BASELINE_VARIANT,
model,
name: `baseline-${model.replace('/', '-')}`
})),
...MODELS.map((model) => ({
...STREAMLINED_VARIANT,
model,
name: `streamlined-${model.replace('/', '-')}`
}))
]
describeWithApiKey('App Chat LLM Evaluation', () => {
const TEST_TIMEOUT = 120_000
if (!OPENROUTER_API_KEY) {
console.warn('OPENROUTER_API_KEY is not set, skipping tests')
}
it(
'test1: creates a simple counter app',
async () => {
const USER_PROMPT = `Create a counter app with increment/decrement buttons`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
// Write results to files
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
it(
'test2: modifies existing counter app to add reset button',
async () => {
// Load initial app from fixture folder
const { initialFrontend, initialBackend } = await loadAppFixtureForEval(
join(__dirname, 'initial', 'test1_counter_app')
)
const USER_PROMPT = `Add a reset button that sets the counter back to 0`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
// Write results to files
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
// ==================== Shopping Cart Tests ====================
it(
'test3: shopping cart - add quantity selector',
async () => {
const { initialFrontend, initialBackend } = await loadAppFixtureForEval(
join(__dirname, 'initial', 'shopping_cart')
)
const USER_PROMPT = `Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
it(
'test4: shopping cart - add discount code',
async () => {
const { initialFrontend, initialBackend } = await loadAppFixtureForEval(
join(__dirname, 'initial', 'shopping_cart')
)
const USER_PROMPT = `Add a discount code input field in the cart. When the code "SAVE10" is entered, apply a 10% discount to the total`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
// ==================== File Manager Tests ====================
it(
'test5: file manager - add search bar',
async () => {
const { initialFrontend, initialBackend } = await loadAppFixtureForEval(
join(__dirname, 'initial', 'file_manager')
)
const USER_PROMPT = `Add a search bar in the toolbar that filters files and folders by name as the user types`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
it(
'test6: file manager - show file details',
async () => {
const { initialFrontend, initialBackend } = await loadAppFixtureForEval(
join(__dirname, 'initial', 'file_manager')
)
const USER_PROMPT = `Show file size (formatted as KB/MB) and modified date in the file list for each item`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
it(
'test7: file manager - add select all checkbox',
async () => {
const { initialFrontend, initialBackend } = await loadAppFixtureForEval(
join(__dirname, 'initial', 'file_manager')
)
const USER_PROMPT = `Add a "Select All" checkbox in the file list header and individual checkboxes for each file. Add a "Delete Selected" button that appears when items are selected`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
// ==================== From-Scratch Creation Tests ====================
it(
'test8: create quiz app from scratch',
async () => {
const USER_PROMPT = `Create a multiple choice quiz app with 5 questions about general knowledge. Show one question at a time with 4 answer options. Track the score and show results at the end with percentage correct.`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
it(
'test9: create recipe book from scratch',
async () => {
const USER_PROMPT = `Create a recipe book app where users can add recipes with a name, ingredients list, and instructions. Include a search bar to filter recipes by name and the ability to delete recipes.`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
expect(true).toBe(true)
},
TEST_TIMEOUT
)
})

View File

@@ -0,0 +1,166 @@
import OpenAI from 'openai'
import type { AppFiles, BackendRunnable } from '../../app/core'
import { BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared'
import type { EvaluationResult } from '../shared'
/**
* Expected app structure for evaluation.
*/
export interface ExpectedApp {
frontend: Record<string, string>
backend: Record<string, BackendRunnable>
}
/**
* Initial app state for evaluation context.
*/
export interface InitialApp {
frontend: Record<string, string>
backend: Record<string, BackendRunnable>
}
/**
* System prompt for evaluating app generation without a reference expected app.
* Evaluates based on user request fulfillment and appropriate modifications to initial state.
*/
const APP_GENERATION_EVALUATOR_SYSTEM_PROMPT = `You are an expert evaluator for Windmill Raw App definitions. Your task is to evaluate a generated app based on:
1. The original user request/prompt
2. The initial app state (if any) - this is what the app looked like before the AI made changes
## Windmill Raw App Context
- Raw Apps consist of frontend files and backend runnables
- Frontend files are TypeScript/JavaScript files bundled with esbuild (entrypoint: index.tsx)
- Backend runnables can be: inline scripts (TypeScript/Python), workspace scripts, workspace flows, or hub scripts
- Frontend calls backend using \`await backend.<runnable_key>(args...)\`
- Each backend runnable has a key (identifier), name (description), type, and configuration
## Backend Runnable Types
- **inline**: Custom code with \`inlineScript.language\` and \`inlineScript.content\`
- **script**: Workspace script reference with \`path\`
- **flow**: Workspace flow reference with \`path\`
- **hubscript**: Hub script reference with \`path\`
## Evaluation Criteria
1. **User Request Fulfillment**: Does the generated app address ALL requirements from the user's original prompt?
- Are all requested features implemented?
- Does the frontend UI match the requirements?
- Are the correct backend runnables created?
2. **Appropriate Modifications** (if initial app was provided):
- Were the changes made relevant to the user's request?
- Was existing functionality preserved where appropriate?
- Were only necessary changes made (no unnecessary removals or additions)?
3. **Frontend Structure**: Are the frontend files correctly organized and implemented?
- Is the code valid TypeScript/JavaScript?
- Are components properly structured?
- Are backend calls correctly made?
4. **Backend Structure**: Are the backend runnables correctly configured?
- Do inline scripts have proper main functions?
- Are types and paths correct for non-inline runnables?
5. **Integration**: Does the frontend correctly call the backend?
- Are the runnable keys correctly referenced?
- Are arguments passed correctly?
6. **Code Quality**: Is the code functionally correct and well-structured?
## Important Notes
- Focus on whether the user's request was fulfilled, not on stylistic preferences
- If an initial app was provided, evaluate the appropriateness of the changes made
- For new apps (no initial state), evaluate completeness and correctness
- Extra helper functions or slightly different approaches can still score high if they accomplish the goal
${BASE_EVALUATOR_RESPONSE_FORMAT}`
/**
* Evaluates how well a generated app fulfills the user's request, considering any initial app state.
* This evaluator does not require an expected reference app - it evaluates based on the request alone.
*
* @param userPrompt The original user request
* @param generatedApp The app generated by the AI
* @param initialApp Optional initial app state (what the app looked like before AI changes)
* @returns Evaluation result with score, statement, and missing requirements
*/
export async function evaluateAppGeneration(
userPrompt: string,
generatedApp: AppFiles,
initialApp?: InitialApp
): Promise<EvaluationResult> {
// @ts-ignore
const apiKey = process.env.OPENROUTER_API_KEY
const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey })
let userMessage = `## User's Original Request
${userPrompt}
`
if (initialApp) {
userMessage += `## Initial App State (before AI modifications)
\`\`\`json
${JSON.stringify(initialApp, null, 2)}
\`\`\`
`
} else {
userMessage += `## Initial App State
No initial app was provided - this is a new app created from scratch.
`
}
userMessage += `## Generated App
\`\`\`json
${JSON.stringify(generatedApp, null, 2)}
\`\`\`
Please evaluate how well the generated app:
1. Fulfills ALL requirements from the user's original request
2. ${initialApp ? 'Makes appropriate modifications to the initial app state' : 'Implements a complete and correct new app'}`
try {
const response = await client.chat.completions.create({
model: 'anthropic/claude-sonnet-4.5',
messages: [
{ role: 'system', content: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT },
{ role: 'user', content: userMessage }
],
temperature: 0
})
const content = response.choices[0]?.message?.content
if (!content) {
return {
success: false,
resemblanceScore: 0,
statement: 'No response from evaluator',
error: 'Empty response from LLM'
}
}
// Parse JSON response - handle potential markdown code blocks
let jsonContent = content.trim()
if (jsonContent.startsWith('```')) {
jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '')
}
const parsed = JSON.parse(jsonContent) as {
resemblanceScore: number
statement: string
missingRequirements?: string[]
}
return {
success: true,
resemblanceScore: Math.max(0, Math.min(100, Math.round(parsed.resemblanceScore))),
statement: parsed.statement,
missingRequirements: parsed.missingRequirements ?? []
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
return {
success: false,
resemblanceScore: 0,
statement: 'Evaluation failed',
error: errorMessage
}
}
}

View File

@@ -0,0 +1,119 @@
import type {
AppAIChatHelpers,
AppFiles,
BackendRunnable,
LintResult,
SelectedContext
} from '../../app/core'
/**
* Creates an empty lint result (no errors or warnings).
*/
function createEmptyLintResult(): LintResult {
return {
errorCount: 0,
warningCount: 0,
errors: { frontend: {}, backend: {} },
warnings: { frontend: {}, backend: {} }
}
}
/**
* Creates mock AppAIChatHelpers for eval testing.
* Tracks app files state in memory and allows tool functions to modify it.
*/
export function createAppEvalHelpers(
initialFrontend: Record<string, string> = {},
initialBackend: Record<string, BackendRunnable> = {}
) {
// In-memory state
let frontend: Record<string, string> = { ...initialFrontend }
let backend: Record<string, BackendRunnable> = { ...initialBackend }
let snapshotId = 0
const snapshots: Map<number, { frontend: Record<string, string>; backend: Record<string, BackendRunnable> }> = new Map()
const helpers: AppAIChatHelpers = {
// Frontend file operations
listFrontendFiles: () => Object.keys(frontend),
getFrontendFile: (path: string) => frontend[path],
getFrontendFiles: () => ({ ...frontend }),
setFrontendFile: (path: string, content: string) => {
frontend[path] = content
// Return mock lint result - in real usage this would validate the file
return createEmptyLintResult()
},
deleteFrontendFile: (path: string) => {
delete frontend[path]
},
// Backend runnable operations
listBackendRunnables: () => {
return Object.entries(backend).map(([key, runnable]) => ({
key,
name: runnable.name
}))
},
getBackendRunnable: (key: string) => backend[key],
getBackendRunnables: () => ({ ...backend }),
setBackendRunnable: async (key: string, runnable: BackendRunnable) => {
backend[key] = runnable
// Return mock lint result - in real usage this would validate the runnable
return createEmptyLintResult()
},
deleteBackendRunnable: (key: string) => {
delete backend[key]
},
// Combined view
getFiles: (): AppFiles => ({
frontend: { ...frontend },
backend: { ...backend }
}),
getSelectedContext: (): SelectedContext => ({
type: 'none'
}),
// Snapshot management
snapshot: () => {
const id = ++snapshotId
snapshots.set(id, {
frontend: { ...frontend },
backend: { ...backend }
})
return id
},
revertToSnapshot: (id: number) => {
const snap = snapshots.get(id)
if (snap) {
frontend = { ...snap.frontend }
backend = { ...snap.backend }
}
},
// Linting
lint: () => {
// Return mock lint result - no actual linting in eval
return createEmptyLintResult()
}
}
return {
helpers,
getFiles: (): AppFiles => ({
frontend: { ...frontend },
backend: { ...backend }
}),
getFrontend: () => ({ ...frontend }),
getBackend: () => ({ ...backend })
}
}

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