Compare commits

..

19 Commits

Author SHA1 Message Date
Ruben Fiszel
8799090942 chore(main): release 1.625.0 (#7782)
* chore(main): release 1.625.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-02-04 19:18:09 +00:00
Ruben Fiszel
d113546169 fix: persist ws_error_handler_muted for flows in create/update (#7797) 2026-02-04 19:11:47 +00:00
Ruben Fiszel
460b9bf82c sqlx 2026-02-04 18:58:03 +00:00
Diego Imbert
a3d75ba10a feat: favorite datatable and ducklake tables + asset page nits (#7795)
* update cf worker hostname

* set remote_url cookie from param

* ephemeral backends v1

* nit

* Run queue server

* ntis

* timeout

* better db process management

* commit hash and worktree

* nit use map

* nit

* err handling

* Revert "err handling"

This reverts commit 19de00c0c0.

* nits

* auto cleanup

* Ephemeral backend command action

* remove checkout

* checkout ee repo

* nits

* process.env.GIT_EE_DEPLOY_KEY_FILE

* resumeURLs logic

* nit

* use windmill flow for ephemeral backend action

* fixes

* new token

* worktree pools

* Delete GH secret on cleanup

* linux deploy

* nit

* nit

* unhandled promises

* nit

* fix docker bridge IP on linux

* pass cf_frontend_url to wmill flow

* git fetch

* release worktree when binary started

* send error

* logger

* logging

* logging 2

* delete log files periodically

* redirect to raw app with logs

* CORS

* MANAGER_AUTH_TOKEN

* Check organization membership

* nit

* bwrap

* nit

* return timeoutAt in resumeUrl

* nit

* Change password

* nit remove https

* Settings icon instead of plain text

* doc link

* favorite icon

* Better favorites state logic

* Asset favorites

* nit fixes and better label display

* global db manager drawer

* Favorites menu open db manager

* favorites in assets page + backend refactor

* Delete favorite dropdown

* parseFavoriteAsset

* git fail

* nit

* git fail

* nit

* nits

* git fail

* Table2 instead of pyramid
2026-02-04 18:48:51 +00:00
Ruben Fiszel
149da9b763 feat: make nsjail available in all standard images (CE) (#7793)
* feat: make nsjail available in all standard images (CE)

Include nsjail binary and runtime deps in the main Dockerfile and
DockerfileSlim so sandboxing is available out of the box. Flip
DISABLE_NSJAIL default to false so nsjail is enabled by default.

Remove DockerfileNsjail (now redundant) and the build_ee_nsjail CI job,
pointing publish_ecr_s3 at the base EE image instead. Add iptables to
DockerfileFullEe to preserve the functionality from the removed nsjail
image.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* revert: keep DISABLE_NSJAIL default as true

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: pin publish_ecr_s3 to exact commit hash

Add type=sha tag to build_ee so it pushes a commit-pinned image tag.
Restore git hash lookup in publish_ecr_s3 to reference the exact image
for that commit, avoiding race conditions with the mutable dev tag.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: publish_ecr_s3 depends on build_ee_full, uses release tag

Only publish to S3 on tag releases, extracting static frontend from the
ee-full image using the semver tag.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove stale windmill-ee-nsjail references, add nsjail to EE slim

The windmill-ee-nsjail image is no longer published since DockerfileNsjail
was deleted. Update all references to use the base EE image (which now
includes nsjail), remove redundant nsjail deps from DockerfileExtra, and
add nsjail build to DockerfileSlimEe for consistency with CE slim.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 18:48:16 +00:00
Diego Imbert
c9ee675de8 Fix asset parser integration test (#7794)
* fix ci

* Fix TS asset parser CI
2026-02-04 18:22:04 +00:00
Ruben Fiszel
145ee68210 nits tracing 2026-02-04 17:56:05 +00:00
hugocasa
63f9d85bf6 feat: public app rate limiting + fork hub raw apps + raw apps publish to hub button (#7789)
* feat: public app rate limiting + fork hub raw apps + raw apps publish to hub button

* sqlx

* missing sqlx file

* cache rate limiting

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-02-04 17:53:55 +00:00
centdix
db56518e4f fix: fix forking raw apps and summary setting in deploy drawer (#7792)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:50:42 +00:00
Ruben Fiszel
8220a0bd8d nits 2026-02-04 17:44:46 +00:00
Guilhem
e6ae87ecc1 job detail header nit (#7786)
* do not truncate worker

* align button right + breadscrum

* remove job arg header

* nit

* Revert "remove job arg header"

This reverts commit b68ee6d2e5.

* improve mem peak formatting

* improve permissioned as layout

* Fix job preview spacing inconsistency

* nit spacing
2026-02-04 16:44:35 +00:00
Diego Imbert
c760a33ccd fix ci (#7790) 2026-02-04 16:44:06 +00:00
Diego Imbert
0caa533fbd feat: column-level asset tracking for ducklake and datatables (#7774)
* Refactor 1

* claude tmp1

* fixes1

* support for insert and update

* Fix returning

* docs: add nuanced mutex selection guidance to Rust backend skill (#7737)

Add "Mutex Selection in Async Code" section explaining when to use
std::sync::Mutex vs tokio::sync::Mutex based on official Tokio docs.
std::sync::Mutex is preferred for data protection as it's faster;
tokio::sync::Mutex only needed when holding locks across .await points.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): revert findCodebase change that broke ../shared codebases (#7740)

* fix(cli): revert findCodebase relative_path check that broke ../shared codebases

The previous change added a check to ensure script paths start with the
codebase's relative_path. However, this broke cases where relative_path
uses parent directory references (e.g., "../shared") because:

1. path.join normalizes paths, so "/project/../shared/f/script.ts" becomes
   "/shared/f/script.ts"
2. FSFSElement strips the cwd prefix, resulting in "f/script.ts"
3. The check "f/script.ts".startsWith("../shared/") failed

The original behavior was correct - relative_path indicates where to find
codebase files, while includes/excludes patterns match against the normalized
paths that get passed during sync.

Fixes regression reported in #7729 comments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(cli): add preview test for codebase with imports

Tests that codebase bundling correctly includes imported modules,
which is the key functionality needed for ../shared codebases.
The test creates a helper module and a main script that imports
from it, then verifies the bundled script executes correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* chore(main): release 1.621.2 (#7735)

* chore(main): release 1.621.2

* Apply automatic changes

---------

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

* feat: workspace dedicated workers (#7741)

* feat: workspace dedicated workers

* ref

* chore: update ee-repo-ref to a18ac31062ac092cb9a5fc87629e217d97f4911d

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

Previous ee-repo-ref: 98cfe3fef764d9d815d326d5056c734a03689d33

New ee-repo-ref: a18ac31062ac092cb9a5fc87629e217d97f4911d

Automated by sync-ee-ref workflow.

* fix(frontend): workspace script in flow steps

---------

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

* fix: remove uuid-ossp extension requirement for RDS compatibility

The uuid-ossp extension was created in the first migration but never
actually used - the codebase uses gen_random_uuid() which is built-in
to PostgreSQL 13+. This allows Windmill to run on AWS RDS where
application users may not have CREATE SCHEMA privileges.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: indexer build error (#7744)

* fix: indexer build error

* prepare sqlx

* Remove changes from Cargo.toml

* refactor: remove seed parameter from AI chat completions (#7745)

* better claude

* refactor: remove seed parameter from AI chat completions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix: visibility bug on deployment UI (issue when renaming items) + add tracking of folders and resource types (#7739)

* fix: Raw apps deployment UI (and merge UI)

* Add folders and resource tpyes to merge UI

* claude first pass on adding the new arg for h_deploy_metadata

* Add missing argument to handle_deployment_metadata in all its calls

* Add support for folders and resource types in merge UI

* Update eereporef for CI

* Update ee repo

* Add migration to reset cached diff with potential artifacts

* fix type in frontend

* Preapare sqlx

* Remove unused import and logs

* update ee-repo

* Update eerepo

* chore: update ee-repo-ref to aca38475afd2cafaf63f4bbffc65be9437d57d86

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

Previous ee-repo-ref: 19c64cf8c61d83f45047b37660054b29658cd403

New ee-repo-ref: aca38475afd2cafaf63f4bbffc65be9437d57d86

Automated by sync-ee-ref workflow.

* Make integration  test for workspace comparisons

* Update SQLx metadata

---------

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

* fix: require AGENT_TOKEN and BASE_INTERNAL_URL for agent mode

- Add AgentConfig struct to validate required env vars on startup
- Change build_agent_http_client to require explicit token and URL
- Remove DEFAULT_BASE_INTERNAL_URL fallback (no more silent localhost:8000)
- Exit immediately if agent cannot connect to server on initial load
- Update integration tests to use dynamic port for BASE_INTERNAL_URL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: forward teams error to client (#7746)

* fix: forward teams error to client

* chore: update ee-repo-ref to 9a3d71f2c6a41ed4d17111a8c05d8e1d4933898d

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

Previous ee-repo-ref: 25d35a8de1cd70e281dc876e51cd30402580b5c0

New ee-repo-ref: 9a3d71f2c6a41ed4d17111a8c05d8e1d4933898d

Automated by sync-ee-ref workflow.

* fix

* fix

* fix

* al

* sqlx

* sqlx

* all

* all

---------

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

* feat: add token usage tracking to AI agent output (#7738)

* feat: add token usage tracking to AI agent output

Add TokenUsage struct to track input/output/cache tokens from AI providers.
Currently implemented for Bedrock provider, with infrastructure in place
for other providers. Usage is included in the AI agent result alongside
output and messages when available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add token usage extraction for Anthropic provider

Extract usage from message_delta SSE event and convert to TokenUsage.
Includes input_tokens, output_tokens, cache_read_input_tokens, and
cache_creation_input_tokens (mapped to cache_write_input_tokens).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add token usage extraction for Google AI/Gemini provider

Extract usage from usageMetadata in Gemini SSE events and convert to TokenUsage.
Maps promptTokenCount -> input_tokens, candidatesTokenCount -> output_tokens,
totalTokenCount -> total_tokens.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add token usage extraction for OpenAI Responses API provider

Extract usage from response.completed SSE event and convert to TokenUsage.
Maps input_tokens, output_tokens, and total_tokens directly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add token usage extraction for Azure OpenAI / Chat Completions API

Add stream_options.include_usage to request and parse usage from final
SSE chunk for providers using the standard OpenAI Chat Completions API
(Azure OpenAI, Mistral, DeepSeek, Groq, TogetherAI, CustomAI).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: cleanup token usage tracking - remove unused Image usage field and accumulate across iterations

- Remove unused `usage` field from ParsedResponse::Image variant
- Add TokenUsage::accumulate() method to sum usage across agent iterations
- Accumulate input/output/total/cache tokens instead of replacing with last iteration

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove verbose debug logging from AI providers

Remove tracing::info!("[debug] ...") statements that were too verbose
for production. These logged raw events on every streaming event.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add retry mechanism for OpenAI-compatible providers without stream_options support

Some OpenAI-compatible providers don't support the stream_options parameter
for usage tracking. This adds a retry mechanism that:
- First attempts the request with stream_options.include_usage
- If it fails with 400 and error mentions stream_options/include_usage,
  automatically retries without the parameter

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unnecessary text parsing overhead in image response handlers

Revert debugging changes that read response as text before parsing JSON.
Using response.json() directly is more efficient.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: centralize TokenUsage conversion with constructor methods

Add new(), from_input_output(), and with_cache() constructors to TokenUsage
to eliminate duplicate conversion logic across providers. Also fixes potential
truncation in Bedrock cache token conversion by using i32::try_from with
fallback to i32::MAX.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: simplify Anthropic usage extraction and add Default derive

- Use idiomatic `if let` pattern instead of `is_some()` check for usage extraction
- Add Default derive to OpenAIChatUsage for consistency with other usage structs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use saturating_add to prevent overflow in token accumulation

In long-running agents with many iterations, token counts could
potentially overflow. Using saturating_add ensures values cap at
i32::MAX instead of wrapping around.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* better claude

* nit

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* chore(main): release 1.622.0 (#7742)

* chore(main): release 1.622.0

* Apply automatic changes

---------

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

* fix npm check

* fix: add schema compatibility layer for MCP clients like n8n (#7747)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* nit ui nextcloud triggers (#7749)

* feat: add PocketID OAuth provider support (#7318)

* feat(oauth): add Pocket-ID OAuth provider component

- Implements PocketIdSetting.svelte following Keycloak pattern
- Configures OIDC endpoints for Pocket-ID (/authorize, /api/oidc/*)
- Supports standard OIDC scopes (openid, profile, email)
- Uses passkey-only authentication via Pocket-ID

Refs #5678

* feat(oauth): register Pocket-ID in SSO provider list

- Import PocketIdSetting component
- Add Pocket-ID to provider list in SSO tab
- Update exclusion filter to prevent duplicate custom entries

Refs #5678

* fix(oauth): add missing PocketID icon and fix component integration

- Create PocketIdIcon.svelte component with user profile icon
- Register pocket-id in APP_TO_ICON_COMPONENT mapping
- Fix PocketIdSetting to use IconedResourceType pattern matching other OAuth providers

This resolves the issue where PocketID toggle was not appearing in SSO settings.

Refs #5678

* refactor: migrate PocketIdSetting to Svelte 5 runes syntax

- Use $props() with $bindable() for reactive prop binding
- Use $state() for local reactive state
- Use $derived() for computed values
- Use $effect() for reactive side effects
- Replace on:change with onchange event handler
- Pre-populate base URL from existing config when editing
- Clean up bracket notation to dot notation for value properties

Addresses reviewer feedback

* fix: rename pocket-id to pocketid for naming convention compliance

Change identifier from 'pocket-id' to 'pocketid' to match Windmill's naming convention.
No OAuth provider uses hyphens - all custom SSO providers (keycloak, authentik, authelia,
kanidm, zitadel) use no separator.

Changes:
- AuthSettings.svelte: oauths['pocket-id'] → oauths['pocketid'] (2 locations)
- PocketIdSetting.svelte: name={'pocket-id'} → name={'pocketid'}
- icons/index.ts: 'pocket-id': PocketIdIcon → pocketid: PocketIdIcon

Note: PocketID does not need oauth_connect.json entry as it's a custom SSO provider
with user-configured endpoints, similar to Keycloak/Authentik.

Addresses reviewer feedback

* fix: use TextInput component for consistency

---------

Co-authored-by: hugocasa <hugo@casademont.ch>

* fix: preserve script envs field during sync push

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* nit frontend fix

* chore(main): release 1.623.0 (#7748)

* chore(main): release 1.623.0

* Apply automatic changes

---------

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

* fix: resolve infinite effect loop in PocketIdSetting component (#7753)

* fix: prevent retention cleanup from deleting jobs of active flows (#7755)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* chore(main): release 1.623.1 (#7754)

* chore(main): release 1.623.1

* Apply automatic changes

---------

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

* feat: default to quickjs on ce for flow eval (#7756)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat: runtime assets (#7656)

* Runtime assets

* Nits

* Revert "Nits"

This reverts commit 3031a2ddd1.

* detection_kinds

* don't delete runtime assets

* Show latest executions

* conditional unique idx

* nit status

* refactor

* nit refactor

* prepare sql

* Detect assets in complex JSON input objects

* false positive prevent

* nit

* redundant idx

* Update frontend/src/lib/components/assets/AssetsUsageDrawer.svelte

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* Update backend/migrations/20260122134517_runtime_assets.up.sql

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* runtime assets are inserted in a loop

* nit

* nit fix

* Don't use lazy static

* fix compilation

* nits

* missing on conflict do nothing

* add index

* Fix max n logic

* created at

* nits

* remove pagination

* sqlx prepare

* Only detect resource assets in input

* get_runtime_asset_sender()

* use global get_runtime_asset_sender to avoid prop drilling

* nit refactor : register_runtime_asset

* get job_id from token

* job as a usage kind

* fixes

* ee

* nit refactor

* merge access types when same job uses same asset multiple times

* Refactor to support wmill s3 API

* nit

* parse_wmill_sdk_sql_assets refactor

* Detect datatable and ducklake usage

* nit order by

* Join with v2_job

* better UI

* add sequential id for cursor pagination

* useInfiniteQuery

* useScrollToBottom

* sql index

* claude code stash

* migration fixes

* Infinite scroll UI

* nit

* style nit

* runtime asset created at

* Asset filters

* fix usage kind filter

* also check runnable_path for jobs when filtering

* better filters

* avoid flickering

* debounced filters

* nit

* tooltips

* fix: update AssetUsage type to match new ListAssetsResponse structure

The ListAssetsResponse changed from an array to an object with an 'assets' property.
Updated the type extraction accordingly.

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

* sqlx prepare

* Delete .claude/hooks/.symlink-manifest

* unnecessary dep

* nit refactor

* nit comment

* nit naming

* CI fix attempt 1

* ee ref

* nit remove alerts

* nit

* chore: update ee-repo-ref to 138a4f5f868f3bded5bb7cb77b222b532c07e4af

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

Previous ee-repo-ref: 7d3a21d53066726e97dfea9f117373299bc9318c

New ee-repo-ref: 138a4f5f868f3bded5bb7cb77b222b532c07e4af

Automated by sync-ee-ref workflow.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* fix: remove $schema field from Google AI output schema requests (#7765)

* fix: remove $schema field from Google AI output schema requests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add $schema field to all output schema integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove $schema field from Google AI tool parameter schemas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add workspace script tool test for AI agents

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Fix app multiselect not refreshing result when creating element (#7766)

* Fix returning

* asset columns are saved and displayed in the assets page

* runtime assets column detectionz

* frontend nits

* update regex parsers

* UI nits

* Display asset columns in flow graph

* Column hint directly in asset node

* nit bg

* sqlx prepare

* ee repo ref

* chore: update ee-repo-ref to 66a68df97e8c65c498b28f302a365ab8687cad9e

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

Previous ee-repo-ref: 0a32bc104cbaec9664a4d7cb1565823722c875a1

New ee-repo-ref: 66a68df97e8c65c498b28f302a365ab8687cad9e

Automated by sync-ee-ref workflow.

---------

Co-authored-by: centdix <40307056+centdix@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: wendrul <53628737+wendrul@users.noreply.github.com>
Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com>
Co-authored-by: Devdatta Talele <50290838+devdattatalele@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
2026-02-04 16:27:53 +00:00
Ruben Fiszel
5e62690d52 update ref 2026-02-04 16:21:28 +00:00
hugocasa
3c8daa9a58 feat: add filters to Kafka triggers (#7750)
* feat: add filters to Kafka triggers

- Introduced a new `filters` field in the Kafka trigger schema, allowing for JSONB array filters.
- Updated the WebSocket trigger to include the new `filters` functionality.
- Created a `TriggerFilters` component for managing filter inputs in the UI.

* update ref

* fix ci

* fix sqlx

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-02-04 15:30:54 +00:00
Ruben Fiszel
02f43d575b more verbose s3 errors 2026-02-04 14:50:59 +00:00
Ruben Fiszel
027573ad2d more verbose s3 errors 2026-02-04 14:48:43 +00:00
Ruben Fiszel
92cbeb7c6e more verbose s3 errors 2026-02-04 14:43:43 +00:00
Ruben Fiszel
4098679fd7 feat: cache lockfile results for scripts with same raw_workspace_dependencies (#7787)
* feat: cache lockfile results for scripts with same raw_workspace_dependencies

Extract fetchScriptLock from updateScriptLock to isolate the remote API
call behind a module-level in-memory cache. When multiple scripts share
the same content, language, and raw_workspace_dependencies, only one
remote call is made and subsequent lookups return the cached lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: only use lock cache when raw_workspace_dependencies are present

Skip caching entirely when rawWorkspaceDependencies is empty so the
cache is only active for scripts that actually use workspace deps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: cache key uses only language+deps, not script content

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: use annotation parser for lock cache key instead of full script content

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add mixed annotated/non-annotated scripts cache test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 14:26:12 +00:00
156 changed files with 3821 additions and 1805 deletions

View File

@@ -24,11 +24,6 @@ on:
description: "Tag the image"
required: true
default: "test"
nsjail:
description: "Build nsjail image (true, false)"
required: false
default: false
type: boolean
slim:
description: "Build slim image (true, false)"
required: false
@@ -106,7 +101,7 @@ jobs:
build_ee:
runs-on: ubicloud
if: (github.event_name != 'workflow_dispatch') || (github.event.inputs.ee || github.event.inputs.nsjail)
if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee
steps:
- uses: actions/checkout@v4
with:
@@ -370,67 +365,10 @@ jobs:
# ignore-unchanged: true
# only-fixed: true
build_ee_nsjail:
needs: [build_ee]
runs-on: ubicloud
if: (github.event_name != 'pull_request') && ((github.event_name != 'workflow_dispatch') || (github.event.inputs.ee || github.event.inputs.nsjail))
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v2
- uses: depot/setup-action@v1
- name: Docker meta
id: meta-ee-public
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-nsjail
flavor: |
latest=false
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,enable=true,priority=100,prefix=,suffix=,format=short
type=ref,event=branch
type=ref,event=pr
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Update Dockerfile image reference
run: |
sed -i 's|FROM ghcr.io/windmill-labs/windmill-ee:dev|FROM ghcr.io/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}|' ./docker/DockerfileNsjail
cat ./docker/DockerfileNsjail | grep "FROM"
- name: Build and push publicly ee
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
file: "./docker/DockerfileNsjail"
tags: |
${{ steps.meta-ee-public.outputs.tags }}
labels: |
${{ steps.meta-ee-public.outputs.labels }}
org.opencontainers.image.licenses=Windmill-Enterprise-License
publish_ecr_s3:
needs: [build_ee_nsjail]
needs: [build_ee_full]
runs-on: ubicloud-standard-2-arm
if: (github.event_name != 'pull_request') && (github.event_name !=
'workflow_dispatch')
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -449,23 +387,18 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: get git hash
if: github.event_name != 'pull_request'
id: git_hash
run: |
git_hash=$(git rev-parse --short "$GITHUB_SHA")
echo "GIT_HASH=${git_hash:0:7}" >> "$GITHUB_OUTPUT"
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- uses: shrink/actions-docker-extract@v3
if: github.event_name != 'pull_request'
id: extract
with:
image: |-
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-nsjail:${{ steps.git_hash.outputs.GIT_HASH }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-full:${{ steps.version.outputs.VERSION }}
path: "/static_frontend/."
- uses: reggionick/s3-deploy@v4
if: github.event_name != 'pull_request'
with:
folder: ${{ steps.extract.outputs.destination }}
bucket: windmill-frontend

View File

@@ -1,5 +1,28 @@
# Changelog
## [1.625.0](https://github.com/windmill-labs/windmill/compare/v1.624.0...v1.625.0) (2026-02-04)
### Features
* add filters to Kafka triggers ([#7750](https://github.com/windmill-labs/windmill/issues/7750)) ([3c8daa9](https://github.com/windmill-labs/windmill/commit/3c8daa9a58b5e4a2e8c85a9805a5b194ed75d055))
* Assets page exploration UI ([#7784](https://github.com/windmill-labs/windmill/issues/7784)) ([0508425](https://github.com/windmill-labs/windmill/commit/05084254a34da81d227813a5190e3ce3dc0f816e))
* cache lockfile results for scripts with same raw_workspace_dependencies ([#7787](https://github.com/windmill-labs/windmill/issues/7787)) ([4098679](https://github.com/windmill-labs/windmill/commit/4098679fd7eca059dfa128a6f8b8e1698a65b632))
* column-level asset tracking for ducklake and datatables ([#7774](https://github.com/windmill-labs/windmill/issues/7774)) ([0caa533](https://github.com/windmill-labs/windmill/commit/0caa533fbd70fffec27d86d62e16bb92cf7a612a))
* favorite datatable and ducklake tables + asset page nits ([#7795](https://github.com/windmill-labs/windmill/issues/7795)) ([a3d75ba](https://github.com/windmill-labs/windmill/commit/a3d75ba10ae85e5ecb55351555879be7fe0bfcca))
* make nsjail available in all standard images (CE) ([#7793](https://github.com/windmill-labs/windmill/issues/7793)) ([149da9b](https://github.com/windmill-labs/windmill/commit/149da9b763e4f5dd93d2905be89b5df81bb61934))
* public app rate limiting + fork hub raw apps + raw apps publish to hub button ([#7789](https://github.com/windmill-labs/windmill/issues/7789)) ([63f9d85](https://github.com/windmill-labs/windmill/commit/63f9d85bf6a5dd25977995978a8b0a4d32fee995))
* replace LISTEN/NOTIFY with polling-based event system ([#7778](https://github.com/windmill-labs/windmill/issues/7778)) ([e860847](https://github.com/windmill-labs/windmill/commit/e860847073b56be469ba37af5e3a8cb7d30ef7bc))
* upgrade bun to v1.3.8 with regression tests ([#7761](https://github.com/windmill-labs/windmill/issues/7761)) ([ef89a51](https://github.com/windmill-labs/windmill/commit/ef89a51f3a1cc1ae562d97b413c78393c0ea92cf))
### Bug Fixes
* fix forking raw apps and summary setting in deploy drawer ([#7792](https://github.com/windmill-labs/windmill/issues/7792)) ([db56518](https://github.com/windmill-labs/windmill/commit/db56518e4fc53931e3498db06bbefd511c343d23))
* handle Date serialization in quickjs flow eval via toJSON ([f151fdc](https://github.com/windmill-labs/windmill/commit/f151fdcf7f91a7b0ac75a133d5193538f4a9b4d8))
* make private registries settings password in the instance settings ([727bd21](https://github.com/windmill-labs/windmill/commit/727bd2164059e4d44f2e2f6f70a567e7fac3a921))
* persist ws_error_handler_muted for flows in create/update ([#7797](https://github.com/windmill-labs/windmill/issues/7797)) ([d113546](https://github.com/windmill-labs/windmill/commit/d113546169a790997d4842b7cfeb43ec2c90c6ea))
## [1.624.0](https://github.com/windmill-labs/windmill/compare/v1.623.1...v1.624.0) (2026-02-03)

View File

@@ -1,6 +1,26 @@
ARG DEBIAN_IMAGE=debian:bookworm-slim
ARG RUST_IMAGE=rust:1.90-slim-bookworm
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 ${RUST_IMAGE} AS rust_base
RUN apt-get update && apt-get install -y git libssl-dev pkg-config npm
@@ -246,6 +266,11 @@ ENV RUSTUP_HOME="/usr/local/rustup"
ENV CARGO_HOME="/usr/local/cargo"
ENV LD_LIBRARY_PATH="."
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
WORKDIR ${APP}
RUN ln -s ${APP}/windmill /usr/local/bin/windmill

View File

@@ -152,6 +152,11 @@
"ordinal": 29,
"name": "success_handler",
"type_info": "Jsonb"
},
{
"ordinal": 30,
"name": "public_app_execution_limit_per_minute",
"type_info": "Int4"
}
],
"parameters": {
@@ -189,6 +194,7 @@
true,
true,
true,
true,
true
]
},

View File

@@ -13,7 +13,8 @@
"kind": {
"Enum": [
"script",
"flow"
"flow",
"job"
]
}
}

View File

@@ -16,7 +16,8 @@
"app",
"script",
"flow",
"raw_app"
"raw_app",
"asset"
]
}
}

View File

@@ -1,53 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, asset_detection_kind, job_id)\n VALUES ($1, $2, $3, $4, $5, $6, 'static', NULL) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
{
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable"
]
}
}
},
{
"Custom": {
"name": "asset_access_type",
"kind": {
"Enum": [
"r",
"w",
"rw"
]
}
}
},
"Varchar",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow"
]
}
}
}
]
},
"nullable": []
},
"hash": "31eada57708ac3347d1e0a4fb5fd412a4a0c6046dbe4cf91849def2eea2e4c62"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE kafka_trigger \n SET \n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n script_path = $4,\n path = $5,\n is_flow = $6,\n edited_by = $7,\n email = $8,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $11,\n error_handler_args = $12,\n retry = $13\n WHERE \n workspace_id = $9 AND path = $10\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"VarcharArray",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "3b6bd7b41f130ce6df62fdecb351a3e01be0726d02d3f863e0ea5c476a8e785e"
}

View File

@@ -1,42 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, asset_detection_kind, job_id)\n VALUES ($1, $2, $3, NULL, $4, $5, 'runtime', $6) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
{
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable"
]
}
}
},
"Varchar",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow"
]
}
}
},
"Uuid"
]
},
"nullable": []
},
"hash": "3c6bed058591ad5438428b8c154ba6ee737e59aa033d833843b54e9f58eef3f2"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1) AND asset_detection_kind = 'static'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "46395d77f3393a058e25ff4efb0b61c743c58418ba2ae1104bcfc99fae7925e4"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', favorite.path\n )) as \"favorite_asset!: _\"\n FROM favorite\n WHERE favorite.workspace_id = $1\n AND favorite.usr = $2\n AND favorite_kind = 'asset'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "favorite_asset!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "4f666058177fed05c25036852f772d3cc5e2a5f947f307597b5a4a50f571c89b"
}

View File

@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT fv.id\n FROM flow f\n INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)]\n WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9"
}

View File

@@ -16,7 +16,8 @@
"app",
"script",
"flow",
"raw_app"
"raw_app",
"asset"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n value, schema, edited_by, edited_at\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10, $11::text::json, $12, now()\n )",
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10,\n $11, $12::text::json, $13, now()\n )",
"describe": {
"columns": [],
"parameters": {
@@ -14,6 +14,7 @@
"Bool",
"Bool",
"Text",
"Bool",
"Jsonb",
"Text",
"Varchar"
@@ -21,5 +22,5 @@
},
"nullable": []
},
"hash": "081dc94a7d0fdaade77cfb593a025d8c48d7eab3dbb30ca0b43fb1ef45d8d8bd"
"hash": "6bde827da007b470b9d0acccfc3e00ce6aac650b9138a236f34c614eed753849"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "6fdab4c131f3126d5020b780ba45927dc778ac6fefdc2f91982f600b7cb9954f"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = $2 AND asset_detection_kind = 'static'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "752fc50d3374ee5ccdb49f10c11e50bbffeec1088364a64f0849fb9b25e11ef4"
}

View File

@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type\n ) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n ORDER BY path, kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "list!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow"
]
}
}
}
]
},
"nullable": [
null
]
},
"hash": "76033e76f15cee2aa0394d4ec2ff62130e7e48cb40d3b1534b0d791760b33ec7"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now()\n WHERE\n path = $11 AND workspace_id = $12",
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now()\n WHERE\n path = $12 AND workspace_id = $13",
"describe": {
"columns": [],
"parameters": {
@@ -12,6 +12,7 @@
"Bool",
"Bool",
"Text",
"Bool",
"Jsonb",
"Text",
"Varchar",
@@ -21,5 +22,5 @@
},
"nullable": []
},
"hash": "207a0721b6f0b8b6ddd4120343eba524a2bc1e9047bdde5f568af4d993dbb74c"
"hash": "77ac7257be02fb04c4b3213e2221e6f60621b4b2909d770de744ef5671e12ed9"
}

View File

@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type\n ) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n AND asset_detection_kind = 'static'\n ORDER BY path, kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "list!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow"
]
}
}
}
]
},
"nullable": [
null
]
},
"hash": "7d73a51d5a83b11c1c4088678dd22ef369e9e26fafb75b351abfdef2b8a7a3e4"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3 AND asset_detection_kind = 'static'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow"
]
}
}
}
]
},
"nullable": []
},
"hash": "8ff8b53147d857db7829889ccbb59d2ca0492704a0b4a3cc8d1f4f733e0c794b"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'flow' AND usage_path = $2 AND asset_detection_kind = 'static'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "97101fae34227628efbcd72457a83a683745a6e2fb120329deba5117cc523bf2"
}

View File

@@ -1,37 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM asset\n WHERE id IN (\n SELECT id FROM (\n SELECT a.id, ROW_NUMBER() OVER (\n PARTITION BY a.workspace_id, a.path, a.kind\n ORDER BY a.created_at DESC\n ) as rn,\n limits.max_n\n FROM asset a\n INNER JOIN (\n SELECT * FROM UNNEST(\n $1::varchar[], \n $2::varchar[], \n $3::asset_kind[],\n $4::int[]\n ) AS t(workspace_id, path, kind, max_n)\n ) limits\n ON a.workspace_id = limits.workspace_id \n AND a.path = limits.path \n AND a.kind = limits.kind\n WHERE a.asset_detection_kind = 'runtime'\n ) ranked\n WHERE rn > max_n\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"VarcharArray",
"VarcharArray",
{
"Custom": {
"name": "asset_kind[]",
"kind": {
"Array": {
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable"
]
}
}
}
}
}
},
"Int4Array"
]
},
"nullable": []
},
"hash": "a14d43c1baebcdf7e8cebcb7c752f78a2212fe55d4247c2321b37d182d2a4386"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', asset.path,\n 'kind', asset.kind,\n 'usages', ARRAY_AGG(DISTINCT jsonb_build_object(\n 'path', asset.usage_path,\n 'kind', asset.usage_kind,\n 'access_type', asset.usage_access_type,\n 'detection_kinds', (\n SELECT ARRAY_AGG(DISTINCT a2.asset_detection_kind)\n FROM asset a2\n WHERE a2.workspace_id = asset.workspace_id\n AND a2.path = asset.path\n AND a2.kind = asset.kind\n AND a2.usage_path = asset.usage_path\n AND a2.usage_kind = asset.usage_kind\n )\n )),\n 'metadata', (CASE\n WHEN asset.kind = 'resource' THEN\n jsonb_build_object('resource_type', resource.resource_type)\n ELSE\n NULL\n END\n )\n )) as \"list!: _\"\n FROM asset\n LEFT JOIN resource ON asset.kind = 'resource'\n AND array_to_string((string_to_array(asset.path, '/'))[1:3], '/') = resource.path -- With specific table, asset path can be e.g u/diego/pg_db/table_name\n AND resource.workspace_id = $1\n WHERE asset.workspace_id = $1\n AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)\n AND (asset.usage_kind <> 'flow' OR asset.usage_path = ANY(SELECT path FROM flow WHERE workspace_id = $1))\n AND (asset.usage_kind <> 'script' OR asset.usage_path = ANY(SELECT path FROM script WHERE workspace_id = $1))\n GROUP BY asset.path, asset.kind, resource.resource_type\n ORDER BY asset.path, asset.kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "list!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "a21337dac33ef7a1aff1c6797a3ca47fed231f542d5bd8725d6c86b8dd1a2535"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -152,6 +152,11 @@
"ordinal": 29,
"name": "success_handler",
"type_info": "Jsonb"
},
{
"ordinal": 30,
"name": "public_app_execution_limit_per_minute",
"type_info": "Int4"
}
],
"parameters": {
@@ -189,8 +194,9 @@
false,
true,
true,
true,
true
]
},
"hash": "289919809e16aee33c81951b05a4795de710421bcd3e4c06588e56092677bd05"
"hash": "a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type,\n 'columns', columns\n )) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n ORDER BY path, kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "list!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow",
"job"
]
}
}
}
]
},
"nullable": [
null
]
},
"hash": "a750630d79166f5b6d5be0e049c36135212666ce74dbf290d701b6402b800f13"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind)\n VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING",
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)\n VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
@@ -40,14 +40,16 @@
"kind": {
"Enum": [
"script",
"flow"
"flow",
"job"
]
}
}
}
},
"Jsonb"
]
},
"nullable": []
},
"hash": "74a2871aba7e35527dcefb2538b8cf41c35618f7c15ea047c5082f8feb7a8464"
"hash": "a9e29764b5b9d94269e2b8aa755c71b61774c8ff8ae218d7a8d6ed0ac0169366"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), $11, $12, $13\n )\n ",
"query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), $12, $13, $14\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -10,6 +10,7 @@
"Varchar",
"Varchar",
"VarcharArray",
"JsonbArray",
"Varchar",
"Bool",
{
@@ -33,5 +34,5 @@
},
"nullable": []
},
"hash": "1de3e078c108a8a0136fccdf9187cc3500260bac39c7f1dfa05a93628569465b"
"hash": "aed5439aa6dad950e505f9f8f6914fa5ca21319c501b2822c9bc751ddfc9a0a4"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', asset.path,\n 'kind', asset.kind,\n 'usages', ARRAY_AGG(jsonb_build_object(\n 'path', asset.usage_path,\n 'kind', asset.usage_kind,\n 'access_type', asset.usage_access_type\n )),\n 'metadata', (CASE\n WHEN asset.kind = 'resource' THEN\n jsonb_build_object('resource_type', resource.resource_type)\n ELSE\n NULL\n END\n )\n )) as \"list!: _\"\n FROM asset\n LEFT JOIN resource ON asset.kind = 'resource'\n AND array_to_string((string_to_array(asset.path, '/'))[1:3], '/') = resource.path -- With specific table, asset path can be e.g u/diego/pg_db/table_name\n AND resource.workspace_id = $1\n WHERE asset.workspace_id = $1\n AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)\n AND (asset.usage_kind <> 'flow' OR asset.usage_path = ANY(SELECT path FROM flow WHERE workspace_id = $1))\n AND (asset.usage_kind <> 'script' OR asset.usage_path = ANY(SELECT path FROM script WHERE workspace_id = $1))\n GROUP BY asset.path, asset.kind, resource.resource_type\n ORDER BY asset.path, asset.kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "list!: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "c94cd50ff1233025b170efee6489e6637676a8b0435a4612a7efbfff6ea2543d"
}

View File

@@ -1,37 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(DISTINCT asset.job_id)::bigint as \"count!\"\n FROM asset\n WHERE asset.workspace_id = $1\n AND asset.path = $2\n AND asset.kind = $3\n AND asset.asset_detection_kind = 'runtime'\n AND asset.job_id IS NOT NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable"
]
}
}
}
]
},
"nullable": [
null
]
},
"hash": "df290155c071e823a1d60573a3cba932d070ed730a37450cd30ba9b90205ed3b"
}

View File

@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n script_path = $5,\n path = $6,\n is_flow = $7,\n edited_by = $8,\n email = $9,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $12,\n error_handler_args = $13,\n retry = $14\n WHERE\n workspace_id = $10 AND path = $11\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"VarcharArray",
"JsonbArray",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "e2921e44c70cf6c76c55177f2b56985e84c59ecb3e1a13fcf27d5f7ae5f8d84c"
}

View File

@@ -1,63 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT\n v2_job.id,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.runnable_path,\n CASE\n WHEN v2_job_completed.id IS NOT NULL THEN v2_job_completed.status::text\n ELSE NULL\n END as status\n FROM asset\n INNER JOIN v2_job ON asset.job_id = v2_job.id\n LEFT JOIN v2_job_completed ON v2_job.id = v2_job_completed.id\n WHERE asset.workspace_id = $1\n AND asset.path = $2\n AND asset.kind = $3\n AND asset.asset_detection_kind = 'runtime'\n AND asset.job_id IS NOT NULL\n ORDER BY v2_job.created_at DESC\n LIMIT $4 OFFSET $5",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "status",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable"
]
}
}
},
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
true,
null
]
},
"hash": "fb6656bc8fbd2e1b2c59f9ecbab55e5cdccdd7c99af0b61946694c6e03e7f636"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'flow' AND usage_path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "ff64f77d46fe8e1a07f26634df040ec5ced23cbb4390cd851c3bd7013dfd5758"
}

View File

@@ -1,97 +0,0 @@
# Backend Compilation Optimization
## Summary
Feature-gated heavy dependencies that were compiled by default but only used behind enterprise/EE feature flags. This reduces the default build from **761 crates to 609 crates** (20% reduction).
## Changes
### Root `Cargo.toml`
- Removed 12 unused direct dependencies: `kube`, `k8s-openapi`, `aws-sigv4`, `aws-sdk-config`, `opentelemetry-proto`, `systemstat`, `globset`, `libloading`, `bitflags`, `memchr`, `quote`, `pep440_rs`
### `windmill-worker/Cargo.toml`
- Made optional (only needed for EE OTEL tracing proxy): `hudsucker`, `hyper-http-proxy`, `hyper-tls`, `hyper-util`, `rcgen`, `opentelemetry-proto`, `prost`
- Made optional (only needed for EE features): `aws-config`, `aws-credential-types`, `aws-smithy-types`
- Created `otel_proxy` feature to group the OTEL proxy deps
- Updated `private` feature to include `otel_proxy`
### `windmill-common/Cargo.toml`
- Made optional: `aws-config`, `aws-credential-types`, `aws-smithy-types`, `systemstat`, `globset`
- Added AWS deps to `private`, `parquet`, `aws_auth`, `bedrock` features
- Added `systemstat` to `private` feature
- Added `globset` to `parquet` feature
### `windmill-api/Cargo.toml`
- Made optional: `aws-sigv4`, `aws-sdk-config`, `aws-credential-types`, `aws-smithy-types`, `windmill-parser-py-imports`, `windmill-autoscaling`
- Added AWS deps to `parquet` and `bedrock` features
- Added `windmill-parser-py-imports` to `python` and `agent_worker_server` features
- Added `windmill-autoscaling` to `enterprise` feature
### `windmill-autoscaling/Cargo.toml`
- Made optional: `kube`, `k8s-openapi` (only used in EE code)
- Added to `private` feature
### `parsers/windmill-parser-py-imports/Cargo.toml`
- Removed unused direct dependencies: `malachite`, `malachite-bigint` (still available transitively via `rustpython-parser`)
## Benchmarks
### Default build (no features)
| Metric | Before | After |
|---|---|---|
| Crates compiled | 761 | 609 |
| Notable deps eliminated | - | aws-sdk-config (9.5s), k8s-openapi (7.3s), zstd-sys (7.7s), kube-client (1.9s) |
### Incremental compilation (stable-state, warm cache)
| Scenario | Before | After |
|---|---|---|
| Touch `windmill-api/src/users.rs` | ~5.6s | ~5.4s |
| Touch `windmill-worker/src/worker.rs` | ~6.7s | ~6.2s |
| Touch `windmill-common/src/worker.rs` (cascade) | ~8.5s | ~8.5s |
Incremental compilation improvement from feature-gating alone is modest because the bottleneck is the compilation of the windmill crates themselves (especially windmill-api at 90k LOC), not the dependencies.
## Developer-Local Speed Tips
These settings are **not committed** because they are developer-local preferences that depend on toolchain availability. Combined, they yield ~16% faster incremental compilation.
### mold linker (~6% improvement)
Install `mold` and add to `.cargo/config.toml`:
```toml
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
```
### Reduced debug info (~10% improvement)
Add to `[profile.dev]` in `Cargo.toml`:
```toml
split-debuginfo = "unpacked"
debug = "line-tables-only"
```
### SQLX offline mode (~4% improvement)
If you're not modifying SQL queries:
```bash
export SQLX_OFFLINE=true
```
### Combined effect
| Scenario | Baseline | With all tips |
|---|---|---|
| Touch `windmill-api` file | 5.6s | **4.7s** |
| Touch `windmill-worker` file | 6.7s | **6.0s** |
| Touch `windmill-common` file (cascade) | 8.5s | **7.6s** |
## What would help more (future work)
The single biggest improvement would be **splitting `windmill-api`** (90k LOC) into smaller crates. Currently, any file change in the crate triggers re-analysis of all 90k lines. However, this requires significant refactoring due to tight coupling between the triggers subsystem, jobs, users, and the axum router initialization.

152
backend/Cargo.lock generated
View File

@@ -829,7 +829,7 @@ dependencies = [
"aws-sdk-sts",
"aws-smithy-async",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-json 0.61.9",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -882,23 +882,23 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.5.18"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "959dab27ce613e6c9658eb3621064d0e2027e5f2acb65bc526a43577facea557"
checksum = "c635c2dc792cb4a11ce1a4f392a925340d1bdf499289b5ec1ec6810954eb43f5"
dependencies = [
"aws-credential-types",
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-eventstream",
"aws-smithy-http 0.62.6",
"aws-smithy-http 0.63.3",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"http 0.2.12",
"http-body 0.4.6",
"http 1.4.0",
"http-body 1.0.1",
"percent-encoding",
"pin-project-lite",
"tracing",
@@ -907,15 +907,15 @@ dependencies = [
[[package]]
name = "aws-sdk-bedrock"
version = "1.129.0"
version = "1.130.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d009c7cbb8332c805be2be32c0def27cb7bd4804d9333f5cf204abfd96b6d25"
checksum = "883f0f0b2014a0bb59b6bfce8265a6ca6dc7e849d09314124f94a347b9d9e9f5"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-http 0.63.3",
"aws-smithy-json 0.62.3",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
@@ -924,6 +924,7 @@ dependencies = [
"bytes",
"fastrand",
"http 0.2.12",
"http 1.4.0",
"regex-lite",
"tracing",
]
@@ -940,7 +941,7 @@ dependencies = [
"aws-smithy-async",
"aws-smithy-eventstream",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-json 0.61.9",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
@@ -964,7 +965,7 @@ dependencies = [
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-json 0.61.9",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -979,16 +980,16 @@ dependencies = [
[[package]]
name = "aws-sdk-rds"
version = "1.123.0"
version = "1.124.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43adf02ed73dcbaa18979ab849162331e4b772e276988e5750fd277687d92897"
checksum = "24627b8374f8f242f351aa8a69adae8a416826a89819a7bfd010fc48b81f51e8"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-http 0.63.3",
"aws-smithy-json 0.62.3",
"aws-smithy-observability",
"aws-smithy-query",
"aws-smithy-runtime",
@@ -998,6 +999,7 @@ dependencies = [
"aws-types",
"fastrand",
"http 0.2.12",
"http 1.4.0",
"regex-lite",
"tracing",
"url",
@@ -1013,7 +1015,7 @@ dependencies = [
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-json 0.61.9",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -1035,7 +1037,7 @@ dependencies = [
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-json 0.61.9",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -1057,7 +1059,7 @@ dependencies = [
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-json 0.61.9",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -1079,7 +1081,7 @@ dependencies = [
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.62.6",
"aws-smithy-json",
"aws-smithy-json 0.61.9",
"aws-smithy-query",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
@@ -1094,13 +1096,13 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.3.7"
version = "1.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c"
checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
"aws-smithy-http 0.62.6",
"aws-smithy-http 0.63.3",
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
@@ -1219,6 +1221,15 @@ dependencies = [
"aws-smithy-types",
]
[[package]]
name = "aws-smithy-json"
version = "0.62.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979"
dependencies = [
"aws-smithy-types",
]
[[package]]
name = "aws-smithy-observability"
version = "0.2.4"
@@ -6959,7 +6970,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots 1.0.5",
"webpki-roots 1.0.6",
]
[[package]]
@@ -10986,7 +10997,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 1.0.5",
"webpki-roots 1.0.6",
]
[[package]]
@@ -11580,7 +11591,7 @@ dependencies = [
"rustls-webpki 0.103.9",
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs 1.0.5",
"webpki-root-certs 1.0.6",
"windows-sys 0.61.2",
]
@@ -15300,14 +15311,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
dependencies = [
"webpki-root-certs 1.0.5",
"webpki-root-certs 1.0.6",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.5"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
@@ -15318,14 +15329,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.5",
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.5"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
@@ -15487,11 +15498,14 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"aws-sdk-config",
"aws-sigv4",
"axum 0.7.9",
"base64 0.22.1",
"bitflags 2.9.4",
"chrono",
"constant_time_eq 0.3.1",
"deno_core",
@@ -15499,10 +15513,18 @@ dependencies = [
"futures",
"gethostname",
"git-version",
"globset",
"k8s-openapi",
"kube",
"lazy_static",
"libloading 0.8.9",
"memchr",
"object_store",
"once_cell",
"opentelemetry-proto 0.29.0",
"pep440_rs",
"prometheus",
"quote",
"rand 0.9.0",
"reqwest 0.13.1",
"rustls 0.23.35",
@@ -15515,6 +15537,7 @@ dependencies = [
"sql-builder",
"sqlx",
"strum 0.27.2",
"systemstat",
"tempfile",
"tikv-jemalloc-ctl",
"tikv-jemalloc-sys",
@@ -15539,7 +15562,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"argon2",
@@ -15575,6 +15598,7 @@ dependencies = [
"constant_time_eq 0.3.1",
"cookie 0.17.0",
"cron",
"dashmap 6.1.0",
"datafusion",
"deno_core",
"deno_error",
@@ -15670,7 +15694,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15680,7 +15704,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15694,7 +15718,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -15713,7 +15737,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15809,7 +15833,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"regex",
"serde",
@@ -15824,7 +15848,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15848,7 +15872,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15864,7 +15888,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15884,7 +15908,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"async-oauth2",
@@ -15908,7 +15932,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15917,7 +15941,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15929,7 +15953,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15941,7 +15965,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15953,7 +15977,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15965,7 +15989,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15977,7 +16001,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -15988,7 +16012,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15999,7 +16023,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16012,12 +16036,14 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"async-recursion",
"itertools 0.14.0",
"lazy_static",
"malachite",
"malachite-bigint",
"pep440_rs",
"phf 0.11.3",
"regex",
@@ -16034,7 +16060,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16048,7 +16074,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16065,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16079,7 +16105,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16098,7 +16124,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"serde",
@@ -16109,7 +16135,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16146,7 +16172,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -16156,7 +16182,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.624.0"
version = "1.625.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17151,9 +17177,9 @@ dependencies = [
[[package]]
name = "zip"
version = "7.2.0"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
checksum = "268bf6f9ceb991e07155234071501490bb41fd1e39c6a588106dad10ae2a5804"
dependencies = [
"crc32fast",
"indexmap 2.11.1",

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.624.0"
version = "1.625.0"
authors.workspace = true
edition.workspace = true
@@ -35,7 +35,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.624.0"
version = "1.625.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -150,13 +150,21 @@ deno_core = { workspace = true, optional = true }
object_store = { workspace = true, optional = true }
sha1 = { workspace = true, optional = true }
constant_time_eq = { workspace = true, optional = true }
quote.workspace = true
memchr.workspace = true
v8 = { workspace = true, optional = true }
rustls.workspace = true
pep440_rs.workspace = true
strum.workspace = true
aws-sigv4.workspace = true
aws-sdk-config.workspace = true
kube.workspace = true
k8s-openapi.workspace = true
libloading.workspace = true
bitflags.workspace = true
globset.workspace = true
opentelemetry-proto.workspace = true
systemstat.workspace = true
[target.'cfg(windows)'.dependencies]
windows-service = "0.7"

View File

@@ -1 +1 @@
138a4f5f868f3bded5bb7cb77b222b532c07e4af
41264d02f64572a3ea3b81ac2833e8d466366e96

View File

@@ -0,0 +1 @@
ALTER TABLE kafka_trigger DROP COLUMN filters;

View File

@@ -0,0 +1 @@
ALTER TABLE kafka_trigger ADD COLUMN filters JSONB[] NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,2 @@
-- Remove columns field from asset table
ALTER TABLE asset DROP COLUMN columns;

View File

@@ -0,0 +1,3 @@
-- Add columns field to asset table to store column-level access information
-- This is a JSONB map of column name to access type (r, w, or rw)
ALTER TABLE asset ADD COLUMN columns JSONB;

View File

@@ -0,0 +1,5 @@
DROP TRIGGER IF EXISTS workspace_rate_limit_change_trigger ON workspace_settings;
DROP FUNCTION IF EXISTS notify_workspace_rate_limit_change();
ALTER TABLE workspace_settings
DROP COLUMN IF EXISTS public_app_execution_limit_per_minute;

View File

@@ -0,0 +1,19 @@
ALTER TABLE workspace_settings
ADD COLUMN IF NOT EXISTS public_app_execution_limit_per_minute INTEGER DEFAULT NULL;
-- Add trigger function for rate limit changes
CREATE OR REPLACE FUNCTION notify_workspace_rate_limit_change()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO notify_event (channel, payload)
VALUES ('notify_workspace_rate_limit_change', NEW.workspace_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger on workspace_settings (drop first if exists)
DROP TRIGGER IF EXISTS workspace_rate_limit_change_trigger ON workspace_settings;
CREATE TRIGGER workspace_rate_limit_change_trigger
AFTER UPDATE OF public_app_execution_limit_per_minute ON workspace_settings
FOR EACH ROW
EXECUTE FUNCTION notify_workspace_rate_limit_change();

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,10 @@
-- Add up migration script here
DO
$do$
BEGIN
ALTER TYPE FAVORITE_KIND ADD VALUE 'asset';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Couldn''t create FAVORITE_KIND::asset: %', SQLERRM;
END
$do$;

View File

@@ -18,6 +18,8 @@ regex.workspace = true
windmill-parser.workspace = true
windmill-common.workspace = true
rustpython-parser.workspace = true
malachite.workspace = true
malachite-bigint.workspace = true
phf.workspace = true
itertools.workspace = true
serde_json.workspace = true

View File

@@ -19,9 +19,12 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
// if a db = wmill.datatable() was never used (e.g db.query(...)),
// we still want to register the asset as unknown access type
if asset_was_used(&assets_finder.assets, (kind, &path)) == false {
assets_finder
.assets
.push(ParseAssetsResult { kind, access_type: None, path });
assets_finder.assets.push(ParseAssetsResult {
kind,
path,
access_type: None,
columns: None,
});
}
}
@@ -48,8 +51,12 @@ impl Visitor for AssetsFinder {
match removed {
Some((kind, path, _)) => {
if !asset_was_used(&self.assets, (kind, &path)) {
self.assets
.push(ParseAssetsResult { kind, access_type: None, path });
self.assets.push(ParseAssetsResult {
kind,
path,
access_type: None,
columns: None,
});
}
}
None => {}
@@ -76,6 +83,7 @@ impl Visitor for AssetsFinder {
kind,
path: path.to_string(),
access_type: None,
columns: None,
});
}
}
@@ -97,6 +105,7 @@ impl Visitor for AssetsFinder {
kind,
path: path.to_string(),
access_type: None,
columns: None,
});
}
}
@@ -252,8 +261,12 @@ impl AssetsFinder {
let path = parse_asset_syntax(&value, false)
.map(|(_, p)| p)
.unwrap_or(&value);
self.assets
.push(ParseAssetsResult { kind, path: path.to_string(), access_type });
self.assets.push(ParseAssetsResult {
kind,
path: path.to_string(),
access_type,
columns: None,
});
}
_ => return Err(()),
};
@@ -266,6 +279,8 @@ struct Arg(usize, &'static str);
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
#[test]
@@ -281,7 +296,8 @@ def main():
Ok(vec![ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/test.csv".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},])
);
}
@@ -299,7 +315,8 @@ def main():
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "main".to_string(),
access_type: None
access_type: None,
columns: None,
},])
);
}
@@ -318,7 +335,8 @@ def main(x: int):
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/friends".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},])
);
}
@@ -340,12 +358,14 @@ def main(x: int):
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/analytics".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/friends".to_string(),
access_type: Some(RW)
access_type: Some(RW),
columns: Some(BTreeMap::from([("x".to_string(), AssetUsageAccessType::W)])),
},
])
);
@@ -372,17 +392,20 @@ def g():
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "another1/customers".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: None,
},
ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "another2".to_string(),
access_type: None
access_type: None,
columns: None,
},
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "main/friends".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},
])
);
@@ -404,12 +427,14 @@ def g():
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "another1".to_string(),
access_type: None
access_type: None,
columns: None,
},
ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main".to_string(),
access_type: None
access_type: None,
columns: None,
},
])
);
@@ -429,7 +454,8 @@ def main(x: int):
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/public.friends".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},])
);
}
@@ -448,7 +474,8 @@ def main():
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "lake1/analytics.metrics".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},])
);
}
@@ -468,7 +495,8 @@ def main(x: int):
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/public.users".to_string(),
access_type: Some(RW)
access_type: Some(RW),
columns: None,
},])
);
}
@@ -486,7 +514,8 @@ def main():
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt".to_string(),
access_type: None
access_type: None,
columns: None,
},])
);
}

View File

@@ -1,9 +1,9 @@
use std::collections::HashMap;
use std::collections::BTreeMap;
use sqlparser::{
ast::{
CopyTarget, Expr, ObjectName, TableFactor, TableObject, Value, ValueWithSpan, Visit,
Visitor,
CopyTarget, Expr, ObjectName, ObjectNamePart, SelectItem, TableFactor, TableObject, Value,
ValueWithSpan, Visit, Visitor,
},
dialect::DuckDbDialect,
parser::Parser,
@@ -24,9 +24,12 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
for (_, (kind, path)) in collector.var_identifiers {
if !asset_was_used(&collector.assets, (kind, &path)) {
collector
.assets
.push(ParseAssetsResult { kind, access_type: None, path: path });
collector.assets.push(ParseAssetsResult {
kind,
access_type: None,
path: path,
columns: None,
});
}
}
@@ -39,7 +42,7 @@ struct AssetCollector {
// e.g set to Read when we are inside a SELECT ... FROM ... statement
current_access_type_stack: Vec<AssetUsageAccessType>,
// e.g ATTACH 'ducklake://a' AS dl; => { "dl": (Ducklake, "a") }
var_identifiers: HashMap<String, (AssetKind, String)>,
var_identifiers: BTreeMap<String, (AssetKind, String)>,
// e.g USE dl;
currently_used_asset: Option<(AssetKind, String)>,
}
@@ -49,15 +52,19 @@ impl AssetCollector {
Self {
assets: Vec::new(),
current_access_type_stack: Vec::with_capacity(8),
var_identifiers: HashMap::new(),
var_identifiers: BTreeMap::new(),
currently_used_asset: None,
}
}
// Detect when we do 'a.b' and 'a' is associated with an asset in var_identifiers
// Or when we access 'b' and we did USE a;
fn get_associated_asset_from_obj_name(&self, name: &ObjectName) -> Option<ParseAssetsResult> {
let access_type = self.current_access_type_stack.last().copied();
fn get_associated_asset_from_obj_name(
&self,
name: &ObjectName,
access_type: Option<AssetUsageAccessType>,
) -> Option<ParseAssetsResult> {
let access_type = access_type.or_else(|| self.current_access_type_stack.last().copied());
if let Some((kind, path)) = &self.currently_used_asset {
// We don't want to infer that any simple identifier refers to an asset if
// we are not in a known R/W context
@@ -81,7 +88,7 @@ impl AssetCollector {
.collect::<Option<Vec<String>>>()?
.join(".");
let path = format!("{}/{}", path, specific_table);
return Some(ParseAssetsResult { kind: *kind, access_type, path });
return Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None });
}
}
@@ -101,7 +108,7 @@ impl AssetCollector {
} else {
path.clone()
};
Some(ParseAssetsResult { kind: *kind, access_type, path })
Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None })
}
fn handle_string_literal(&mut self, s: &str) {
@@ -112,6 +119,7 @@ impl AssetCollector {
kind,
path: path.to_string(),
access_type: self.current_access_type_stack.last().copied(),
columns: None,
});
}
}
@@ -126,13 +134,6 @@ impl AssetCollector {
if let Some(str_lit) = get_str_lit_from_obj_name(name) {
self.handle_string_literal(str_lit);
}
// Writes to tables should be handled directly when visiting the statement
if self.current_access_type_stack.last() == Some(&R) {
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
self.assets.push(asset);
}
}
}
fn handle_obj_name_post(&mut self, name: &ObjectName) {
@@ -146,20 +147,144 @@ impl AssetCollector {
}
}
fn handle_table_with_joins(&mut self, table_with_joins: &sqlparser::ast::TableWithJoins) {
if let TableFactor::Table { name, .. } = &table_with_joins.relation {
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
fn handle_table_with_joins(
&mut self,
table_with_joins: &sqlparser::ast::TableWithJoins,
access_type: Option<AssetUsageAccessType>,
) {
if let TableFactor::Table { name, args, .. } = &table_with_joins.relation {
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
return;
}
if let Some(asset) = self.get_associated_asset_from_obj_name(name, access_type) {
self.assets.push(asset);
}
}
for join in &table_with_joins.joins {
if let TableFactor::Table { name, .. } = &join.relation {
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
if let Some(asset) = self.get_associated_asset_from_obj_name(name, access_type) {
self.assets.push(asset);
}
}
}
}
// Extract columns from SELECT items and create individual asset results for each column
// Only processes columns that reference known assets to avoid false positives
fn extract_column_assets(
&mut self,
projection: &[SelectItem],
from_tables: &[sqlparser::ast::TableWithJoins],
) {
// Check if this is a single-table SELECT (to avoid ambiguity)
let single_table = if from_tables.len() == 1 {
if let TableFactor::Table { name, args, .. } = &from_tables[0].relation {
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
return; // Skip table functions
}
self.get_associated_asset_from_obj_name(name, Some(R))
} else {
None
}
} else {
None
};
// Build a map of table aliases/names to assets for multi-table queries
let mut table_to_asset: BTreeMap<String, ParseAssetsResult> = BTreeMap::new();
for table_with_joins in from_tables {
if let TableFactor::Table { name, alias, args, .. } = &table_with_joins.relation {
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
continue; // Skip table functions
}
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(R)) {
// Use alias if present, otherwise use the table name
let table_key = if let Some(alias) = alias {
alias.name.value.clone()
} else {
// For qualified names like "dl.table1", use just the last part
name.0
.last()
.and_then(|id| id.as_ident())
.map(|id| id.value.clone())
.unwrap_or_default()
};
table_to_asset.insert(table_key, asset);
}
}
}
// Process each SELECT item
for item in projection {
match item {
SelectItem::UnnamedExpr(Expr::Identifier(ident))
| SelectItem::ExprWithAlias { expr: Expr::Identifier(ident), .. } => {
// Simple column: SELECT a
// Only add if we have a single table (unambiguous)
if let Some(asset) = &single_table {
let mut columns = BTreeMap::new();
columns.insert(ident.value.clone(), R);
self.assets.push(ParseAssetsResult {
kind: asset.kind,
path: asset.path.clone(),
access_type: Some(R),
columns: Some(columns),
});
}
}
SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts))
| SelectItem::ExprWithAlias { expr: Expr::CompoundIdentifier(parts), .. } => {
// Qualified column: SELECT table1.a or SELECT x.table1.a
if parts.len() >= 2 {
let column_name = parts.last().map(|id| id.value.clone());
if let Some(column_name) = column_name {
// Check if the prefix matches a known table
let table_prefix = parts.first().map(|id| id.value.clone());
if let Some(table_prefix) = table_prefix {
if let Some(asset) = table_to_asset.get(&table_prefix) {
// Found a matching table, add column asset
let mut columns = BTreeMap::new();
columns.insert(column_name.clone(), R);
self.assets.push(ParseAssetsResult {
kind: asset.kind,
path: asset.path.clone(),
access_type: Some(R),
columns: Some(columns),
});
} else if parts.len() >= 3 {
// Could be x.table1.column format or db.schema.table.column
// Convert Idents to ObjectNameParts
let obj_parts: Vec<ObjectNamePart> = parts[..parts.len() - 1]
.iter()
.cloned()
.map(|ident| ObjectNamePart::Identifier(ident))
.collect();
let obj_name = ObjectName(obj_parts);
if let Some(asset) =
self.get_associated_asset_from_obj_name(&obj_name, Some(R))
{
let mut columns = BTreeMap::new();
columns.insert(column_name.clone(), R);
self.assets.push(ParseAssetsResult {
kind: asset.kind,
path: asset.path.clone(),
access_type: Some(R),
columns: Some(columns),
});
}
}
}
}
}
}
_ => {
// Ignore wildcards, expressions, etc.
}
}
}
}
}
impl Visitor for AssetCollector {
@@ -218,51 +343,154 @@ impl Visitor for AssetCollector {
statement: &sqlparser::ast::Statement,
) -> std::ops::ControlFlow<Self::Break> {
match statement {
sqlparser::ast::Statement::Query(_) => {
// don't forget pop() in post_visit_statement
self.current_access_type_stack.push(R);
sqlparser::ast::Statement::Query(q) => {
if let Some(select) = q.body.as_select() {
// First, handle table references (adds table-level assets)
for t in &select.from {
self.handle_table_with_joins(t, Some(R));
}
// Then, extract column-level assets
self.extract_column_assets(&select.projection, &select.from);
}
}
sqlparser::ast::Statement::Insert(insert) => {
let access_type = if insert.returning.is_some() { RW } else { W };
self.current_access_type_stack.push(access_type);
match insert.table {
TableObject::TableName(ref name) => {
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
self.assets.push(asset);
if let Some(asset) =
self.get_associated_asset_from_obj_name(name, Some(access_type))
{
// Add table-level asset
self.assets.push(ParseAssetsResult {
kind: asset.kind,
path: asset.path.clone(),
access_type: asset.access_type,
columns: None,
});
// Extract column information for INSERT with explicit columns (Write access)
if !insert.columns.is_empty() {
for col in &insert.columns {
let columns = BTreeMap::from([(col.value.clone(), W)]);
self.assets.push(ParseAssetsResult {
kind: asset.kind,
path: asset.path.clone(),
access_type: Some(W),
columns: Some(columns),
});
}
}
// Extract column information from RETURNING clause (Read access)
if let Some(returning) = &insert.returning {
for item in returning {
match item {
SelectItem::UnnamedExpr(Expr::Identifier(ident))
| SelectItem::ExprWithAlias {
expr: Expr::Identifier(ident),
..
} => {
let mut col_map = BTreeMap::new();
col_map.insert(ident.value.clone(), R);
self.assets.push(ParseAssetsResult {
kind: asset.kind,
path: asset.path.clone(),
access_type: Some(R),
columns: Some(col_map),
});
}
_ => {
// Ignore wildcards and complex expressions
}
}
}
}
}
}
_ => {}
}
self.current_access_type_stack.pop();
}
sqlparser::ast::Statement::Update { returning, table, from, .. } => {
sqlparser::ast::Statement::Update { returning, table, from, assignments, .. } => {
if let Some(from_tables) = from {
let from_tables = match from_tables {
sqlparser::ast::UpdateTableFromKind::AfterSet(tables) => tables,
sqlparser::ast::UpdateTableFromKind::BeforeSet(tables) => tables,
};
self.current_access_type_stack.push(R);
for table_with_joins in from_tables {
self.handle_table_with_joins(table_with_joins);
self.handle_table_with_joins(table_with_joins, Some(R));
}
self.current_access_type_stack.pop();
}
let access_type = if returning.is_some() { RW } else { W };
self.current_access_type_stack.push(access_type);
self.handle_table_with_joins(table, Some(access_type));
self.handle_table_with_joins(table);
// Extract column information from UPDATE SET clauses (Write access)
// Only process if it's a single table update
if let TableFactor::Table { name, .. } = &table.relation {
if let Some(asset) =
self.get_associated_asset_from_obj_name(name, Some(access_type))
{
// Process each assignment to extract column names
for assignment in assignments {
// assignment.target is an AssignmentTarget enum
// We only handle simple column names (ColumnName variant)
if let sqlparser::ast::AssignmentTarget::ColumnName(col_name) =
&assignment.target
{
// For simple column updates, this is typically a single ident
if col_name.0.len() == 1 {
if let Some(col_ident) =
col_name.0.first().and_then(|p| p.as_ident())
{
let mut col_map = BTreeMap::new();
col_map.insert(col_ident.value.clone(), W);
self.assets.push(ParseAssetsResult {
kind: asset.kind,
path: asset.path.clone(),
access_type: Some(W),
columns: Some(col_map),
});
}
}
}
}
self.current_access_type_stack.pop();
// Extract column information from RETURNING clause (Read access)
if let Some(returning_items) = returning {
for item in returning_items {
match item {
SelectItem::UnnamedExpr(Expr::Identifier(ident))
| SelectItem::ExprWithAlias {
expr: Expr::Identifier(ident),
..
} => {
let mut col_map = BTreeMap::new();
col_map.insert(ident.value.clone(), R);
self.assets.push(ParseAssetsResult {
kind: asset.kind,
path: asset.path.clone(),
access_type: Some(R),
columns: Some(col_map),
});
}
_ => {
// Ignore wildcards and complex expressions
}
}
}
}
}
}
}
sqlparser::ast::Statement::Delete(delete) => {
let access_type = if delete.returning.is_some() { RW } else { W };
self.current_access_type_stack.push(access_type);
for name in &delete.tables {
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
if let Some(asset) =
self.get_associated_asset_from_obj_name(name, Some(access_type))
{
self.assets.push(asset);
}
}
@@ -271,25 +499,22 @@ impl Visitor for AssetCollector {
sqlparser::ast::FromTable::WithoutKeyword(tables) => tables,
};
for table_with_joins in tables {
self.handle_table_with_joins(table_with_joins);
self.handle_table_with_joins(table_with_joins, Some(access_type));
}
self.current_access_type_stack.pop();
}
sqlparser::ast::Statement::CreateTable(create_table) => {
self.current_access_type_stack.push(W);
if let Some(asset) = self.get_associated_asset_from_obj_name(&create_table.name) {
if let Some(asset) =
self.get_associated_asset_from_obj_name(&create_table.name, Some(W))
{
self.assets.push(asset);
}
self.current_access_type_stack.pop();
}
sqlparser::ast::Statement::CreateView { name, .. } => {
self.current_access_type_stack.push(W);
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(W)) {
self.assets.push(asset);
}
self.current_access_type_stack.pop();
}
sqlparser::ast::Statement::Copy { target: CopyTarget::File { filename }, .. } => {
@@ -339,14 +564,8 @@ impl Visitor for AssetCollector {
fn post_visit_statement(
&mut self,
statement: &sqlparser::ast::Statement,
_statement: &sqlparser::ast::Statement,
) -> std::ops::ControlFlow<Self::Break> {
match statement {
sqlparser::ast::Statement::Query(_) => {
self.current_access_type_stack.pop();
}
_ => {}
}
std::ops::ControlFlow::Continue(())
}
@@ -409,17 +628,20 @@ mod tests {
ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/a.parquet".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None
},
ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/c.parquet".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: None
},
ParseAssetsResult {
kind: AssetKind::S3Object,
path: "snd/b.parquet".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None
},
])
);
@@ -438,7 +660,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "my_dl".to_string(),
access_type: None
access_type: None,
columns: None
},])
);
}
@@ -455,7 +678,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "my_dl/table1".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None
},])
);
}
@@ -473,7 +697,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "my_dt/table1".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: None
},])
);
}
@@ -504,7 +729,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "my_dl/table1".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: None
},])
);
}
@@ -521,7 +747,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "main/table1".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: None
},])
);
}
@@ -543,7 +770,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main/friends".to_string(),
access_type: Some(RW)
access_type: Some(RW),
columns: None
},])
);
}
@@ -561,7 +789,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main".to_string(),
access_type: None
access_type: None,
columns: None
},])
);
}
@@ -579,7 +808,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main/table1".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: None
},])
);
}
@@ -597,7 +827,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main/table1".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: Some(BTreeMap::from([("id".to_string(), W)])),
},])
);
}
@@ -615,7 +846,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Resource,
path: "u/user/pg_resource/table1".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None
},])
);
}
@@ -632,7 +864,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main/table1".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: Some(BTreeMap::from([("id".to_string(), W)])),
},])
);
}
@@ -650,7 +883,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main/sch.table1".to_string(),
access_type: Some(RW)
access_type: Some(RW),
columns: Some(BTreeMap::from([("id".to_string(), W)])),
},])
);
}
@@ -669,8 +903,289 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main/sch.table1".to_string(),
access_type: Some(RW)
access_type: Some(RW),
columns: Some(BTreeMap::from([("id".to_string(), W)])),
},])
);
}
#[test]
fn test_sql_asset_parser_single_table_column_detection() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
SELECT a, b FROM dl.table1;
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Should have one asset with merged columns
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "my_dl/table1");
assert_eq!(result[0].access_type, Some(R));
// Check that both columns are present in the merged asset
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.len(), 2);
assert_eq!(columns.get("a"), Some(&R));
assert_eq!(columns.get("b"), Some(&R));
}
#[test]
fn test_sql_asset_parser_explicit_table_prefix_columns() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
SELECT dl.table1.a, dl.table1.b FROM dl.table1;
"#;
let s = parse_assets(input).map(|s| s.assets);
// Should detect columns with explicit table prefix
let result = s.unwrap();
// Check we have the table asset
// Check we have column assets
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("a"))
}));
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("b"))
}));
}
#[test]
fn test_sql_asset_parser_multi_table_no_simple_columns() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
SELECT a, b FROM dl.table1, dl.table2;
"#;
let s = parse_assets(input).map(|s| s.assets);
// Simple columns (a, b) should NOT be detected with multiple tables
// Only table-level assets should be present
let result = s.unwrap();
// Should have 2 table assets
assert_eq!(result.iter().filter(|a| a.columns.is_none()).count(), 2);
// Should have NO column assets (ambiguous which table they belong to)
assert_eq!(result.iter().filter(|a| a.columns.is_some()).count(), 0);
}
#[test]
fn test_sql_asset_parser_multi_table_with_qualified_columns() {
let input = r#"
ATTACH 'ducklake://my_dl1' AS dl1;
ATTACH 'ducklake://my_dl2' AS dl2;
SELECT table1.a, table2.b FROM dl1.table1, dl2.table2;
"#;
let s = parse_assets(input).map(|s| s.assets);
// Qualified columns should be detected even with multiple tables
let result = s.unwrap();
// Check we have column assets for both tables
assert!(result.iter().any(|a| {
a.path == "my_dl1/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("a"))
}));
assert!(result.iter().any(|a| {
a.path == "my_dl2/table2"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("b"))
}));
}
#[test]
fn test_sql_asset_parser_use_with_simple_columns() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
USE dl;
SELECT a, b, c FROM table1;
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Should detect columns since it's a single table
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("a"))
}));
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("b"))
}));
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("c"))
}));
}
#[test]
fn test_sql_asset_parser_wildcard_no_columns() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
SELECT * FROM dl.table1;
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Wildcard should NOT create column assets, only table asset
assert_eq!(result.len(), 1);
assert!(result[0].columns.is_none());
}
#[test]
fn test_sql_asset_parser_columns_with_alias() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
SELECT a AS column_a, b AS column_b FROM dl.table1;
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Should detect columns even when aliased
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("a"))
}));
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("b"))
}));
}
#[test]
fn test_sql_asset_parser_columns_with_table_alias() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
SELECT t.a, t.b FROM dl.table1 AS t;
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Should detect columns using the table alias
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("a"))
}));
assert!(result.iter().any(|a| {
a.path == "my_dl/table1"
&& a.columns
.as_ref()
.map_or(false, |cols| cols.contains_key("b"))
}));
}
#[test]
fn test_sql_asset_parser_insert_with_columns() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
INSERT INTO dl.table1 (name, age, email) VALUES ('John', 30, 'john@example.com');
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Should have one asset with merged columns
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "my_dl/table1");
assert_eq!(result[0].access_type, Some(W));
// Check that all columns are present in the merged asset
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.len(), 3);
assert_eq!(columns.get("name"), Some(&W));
assert_eq!(columns.get("age"), Some(&W));
assert_eq!(columns.get("email"), Some(&W));
}
#[test]
fn test_sql_asset_parser_insert_without_columns() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
INSERT INTO dl.table1 VALUES ('John', 30);
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Should have one asset without column information
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "my_dl/table1");
assert_eq!(result[0].access_type, Some(W));
assert!(result[0].columns.is_none());
}
#[test]
fn test_sql_asset_parser_update_multiple_columns() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
UPDATE dl.table1 SET name = 'Jane', age = 25, active = true;
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Should have one asset with merged columns
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "my_dl/table1");
assert_eq!(result[0].access_type, Some(W));
// Check that all columns are present
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.len(), 3);
assert_eq!(columns.get("name"), Some(&W));
assert_eq!(columns.get("age"), Some(&W));
assert_eq!(columns.get("active"), Some(&W));
}
#[test]
fn test_sql_asset_parser_update_returning() {
let input = r#"
ATTACH 'ducklake://my_dl' AS dl;
UPDATE dl.table1 SET name = 'Jane', age = 26 RETURNING id, name;
"#;
let s = parse_assets(input).map(|s| s.assets);
let result = s.unwrap();
// Should have RW access type when RETURNING is used
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "my_dl/table1");
assert_eq!(result[0].access_type, Some(RW));
// Check that columns are present with correct access types
// name and age are written (W), id and name are read (R)
// name should be RW (both written and read)
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.len(), 3);
assert_eq!(columns.get("name"), Some(&RW)); // Written in SET, read in RETURNING
assert_eq!(columns.get("age"), Some(&W)); // Only written
assert_eq!(columns.get("id"), Some(&R)); // Only read
}
}

View File

@@ -117,6 +117,7 @@ impl Visit for AssetsFinder {
kind,
path: path.to_string(),
access_type: None,
columns: None,
});
}
}
@@ -177,8 +178,12 @@ impl Visit for AssetsFinder {
if asset_was_used(&self.assets, (kind, path)) {
continue;
}
self.assets
.push(ParseAssetsResult { kind, access_type: None, path: path.clone() });
self.assets.push(ParseAssetsResult {
kind,
access_type: None,
path: path.clone(),
columns: None,
});
}
// Restore state - identifiers declared in this block go out of scope
@@ -294,8 +299,12 @@ impl AssetsFinder {
let path = parse_asset_syntax(&value, false)
.map(|(_, p)| p)
.unwrap_or(&value);
self.assets
.push(ParseAssetsResult { kind, path: path.to_string(), access_type });
self.assets.push(ParseAssetsResult {
kind,
path: path.to_string(),
access_type,
columns: None,
});
}
_ => return Err(()),
}
@@ -305,6 +314,8 @@ impl AssetsFinder {
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
#[test]
@@ -321,7 +332,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/test.csv".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},])
);
}
@@ -340,7 +352,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt".to_string(),
access_type: None
access_type: None,
columns: None,
},])
);
}
@@ -360,7 +373,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/friends".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},])
);
}
@@ -383,12 +397,17 @@ mod tests {
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/analytics".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/friends".to_string(),
access_type: Some(RW)
access_type: Some(RW),
columns: Some(BTreeMap::from([(
"name".to_string(),
AssetUsageAccessType::W
)])),
},
])
);
@@ -418,17 +437,20 @@ mod tests {
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "another1/customers".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: None,
},
ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "another2".to_string(),
access_type: None
access_type: None,
columns: None,
},
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "main/friends".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},
])
);
@@ -452,12 +474,14 @@ mod tests {
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "another1".to_string(),
access_type: None
access_type: None,
columns: None,
},
ParseAssetsResult {
kind: AssetKind::Ducklake,
path: "main".to_string(),
access_type: None
access_type: None,
columns: None,
},
])
);
@@ -478,7 +502,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "main/myschema.friends".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},])
);
}
@@ -499,7 +524,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/public.users".to_string(),
access_type: Some(RW)
access_type: Some(RW),
columns: None,
},])
);
}
@@ -518,7 +544,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt".to_string(),
access_type: None
access_type: None,
columns: None,
},])
);
}
@@ -539,7 +566,8 @@ mod tests {
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/users".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},])
);
}
@@ -562,12 +590,14 @@ mod tests {
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/private.users".to_string(),
access_type: Some(R)
access_type: Some(R),
columns: None,
},
ParseAssetsResult {
kind: AssetKind::DataTable,
path: "dt/test".to_string(),
access_type: Some(W)
access_type: Some(W),
columns: None,
},
])
);

View File

@@ -12,6 +12,7 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
kind: AssetKind::Resource,
path: delegate_to_git_repo_details.resource,
access_type: Some(AssetUsageAccessType::R),
columns: None,
})
}
@@ -21,6 +22,7 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
kind: AssetKind::Resource,
path: pinned_res,
access_type: Some(AssetUsageAccessType::R),
columns: None,
})
}
}
@@ -31,6 +33,7 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
kind: AssetKind::Resource,
path: resource,
access_type: Some(AssetUsageAccessType::R),
columns: None,
})
}
}

View File

@@ -1,4 +1,5 @@
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize, PartialEq, Clone, Copy, Debug)]
#[serde(rename_all(serialize = "lowercase"))]
@@ -19,12 +20,14 @@ pub enum AssetKind {
DataTable,
}
#[derive(Serialize, Debug, PartialEq)]
#[derive(Serialize, Debug, PartialEq, Clone)]
pub struct ParseAssetsResult {
pub kind: AssetKind,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_type: Option<AssetUsageAccessType>, // None in case of ambiguity
#[serde(skip_serializing_if = "Option::is_none")]
pub columns: Option<BTreeMap<String, AssetUsageAccessType>>, // Map column name to access type, "*" represents wildcard
}
#[derive(Serialize, Debug, PartialEq)]
@@ -66,6 +69,8 @@ pub fn merge_assets(assets: Vec<ParseAssetsResult>) -> Vec<ParseAssetsResult> {
(Some(R), Some(R)) => Some(R),
(Some(W), Some(W)) => Some(W),
};
// merge columns: union the column sets and merge access types per column
existing.columns = merge_column_maps(existing.columns.take(), asset.columns);
} else {
arr.push(asset);
}
@@ -74,6 +79,36 @@ pub fn merge_assets(assets: Vec<ParseAssetsResult>) -> Vec<ParseAssetsResult> {
arr
}
fn merge_column_maps(
existing: Option<BTreeMap<String, AssetUsageAccessType>>,
new: Option<BTreeMap<String, AssetUsageAccessType>>,
) -> Option<BTreeMap<String, AssetUsageAccessType>> {
match (existing, new) {
(None, None) => None,
(Some(map), None) | (None, Some(map)) => Some(map),
(Some(mut existing_map), Some(new_map)) => {
for (col_name, new_access) in new_map {
existing_map
.entry(col_name)
.and_modify(|existing_access| {
*existing_access = merge_access_types(*existing_access, new_access);
})
.or_insert(new_access);
}
Some(existing_map)
}
}
}
fn merge_access_types(a: AssetUsageAccessType, b: AssetUsageAccessType) -> AssetUsageAccessType {
match (a, b) {
(R, W) | (W, R) => RW,
(RW, _) | (_, RW) => RW,
(R, R) => R,
(W, W) => W,
}
}
// Will return false if the user assigned an asset to a variable like:
// let sql = wmill.datatable('main')
// But never used it. In that case we don't know which table is being used,

View File

@@ -1145,16 +1145,22 @@ Windmill Community Edition {GIT_VERSION}
let db = db.clone();
let h = tokio::spawn(async move {
// Initialize last_event_id to current max to avoid processing old events on startup
let mut last_event_id: i64 = match windmill_common::notify_events::get_latest_event_id(&db).await {
Ok(id) => {
tracing::info!("Initialized notify event polling with last_event_id: {}", id);
id
}
Err(e) => {
tracing::warn!("Could not get latest event id, starting from 0: {e:#}");
0
}
};
let mut last_event_id: i64 =
match windmill_common::notify_events::get_latest_event_id(&db).await {
Ok(id) => {
tracing::info!(
"Initialized notify event polling with last_event_id: {}",
id
);
id
}
Err(e) => {
tracing::warn!(
"Could not get latest event id, starting from 0: {e:#}"
);
0
}
};
let mut last_settings_reload = Instant::now();
let mut monitor_iteration: u64 = 0;
let rd_shift: u8 = rand::rng().random_range(0..200);
@@ -1382,36 +1388,57 @@ async fn process_notify_event(
tx: &KillpillSender,
server_mode: bool,
worker_mode: bool,
#[cfg(feature = "parquet")]
disable_s3_store: bool,
#[cfg(feature = "parquet")] disable_s3_store: bool,
) {
match channel {
"notify_config_change" => {
if payload == "server" && server_mode {
tracing::error!("Server config change detected but server config is obsolete: {}", payload);
tracing::error!(
"Server config change detected but server config is obsolete: {}",
payload
);
} else if worker_mode && payload == format!("worker__{}", *WORKER_GROUP) {
tracing::info!("Worker config change detected: {}", payload);
reload_worker_config(db, tx.clone(), true).await;
} else {
tracing::debug!("config changed but did not target this server/worker");
}
},
}
"notify_webhook_change" => {
tracing::info!("Webhook change detected, invalidating webhook cache: {}", payload);
tracing::info!(
"Webhook change detected, invalidating webhook cache: {}",
payload
);
windmill_api::webhook_util::WEBHOOK_CACHE.remove(payload);
},
}
"notify_workspace_envs_change" => {
tracing::info!("Workspace envs change detected, invalidating workspace envs cache: {}", payload);
tracing::info!(
"Workspace envs change detected, invalidating workspace envs cache: {}",
payload
);
windmill_common::variables::CUSTOM_ENVS_CACHE.remove(payload);
},
}
"notify_workspace_key_change" => {
tracing::info!("Workspace key change detected, invalidating workspace key cache: {}", payload);
tracing::info!(
"Workspace key change detected, invalidating workspace key cache: {}",
payload
);
windmill_common::variables::WORKSPACE_CRYPT_CACHE.remove(payload);
},
}
"notify_workspace_premium_change" => {
tracing::info!("Workspace premium change detected, invalidating workspace premium cache: {}", payload);
tracing::info!(
"Workspace premium change detected, invalidating workspace premium cache: {}",
payload
);
windmill_common::workspaces::TEAM_PLAN_CACHE.remove(payload);
},
}
"notify_workspace_rate_limit_change" => {
tracing::info!(
"Workspace rate limit change detected, invalidating rate limit cache: {}",
payload
);
windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE.remove(payload);
}
"notify_runnable_version_change" => {
tracing::info!("Runnable version change detected: {}", payload);
match payload.split(':').collect::<Vec<&str>>().as_slice() {
@@ -1446,39 +1473,48 @@ async fn process_notify_event(
}
}
"flow" => {
let dynamic_input_key = windmill_common::jobs::generate_dynamic_input_key(workspace_id, path);
let dynamic_input_key =
windmill_common::jobs::generate_dynamic_input_key(
workspace_id,
path,
);
windmill_common::DYNAMIC_INPUT_CACHE.remove(&dynamic_input_key);
windmill_common::FLOW_VERSION_CACHE.remove(&key);
},
}
_ => {
tracing::warn!("Unknown runnable version change payload: {}", payload);
}
}
},
}
_ => {
tracing::warn!("Unknown runnable version change payload: {}", payload);
}
}
},
}
#[cfg(feature = "http_trigger")]
"notify_http_trigger_change" => {
tracing::info!("HTTP trigger change detected: {}", payload);
match windmill_api::triggers::http::refresh_routers(db).await {
Ok((true, _)) => {
tracing::info!("Refreshed HTTP routers (trigger change)");
},
}
Ok((false, _)) => {
tracing::warn!("Should have refreshed HTTP routers (trigger change) but did not");
},
tracing::warn!(
"Should have refreshed HTTP routers (trigger change) but did not"
);
}
Err(err) => {
tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}");
}
};
},
}
"notify_token_invalidation" => {
tracing::info!("Token invalidation detected for token: {}...", payload.get(..8).unwrap_or(payload));
tracing::info!(
"Token invalidation detected for token: {}...",
payload.get(..8).unwrap_or(payload)
);
windmill_api::auth::invalidate_token_from_cache(payload);
},
}
"notify_global_setting_change" => {
tracing::info!("Global setting change detected: {}", payload);
match payload {
@@ -1486,176 +1522,150 @@ async fn process_notify_event(
if let Err(e) = reload_base_url_setting(conn).await {
tracing::error!(error = %e, "Could not reload base url setting");
}
},
}
OAUTH_SETTING => {
if let Err(e) = reload_base_url_setting(conn).await {
tracing::error!(error = %e, "Could not reload oauth setting");
}
},
}
CUSTOM_TAGS_SETTING => {
if let Err(e) = reload_custom_tags_setting(db).await {
tracing::error!(error = %e, "Could not reload custom tags setting");
}
},
}
LICENSE_KEY_SETTING => {
if let Err(e) = reload_license_key(&db.into()).await {
tracing::error!("Failed to reload license key: {e:#}");
}
},
}
DEFAULT_TAGS_PER_WORKSPACE_SETTING => {
if let Err(e) = load_tag_per_workspace_enabled(db).await {
tracing::error!("Error loading default tag per workspace: {e:#}");
}
},
}
DEFAULT_TAGS_WORKSPACES_SETTING => {
if let Err(e) = load_tag_per_workspace_workspaces(db).await {
tracing::error!("Error loading default tag per workspace workspaces: {e:#}");
tracing::error!(
"Error loading default tag per workspace workspaces: {e:#}"
);
}
},
}
SMTP_SETTING => {
reload_smtp_config(db).await;
},
}
TEAMS_SETTING => {
tracing::info!("Teams setting changed.");
},
}
INDEXER_SETTING => {
reload_indexer_config(db).await;
},
TIMEOUT_WAIT_RESULT_SETTING => {
reload_timeout_wait_result_setting(conn).await
},
RETENTION_PERIOD_SECS_SETTING => {
reload_retention_period_setting(conn).await
},
}
TIMEOUT_WAIT_RESULT_SETTING => reload_timeout_wait_result_setting(conn).await,
RETENTION_PERIOD_SECS_SETTING => reload_retention_period_setting(conn).await,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING => {
reload_delete_logs_periodically_setting(conn).await
},
JOB_DEFAULT_TIMEOUT_SECS_SETTING => {
reload_job_default_timeout_setting(conn).await
},
}
JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await,
#[cfg(feature = "parquet")]
OBJECT_STORE_CONFIG_SETTING => {
if !disable_s3_store {
reload_object_store_setting(db).await;
}
},
SCIM_TOKEN_SETTING => {
reload_scim_token_setting(conn).await
},
EXTRA_PIP_INDEX_URL_SETTING => {
reload_extra_pip_index_url_setting(conn).await
},
PIP_INDEX_URL_SETTING => {
reload_pip_index_url_setting(conn).await
},
}
SCIM_TOKEN_SETTING => reload_scim_token_setting(conn).await,
EXTRA_PIP_INDEX_URL_SETTING => reload_extra_pip_index_url_setting(conn).await,
PIP_INDEX_URL_SETTING => reload_pip_index_url_setting(conn).await,
INSTANCE_PYTHON_VERSION_SETTING => {
reload_instance_python_version_setting(conn).await
},
NPM_CONFIG_REGISTRY_SETTING => {
reload_npm_config_registry_setting(conn).await
},
BUNFIG_INSTALL_SCOPES_SETTING => {
reload_bunfig_install_scopes_setting(conn).await
},
NUGET_CONFIG_SETTING => {
reload_nuget_config_setting(conn).await
},
POWERSHELL_REPO_URL_SETTING => {
reload_powershell_repo_url_setting(conn).await
},
POWERSHELL_REPO_PAT_SETTING => {
reload_powershell_repo_pat_setting(conn).await
},
MAVEN_REPOS_SETTING => {
reload_maven_repos_setting(conn).await
},
NO_DEFAULT_MAVEN_SETTING => {
reload_no_default_maven_setting(conn).await
},
RUBY_REPOS_SETTING => {
reload_ruby_repos_setting(conn).await
},
HUB_API_SECRET_SETTING => {
reload_hub_api_secret_setting(conn).await
},
}
NPM_CONFIG_REGISTRY_SETTING => reload_npm_config_registry_setting(conn).await,
BUNFIG_INSTALL_SCOPES_SETTING => reload_bunfig_install_scopes_setting(conn).await,
NUGET_CONFIG_SETTING => reload_nuget_config_setting(conn).await,
POWERSHELL_REPO_URL_SETTING => reload_powershell_repo_url_setting(conn).await,
POWERSHELL_REPO_PAT_SETTING => reload_powershell_repo_pat_setting(conn).await,
MAVEN_REPOS_SETTING => reload_maven_repos_setting(conn).await,
NO_DEFAULT_MAVEN_SETTING => reload_no_default_maven_setting(conn).await,
RUBY_REPOS_SETTING => reload_ruby_repos_setting(conn).await,
HUB_API_SECRET_SETTING => reload_hub_api_secret_setting(conn).await,
KEEP_JOB_DIR_SETTING => {
load_keep_job_dir(conn).await;
},
}
OTEL_TRACING_PROXY_SETTING => {
reload_otel_tracing_proxy_setting(conn).await;
if worker_mode {
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
send_delayed_killpill(tx, 4, "OTEL tracing proxy setting change").await;
}
},
}
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
load_require_preexisting_user(db).await;
},
}
EXPOSE_METRICS_SETTING => {
tracing::info!("Metrics setting changed, restarting");
send_delayed_killpill(tx, 40, "metrics setting change").await;
},
}
EMAIL_DOMAIN_SETTING => {
tracing::info!("Email domain setting changed");
if server_mode {
send_delayed_killpill(tx, 4, "email domain setting change").await;
}
},
}
EXPOSE_DEBUG_METRICS_SETTING => {
if let Err(e) = load_metrics_debug_enabled(conn).await {
tracing::error!(error = %e, "Could not reload debug metrics setting");
}
},
}
APP_WORKSPACED_ROUTE_SETTING => {
if let Err(e) = reload_app_workspaced_route_setting(db).await {
tracing::error!(error = %e, "Could not reload app workspaced route setting");
}
},
}
OTEL_SETTING => {
tracing::info!("OTEL setting changed, restarting");
send_delayed_killpill(tx, 4, "OTEL setting change").await;
},
}
REQUEST_SIZE_LIMIT_SETTING => {
if server_mode {
tracing::info!("Request limit size change detected, killing server expecting to be restarted");
send_delayed_killpill(tx, 4, "request size limit change").await;
}
},
}
SAML_METADATA_SETTING => {
tracing::info!("SAML metadata change detected, killing server expecting to be restarted");
tracing::info!(
"SAML metadata change detected, killing server expecting to be restarted"
);
send_delayed_killpill(tx, 0, "SAML metadata change").await;
},
}
HUB_BASE_URL_SETTING => {
if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
tracing::error!(error = %e, "Could not reload hub base url setting");
}
},
}
CRITICAL_ERROR_CHANNELS_SETTING => {
if let Err(e) = reload_critical_error_channels_setting(db).await {
tracing::error!(error = %e, "Could not reload critical error emails setting");
}
},
}
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING => {
if let Err(e) = reload_critical_alerts_on_db_oversize(db).await {
tracing::error!(error = %e, "Could not reload critical alerts on db oversize setting");
}
},
}
JWT_SECRET_SETTING => {
if let Err(e) = reload_jwt_secret_setting(db).await {
tracing::error!(error = %e, "Could not reload jwt secret setting");
}
},
}
CRITICAL_ALERT_MUTE_UI_SETTING => {
tracing::info!("Critical alert UI setting changed");
if let Err(e) = reload_critical_alert_mute_ui_setting(conn).await {
tracing::error!(error = %e, "Could not reload critical alert UI setting");
}
},
}
_ => {
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload);
}
}
},
}
_ => {
tracing::warn!("Unknown notification channel: {}", channel);
}

View File

@@ -11,13 +11,13 @@ path = "src/lib.rs"
[features]
default = []
private = ["windmill-audit/private", "windmill-common/private"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker/enterprise", "dep:windmill-autoscaling"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker/enterprise"]
stripe = []
agent_worker_server = ["dep:windmill-parser-py-imports"]
agent_worker_server = []
enterprise_saml = ["dep:samael", "dep:libxml"]
benchmark = []
embedding = ["dep:tinyvector", "dep:hf-hub", "dep:tokenizers", "dep:candle-core", "dep:candle-transformers", "dep:candle-nn"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker/parquet"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect"]
tantivy = ["dep:windmill-indexer"]
@@ -38,8 +38,8 @@ deno_core = ["dep:deno_core", "dep:deno_error"]
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
cloud = ["windmill-common/cloud"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth"]
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config", "dep:aws-sdk-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
python = ["dep:windmill-parser-py-imports"]
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config"]
python = []
[dependencies]
windmill-mcp = { workspace = true, optional = true }
@@ -50,10 +50,10 @@ windmill-parser.workspace = true
windmill-parser-sql.workspace = true
windmill-parser-ts.workspace = true
windmill-parser-py.workspace = true
windmill-parser-py-imports = { workspace = true, optional = true }
windmill-parser-py-imports.workspace = true
windmill-git-sync.workspace = true
windmill-indexer = { workspace = true, optional = true }
windmill-autoscaling = { workspace = true, optional = true }
windmill-autoscaling.workspace = true
windmill-worker.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
@@ -152,13 +152,13 @@ aws-sdk-ssooidc = { workspace = true, optional = true }
aws-sdk-sts = { workspace = true, optional = true }
rustls = { workspace = true }
aws-sigv4 = { workspace = true, optional = true }
aws-sdk-config = { workspace = true, optional = true }
aws-sigv4.workspace = true
aws-sdk-config.workspace = true
aws-config = { workspace = true, optional = true }
aws-credential-types = { workspace = true, optional = true }
aws-credential-types.workspace = true
aws-sdk-bedrock = { workspace = true, optional = true }
aws-sdk-bedrockruntime = { workspace = true, optional = true }
aws-smithy-types = { workspace = true, optional = true }
aws-smithy-types.workspace = true
async-trait.workspace = true
google-cloud-pubsub = { workspace = true, optional = true }
google-cloud-googleapis = { workspace = true , optional = true }
@@ -169,6 +169,7 @@ tar.workspace = true
flate2.workspace = true
backon = {workspace = true, optional = true}
strum = { workspace = true, optional = true }
dashmap.workspace = true
[build-dependencies]
deno_core = { workspace = true, optional = true }

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.624.0
version: 1.625.0
title: Windmill API
contact:
@@ -2383,6 +2383,9 @@ paths:
type: string
operator_settings:
$ref: "#/components/schemas/OperatorSettings"
public_app_execution_limit_per_minute:
type: integer
description: Rate limit for public app executions per minute per server. NULL or 0 means disabled.
/w/{workspace}/workspaces/get_deploy_to:
get:
@@ -4152,6 +4155,35 @@ paths:
type: string
example: "Updated mute critical alert UI settings for workspace: workspace_id"
/w/{workspace}/workspaces/public_app_rate_limit:
post:
summary: Set public app rate limit for this workspace
operationId: setPublicAppRateLimit
tags:
- setting
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Public app rate limit configuration
required: true
content:
application/json:
schema:
type: object
properties:
public_app_execution_limit_per_minute:
type: integer
description: Rate limit for public app executions per minute per server. NULL or 0 to disable.
example: 100
responses:
"200":
description: Successfully updated public app rate limit settings.
content:
application/json:
schema:
type: string
example: "Updated public app rate limit for workspace: workspace_id"
/oauth/login_callback/{client_name}:
post:
security: []
@@ -5403,6 +5435,34 @@ paths:
required:
- app
/apps/hub/get_raw/{id}:
get:
summary: get hub raw app by id
operationId: getHubRawAppById
tags:
- app
parameters:
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: raw app
content:
application/json:
schema:
type: object
properties:
app:
type: object
properties:
summary:
type: string
value: {}
required:
- summary
- value
required:
- app
/apps_u/public_app_by_custom_path/{custom_path}:
get:
summary: get public app by custom path
@@ -14958,7 +15018,7 @@ paths:
type: string
favorite_kind:
type: string
enum: [flow, app, script, raw_app]
enum: [flow, app, script, raw_app, asset]
responses:
"200":
description: star item
@@ -14981,7 +15041,7 @@ paths:
type: string
favorite_kind:
type: string
enum: [flow, app, script, raw_app]
enum: [flow, app, script, raw_app, asset]
responses:
"200":
description: unstar item
@@ -16526,6 +16586,11 @@ paths:
$ref: "#/components/schemas/AssetUsageKind"
access_type:
$ref: "#/components/schemas/AssetUsageAccessType"
columns:
type: object
description: The columns used (for tables)
additionalProperties:
$ref: "#/components/schemas/AssetUsageAccessType"
created_at:
type: string
format: date-time
@@ -16606,6 +16671,30 @@ paths:
access_type:
$ref: "#/components/schemas/AssetUsageAccessType"
/w/{workspace}/assets/list_favorites:
get:
summary: List all favorite assets for the authenticated user
operationId: listFavoriteAssets
tags:
- asset
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of favorite assets
content:
application/json:
schema:
type: array
items:
type: object
required: [path]
properties:
path:
type: string
description: The asset path
/mcp/w/{workspace}/list_tools:
get:
summary: list available MCP tools
@@ -20710,6 +20799,17 @@ components:
type: array
items:
type: string
filters:
type: array
items:
type: object
properties:
key:
type: string
value: {}
required:
- key
- value
server_id:
type: string
last_server_ping:
@@ -20728,6 +20828,7 @@ components:
- kafka_resource_path
- group_id
- topics
- filters
NewKafkaTrigger:
type: object
@@ -20746,6 +20847,17 @@ components:
type: array
items:
type: string
filters:
type: array
items:
type: object
properties:
key:
type: string
value: {}
required:
- key
- value
mode:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
@@ -20762,6 +20874,7 @@ components:
- kafka_resource_path
- group_id
- topics
- filters
EditKafkaTrigger:
type: object
@@ -20774,6 +20887,17 @@ components:
type: array
items:
type: string
filters:
type: array
items:
type: object
properties:
key:
type: string
value: {}
required:
- key
- value
path:
type: string
script_path:
@@ -20793,6 +20917,7 @@ components:
- kafka_resource_path
- group_id
- topics
- filters
- is_flow
NatsTrigger:

View File

@@ -126,6 +126,7 @@ pub fn global_service() -> Router {
Router::new()
.route("/hub/list", get(list_hub_apps))
.route("/hub/get/:id", get(get_hub_app_by_id))
.route("/hub/get_raw/:id", get(get_hub_raw_app_by_id))
}
#[derive(FromRow, Deserialize, Serialize)]
@@ -1312,6 +1313,24 @@ pub async fn get_hub_app_by_id(
Ok(Json(value))
}
pub async fn get_hub_raw_app_by_id(
Path(id): Path<i32>,
Extension(db): Extension<DB>,
) -> JsonResult<Box<serde_json::value::RawValue>> {
let value = http_get_from_hub(
&HTTP_CLIENT,
&format!("{}/raw_apps/{}/json", *HUB_BASE_URL.read().await, id),
false,
None,
Some(&db),
)
.await?
.json()
.await
.map_err(to_anyhow)?;
Ok(Json(value))
}
async fn delete_app(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1910,6 +1929,15 @@ async fn execute_component(
}
};
// Check rate limit for anonymous (public) executions
if matches!(policy.execution_mode, ExecutionMode::Anonymous) && opt_authed.is_none() {
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
if limit > 0 {
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
}
}
}
// Execution is publisher and an user is authenticated: check if the user is authorized to
// execute the app.
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode, opt_authed.as_ref()) {

View File

@@ -18,6 +18,7 @@ pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_assets))
.route("/list_by_usages", post(list_assets_by_usages))
.route("/list_favorites", get(list_favorites))
}
#[derive(Deserialize)]
@@ -158,6 +159,7 @@ async fn list_assets(
'path', asset.usage_path,
'kind', asset.usage_kind,
'access_type', asset.usage_access_type,
'columns', asset.columns,
'created_at', asset.created_at,
'metadata', (CASE
WHEN asset.usage_kind = 'job' THEN
@@ -266,11 +268,12 @@ async fn list_assets_by_usages(
for usage in body.usages {
let assets = sqlx::query_scalar!(
r#"SELECT
jsonb_build_object(
jsonb_strip_nulls(jsonb_build_object(
'path', path,
'kind', kind,
'access_type', usage_access_type
) as "list!: _"
'access_type', usage_access_type,
'columns', columns
)) as "list!: _"
FROM asset
WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3
ORDER BY path, kind"#,
@@ -284,3 +287,29 @@ async fn list_assets_by_usages(
}
Ok(Json(assets_vec))
}
async fn list_favorites(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<Vec<Value>> {
let mut tx = user_db.begin(&authed).await?;
let favorites = sqlx::query_scalar!(
r#"SELECT
jsonb_strip_nulls(jsonb_build_object(
'path', favorite.path
)) as "favorite_asset!: _"
FROM favorite
WHERE favorite.workspace_id = $1
AND favorite.usr = $2
AND favorite_kind = 'asset'
"#,
&w_id,
&authed.username
)
.fetch_all(&mut *tx)
.await?;
Ok(Json(favorites))
}

View File

@@ -31,6 +31,7 @@ pub enum FavoriteKind {
App,
#[allow(non_camel_case_types)]
Raw_App,
Asset,
}
#[derive(Deserialize)]
pub struct Favorite {

View File

@@ -473,12 +473,14 @@ async fn create_flow(
workspace_id, path, summary, description,
dependency_job, lock_error_logs, draft_only, tag,
dedicated_worker, visible_to_runner_only, on_behalf_of_email,
ws_error_handler_muted,
value, schema, edited_by, edited_at
) VALUES (
$1, $2, $3, $4,
NULL, '', $5, $6,
$7, $8, $9,
$10, $11::text::json, $12, now()
$10,
$11, $12::text::json, $13, now()
)"#,
w_id,
nf.path,
@@ -489,6 +491,7 @@ async fn create_flow(
nf.dedicated_worker,
nf.visible_to_runner_only.unwrap_or(false),
nf.on_behalf_of_email.and(Some(&authed.email)),
nf.ws_error_handler_muted.unwrap_or(false),
sqlx::types::Json(&nf.value) as _,
schema_str,
&authed.username,
@@ -897,12 +900,13 @@ async fn update_flow(
dedicated_worker = $5,
visible_to_runner_only = $6,
on_behalf_of_email = $7,
value = $8,
schema = $9::text::json,
edited_by = $10,
ws_error_handler_muted = $8,
value = $9,
schema = $10::text::json,
edited_by = $11,
edited_at = now()
WHERE
path = $11 AND workspace_id = $12",
path = $12 AND workspace_id = $13",
if is_new_path { flow_path } else { &nf.path },
nf.summary,
nf.description.as_deref().unwrap_or(""),
@@ -910,6 +914,7 @@ async fn update_flow(
nf.dedicated_worker,
nf.visible_to_runner_only.unwrap_or(false),
nf.on_behalf_of_email.and(Some(&authed.email)),
nf.ws_error_handler_muted.unwrap_or(false),
sqlx::types::Json(&nf.value) as _,
schema_str,
authed.username,

View File

@@ -6378,10 +6378,18 @@ fn register_potential_assets_on_inline_execution(
match assets {
Some(Ok(assets)) => {
for asset in assets {
let columns = asset.columns.as_ref().map(|cols| {
cols.iter()
.map(|(col_name, col_access_type)| {
(col_name.clone(), (*col_access_type).into())
})
.collect()
});
register_runtime_asset(InsertRuntimeAssetParams {
access_type: asset.access_type.map(|a| a.into()),
asset_kind: asset.kind.into(),
asset_path: asset.path,
columns,
job_id,
workspace_id: w_id.to_string(),
created_at: None,

View File

@@ -167,6 +167,7 @@ mod teams_approvals_oss;
#[cfg(feature = "native_trigger")]
pub mod native_triggers;
mod public_app_layer;
mod public_app_rate_limit;
mod static_assets;
#[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))]
pub mod stripe_ee;

View File

@@ -0,0 +1,48 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* 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 chrono::Utc;
use dashmap::DashMap;
use hyper::StatusCode;
use std::sync::LazyLock;
use windmill_common::error::{Error, Result};
struct RateLimitEntry {
count: i32,
minute_bucket: i64,
}
static RATE_LIMIT_COUNTER: LazyLock<DashMap<String, RateLimitEntry>> =
LazyLock::new(DashMap::new);
pub fn check_and_increment(workspace_id: &str, limit: i32) -> Result<()> {
let current_minute = Utc::now().timestamp() / 60;
let mut entry = RATE_LIMIT_COUNTER
.entry(workspace_id.to_string())
.or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute });
if entry.minute_bucket != current_minute {
entry.count = 0;
entry.minute_bucket = current_minute;
}
if entry.count >= limit {
return Err(Error::Generic(
StatusCode::TOO_MANY_REQUESTS,
format!(
"Rate limit exceeded for public app executions in workspace '{}'. \
Limit: {} per minute per server.",
workspace_id, limit
),
));
}
entry.count += 1;
Ok(())
}

View File

@@ -0,0 +1,144 @@
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer,
};
use serde_json::Value;
use std::fmt;
#[derive(Deserialize)]
pub struct JsonFilter {
pub key: String,
pub value: Value,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Filter {
JsonFilter(JsonFilter),
}
struct SupersetVisitor<'a> {
key: &'a str,
value_to_check: &'a Value,
}
impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> {
type Value = bool;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a JSON object with a specific key at the top level")
}
fn visit_map<V>(self, mut map: V) -> std::result::Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut result = false;
let mut found = false;
// Must consume entire map to satisfy deserializer contract
while let Some(key) = map.next_key::<String>()? {
if !found && key == self.key {
let json_value: Value = map.next_value()?;
result = is_superset(&json_value, self.value_to_check);
found = true;
} else {
// Skip values we don't need (cheaper than full deserialization)
let _ = map.next_value::<de::IgnoredAny>()?;
}
}
Ok(result)
}
}
pub fn is_superset(json_value: &Value, value_to_check: &Value) -> bool {
match (json_value, value_to_check) {
(Value::Object(json_map), Value::Object(check_map)) => {
check_map.iter().all(|(k, v)| {
json_map
.get(k)
.map_or(false, |json_val| is_superset(json_val, v))
})
}
(Value::Array(json_array), Value::Array(check_array)) => {
check_array.iter().all(|check_item| {
json_array
.iter()
.any(|json_item| is_superset(json_item, check_item))
})
}
_ => json_value == value_to_check,
}
}
pub fn is_value_superset<'a, 'de, D>(
deserializer: D,
key: &'a str,
value_to_check: &'a Value,
) -> std::result::Result<bool, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_map(SupersetVisitor { key, value_to_check })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_filter_with_other_top_level_keys() {
let payload = r#"{"event_type": "test", "other": "data"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match when key exists with correct value");
}
#[test]
fn test_filter_with_key_not_first() {
let payload = r#"{"other": "data", "event_type": "test"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match even when key is not first");
}
#[test]
fn test_filter_with_nested_object() {
let payload = r#"{"data": {"status": "active", "count": 5}, "other": "value"}"#;
let key = "data";
let value = json!({"status": "active"});
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match when nested object is superset");
}
#[test]
fn test_filter_no_match() {
let payload = r#"{"event_type": "other", "data": "value"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(!result, "Should not match when value differs");
}
#[test]
fn test_filter_key_not_found() {
let payload = r#"{"other": "data"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(!result, "Should not match when key doesn't exist");
}
}

View File

@@ -963,7 +963,13 @@ async fn route_job(
let s3_object = s3_object.map_err(|err| {
tracing::warn!("Error retrieving file from S3: {:?}", err);
Error::internal_err(format!("Error retrieving file: {}", err.to_string()))
let mut msg = format!("Error retrieving file: {err}");
let mut source = std::error::Error::source(&err);
while let Some(e) = source {
msg.push_str(&format!("\n caused by: {e}"));
source = e.source();
}
Error::internal_err(msg)
})?;
let mut response_headers = http::HeaderMap::new();

View File

@@ -30,6 +30,7 @@ pub mod sqs;
#[cfg(feature = "websocket")]
pub mod websocket;
pub mod filter;
pub mod global_handler;
mod handler;
mod listener;

View File

@@ -1,5 +1,6 @@
use super::WebsocketTrigger;
use crate::triggers::{
filter::{is_value_superset, Filter, JsonFilter},
listener::ListeningTrigger,
trigger_helpers::{
trigger_runnable, trigger_runnable_and_wait_for_raw_result,
@@ -13,12 +14,9 @@ use async_trait::async_trait;
use futures::{stream::SplitSink, SinkExt, StreamExt};
use http::Response;
use itertools::Itertools;
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer,
};
use serde_json::{value::RawValue, Value};
use std::{borrow::Cow, collections::HashMap, fmt, sync::Arc};
use serde::Deserialize;
use serde_json::value::RawValue;
use std::{borrow::Cow, collections::HashMap, sync::Arc};
use tokio::{net::TcpStream, sync::RwLock};
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use windmill_common::{
@@ -443,18 +441,6 @@ impl Listener for WebsocketTrigger {
}
}
#[derive(Deserialize)]
pub struct JsonFilter {
key: String,
value: Value,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Filter {
JsonFilter(JsonFilter),
}
pub struct ReturnMessageChannels {
send_message_tx: tokio::sync::mpsc::Sender<String>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
@@ -477,71 +463,6 @@ enum InitialMessage {
RunnableResult { path: String, args: Box<RawValue>, is_flow: bool },
}
struct SupersetVisitor<'a> {
key: &'a str,
value_to_check: &'a Value,
}
impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> {
type Value = bool;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a JSON object with a specific key at the top level")
}
fn visit_map<V>(self, mut map: V) -> std::result::Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
while let Some(key) = map.next_key::<String>()? {
if key == self.key {
// Deserialize the value for the key and check if it's a superset
let json_value: Value = map.next_value()?;
return Ok(is_superset(&json_value, self.value_to_check));
} else {
// Skip the value if it's not the one we're interested in
let _ = map.next_value::<de::IgnoredAny>()?;
}
}
// If the key was not found, return false
Ok(false)
}
}
fn is_superset(json_value: &Value, value_to_check: &Value) -> bool {
match (json_value, value_to_check) {
(Value::Object(json_map), Value::Object(check_map)) => {
// Check that all keys and values in check_map exist and match in json_map
check_map.iter().all(|(k, v)| {
json_map
.get(k)
.map_or(false, |json_val| is_superset(json_val, v))
})
}
(Value::Array(json_array), Value::Array(check_array)) => {
// Check that all elements in check_array exist in json_array
check_array.iter().all(|check_item| {
json_array
.iter()
.any(|json_item| is_superset(json_item, check_item))
})
}
_ => json_value == value_to_check,
}
}
// A function to deserialize and check if the value at the given key is a superset of a passed value
fn is_value_superset<'a, 'de, D>(
deserializer: D,
key: &'a str,
value_to_check: &'a Value,
) -> std::result::Result<bool, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_map(SupersetVisitor { key, value_to_check })
}
fn raw_value_to_args_hashmap(
args: Option<&Box<RawValue>>,
) -> Result<HashMap<String, Box<RawValue>>> {

View File

@@ -176,6 +176,7 @@ pub fn workspaced_service() -> Router {
post(acknowledge_all_critical_alerts),
)
.route("/critical_alerts/mute", post(mute_critical_alerts))
.route("/public_app_rate_limit", post(edit_public_app_rate_limit))
.route("/operator_settings", post(update_operator_settings))
.route(
"/create_workspace_fork_branch",
@@ -287,6 +288,8 @@ pub struct WorkspaceSettings {
pub error_handler: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub success_handler: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_app_execution_limit_per_minute: Option<i32>,
}
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
@@ -625,7 +628,8 @@ async fn get_settings(
git_app_installations,
auto_invite,
error_handler,
success_handler
success_handler,
public_app_execution_limit_per_minute
FROM
workspace_settings
WHERE
@@ -4401,6 +4405,72 @@ pub async fn mute_critical_alerts() -> Error {
Error::NotFound("Critical Alerts require EE".to_string())
}
#[derive(Deserialize)]
pub struct EditPublicAppRateLimitRequest {
pub public_app_execution_limit_per_minute: Option<i32>,
}
async fn edit_public_app_rate_limit(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
authed: ApiAuthed,
Json(req): Json<EditPublicAppRateLimitRequest>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
sqlx::query!(
"UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
req.public_app_execution_limit_per_minute,
&w_id
)
.execute(&db)
.await?;
// Cache is invalidated via DB trigger -> notify_event -> polling in main.rs
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() },
None,
false,
None,
)
.await?;
Ok(format!(
"Updated public app rate limit for workspace: {}",
&w_id
))
}
// 5 minutes fallback TTL (in addition to event-based invalidation)
const PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS: i64 = 300;
pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result<Option<i32>> {
use windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE;
let now = Utc::now().timestamp();
if let Some((rate_limit, cached_at)) = PUBLIC_APP_RATE_LIMIT_CACHE.get(w_id) {
if now - cached_at < PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS {
return Ok(rate_limit);
}
}
let result: Option<Option<i32>> = sqlx::query_scalar(
"SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1",
)
.bind(w_id)
.fetch_optional(db)
.await?;
let rate_limit = result.flatten();
PUBLIC_APP_RATE_LIMIT_CACHE.insert(w_id.to_string(), (rate_limit, now));
Ok(rate_limit)
}
#[derive(Deserialize, Serialize)]
struct ChangeOperatorSettings {
#[serde(default)]

View File

@@ -10,7 +10,7 @@ path = "./src/lib.rs"
[features]
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"]
private = ["dep:kube", "dep:k8s-openapi"]
private = []
default = []
[dependencies]
@@ -22,8 +22,8 @@ tracing.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-queue.workspace = true
anyhow.workspace = true
kube = { workspace = true, optional = true }
k8s-openapi = { workspace = true, optional = true }
kube.workspace = true
k8s-openapi.workspace = true
tokio.workspace = true
thiserror.workspace = true
axum.workspace = true

View File

@@ -7,20 +7,20 @@ edition.workspace = true
[features]
default = []
enterprise = []
private = ["dep:aws-sdk-rds", "dep:systemstat", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
private = ["dep:aws-sdk-rds"]
jemalloc = ["dep:tikv-jemalloc-ctl"]
tantivy = []
prometheus = ["dep:prometheus"]
benchmark = []
parquet = ["dep:object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:globset"]
aws_auth = ["dep:aws-sdk-sts", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
parquet = ["dep:object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion"]
aws_auth = ["dep:aws-sdk-sts"]
otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk",
"dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic", "dep:opentelemetry"]
smtp = ["dep:mail-send"]
scoped_cache = []
cloud = []
openidconnect = ["dep:openidconnect"]
bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
bedrock = ["dep:aws-sdk-bedrockruntime"]
[lib]
name = "windmill_common"
@@ -63,10 +63,10 @@ cron.workspace = true
magic-crypt.workspace = true
object_store = { workspace = true, optional = true }
prometheus = { workspace = true, optional = true }
aws-config = { workspace = true, optional = true }
aws-config.workspace = true
aws-sdk-sts = { workspace = true, optional = true }
aws-credential-types = { workspace = true, optional = true }
aws-smithy-types = { workspace = true, optional = true }
aws-credential-types.workspace = true
aws-smithy-types.workspace = true
aws-sdk-bedrockruntime = { workspace = true, optional = true }
base64.workspace = true
bitflags.workspace = true
@@ -100,7 +100,7 @@ url.workspace = true
urlencoding.workspace = true
async-recursion.workspace = true
pep440_rs.workspace = true
systemstat = { workspace = true, optional = true }
systemstat.workspace = true
size.workspace = true
semver.workspace = true
@@ -109,7 +109,7 @@ quick_cache.workspace = true
pin-project-lite.workspace = true
futures.workspace = true
tempfile.workspace = true
globset = { workspace = true, optional = true }
globset.workspace = true
opentelemetry-semantic-conventions = { workspace = true, optional = true }
opentelemetry-otlp = { workspace = true, optional = true }

View File

@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use sqlx::PgExecutor;
use std::collections::BTreeMap;
use crate::{error, scripts::ScriptHash};
@@ -37,23 +38,15 @@ pub enum AssetUsageAccessType {
RW,
}
pub struct Asset {
pub path: String,
pub kind: AssetKind,
}
pub struct AssetUsage {
pub path: String,
pub kind: AssetUsageKind,
pub access_type: AssetUsageAccessType,
}
#[derive(Serialize, Deserialize, Debug, Clone, Hash, sqlx::Type)]
#[derive(Serialize, Deserialize, Debug, Clone, Hash)]
pub struct AssetWithAltAccessType {
pub path: String,
pub kind: AssetKind,
pub access_type: Option<AssetUsageAccessType>,
pub alt_access_type: Option<AssetUsageAccessType>,
/// Map of column name to access type for column-level access tracking
#[serde(skip_serializing_if = "Option::is_none")]
pub columns: Option<BTreeMap<String, AssetUsageAccessType>>,
}
pub async fn insert_static_asset_usage<'e>(
@@ -63,15 +56,22 @@ pub async fn insert_static_asset_usage<'e>(
usage_path: &str,
usage_kind: AssetUsageKind,
) -> error::Result<()> {
// Convert columns BTreeMap to JSONB format
let columns_json = asset
.columns
.as_ref()
.map(|cols| serde_json::to_value(cols).unwrap_or(serde_json::Value::Null));
sqlx::query!(
r#"INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind)
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING"#,
r#"INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)
VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING"#,
workspace_id,
asset.path,
asset.kind as AssetKind,
(asset.access_type.or(asset.alt_access_type)) as Option<AssetUsageAccessType>,
usage_path,
usage_kind as AssetUsageKind
usage_kind as AssetUsageKind,
columns_json as Option<serde_json::Value>
)
.execute(executor)
.await?;
@@ -125,6 +125,28 @@ pub fn merge_asset_usage_access_types(
}
}
pub fn merge_asset_columns(
a: &Option<BTreeMap<String, AssetUsageAccessType>>,
b: &Option<BTreeMap<String, AssetUsageAccessType>>,
) -> Option<BTreeMap<String, AssetUsageAccessType>> {
match (a, b) {
(None, None) => None,
(Some(cols), None) | (None, Some(cols)) => Some(cols.clone()),
(Some(cols_a), Some(cols_b)) => {
let mut merged = cols_a.clone();
for (col, access_b) in cols_b {
let access_a = merged.get(col);
let merged_access =
merge_asset_usage_access_types(access_a.cloned(), Some(*access_b));
if let Some(access) = merged_access {
merged.insert(col.clone(), access);
}
}
Some(merged)
}
}
}
impl From<windmill_parser::asset_parser::AssetKind> for AssetKind {
fn from(parser_kind: windmill_parser::asset_parser::AssetKind) -> Self {
match parser_kind {

View File

@@ -1,13 +1,19 @@
use std::{collections::HashMap, sync::OnceLock};
use std::{
collections::{BTreeMap, HashMap},
sync::OnceLock,
};
use itertools::Itertools;
use serde_json::value::RawValue;
use sqlx::{Pool, Postgres, QueryBuilder};
use sqlx::{types::Json, Pool, Postgres, QueryBuilder};
use tokio::sync::mpsc;
use windmill_parser::asset_parser::parse_asset_syntax;
use crate::{
assets::{merge_asset_usage_access_types, AssetKind, AssetUsageAccessType, AssetUsageKind},
assets::{
merge_asset_columns, merge_asset_usage_access_types, AssetKind, AssetUsageAccessType,
AssetUsageKind,
},
error,
};
@@ -59,6 +65,7 @@ pub struct InsertRuntimeAssetParams {
pub job_id: uuid::Uuid,
pub access_type: Option<AssetUsageAccessType>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub columns: Option<BTreeMap<String, AssetUsageAccessType>>,
}
async fn insert_runtime_assets(
@@ -66,13 +73,14 @@ async fn insert_runtime_assets(
assets: &[InsertRuntimeAssetParams],
) -> error::Result<()> {
for chunk in assets.chunks(1000) {
let mut query_builder = QueryBuilder::new("INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, created_at) ");
let mut query_builder = QueryBuilder::new("INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, columns, usage_kind, created_at) ");
query_builder.push_values(chunk, |mut b, asset| {
b.push_bind(&asset.workspace_id)
.push_bind(&asset.asset_path)
.push_bind(&asset.asset_kind)
.push_bind(&asset.access_type)
.push_bind(asset.job_id.to_string())
.push_bind(Json(&asset.columns))
.push_bind(&AssetUsageKind::Job)
.push_bind(&asset.created_at);
});
@@ -104,6 +112,7 @@ async fn prune_runtime_assets(
// Same job used the same asset multiple times
last_same_job.access_type =
merge_asset_usage_access_types(last_same_job.access_type, asset.access_type);
last_same_job.columns = merge_asset_columns(&last_same_job.columns, &asset.columns);
} else if v.len() < max_n {
v.push(asset);
}

View File

@@ -793,8 +793,8 @@ pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result<Arc<
let store = store_builder.build().map_err(|err| {
tracing::error!("Error building object store client: {:?}", err);
error::Error::internal_err(format!(
"Error building object store client: {}",
err.to_string()
"Error building object store client: {:?}",
err
))
})?;
@@ -860,8 +860,8 @@ fn build_azure_blob_client(
let store = store_builder.build().map_err(|err| {
tracing::error!("Error building object store client: {:?}", err);
error::Error::internal_err(format!(
"Error building object store client: {}",
err.to_string()
"Error building object store client: {:?}",
err
))
})?;
@@ -900,8 +900,8 @@ async fn build_gcs_client(gcs_resource_ref: &GcsResource) -> error::Result<Arc<d
.map_err(|err| {
tracing::error!("Error building GCS object store client: {:?}", err);
error::Error::internal_err(format!(
"Error building GCS object store client: {}",
err.to_string()
"Error building GCS object store client: {:?}",
err
))
})?;
@@ -1257,21 +1257,21 @@ pub fn lfs_to_object_store_resource(
match lfs {
LargeFileStorage::S3Storage(_) | LargeFileStorage::S3AwsOidc(_) => {
let s3_resource: S3Resource = serde_json::from_value(resource_value).map_err(|e| {
error::Error::internal_err(format!("Error parsing S3 resource: {}", e))
error::Error::internal_err(format!("Error parsing S3 resource: {e:?}"))
})?;
Ok(ObjectStoreResource::S3(s3_resource))
}
LargeFileStorage::AzureBlobStorage(_) | LargeFileStorage::AzureWorkloadIdentity(_) => {
let azure_blob_resource: AzureBlobResource = serde_json::from_value(resource_value)
.map_err(|e| {
error::Error::internal_err(format!("Error parsing Azure Blob resource: {}", e))
error::Error::internal_err(format!("Error parsing Azure Blob resource: {e:?}"))
})?;
Ok(ObjectStoreResource::Azure(azure_blob_resource))
}
LargeFileStorage::GoogleCloudStorage(_) => {
let gcs_resource: GcsResource =
serde_json::from_value(resource_value).map_err(|e| {
error::Error::internal_err(format!("Error parsing GCS resource: {}", e))
error::Error::internal_err(format!("Error parsing GCS resource: {e:?}"))
})?;
Ok(ObjectStoreResource::Gcs(gcs_resource))
}

View File

@@ -119,6 +119,8 @@ pub struct TeamPlanStatus {
lazy_static::lazy_static! {
pub static ref TEAM_PLAN_CACHE: Cache<String, TeamPlanStatus> = Cache::new(5000);
// Value: (rate_limit, cached_at_timestamp)
pub static ref PUBLIC_APP_RATE_LIMIT_CACHE: Cache<String, (Option<i32>, i64)> = Cache::new(1000);
}
#[cfg(feature = "cloud")]

View File

@@ -10,7 +10,7 @@ path = "src/lib.rs"
[features]
default = []
private = ["otel_proxy"]
private = []
mcp = ["dep:windmill-mcp"]
prometheus = ["dep:prometheus", "windmill-common/prometheus"]
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "dep:pem", "dep:tokio-util"]
@@ -25,7 +25,7 @@ deno_core = ["dep:deno_fetch", "dep:deno_webidl", "dep:deno_web", "dep:deno_net"
"dep:deno_ast", "dep:deno_tls", "dep:deno_permissions", "dep:deno_io", "dep:deno_runtime", "dep:deno_telemetry", "dep:deno_error", "dep:winapi", "dep:rustls-pemfile",
"quickjs"]
libffi_mac = ["dep:libffi-sys"]
otel = ["windmill-common/otel", "dep:opentelemetry", "dep:tracing-opentelemetry", "dep:opentelemetry-proto", "dep:prost"]
otel = ["windmill-common/otel", "dep:opentelemetry", "dep:tracing-opentelemetry"]
dind = ["dep:bollard"]
php = ["dep:windmill-parser-php"]
mysql = ["dep:mysql_async"]
@@ -39,7 +39,6 @@ ruby = ["dep:windmill-parser-ruby"]
duckdb = ["dep:libloading"]
quickjs = ["dep:rquickjs"]
bedrock = ["dep:aws-sdk-bedrockruntime", "windmill-common/bedrock"]
otel_proxy = ["dep:hudsucker", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util", "dep:rcgen", "otel"]
[dependencies]
windmill-queue.workspace = true
@@ -64,9 +63,9 @@ windmill-parser-graphql.workspace = true
windmill-parser-php = { workspace = true, optional = true }
windmill-git-sync.workspace = true
aws-sdk-bedrockruntime = { workspace = true, optional = true }
aws-config = { workspace = true, optional = true }
aws-credential-types = { workspace = true, optional = true }
aws-smithy-types = { workspace = true, optional = true }
aws-config.workspace = true
aws-credential-types.workspace = true
aws-smithy-types.workspace = true
flume.workspace = true
sqlx.workspace = true
uuid.workspace = true
@@ -141,19 +140,19 @@ process-wrap.workspace = true
async-once-cell.workspace = true
libloading = { workspace = true, optional = true }
opentelemetry-proto = { workspace = true, optional = true }
opentelemetry-proto.workspace = true
opentelemetry = { workspace = true, optional = true }
tracing-opentelemetry = { workspace = true, optional = true }
prost = { workspace = true, optional = true }
prost.workspace = true
axum.workspace = true
bollard = { workspace = true, optional = true }
oracle = { workspace = true, optional = true }
rquickjs = { workspace = true, optional = true }
hudsucker = { workspace = true, optional = true }
hyper-http-proxy = { workspace = true, optional = true }
hyper-tls = { workspace = true, optional = true }
hyper-util = { workspace = true, optional = true }
rcgen = { workspace = true, optional = true }
hudsucker.workspace = true
hyper-http-proxy.workspace = true
hyper-tls.workspace = true
hyper-util.workspace = true
rcgen.workspace = true
[build-dependencies]
deno_fetch = { workspace = true, optional = true }

View File

@@ -29,12 +29,6 @@ mount {
mandatory: false
}
mount {
src: "/bin"
dst: "/bin"
is_bind: true
}
mount {
src: "/opt/microsoft"
dst: "/opt/microsoft"

View File

@@ -486,7 +486,8 @@ lazy_static::lazy_static! {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"nsjail test failed: {}. Jobs will run without nsjail sandboxing. \
To enable nsjail: install nsjail binary or use windmill image with -nsjail suffix",
nsjail should be included in all standard windmill images. \
Check that the nsjail binary is installed and working correctly.",
stderr.trim()
);
None
@@ -495,7 +496,8 @@ lazy_static::lazy_static! {
if e.kind() == std::io::ErrorKind::NotFound {
tracing::warn!(
"nsjail not found at '{}'. Jobs will run without nsjail sandboxing. \
To enable nsjail: install nsjail binary or use windmill image with -nsjail suffix",
nsjail should be included in all standard windmill images. \
Check that the nsjail binary is installed at the expected path.",
nsjail_path
);
} else {
@@ -1533,7 +1535,11 @@ pub async fn run_worker(
let is_dedicated_worker: bool = {
let config = WORKER_CONFIG.read().await;
config.dedicated_worker.is_some() || config.dedicated_workers.as_ref().is_some_and(|dws| !dws.is_empty())
config.dedicated_worker.is_some()
|| config
.dedicated_workers
.as_ref()
.is_some_and(|dws| !dws.is_empty())
};
#[cfg(feature = "benchmark")]
@@ -2045,7 +2051,9 @@ pub async fn run_worker(
dedicated_workers.get(&key)
})
} else {
job.runnable_path.as_ref().and_then(|path| dedicated_workers.get(path))
job.runnable_path
.as_ref()
.and_then(|path| dedicated_workers.get(path))
};
if let Some(dedicated_worker_tx) = dedicated_worker_tx {
let dedicated_job = DedicatedWorkerJob {
@@ -2773,6 +2781,7 @@ async fn detect_and_store_runtime_assets_from_job_args(
asset_kind: asset.kind,
access_type: None,
created_at: None,
columns: None,
};
register_runtime_asset(asset);
}

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.624.0";
export const VERSION = "v1.625.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({

2
cli/deno.lock generated
View File

@@ -1553,6 +1553,8 @@
"https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73",
"https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19",
"https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece",
"https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
"https://deno.land/std@0.224.0/encoding/hex.ts": "6270f25e5d85f99fcf315278670ba012b04b7c94b67715b53f30d03249687c07",
"https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5",
"https://deno.land/std@0.224.0/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412",
"https://deno.land/std@0.224.0/fs/_get_file_info_type.ts": "da7bec18a7661dba360a1db475b826b18977582ce6fc9b25f3d4ee0403fe8cbd",

View File

@@ -77,7 +77,7 @@ export {
// }
// });
export const VERSION = "1.624.0";
export const VERSION = "1.625.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";

View File

@@ -211,6 +211,180 @@ export async function updateScriptSchema(
}
}
// ---------------------------------------------------------------------------
// Annotation parser — mirrors backend's WorkspaceDependenciesAnnotatedRefs::parse
// (windmill-common/src/workspace_dependencies.rs) so the cache key captures
// exactly the parts of scriptContent that affect lockfile generation.
// ---------------------------------------------------------------------------
type AnnotationMode = "manual" | "extra";
interface WorkspaceDepsAnnotation {
mode: AnnotationMode;
external: string[];
inline: string | null;
}
const LANG_ANNOTATION_CONFIG: Partial<
Record<ScriptLanguage, { comment: string; keyword: string; validityRe?: RegExp }>
> = {
python3: { comment: "#", keyword: "requirements", validityRe: /^#\s?(\S+)\s*$/ },
bun: { comment: "//", keyword: "package_json" },
nativets: { comment: "//", keyword: "package_json" },
go: { comment: "//", keyword: "go_mod" },
php: { comment: "//", keyword: "composer_json" },
};
export function extractWorkspaceDepsAnnotation(
scriptContent: string,
language: ScriptLanguage,
): WorkspaceDepsAnnotation | null {
const config = LANG_ANNOTATION_CONFIG[language];
if (!config) return null;
const { comment, keyword, validityRe } = config;
const extraMarker = `extra_${keyword}:`;
const manualMarker = `${keyword}:`;
const lines = scriptContent.split("\n");
// Find first annotation line (mirrors Rust find_position)
let pos = -1;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (l.startsWith(comment) && (l.includes(extraMarker) || l.includes(manualMarker))) {
pos = i;
break;
}
}
if (pos === -1) return null;
const annotationLine = lines[pos];
const mode: AnnotationMode = annotationLine.includes(extraMarker) ? "extra" : "manual";
// Parse external references from the annotation line
const marker = mode === "extra" ? extraMarker : manualMarker;
const unparsed = annotationLine.replaceAll(marker, "").replaceAll(comment, "");
const external = unparsed
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
// Parse inline deps from subsequent lines
const inlineParts: string[] = [];
for (let i = pos + 1; i < lines.length; i++) {
const l = lines[i];
if (validityRe) {
const match = validityRe.exec(l);
if (match && match[1]) {
inlineParts.push(match[1]);
} else {
break;
}
} else {
if (!l.startsWith(comment)) {
break;
}
inlineParts.push(l.substring(comment.length));
}
}
const inlineStr = inlineParts.join("\n");
const inline = inlineStr.trim().length > 0 ? inlineStr : null;
return { mode, external, inline };
}
export async function computeLockCacheKey(
scriptContent: string,
language: ScriptLanguage,
rawWorkspaceDependencies: Record<string, string>,
): Promise<string> {
const annotation = extractWorkspaceDepsAnnotation(scriptContent, language);
const annotationStr = annotation
? `${annotation.mode}|${annotation.external.join(",")}|${annotation.inline ?? ""}`
: "none";
const sortedDepsKeys = Object.keys(rawWorkspaceDependencies).sort();
const depsStr = sortedDepsKeys.map((k) => `${k}=${rawWorkspaceDependencies[k]}`).join(";");
return await generateHash(`${language}|${annotationStr}|${depsStr}`);
}
const lockCache = new Map<string, string>();
export function clearLockCache(): void {
lockCache.clear();
}
async function fetchScriptLock(
workspace: Workspace,
scriptContent: string,
language: ScriptLanguage,
remotePath: string,
rawWorkspaceDependencies: Record<string, string>,
): Promise<string> {
const hasRawDeps = Object.keys(rawWorkspaceDependencies).length > 0;
const cacheKey = hasRawDeps
? await computeLockCacheKey(scriptContent, language, rawWorkspaceDependencies)
: undefined;
if (cacheKey && lockCache.has(cacheKey)) {
log.info(`Using cached lockfile for ${remotePath}`);
return lockCache.get(cacheKey)!;
}
const extraHeaders = getHeaders();
const rawResponse = await fetch(
`${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/dependencies`,
{
method: "POST",
headers: {
Cookie: `token=${workspace.token}`,
"Content-Type": "application/json",
...extraHeaders,
},
body: JSON.stringify({
raw_scripts: [
{
raw_code: scriptContent,
language: language,
script_path: remotePath,
},
],
raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0
? rawWorkspaceDependencies : null,
entrypoint: remotePath,
}),
}
);
let responseText = "reading response failed";
try {
responseText = await rawResponse.text();
const response = JSON.parse(responseText);
const lock = response.lock;
if (lock === undefined) {
if (response?.["error"]?.["message"]) {
throw new LockfileGenerationError(
`Failed to generate lockfile: ${response?.["error"]?.["message"]}`
);
}
throw new LockfileGenerationError(
`Failed to generate lockfile: ${JSON.stringify(response, null, 2)}`
);
}
if (cacheKey) {
lockCache.set(cacheKey, lock);
}
return lock;
} catch (e) {
if (e instanceof LockfileGenerationError) {
throw e;
}
throw new LockfileGenerationError(
`Failed to generate lockfile:${rawResponse.statusText}, ${responseText}, ${e}`
);
}
}
async function updateScriptLock(
workspace: Workspace,
scriptContent: string,
@@ -235,70 +409,28 @@ async function updateScriptLock(
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();
const rawResponse = await fetch(
`${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/dependencies`,
{
method: "POST",
headers: {
Cookie: `token=${workspace.token}`,
"Content-Type": "application/json",
...extraHeaders,
},
body: JSON.stringify({
raw_scripts: [
{
raw_code: scriptContent,
language: language,
script_path: remotePath,
},
],
raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0
? rawWorkspaceDependencies : null,
entrypoint: remotePath,
}),
}
const lock = await fetchScriptLock(
workspace,
scriptContent,
language,
remotePath,
rawWorkspaceDependencies,
);
let responseText = "reading response failed";
try {
responseText = await rawResponse.text();
const response = JSON.parse(responseText);
const lock = response.lock;
if (lock === undefined) {
if (response?.["error"]?.["message"]) {
throw new LockfileGenerationError(
`Failed to generate lockfile: ${response?.["error"]?.["message"]}`
);
const lockPath = remotePath + ".script.lock";
if (lock != "") {
await Deno.writeTextFile(lockPath, lock);
metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/");
} else {
try {
if (await Deno.stat(lockPath)) {
await Deno.remove(lockPath);
}
throw new LockfileGenerationError(
`Failed to generate lockfile: ${JSON.stringify(response, null, 2)}`
);
} catch (e) {
log.info(colors.yellow(`Error removing lock file ${lockPath}: ${e}`));
}
const lockPath = remotePath + ".script.lock";
if (lock != "") {
await Deno.writeTextFile(lockPath, lock);
metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/");
} else {
try {
if (await Deno.stat(lockPath)) {
await Deno.remove(lockPath);
}
} catch (e) {
log.info(colors.yellow(`Error removing lock file ${lockPath}: ${e}`));
}
metadataContent.lock = "";
}
} catch (e) {
if (e instanceof LockfileGenerationError) {
throw e;
}
throw new LockfileGenerationError(
`Failed to generate lockfile:${rawResponse.statusText}, ${responseText}, ${e}`
);
metadataContent.lock = "";
}
}

572
cli/test/lock_cache.test.ts Normal file
View File

@@ -0,0 +1,572 @@
/**
* Lock Cache Tests
*
* Tests the in-memory lock cache used when fetching lockfiles for scripts with
* raw_workspace_dependencies.
*
* Part 1: Unit tests for annotation parsing (mirrors backend).
* Part 2: Unit tests for cache key computation.
* Part 3: Behavioral tests comparing old logic (no cache, always fetches)
* vs new logic (caches by key, skips duplicate fetches).
*/
import {
assertEquals,
assertNotEquals,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts";
// ---------------------------------------------------------------------------
// Mirrors extractWorkspaceDepsAnnotation + computeLockCacheKey from
// src/utils/metadata.ts so we can test the algorithm without pulling in the
// full (unresolvable-in-tests) module graph.
// ---------------------------------------------------------------------------
type AnnotationMode = "manual" | "extra";
interface WorkspaceDepsAnnotation {
mode: AnnotationMode;
external: string[];
inline: string | null;
}
const LANG_ANNOTATION_CONFIG: Record<
string,
{ comment: string; keyword: string; validityRe?: RegExp } | undefined
> = {
python3: { comment: "#", keyword: "requirements", validityRe: /^#\s?(\S+)\s*$/ },
bun: { comment: "//", keyword: "package_json" },
nativets: { comment: "//", keyword: "package_json" },
go: { comment: "//", keyword: "go_mod" },
php: { comment: "//", keyword: "composer_json" },
};
function extractWorkspaceDepsAnnotation(
scriptContent: string,
language: string,
): WorkspaceDepsAnnotation | null {
const config = LANG_ANNOTATION_CONFIG[language];
if (!config) return null;
const { comment, keyword, validityRe } = config;
const extraMarker = `extra_${keyword}:`;
const manualMarker = `${keyword}:`;
const lines = scriptContent.split("\n");
let pos = -1;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (l.startsWith(comment) && (l.includes(extraMarker) || l.includes(manualMarker))) {
pos = i;
break;
}
}
if (pos === -1) return null;
const annotationLine = lines[pos];
const mode: AnnotationMode = annotationLine.includes(extraMarker) ? "extra" : "manual";
const marker = mode === "extra" ? extraMarker : manualMarker;
const unparsed = annotationLine.replaceAll(marker, "").replaceAll(comment, "");
const external = unparsed
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const inlineParts: string[] = [];
for (let i = pos + 1; i < lines.length; i++) {
const l = lines[i];
if (validityRe) {
const match = validityRe.exec(l);
if (match && match[1]) {
inlineParts.push(match[1]);
} else {
break;
}
} else {
if (!l.startsWith(comment)) {
break;
}
inlineParts.push(l.substring(comment.length));
}
}
const inlineStr = inlineParts.join("\n");
const inline = inlineStr.trim().length > 0 ? inlineStr : null;
return { mode, external, inline };
}
async function computeLockCacheKey(
scriptContent: string,
language: string,
rawWorkspaceDependencies: Record<string, string>,
): Promise<string> {
const annotation = extractWorkspaceDepsAnnotation(scriptContent, language);
const annotationStr = annotation
? `${annotation.mode}|${annotation.external.join(",")}|${annotation.inline ?? ""}`
: "none";
const sortedDepsKeys = Object.keys(rawWorkspaceDependencies).sort();
const depsStr = sortedDepsKeys
.map((k) => `${k}=${rawWorkspaceDependencies[k]}`)
.join(";");
const content = `${language}|${annotationStr}|${depsStr}`;
const buf = new TextEncoder().encode(content);
return encodeHex(await crypto.subtle.digest("SHA-256", buf));
}
// ---------------------------------------------------------------------------
// Helpers that mirror the two fetch strategies (old / new).
// ---------------------------------------------------------------------------
interface ScriptInput {
scriptContent: string;
language: string;
remotePath: string;
rawWorkspaceDependencies: Record<string, string>;
}
/** Old logic: always calls the remote for every script. */
async function fetchScriptLockOld(
input: ScriptInput,
remoteFn: (input: ScriptInput) => Promise<string>,
): Promise<string> {
return await remoteFn(input);
}
/** New logic: only caches when raw_workspace_dependencies are non-empty. */
async function fetchScriptLockNew(
input: ScriptInput,
remoteFn: (input: ScriptInput) => Promise<string>,
cache: Map<string, string>,
): Promise<string> {
const hasRawDeps = Object.keys(input.rawWorkspaceDependencies).length > 0;
const cacheKey = hasRawDeps
? await computeLockCacheKey(
input.scriptContent,
input.language,
input.rawWorkspaceDependencies,
)
: undefined;
if (cacheKey && cache.has(cacheKey)) {
return cache.get(cacheKey)!;
}
const lock = await remoteFn(input);
if (cacheKey) {
cache.set(cacheKey, lock);
}
return lock;
}
// =============================================================================
// Part 1 — Annotation parsing
// =============================================================================
Deno.test("python: manual requirements with external refs + inline deps", () => {
const code = `# requirements: default, base
#requests==2.31.0
#pandas>=1.5.0
def main():
pass`;
const r = extractWorkspaceDepsAnnotation(code, "python3")!;
assertEquals(r.mode, "manual");
assertEquals(r.external, ["default", "base"]);
assertEquals(r.inline, "requests==2.31.0\npandas>=1.5.0");
});
Deno.test("python: extra_requirements mode", () => {
const code = `# extra_requirements: utils
#numpy>=1.24.0
def main():
pass`;
const r = extractWorkspaceDepsAnnotation(code, "python3")!;
assertEquals(r.mode, "extra");
assertEquals(r.external, ["utils"]);
assertEquals(r.inline, "numpy>=1.24.0");
});
Deno.test("python: empty requirements (opt-out)", () => {
const code = `# requirements:
def main():
pass`;
const r = extractWorkspaceDepsAnnotation(code, "python3")!;
assertEquals(r.mode, "manual");
assertEquals(r.external, []);
assertEquals(r.inline, null);
});
Deno.test("python: no annotation → null", () => {
const code = `def main():
print("hello")`;
assertEquals(extractWorkspaceDepsAnnotation(code, "python3"), null);
});
Deno.test("bun: package_json annotation with inline", () => {
const code = `// package_json: utils, base
//{
// "dependencies": {
// "axios": "^1.6.0"
// }
//}
export function main() {}`;
const r = extractWorkspaceDepsAnnotation(code, "bun")!;
assertEquals(r.mode, "manual");
assertEquals(r.external, ["utils", "base"]);
assertEquals(r.inline, `{
"dependencies": {
"axios": "^1.6.0"
}
}`);
});
Deno.test("go: go_mod annotation", () => {
const code = `// go_mod: base,
//github.com/gin-gonic/gin v1.9.1
package main
func main() {}`;
const r = extractWorkspaceDepsAnnotation(code, "go")!;
assertEquals(r.mode, "manual");
assertEquals(r.external, ["base"]);
assertEquals(r.inline, "github.com/gin-gonic/gin v1.9.1");
});
Deno.test("unsupported language → null", () => {
assertEquals(extractWorkspaceDepsAnnotation("print(1)", "deno"), null);
assertEquals(extractWorkspaceDepsAnnotation("print(1)", "bash"), null);
});
// =============================================================================
// Part 2 — Cache key computation
// =============================================================================
Deno.test("same annotation + language + deps → same key", async () => {
const code = `# requirements: default
#requests==2.31.0
print("hello")`;
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const a = await computeLockCacheKey(code, "python3", deps);
const b = await computeLockCacheKey(code, "python3", deps);
assertEquals(a, b);
});
Deno.test("different code, same annotation → same key", async () => {
const codeA = `# requirements: default
#requests==2.31.0
print("hello")`;
const codeB = `# requirements: default
#requests==2.31.0
print("world")`;
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
assertEquals(
await computeLockCacheKey(codeA, "python3", deps),
await computeLockCacheKey(codeB, "python3", deps),
);
});
Deno.test("different annotation inline → different key", async () => {
const codeA = `# requirements: default
#requests==2.31.0
print("hello")`;
const codeB = `# requirements: default
#flask==3.0.0
print("hello")`;
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
assertNotEquals(
await computeLockCacheKey(codeA, "python3", deps),
await computeLockCacheKey(codeB, "python3", deps),
);
});
Deno.test("different annotation external refs → different key", async () => {
const codeA = `# requirements: default
print("hello")`;
const codeB = `# requirements: base
print("hello")`;
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
assertNotEquals(
await computeLockCacheKey(codeA, "python3", deps),
await computeLockCacheKey(codeB, "python3", deps),
);
});
Deno.test("manual vs extra mode → different key", async () => {
const codeA = `# requirements: default
print("hello")`;
const codeB = `# extra_requirements: default
print("hello")`;
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
assertNotEquals(
await computeLockCacheKey(codeA, "python3", deps),
await computeLockCacheKey(codeB, "python3", deps),
);
});
Deno.test("no annotation, same code → same key", async () => {
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
assertEquals(
await computeLockCacheKey("print('a')", "python3", deps),
await computeLockCacheKey("print('b')", "python3", deps),
);
});
Deno.test("different deps → different key", async () => {
const code = `# requirements: default
print("hello")`;
assertNotEquals(
await computeLockCacheKey(code, "python3", { d: "a" }),
await computeLockCacheKey(code, "python3", { d: "b" }),
);
});
Deno.test("different language → different key", async () => {
const deps = { d: "v" };
assertNotEquals(
await computeLockCacheKey("x", "bun", deps),
await computeLockCacheKey("x", "python3", deps),
);
});
Deno.test("dep key order does not matter", async () => {
const code = "print('hello')";
assertEquals(
await computeLockCacheKey(code, "python3", { a: "1", b: "2" }),
await computeLockCacheKey(code, "python3", { b: "2", a: "1" }),
);
});
// =============================================================================
// Part 3 — Multi-script fetch behavior: old logic vs new logic
// =============================================================================
function makeRemoteFn(): {
remoteFn: (input: ScriptInput) => Promise<string>;
callCount: () => number;
} {
const calls: ScriptInput[] = [];
return {
remoteFn: async (input: ScriptInput) => {
calls.push(input);
const depsStr = Object.entries(input.rawWorkspaceDependencies).sort().map(([k,v]) => `${k}=${v}`).join(",");
return `lock for ${input.language}:${input.scriptContent}:${depsStr}`;
},
callCount: () => calls.length,
};
}
// -- Two scripts, same annotation + language + deps -------------------------
Deno.test("old logic: two scripts same annotation → 2 remote calls", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const scripts: ScriptInput[] = [
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
{ scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
];
for (const s of scripts) await fetchScriptLockOld(s, remoteFn);
assertEquals(callCount(), 2);
});
Deno.test("new logic: two scripts same annotation → 1 remote call (cache shared)", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const cache = new Map<string, string>();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const scripts: ScriptInput[] = [
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
{ scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
];
const results: string[] = [];
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
assertEquals(callCount(), 1);
assertEquals(results[0], results[1]);
});
// -- Two scripts, different annotations + same deps -------------------------
Deno.test("new logic: different annotations same deps → 2 remote calls", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const cache = new Map<string, string>();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const scripts: ScriptInput[] = [
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
{ scriptContent: "# requirements: base\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
];
const results: string[] = [];
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
assertEquals(callCount(), 2);
assertNotEquals(results[0], results[1]);
});
// -- Two scripts, same annotation + different deps --------------------------
Deno.test("new logic: same annotation different deps → 2 remote calls", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const cache = new Map<string, string>();
const scripts: ScriptInput[] = [
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a",
rawWorkspaceDependencies: { "dependencies/requirements.in": "requests==2.31.0" } },
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "b",
rawWorkspaceDependencies: { "dependencies/requirements.in": "requests==2.32.0" } },
];
const results: string[] = [];
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
assertEquals(callCount(), 2);
assertNotEquals(results[0], results[1]);
});
// -- Many scripts, same annotation + deps -----------------------------------
Deno.test("old logic: 5 scripts same annotation+deps → 5 remote calls", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const ann = "# requirements: default\n";
const scripts: ScriptInput[] = [
{ scriptContent: ann + "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
{ scriptContent: ann + "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
{ scriptContent: ann + "print(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps },
{ scriptContent: ann + "print(1)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps },
{ scriptContent: ann + "print(2)", language: "python3", remotePath: "e", rawWorkspaceDependencies: deps },
];
for (const s of scripts) await fetchScriptLockOld(s, remoteFn);
assertEquals(callCount(), 5);
});
Deno.test("new logic: 5 scripts same annotation+deps → 1 remote call", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const cache = new Map<string, string>();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const ann = "# requirements: default\n";
const scripts: ScriptInput[] = [
{ scriptContent: ann + "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
{ scriptContent: ann + "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
{ scriptContent: ann + "print(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps },
{ scriptContent: ann + "print(1)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps },
{ scriptContent: ann + "print(2)", language: "python3", remotePath: "e", rawWorkspaceDependencies: deps },
];
const results: string[] = [];
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
assertEquals(callCount(), 1);
for (let i = 1; i < results.length; i++) {
assertEquals(results[0], results[i]);
}
});
// -- Many scripts, 2 annotation groups + same deps -------------------------
Deno.test("new logic: 4 scripts with 2 annotation groups → 2 remote calls", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const cache = new Map<string, string>();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const scripts: ScriptInput[] = [
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
{ scriptContent: "# requirements: base\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
{ scriptContent: "# requirements: default\nprint(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps },
{ scriptContent: "# requirements: base\nprint(4)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps },
];
const results: string[] = [];
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
assertEquals(callCount(), 2);
assertEquals(results[0], results[2]); // same annotation "default"
assertEquals(results[1], results[3]); // same annotation "base"
assertNotEquals(results[0], results[1]);
});
// -- Scripts with no workspace deps (empty) ---------------------------------
Deno.test("new logic: empty deps → no caching", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const cache = new Map<string, string>();
const scripts: ScriptInput[] = [
{ scriptContent: "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: {} },
{ scriptContent: "print(1)", language: "python3", remotePath: "b", rawWorkspaceDependencies: {} },
];
for (const s of scripts) await fetchScriptLockNew(s, remoteFn, cache);
assertEquals(callCount(), 2);
assertEquals(cache.size, 0);
});
// -- No annotation scripts with raw deps → share cache ---------------------
Deno.test("new logic: no annotation + same deps → 1 remote call", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const cache = new Map<string, string>();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const scripts: ScriptInput[] = [
{ scriptContent: "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
{ scriptContent: "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
];
const results: string[] = [];
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
assertEquals(callCount(), 1);
assertEquals(results[0], results[1]);
});
// -- Mix of annotated and non-annotated scripts -----------------------------
Deno.test("new logic: mix of annotated and non-annotated → separate cache groups", async () => {
const { remoteFn, callCount } = makeRemoteFn();
const cache = new Map<string, string>();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
const scripts: ScriptInput[] = [
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
{ scriptContent: "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
{ scriptContent: "# requirements: default\nprint(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps },
{ scriptContent: "print(4)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps },
];
const results: string[] = [];
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
assertEquals(callCount(), 2); // one for annotated group, one for no-annotation group
assertEquals(results[0], results[2]); // both annotated "default"
assertEquals(results[1], results[3]); // both no annotation
assertNotEquals(results[0], results[1]); // annotated ≠ non-annotated
});
// -- Cache returns correct lock value ---------------------------------------
Deno.test("new logic: cached value matches original remote response", async () => {
const cache = new Map<string, string>();
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
let callIdx = 0;
const remoteFn = async (_input: ScriptInput) => {
callIdx++;
return "resolved-lock-content-abc123";
};
const r1 = await fetchScriptLockNew(
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
remoteFn, cache,
);
const r2 = await fetchScriptLockNew(
{ scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
remoteFn, cache,
);
assertEquals(callIdx, 1);
assertEquals(r1, "resolved-lock-content-abc123");
assertEquals(r2, "resolved-lock-content-abc123");
});

View File

@@ -906,7 +906,9 @@ Deno.test("readDirRecursive reads all files correctly", async () => {
// Integration Tests (use withTestBackend for automated backend setup)
// =============================================================================
import { yamlParseFile } from "../deps.ts";
import { withTestBackend } from "./test_backend.ts";
import { shouldSkipOnCI } from "./cargo_backend.ts";
Deno.test({
name: "Integration: Pull creates correct local structure",
@@ -1950,3 +1952,212 @@ excludes: []
});
},
});
// =============================================================================
// ws_error_handler_muted Persistence Tests
// =============================================================================
Deno.test({
name: "Integration: Script ws_error_handler_muted is persisted through push/pull",
ignore: shouldSkipOnCI(), // Requires EE features
sanitizeResources: false,
sanitizeOps: false,
async fn() {
await withTestBackend(async (backend, tempDir) => {
await Deno.writeTextFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "**"
excludes: []
`,
);
const uniqueId = Date.now();
await ensureDir(`${tempDir}/f/test`);
// Create a script with ws_error_handler_muted: true
const scriptName = `f/test/muted_script_${uniqueId}`;
const script = createScriptFixture(scriptName, "deno");
await Deno.writeTextFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content);
// Add ws_error_handler_muted to the metadata
const metadataWithMuted = script.metadataFile.content + `ws_error_handler_muted: true\n`;
await Deno.writeTextFile(`${tempDir}/${script.metadataFile.path}`, metadataWithMuted);
// Push
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes", "--includes", `f/test/muted_script_${uniqueId}**`],
tempDir,
);
assertEquals(
pushResult.code,
0,
`Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`,
);
// Verify via API that ws_error_handler_muted was persisted
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/scripts/get/p/${scriptName}`,
);
assertEquals(apiResp.status, 200, "API should return the script");
const scriptData = await apiResp.json();
assertEquals(
scriptData.ws_error_handler_muted,
true,
"API should return ws_error_handler_muted: true for the pushed script",
);
// Pull into a fresh directory and verify the field round-trips
const pullDir = await Deno.makeTempDir({ prefix: "wmill_muted_script_pull_" });
try {
await Deno.writeTextFile(
`${pullDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "f/test/muted_script_${uniqueId}**"
excludes: []
`,
);
const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir);
assertEquals(
pullResult.code,
0,
`Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`,
);
// Verify ws_error_handler_muted is in the pulled metadata
const pulledMetadata = await Deno.readTextFile(`${pullDir}/${script.metadataFile.path}`);
assertStringIncludes(
pulledMetadata,
"ws_error_handler_muted: true",
"Pulled script metadata should contain ws_error_handler_muted: true",
);
// Verify push from pulled dir is idempotent (no changes)
const push2 = await backend.runCLICommand(
["sync", "push", "--dry-run", "--includes", `f/test/muted_script_${uniqueId}**`],
pullDir,
);
assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`);
const output = (push2.stdout + push2.stderr).toLowerCase();
assert(
output.includes("0 change") || output.includes("no change") || output.includes("nothing"),
`Should have no changes after push-pull cycle for script with ws_error_handler_muted. Output: ${output}`,
);
} finally {
await Deno.remove(pullDir, { recursive: true }).catch(() => {});
}
});
},
});
Deno.test({
name: "Integration: Flow ws_error_handler_muted is persisted through push/pull",
ignore: shouldSkipOnCI(), // Requires EE features
sanitizeResources: false,
sanitizeOps: false,
async fn() {
await withTestBackend(async (backend, tempDir) => {
await Deno.writeTextFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "**"
excludes: []
`,
);
const uniqueId = Date.now();
const flowName = `f/test/muted_flow_${uniqueId}`;
const flowFixture = createFlowFixture(flowName);
// Create flow directory and files
await ensureDir(`${tempDir}/f/test/muted_flow_${uniqueId}${getFolderSuffix("flow")}`);
for (const [key, file] of Object.entries(flowFixture)) {
if (key === "metadata") {
// Add ws_error_handler_muted to flow metadata
const contentWithMuted = file.content + `ws_error_handler_muted: true\n`;
await Deno.writeTextFile(`${tempDir}/${file.path}`, contentWithMuted);
} else {
await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content);
}
}
// Push
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes", "--includes", `f/test/muted_flow_${uniqueId}*/**`],
tempDir,
);
assertEquals(
pushResult.code,
0,
`Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`,
);
// Verify via API that ws_error_handler_muted was persisted
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/flows/get/${flowName}`,
);
assertEquals(apiResp.status, 200, "API should return the flow");
const flowData = await apiResp.json();
assertEquals(
flowData.ws_error_handler_muted,
true,
"API should return ws_error_handler_muted: true for the pushed flow",
);
// Pull into a fresh directory and verify the field round-trips
const pullDir = await Deno.makeTempDir({ prefix: "wmill_muted_flow_pull_" });
try {
await Deno.writeTextFile(
`${pullDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "f/test/muted_flow_${uniqueId}*/**"
excludes: []
`,
);
const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir);
assertEquals(
pullResult.code,
0,
`Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`,
);
// Verify ws_error_handler_muted is in the pulled flow.yaml
const flowYamlPath = `${pullDir}/${flowFixture.metadata.path}`;
const pulledFlowYaml = await Deno.readTextFile(flowYamlPath);
assertStringIncludes(
pulledFlowYaml,
"ws_error_handler_muted: true",
"Pulled flow.yaml should contain ws_error_handler_muted: true",
);
// Parse the YAML to confirm it's a proper boolean value
// deno-lint-ignore no-explicit-any
const parsed = await yamlParseFile(flowYamlPath) as any;
assertEquals(
parsed.ws_error_handler_muted,
true,
"ws_error_handler_muted should be boolean true in parsed flow YAML",
);
// Verify push from pulled dir is idempotent (no changes)
const push2 = await backend.runCLICommand(
["sync", "push", "--dry-run", "--includes", `f/test/muted_flow_${uniqueId}*/**`],
pullDir,
);
assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`);
const output = (push2.stdout + push2.stderr).toLowerCase();
assert(
output.includes("0 change") || output.includes("no change") || output.includes("nothing"),
`Should have no changes after push-pull cycle for flow with ws_error_handler_muted. Output: ${output}`,
);
} finally {
await Deno.remove(pullDir, { recursive: true }).catch(() => {});
}
});
},
});

View File

@@ -15,8 +15,8 @@
# With nsjail enabled:
# docker run -p 5679:5679 --privileged windmill-debugger --nsjail
# Stage 1: Get nsjail and windmill from the official windmill image
FROM ghcr.io/windmill-labs/windmill-ee-nsjail:main AS windmill-source
# Stage 1: Get nsjail and windmill from the official windmill EE image
FROM ghcr.io/windmill-labs/windmill-ee:main AS windmill-source
# Stage 2: Build the debug service
FROM oven/bun:1 AS runtime

View File

@@ -17,12 +17,7 @@
# docker run -p 3001:3001 -p 3002:3002 -p 3003:3003 windmill-extra
# ============================================================================
# Stage 1: Get nsjail from the nsjail image
# ============================================================================
FROM ghcr.io/windmill-labs/windmill-ee-nsjail:main AS nsjail-source
# ============================================================================
# Stage 2: Build final extra services image from windmill-slim
# Build final extra services image from windmill-ee-slim (includes nsjail)
# ============================================================================
FROM ghcr.io/windmill-labs/windmill-ee-slim:latest AS final
@@ -40,11 +35,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Install additional system dependencies
# - shellcheck: for bash LSP
# - libprotobuf-dev, libnl-route-3-dev: for nsjail runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
shellcheck \
libprotobuf-dev \
libnl-route-3-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -68,9 +60,6 @@ RUN /usr/local/go/bin/go install -v golang.org/x/tools/gopls@latest
# Copy Deno for Deno LSP
COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno
# Copy nsjail from nsjail image (for sandboxed debugging)
COPY --from=nsjail-source /bin/nsjail /bin/nsjail
# ============================================================================
# LSP Setup
# ============================================================================

View File

@@ -51,6 +51,9 @@ RUN /usr/bin/java -jar /usr/bin/coursier about
# Ruby
RUN apt-get install -y ruby ruby-bundler
# iptables
RUN apt-get install -y iptables
# Fix UV cache permissions for non-root user support (uid 1000, etc.)
# The uv tool install ansible command populates the UV cache with root-owned files
RUN chmod -R a+rw /tmp/windmill/cache/uv && \

View File

@@ -1,82 +0,0 @@
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
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev
# Rust
COPY --from=rust:1.80.1 /usr/local/cargo /usr/local/cargo
COPY --from=rust:1.80.1 /usr/local/rustup /usr/local/rustup
RUN /usr/local/cargo/bin/cargo install cargo-sweep --version ^0.7
# Ansible
RUN uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -s -t "$UV_TOOL_BIN_DIR/" || true
# dotnet SDK
RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \
&& chmod +x dotnet-install.sh \
&& ./dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet \
&& ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet \
&& rm dotnet-install.sh
# Oracle DB Client
COPY --from=oracledb-client /opt/oracle/23/lib /opt/oracle/23/lib
RUN apt-get -y update && apt-get install -y libaio1
RUN echo /opt/oracle/23/lib > /etc/ld.so.conf.d/oracle-instantclient.conf && ldconfig
# Nushell
COPY --from=ghcr.io/nushell/nushell:0.101.0-bookworm /usr/bin/nu /usr/bin/nu
# Java
RUN apt-get install -y default-jdk
RUN curl -fLo coursier https://github.com/coursier/coursier/releases/download/v2.1.24/coursier \
&& mv ./coursier /usr/bin/coursier \
&& chmod +x /usr/bin/coursier
RUN /usr/bin/java -jar /usr/bin/coursier about
# Ruby
RUN apt-get install -y ruby ruby-bundler
# iptables
RUN apt-get install -y iptables
COPY --from=nsjail /nsjail/nsjail /bin/nsjail

View File

@@ -1,5 +1,25 @@
ARG DEBIAN_IMAGE=debian:bookworm-slim
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 ${DEBIAN_IMAGE}
ARG APP=/usr/src/app
@@ -39,6 +59,11 @@ COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
# add the docker client to call docker from a worker if enabled
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
WORKDIR ${APP}
COPY --from=ghcr.io/windmill-labs/windmill:dev --chmod=755 ${APP}/windmill ${APP}/windmill

View File

@@ -1,5 +1,25 @@
ARG DEBIAN_IMAGE=debian:bookworm-slim
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 ${DEBIAN_IMAGE}
ARG APP=/usr/src/app
@@ -39,6 +59,11 @@ COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
# add the docker client to call docker from a worker if enabled
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
WORKDIR ${APP}
COPY --from=ghcr.io/windmill-labs/windmill-ee:dev --chmod=755 ${APP}/windmill ${APP}/windmill

View File

@@ -75,13 +75,6 @@ def patch_docker_compose [
$compose.services.windmill_server.pull_policy = "never"
$compose.services.windmill_indexer.pull_policy = "never"
if ($custom_dockerfile | default 'none' | path basename) == "DockerfileNsjail" {
$compose.services.windmill_worker.privileged = true
$compose.services.windmill_worker_native.privileged = true
$compose.services.windmill_worker.environment ++= ["DISABLE_NSJAIL=false"]
$compose.services.windmill_worker_native.environment ++= ["DISABLE_NSJAIL=false"]
}
return ($compose | to yaml)
}

View File

@@ -113,8 +113,8 @@ it can build the image and run on local repository.
```bash
# Issue the build
docker/dev.nu up --features "python,static_frontend" docker/DockerfileNsjail --rebuild
# Will create and run `main__nsjail__python-static_frontend`
docker/dev.nu up --features "python,static_frontend" --rebuild
# Will create and run `main-python-static_frontend`
```
If you develop wasm parser for new language you can also pass `--wasm-pkg <language>` and it will include local parser to the image. For more information please see the script directly or run it with `--help` flag.

View File

@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.624.0",
"version": "1.625.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.624.0",
"version": "1.625.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -47,6 +47,7 @@
"hash-sum": "^2.0.0",
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"jszip": "^3.10.1",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
@@ -78,11 +79,11 @@
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.574.1",
"windmill-parser-wasm-py": "1.601.1",
"windmill-parser-wasm-regex": "1.593.0",
"windmill-parser-wasm-py": "1.623.1",
"windmill-parser-wasm-regex": "1.623.1",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.558.1",
"windmill-parser-wasm-ts": "1.593.0",
"windmill-parser-wasm-ts": "1.623.1",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
"windmill-utils-internal": "^1.3.2",
@@ -4308,6 +4309,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
@@ -6926,7 +6933,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/ini": {
@@ -7129,6 +7135,12 @@
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7331,6 +7343,54 @@
"node": ">=0.10.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7572,6 +7632,21 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lie/node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -10781,6 +10856,12 @@
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -11591,6 +11672,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -13351,7 +13438,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/validate-npm-package-license": {
@@ -14517,14 +14603,14 @@
"integrity": "sha512-COyid6B1RYs+bpzUCInsA4HY/WZkpDLfkQ90+AqU/TVTpzYSbAC2JCbIwy0cRElBvlhI4bQ+9Wg6hSQKMpEkpA=="
},
"node_modules/windmill-parser-wasm-py": {
"version": "1.601.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.601.1.tgz",
"integrity": "sha512-xcNZE/8B29yfl6UuQDPSXMD+83/W2Hzt2uhn+WrNvy0+qzk6nLh/vJGrf2srLBngYX1TxhUI5Jgseg0PK9yvNw=="
"version": "1.623.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.623.1.tgz",
"integrity": "sha512-lFBlZg6hvhHzsU5oPJq0478UyMTZ9UKVd8Hc8ggmxPIHZaJBeJ+56NR75hmGwg0VJcRff8ed+zEm1PgmjVhD+w=="
},
"node_modules/windmill-parser-wasm-regex": {
"version": "1.593.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.593.0.tgz",
"integrity": "sha512-m8BvTGJc2710YODmKKDXiASfssoJ/YFJGfYRhRnQvltENvaRee2NZuf4XoUkOVJESDdCSEX6U6fVkFvF9rXp2Q=="
"version": "1.623.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.623.1.tgz",
"integrity": "sha512-rW3pl4ysIXmVAmwxSTKhR2sUfScXKfTqxrUebDg0ZNcUzY97S8mNRdQGZfaQ1NWhWPRQNXfL/z/yaUOQh5ConQ=="
},
"node_modules/windmill-parser-wasm-ruby": {
"version": "1.526.1",
@@ -14537,9 +14623,9 @@
"integrity": "sha512-21S7lm1KF8zO1187rbq14hzPHII2RdM2+D44MoAh1F6VoaScj+Puq0z5B1O/hwn/95R/a9jBlL2D8jbkXtlD1A=="
},
"node_modules/windmill-parser-wasm-ts": {
"version": "1.593.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.593.0.tgz",
"integrity": "sha512-NFY9gaEIpJOwGZJeGYDS3+/16QiPYdxFgmb1bKhXyEAdWjYMQ+otrTezf+K09lmvPzB+len37GlNCU72OgyQ6A=="
"version": "1.623.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.623.1.tgz",
"integrity": "sha512-FBwi/zXxjhZcCvi04oFdNivazru1ynIqSbafHSArfaaBWesBO3nye9UO/WXUlWZm5a7BExbU+3R/eVJrGaornw=="
},
"node_modules/windmill-parser-wasm-yaml": {
"version": "1.593.0",

View File

@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.624.0",
"version": "1.625.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -117,6 +117,7 @@
"hash-sum": "^2.0.0",
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"jszip": "^3.10.1",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
@@ -148,11 +149,11 @@
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.574.1",
"windmill-parser-wasm-py": "1.601.1",
"windmill-parser-wasm-regex": "1.593.0",
"windmill-parser-wasm-py": "1.623.1",
"windmill-parser-wasm-regex": "1.623.1",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.558.1",
"windmill-parser-wasm-ts": "1.593.0",
"windmill-parser-wasm-ts": "1.623.1",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
"windmill-utils-internal": "^1.3.2",

View File

@@ -112,7 +112,6 @@
} else if (kind === 'folder') {
const folder = await FolderService.getFolder({ workspace, name: path.slice(2) })
return folder.summary
}
} catch (error) {
console.error(`Failed to fetch summary for ${kind}:${path}`, error)
@@ -122,7 +121,9 @@
async function fetchSummaries(diffs: WorkspaceItemDiff[]) {
// Only fetch summaries for scripts, flows, and apps
const itemsToFetch = diffs.filter((diff) => ['script', 'flow', 'app', 'folder'].includes(diff.kind))
const itemsToFetch = diffs.filter((diff) =>
['script', 'flow', 'app', 'folder'].includes(diff.kind)
)
for (const diff of itemsToFetch) {
const key = getItemKey(diff)
@@ -915,7 +916,6 @@
kind={diff.kind}
canFavorite={false}
workspaceId=""
starred={false}
>
{#snippet customSummary()}
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}

View File

@@ -23,6 +23,8 @@
import { resource } from 'runed'
import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils'
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Star from './Star.svelte'
import type { Asset } from '$lib/gen'
/** Represents a selected table with its schema */
export interface SelectedTable {
@@ -50,6 +52,7 @@
/** Tables that are already added and should show as disabled */
disabledTables?: SelectedTable[]
features?: DbFeatures
asset?: Asset
}
let {
dbType,
@@ -67,7 +70,8 @@
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
features
features,
asset
}: Props = $props()
// Helper to check if a table is selected in multi-select mode
@@ -442,16 +446,26 @@
{:else}
<!-- Normal mode: show tables for selected schema -->
{#each filteredTableKeys as tableKey}
<!-- PLACEHOLDER -->
<button
class={'w-full text-sm font-normal flex gap-2 items-center h-10 cursor-pointer pl-3 pr-1 ' +
(selected.tableKey === tableKey ? 'bg-gray-500/25' : 'hover:bg-gray-500/10')}
(selected.tableKey === tableKey ? 'bg-surface-secondary' : 'hover:bg-surface-hover')}
onclick={() => (selected.tableKey = tableKey)}
>
<Table2 class="text-primary shrink-0" size={16} />
{#if asset}
<Star
kind="asset"
path={`${asset.kind}://${asset.path == 'main' ? '' : asset.path}/${selected.schemaKey}.${tableKey}`}
/>
{:else}
<Table2 class="text-primary shrink-0" size={14} />
{/if}
<p
class="db-manager-table-key truncate text-ellipsis grow text-left text-emphasis text-xs"
>{tableKey}</p
>
{tableKey}
</p>
<DropdownV2
items={() => [
{

View File

@@ -164,6 +164,11 @@
})}
initialTableKey={input.specificTable}
initialSchemaKey={input.type == 'database' ? input.specificSchema : undefined}
asset={_input.type == 'ducklake'
? { kind: 'ducklake', path: _input.ducklake }
: _input.resourcePath.startsWith('datatable://')
? { kind: 'datatable', path: _input.resourcePath.substring('datatable://'.length) }
: undefined}
{dbType}
refresh={() => refresh()}
{dbSelector}

View File

@@ -57,6 +57,7 @@
)
export function openDrawer(nInput: DbInput) {
console.log('Opening DB Manager with input:', nInput)
input = nInput
if (isDatatableInput) {
datatables.refetch()
@@ -73,6 +74,8 @@
input = undefined
selectedDatatable = undefined
dbManagerContent?.clearReplResult()
if (window.location.hash.startsWith('#dbmanager:'))
history.replaceState('', document.title, window.location.href.replace(/#dbmanager:.*$/, ''))
}
let windowWidth = $state(window.innerWidth)

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
import Select from './select/Select.svelte'
import DbManagerDrawer from './DBManagerDrawer.svelte'
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
@@ -32,7 +31,7 @@
let ducklakes = usePromise(() =>
WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' })
)
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
</script>
<div class={className}>
@@ -53,6 +52,4 @@
{dbManagerDrawer}
/>
{/if}
<DbManagerDrawer bind:this={dbManagerDrawer} />
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
import { onMount, untrack } from 'svelte'
import AppConnect from './AppConnectDrawer.svelte'
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
@@ -9,7 +9,6 @@
import { Loader2, Pen, Plus, RotateCw } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import Select from './select/Select.svelte'
import DbManagerDrawer from './DBManagerDrawer.svelte'
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
import DropdownV2 from './DropdownV2.svelte'
@@ -156,7 +155,7 @@
let appConnect: AppConnect | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
let hovering = $state(false)
</script>
@@ -300,5 +299,3 @@
/>
{/if}
</div>
<DbManagerDrawer bind:this={dbManagerDrawer} />

View File

@@ -1,41 +1,25 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy'
import { FavoriteService } from '$lib/gen'
import { starStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Star, StarOff } from 'lucide-svelte'
import { favoriteManager, type FavoriteKind } from './sidebar/FavoriteMenu.svelte'
interface Props {
path: string
kind: 'flow' | 'app' | 'script' | 'raw_app'
starred?: boolean
workspace_id: string
onStarred?: (starred: boolean) => void
kind: FavoriteKind
summary?: string
workspaceId?: string
}
let { path, kind, starred = false, workspace_id, onStarred }: Props = $props()
let { path, kind, workspaceId, summary }: Props = $props()
let buttonHover = $state(false)
let starred = $derived(favoriteManager.isStarred(path, kind))
async function onClick() {
buttonHover = false
if (starred) {
await FavoriteService.unstar({
workspace: workspace_id,
requestBody: { path, favorite_kind: kind }
})
sendUserToast('Unstarred')
$starStore = $starStore + 1
} else {
await FavoriteService.star({
workspace: workspace_id,
requestBody: { path, favorite_kind: kind }
})
sendUserToast('Marked as favorite, it will appear first')
$starStore = $starStore + 1
}
onStarred?.(!starred)
if (starred) favoriteManager.unstar(path, kind, workspaceId)
else favoriteManager.star(path, kind, workspaceId, summary)
}
</script>
@@ -43,7 +27,7 @@
onclick={preventDefault(onClick)}
onmouseenter={() => (buttonHover = true)}
onmouseleave={() => (buttonHover = false)}
class="p-2"
class="p-1"
>
{#if starred}
{#if buttonHover}

View File

@@ -2,13 +2,19 @@
import Markdown from 'svelte-exmarkdown'
import { ExternalLink } from 'lucide-svelte'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { twMerge } from 'tailwind-merge'
export let documentationLink: string | undefined = undefined
export let markdownTooltip: string | undefined = undefined
export let customBgClass: string | undefined = undefined
const plugins = [gfmPlugin()]
</script>
<div
class="shadow-lg max-w-sm break-words py-2 px-3 rounded-md text-xs font-normal text-primary bg-surface-secondary whitespace-normal text-left dark:border max-h-64 overflow-y-auto"
class={twMerge(
'shadow-lg max-w-sm break-words py-2 px-3 rounded-md text-xs font-normal text-primary whitespace-normal text-left dark:border max-h-64 overflow-y-auto',
customBgClass || 'bg-surface-secondary'
)}
>
{#if markdownTooltip}
<div class="prose-sm">

View File

@@ -45,7 +45,7 @@
--ag-input-disabled-border-color: rgba(186, 191, 199, 0.3);
--ag-input-disabled-background-color: rgba(186, 191, 199, 0.15);
--ag-borders: solid 1px;
--ag-border-radius: 8px;
--ag-border-radius: 4px;
--ag-borders-side-button: none;
--ag-side-button-selected-background-color: transparent;
--ag-header-column-resize-handle-display: block;
@@ -66,8 +66,9 @@
--ag-widget-vertical-spacing: calc(var(--ag-grid-size) * 1.5);
--ag-toggle-button-height: 18px;
--ag-toggle-button-width: 28px;
--ag-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
Cantarell, 'Helvetica Neue', sans-serif;
--ag-font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
'Helvetica Neue', sans-serif;
--ag-font-size: 0.7rem;
--ag-icon-font-family: agGridalpine;
--ag-selected-tab-underline-color: var(--ag-alpine-active-color);
@@ -92,8 +93,8 @@
--ag-control-panel-background-color: #222628;
--ag-subheader-background-color: #000;
--ag-input-disabled-background-color: #282c2f;
--ag-input-focus-box-shadow: 0 0 2px 0.5px rgba(255, 255, 255, 0.5),
0 0 4px 3px var(--ag-input-focus-border-color);
--ag-input-focus-box-shadow:
0 0 2px 0.5px rgba(255, 255, 255, 0.5), 0 0 4px 3px var(--ag-input-focus-border-color);
--ag-card-shadow: 0 1px 20px 1px black;
--ag-disabled-foreground-color: rgba(255, 255, 255, 0.5);
--ag-chip-background-color: rgba(255, 255, 255, 0.07);
@@ -119,8 +120,8 @@
--ag-control-panel-background-color: #222628;
--ag-subheader-background-color: #000;
--ag-input-disabled-background-color: #282c2f;
--ag-input-focus-box-shadow: 0 0 2px 0.5px rgba(255, 255, 255, 0.5),
0 0 4px 3px var(--ag-input-focus-border-color);
--ag-input-focus-box-shadow:
0 0 2px 0.5px rgba(255, 255, 255, 0.5), 0 0 4px 3px var(--ag-input-focus-border-color);
--ag-card-shadow: 0 1px 20px 1px black;
--ag-disabled-foreground-color: rgba(255, 255, 255, 0.5);
--ag-chip-background-color: rgba(255, 255, 255, 0.07);

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