Merge branch 'main' into feat/asset-graph-view

This commit is contained in:
Ruben Fiszel
2026-05-11 22:38:47 +00:00
102 changed files with 2477 additions and 3537 deletions
+4 -1
View File
@@ -55,7 +55,10 @@
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(**/credentials.json)",
"Read(**/*secret*)",
"Read(**/.secret*)",
"Read(**/.secrets*)",
"Read(**/*.secret)",
"Read(**/*.secrets)",
"Edit(.env)",
"Edit(.env.*)",
"Edit(**/.env)",
+12 -2
View File
@@ -82,10 +82,11 @@ jobs:
EVENT_TITLE: ${{ github.event.pull_request.title }}
EVENT_BODY: ${{ github.event.pull_request.body }}
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
if [ -n "$INPUT_PR_NUMBER" ]; then
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository)
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author)
PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
@@ -93,6 +94,7 @@ jobs:
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""')
else
PR_NUMBER="$EVENT_PR_NUMBER"
BASE_REF="$EVENT_BASE_REF"
@@ -101,6 +103,7 @@ jobs:
PR_TITLE="$EVENT_TITLE"
PR_BODY="$EVENT_BODY"
IS_FORK="$EVENT_FORK"
PR_AUTHOR="$EVENT_AUTHOR"
fi
if [ "$IS_FORK" = "true" ]; then
echo "Skipping Codex review for fork PR."
@@ -113,6 +116,7 @@ jobs:
echo "base_ref=$BASE_REF"
echo "base_sha=$BASE_SHA"
echo "head_sha=$HEAD_SHA"
echo "pr_author=$PR_AUTHOR"
echo 'title<<PR_TITLE_EOF'
printf '%s\n' "$PR_TITLE"
echo 'PR_TITLE_EOF'
@@ -211,6 +215,7 @@ jobs:
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PR_TITLE: ${{ steps.pr.outputs.title }}
PR_BODY: ${{ steps.pr.outputs.body }}
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
mkdir -p .github/codex
@@ -219,6 +224,11 @@ jobs:
const lines = [
`Repository: ${process.env.PR_REPOSITORY}`,
`PR number: ${process.env.PR_NUMBER}`,
];
if (process.env.PR_AUTHOR) {
lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`);
}
lines.push(
`Base SHA: ${process.env.PR_BASE_SHA}`,
`Head SHA: ${process.env.PR_HEAD_SHA}`,
'',
@@ -236,7 +246,7 @@ jobs:
'',
'Full review diff command:',
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
];
);
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
}
+12 -2
View File
@@ -82,10 +82,11 @@ jobs:
EVENT_TITLE: ${{ github.event.pull_request.title }}
EVENT_BODY: ${{ github.event.pull_request.body }}
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
if [ -n "$INPUT_PR_NUMBER" ]; then
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository)
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author)
PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
@@ -93,6 +94,7 @@ jobs:
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""')
else
PR_NUMBER="$EVENT_PR_NUMBER"
BASE_REF="$EVENT_BASE_REF"
@@ -101,6 +103,7 @@ jobs:
PR_TITLE="$EVENT_TITLE"
PR_BODY="$EVENT_BODY"
IS_FORK="$EVENT_FORK"
PR_AUTHOR="$EVENT_AUTHOR"
fi
if [ "$IS_FORK" = "true" ]; then
echo "Skipping Pi review for fork PR."
@@ -113,6 +116,7 @@ jobs:
echo "base_ref=$BASE_REF"
echo "base_sha=$BASE_SHA"
echo "head_sha=$HEAD_SHA"
echo "pr_author=$PR_AUTHOR"
echo 'title<<PR_TITLE_EOF'
printf '%s\n' "$PR_TITLE"
echo 'PR_TITLE_EOF'
@@ -195,6 +199,7 @@ jobs:
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PR_TITLE: ${{ steps.pr.outputs.title }}
PR_BODY: ${{ steps.pr.outputs.body }}
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
mkdir -p .github/pi
@@ -203,6 +208,11 @@ jobs:
const lines = [
`Repository: ${process.env.PR_REPOSITORY}`,
`PR number: ${process.env.PR_NUMBER}`,
];
if (process.env.PR_AUTHOR) {
lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`);
}
lines.push(
`Base SHA: ${process.env.PR_BASE_SHA}`,
`Head SHA: ${process.env.PR_HEAD_SHA}`,
'',
@@ -220,7 +230,7 @@ jobs:
'',
'Full review diff command:',
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
];
);
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
}
+10 -2
View File
@@ -90,14 +90,21 @@ jobs:
- name: Resolve PR number
id: resolve
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
if [ -n "$INPUT_PR_NUMBER" ]; then
echo "pr_number=$INPUT_PR_NUMBER" >> "$GITHUB_OUTPUT"
PR_NUMBER="$INPUT_PR_NUMBER"
PR_AUTHOR=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.user.login')
else
echo "pr_number=$EVENT_PR_NUMBER" >> "$GITHUB_OUTPUT"
PR_NUMBER="$EVENT_PR_NUMBER"
PR_AUTHOR="$EVENT_PR_AUTHOR"
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
- name: Fetch prior PR discussion
id: prior
@@ -148,6 +155,7 @@ jobs:
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ steps.resolve.outputs.pr_number }}
PR AUTHOR: ${{ steps.resolve.outputs.pr_author }}
${{ env.REVIEW_PROMPT }}
claude_args: |
+5 -1
View File
@@ -9,7 +9,7 @@ You are reviewing a GitHub pull request for this repository. Apply this policy a
## Verdict (first line of the review)
Start every review with a single verdict line, before any other section. Pick exactly one:
Start every review with a single verdict line, before any other section (the only thing that may appear above the verdict is the optional `cc @<PR_AUTHOR>` ping described in "Pinging the author" below). Pick exactly one:
- **Good to merge** — no blocking issues and no nits worth surfacing.
- **Mergeable, but should ideally address nits: <short list>** — no blockers, but P2 findings that are worth a look. The list must name each nit briefly (e.g. "doc/code mismatch in `foo.rs`, half-finished `pub fn bar`").
@@ -17,6 +17,10 @@ Start every review with a single verdict line, before any other section. Pick ex
The names in the list must match findings detailed later in the review. If you list a nit or issue here, it must appear with full context in the body. Do not invent items that aren't in the body, and do not bury blockers in the body without surfacing them in the verdict.
## Pinging the author
If the prompt context provides a `PR AUTHOR` (GitHub login) and the verdict is NOT "Good to merge" (i.e. it is "Mergeable, but should ideally address nits: ..." or "Should address issues before merging: ..."), prepend a single line `cc @<PR_AUTHOR>` to the top-level review comment, above the verdict line. This pings the author so they get a notification that there are items to address. Skip the ping entirely when the verdict is "Good to merge" — there is nothing for the author to act on. Do not add the ping to inline comments; the top-level summary comment is the only place it belongs.
## Review policy
- Only report issues you are confident are real and introduced by this pull request.
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT item_kind, path FROM ws_specific WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "item_kind",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.item_kind, s.path\n FROM ws_specific s\n WHERE s.workspace_id = $1\n AND (\n (s.item_kind = 'resource' AND EXISTS (\n SELECT 1 FROM resource r\n WHERE r.workspace_id = s.workspace_id AND r.path = s.path\n ))\n OR (s.item_kind = 'variable' AND EXISTS (\n SELECT 1 FROM variable v\n WHERE v.workspace_id = s.workspace_id AND v.path = s.path\n ))\n )\n ",
"query": "\n SELECT s.item_kind, s.path\n FROM ws_specific s\n WHERE s.workspace_id = $1\n AND (\n (s.item_kind = 'resource' AND EXISTS (\n SELECT 1 FROM resource r\n WHERE r.workspace_id = s.workspace_id AND r.path = s.path\n ))\n OR (s.item_kind = 'variable' AND EXISTS (\n SELECT 1 FROM variable v\n WHERE v.workspace_id = s.workspace_id AND v.path = s.path\n ))\n )\n ORDER BY s.item_kind, s.path\n ",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
false
]
},
"hash": "8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac"
"hash": "290599fc173947acb518344d6fb631af9f524389309f17ef04f70c773b1d5e75"
}
@@ -1,58 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "expiration",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true,
true,
true,
false,
true,
true
]
},
"hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "bool_and",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "WITH potential AS (\n SELECT email, operator FROM usr\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "authors!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "operators!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455"
}
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT memory, worker, native_mode FROM worker_ping WHERE ping_at > now() - interval '2 minutes'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "memory",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "worker",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "native_mode",
"type_info": "Bool"
}
],
"parameters": {
"Left": []
},
"nullable": [
true,
false,
false
]
},
"hash": "f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd"
}
+540 -2865
View File
File diff suppressed because it is too large Load Diff
+60 -23
View File
@@ -207,6 +207,36 @@ all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embeddin
object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7" }
# Use tiberius main branch for libgssapi 0.8.1 fix (https://github.com/prisma/tiberius/issues/343)
tiberius = { git = "https://github.com/prisma/tiberius", rev = "59db57960a14b422fb3a1309aa4aa47880896ff8" }
# Pin tokio-postgres / postgres-types / postgres-protocol to the
# MaterializeInc fork. windmill-trigger-postgres already pulled this
# fork in transitively for the postgres-replication crate
# (CopyBothDuplex, LogicalReplicationStream, TupleData with binary
# tuple support) which upstream rust-postgres has declined to merge
# since 2021 (PR #752 → #778, both still unmerged).
#
# MI also carries a mitigation for the
# Client::query_typed_raw / Client::prepare deadlock on result columns
# whose Oid the client doesn't know about yet (citext, custom enums /
# domains, postgis): MI's 2025-12-11 PR #33 resized the per-request
# response channel from mpsc::channel(1) → mpsc::channel(1024).
# bounded(1024) is sufficient for any realistic typeinfo deferral
# (need ~2-3 batches) but leaves a theoretical failure mode at
# >~64 MB results with a custom-Oid column. The strict-correct fix is
# mpsc::unbounded(); a follow-up PR to MI is open proposing that.
#
# The [patch.crates-io] entries below force windmill-worker's
# pg_executor (which imports `tokio_postgres::` directly from
# crates.io) onto the same fork as windmill-trigger-postgres, so the
# deadlock mitigation reaches both consumers.
#
# Upstream deadlock PRs (open, not on the critical path now that MI
# is mitigated):
# https://github.com/rust-postgres/rust-postgres/pull/1348
# https://github.com/rust-postgres/rust-postgres/pull/1349
# Reproducer: https://github.com/rubenfiszel/tokio-postgres-deadlock-repro
tokio-postgres = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
postgres-types = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
postgres-protocol = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
[dependencies]
anyhow.workspace = true
@@ -387,8 +417,7 @@ tokio-stream = { version = "0.1.17" }
tower = "^0"
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
tower-cookies = "^0.11"
#stuck because of swc for now
serde = "=1.0.220"
serde = "^1"
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
serde_yml = "0.0.12"
uuid = { version = "^1", features = ["serde", "v4", "js"] }
@@ -443,21 +472,29 @@ aws-sdk-rds = "^1"
async-trait = "0.1.88"
v8 = "=130.0.7" # Exact version NOTE: Do not forget to update version and hash in flake.nix
deno_fetch = "0.214.0"
deno_tls = "0.177.0"
deno_console = "0.190.0"
deno_url = "0.190.0"
deno_webidl = "0.190.0"
deno_web = "0.221.0"
deno_io = "0.100.0"
deno_net = "0.182.0"
deno_core = "0.336.0"
deno_ast = { version = "=0.44.0", features = ["transpiling"] }
deno_permissions = "0.49.0"
deno_runtime = { version = "0.198.0", features = ["transpile"] }
deno_telemetry = "0.12.0"
deno_error = "=0.5.5"
v8 = "=137.1.0" # Exact version NOTE: Do not forget to update version and hash in flake.nix
# deno_* pin set: deno v2.4.0 base, with deno_ast force-overridden to =0.51.0.
# Rationale: deno_ast 0.51.0 is the first version pulling swc_common =14.0.4,
# the first swc_common patch that dropped `pub use serde::__private as serde;`
# (the line that capped our workspace serde pin at =1.0.220). v2.4.0's other
# pins keep deno_tls at 0.196.0 which uses permissive `rustls ^0.23.11`,
# compatible with aws-sdk-bedrockruntime's `^0.23.31` requirement. deno_tls
# 0.198+ tightened that to exact `=0.23.28`, which would have made any
# meaningful deno bump resolver-impossible against aws-sdk.
deno_fetch = "0.233.0"
deno_tls = "0.196.0"
deno_console = "0.209.0"
deno_url = "0.209.0"
deno_webidl = "0.209.0"
deno_web = "0.240.0"
deno_io = "0.119.0"
deno_fs = "0.119.0"
deno_net = "0.201.0"
deno_core = "0.352.0"
deno_ast = { version = "=0.51.0", features = ["transpiling"] }
deno_permissions = "0.68.0"
deno_telemetry = "0.31.0"
deno_error = "=0.6.1"
rustls-pemfile = "2.2.0"
# only used with special deno_core_mac feature to prevent ffi issue on macos, requires libffi to be installed
@@ -470,10 +507,10 @@ google-cloud-googleapis = {version = "0.16.1", features = ["pubsub"]}
winapi = { version = "0.3.9", features = ["sysinfoapi"] }
sysinfo = { version = "0.32.1" }
swc_common = "=0.37.5"
swc_ecma_parser = "=0.149.1"
swc_ecma_ast = "=0.118.2"
swc_ecma_visit = "=0.104.8"
swc_common = "=14.0.4"
swc_ecma_parser = "=24.0.3"
swc_ecma_ast = "=15.0.0"
swc_ecma_visit = "=15.0.0"
async-recursion = "^1"
@@ -517,8 +554,8 @@ wasm-bindgen-test = "^0"
convert_case = "0.6.0"
getrandom = "0.2"
tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]}
rust-postgres = { package = "tokio-postgres", git = "https://github.com/imor/rust-postgres", rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b"}
rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/imor/rust-postgres", features = ["runtime"], rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b" }
rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"}
rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
bit-vec = "=0.6.3"
mappable-rc = "^0"
mysql_async = { version = "*", default-features = false, features = ["minimal", "default", "native-tls-tls", "rust_decimal"]}
+1 -1
View File
@@ -1 +1 @@
c8d100d74b8de6bd26fc973d5edbd8853d54dd8b
c6cd1afe2d9e04809b30751cd1687b28a65e62b1
@@ -0,0 +1,19 @@
-- Remove "assets" key from operator_settings
UPDATE workspace_settings
SET operator_settings = operator_settings - 'assets'
WHERE operator_settings IS NOT NULL
AND operator_settings ? 'assets';
-- Revert the column default
ALTER TABLE workspace_settings
ALTER COLUMN operator_settings SET DEFAULT '{
"runs": true,
"groups": true,
"folders": true,
"workers": true,
"triggers": true,
"resources": true,
"schedules": true,
"variables": true,
"audit_logs": true
}';
@@ -0,0 +1,21 @@
-- Add "assets": true to operator_settings for all workspaces that have operator_settings
-- but don't already have an "assets" key
UPDATE workspace_settings
SET operator_settings = operator_settings || '{"assets": true}'::jsonb
WHERE operator_settings IS NOT NULL
AND NOT operator_settings ? 'assets';
-- Update the column default to include assets
ALTER TABLE workspace_settings
ALTER COLUMN operator_settings SET DEFAULT '{
"runs": true,
"groups": true,
"folders": true,
"workers": true,
"triggers": true,
"resources": true,
"schedules": true,
"variables": true,
"audit_logs": true,
"assets": true
}';
@@ -12,7 +12,7 @@ use AssetUsageAccessType::*;
pub fn parse_assets(code: &str) -> anyhow::Result<ParseAssetsOutput> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
let lexer = Lexer::new(
// We want to parse ecmascript
Syntax::Typescript(TsSyntax::default()),
@@ -129,7 +129,7 @@ impl Visit for ImportsFinder {
/// See also: [`parse_relative_imports`] for resolved absolute paths.
pub fn parse_expr_for_imports(code: &str, skip_type_only: bool) -> anyhow::Result<Vec<String>> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.to_string());
let mut tss = TsSyntax::default();
tss.disallow_ambiguous_jsx_like;
tss.tsx = true;
@@ -263,7 +263,7 @@ impl Visit for OutputFinder {
pub fn parse_expr_for_ids(code: &str) -> anyhow::Result<Vec<(String, String)>> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
let lexer = Lexer::new(
// We want to parse ecmascript
Syntax::Es(EsSyntax { jsx: false, ..Default::default() }),
@@ -305,7 +305,7 @@ pub fn parse_deno_signature(
entrypoint_override: Option<String>,
) -> anyhow::Result<MainArgSignature> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
let lexer = Lexer::new(
// We want to parse ecmascript
Syntax::Typescript(TsSyntax::default()),
@@ -712,7 +712,7 @@ fn extract_ts_params(params: &[swc_ecma_ast::Param], cm: &Lrc<SourceMap>) -> Vec
pub fn parse_ts_workflow(code: &str) -> Result<WorkflowDag, Vec<CompileError>> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("workflow.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("workflow.ts".into()).into(), code.to_string());
let lexer = Lexer::new(
Syntax::Typescript(TsSyntax::default()),
Default::default(),
+1 -1
View File
@@ -8,6 +8,6 @@ pub async fn set_license_key(_license_key: String, _db: Option<&windmill_common:
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn verify_license_key() -> () {
pub async fn verify_license_key(_db: Option<&windmill_common::db::DB>) -> () {
// Implementation is not open source
}
+1 -1
View File
@@ -1441,7 +1441,7 @@ Windmill Community Edition {GIT_VERSION}
tracing::error!("Failed to reload license key on agent: {e:#}");
}
#[cfg(feature = "enterprise")]
ee_oss::verify_license_key().await;
ee_oss::verify_license_key(conn.as_sql()).await;
}
// update min version explicitly.
+19 -1
View File
@@ -2373,7 +2373,19 @@ pub async fn monitor_db(
let verify_license_key_f = async {
#[cfg(feature = "enterprise")]
if !initial_load {
verify_license_key().await;
verify_license_key(conn.as_sql()).await;
}
};
let enforce_offline_caps_f = async {
#[cfg(feature = "enterprise")]
if server_mode && !initial_load {
if let Some(db) = conn.as_sql() {
// Cheap: one query for workers active in the last 2 minutes.
if let Err(e) = windmill_common::ee_oss::enforce_offline_caps(db).await {
tracing::error!("Failed to enforce offline license caps: {e:#}");
}
}
}
};
@@ -2522,6 +2534,7 @@ pub async fn monitor_db(
vacuum_queue_f,
expose_queue_metrics_f,
verify_license_key_f,
enforce_offline_caps_f,
worker_groups_alerts_f,
jobs_waiting_alerts_f,
low_disk_alerts_f,
@@ -2853,6 +2866,11 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
IS_SECURE.store(is_secure, Ordering::Relaxed);
#[cfg(feature = "enterprise")]
{
crate::ee_oss::verify_license_key(conn.as_sql()).await;
}
Ok(())
}
+6
View File
@@ -21,6 +21,10 @@ windmill-mcp = { workspace = true, optional = true }
async-trait.workspace = true
base64.workspace = true
bytes.workspace = true
eventsource-stream.workspace = true
futures.workspace = true
mime_guess.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -29,6 +33,8 @@ uuid.workspace = true
lazy_static.workspace = true
tracing.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
ulid.workspace = true
# Bedrock (optional)
aws-config = { workspace = true, optional = true }
@@ -1,16 +1,18 @@
use crate::types::*;
use base64::Engine;
use futures;
use ulid;
use uuid::Uuid;
use windmill_common::{client::AuthedClient, error::Error};
use windmill_queue::MiniPulledJob;
use windmill_types::s3::S3Object;
use crate::ai::types::*;
/// Upload image to S3 and return S3Object
/// Upload image to S3 and return S3Object.
///
/// The caller must provide an AuthedClient authorized for `workspace_id`.
pub async fn upload_image_to_s3(
base64_image: &str,
job: &MiniPulledJob,
workspace_id: &str,
job_id: &Uuid,
client: &AuthedClient,
) -> Result<S3Object, Error> {
let image_bytes = base64::engine::general_purpose::STANDARD
@@ -19,7 +21,7 @@ pub async fn upload_image_to_s3(
// Generate unique S3 key
let unique_id = ulid::Ulid::new().to_string();
let s3_key = format!("ai_images/{}/{}.png", job.id, unique_id);
let s3_key = format!("ai_images/{}/{}.png", job_id, unique_id);
// Create byte stream
let byte_stream = futures::stream::once(async move {
@@ -29,7 +31,7 @@ pub async fn upload_image_to_s3(
// Upload to S3
client
.upload_s3_file(
&job.workspace_id,
workspace_id,
s3_key.clone(),
None, // storage - use default
byte_stream,
@@ -45,7 +47,9 @@ pub async fn upload_image_to_s3(
})
}
/// Download an S3 image and convert it to a base64 data URL
/// Download an S3 image and convert it to a base64 data URL.
///
/// The caller must provide an AuthedClient authorized for `workspace_id`.
pub async fn download_and_encode_s3_image(
image: &S3Object,
client: &AuthedClient,
@@ -71,6 +75,8 @@ pub async fn download_and_encode_s3_image(
}
/// Convert an S3Object to the appropriate ContentPart based on MIME type.
///
/// The caller must provide an AuthedClient authorized for `workspace_id`.
pub async fn s3_object_to_content_part(
s3_object: &S3Object,
client: &AuthedClient,
@@ -80,7 +86,7 @@ pub async fn s3_object_to_content_part(
download_and_encode_s3_image(s3_object, client, workspace_id).await?;
let data_url = format!("data:{};base64,{}", mime_type, file_bytes);
if windmill_ai::ai_types::is_document_mime(&mime_type) {
if crate::ai_types::is_document_mime(&mime_type) {
let filename = s3_object
.s3
.rsplit('/')
@@ -93,7 +99,9 @@ pub async fn s3_object_to_content_part(
}
}
/// Prepare messages for API by converting S3Objects to base64 ImageUrls
/// Prepare messages for API by converting S3Objects to base64 ImageUrls.
///
/// The caller must provide an AuthedClient authorized for `workspace_id`.
pub async fn prepare_messages_for_api(
messages: &[OpenAIMessage],
client: &AuthedClient,
+3
View File
@@ -4,5 +4,8 @@ pub mod ai_cache;
pub mod ai_google;
pub mod ai_providers;
pub mod ai_types;
pub mod image_handler;
pub mod query_builder;
pub mod sse;
pub mod types;
pub mod utils;
@@ -3,17 +3,15 @@ use std::collections::HashMap;
use eventsource_stream::Eventsource;
use reqwest::Response;
use serde::Deserialize;
use serde_json;
use tokio_stream::StreamExt;
use windmill_ai::{
ai_google::{parse_gemini_sse_event, GeminiUsageMetadata},
ai_types::{ExtraContent, GoogleExtraContent, OpenAIFunction, OpenAIToolCall},
};
use windmill_common::{error::Error, utils::rd_string};
use crate::ai::{
use crate::{
ai_google::{parse_gemini_sse_event, GeminiUsageMetadata},
ai_types::UrlCitation,
ai_types::{ExtraContent, GoogleExtraContent, OpenAIFunction, OpenAIToolCall},
query_builder::StreamEventSink,
types::{StreamingEvent, UrlCitation},
types::StreamingEvent,
};
#[derive(Deserialize)]
@@ -64,6 +62,7 @@ lazy_static::lazy_static! {
.parse::<bool>()
.unwrap_or(false);
}
#[allow(async_fn_in_trait)]
pub trait SSEParser {
async fn parse_event_data(&mut self, data: &str) -> Result<(), Error>;
@@ -459,11 +458,11 @@ impl SSEParser for AnthropicSSEParser {
// Gemini SSE Parser
// ============================================================================
/// Accumulates Gemini streaming events and converts them into the worker's
/// internal [`OpenAIToolCall`] / [`StreamingEvent`] representation.
/// Accumulates Gemini streaming events and converts them into the shared
/// [`OpenAIToolCall`] / [`StreamingEvent`] representation.
///
/// The actual SSE parsing is delegated to [`parse_gemini_sse_event`] from
/// `windmill_common::ai_google` so the logic can be shared with the API proxy.
/// `windmill_ai::ai_google` so the logic can be shared with the API proxy.
pub struct GeminiSSEParser {
pub accumulated_content: String,
pub accumulated_tool_calls: HashMap<i64, OpenAIToolCall>,
+56
View File
@@ -0,0 +1,56 @@
use crate::{
ai_providers::AIProvider,
ai_types::{ContentPart, OpenAIContent},
};
lazy_static::lazy_static! {
/// Parse AI_HTTP_HEADERS environment variable into a vector of (header_name, header_value) tuples
/// Format: "header1: value1, header2: value2"
pub static ref AI_HTTP_HEADERS: Vec<(String, String)> = {
std::env::var("AI_HTTP_HEADERS")
.ok()
.map(|headers_str| {
headers_str
.split(',')
.filter_map(|header| {
let parts: Vec<&str> = header.splitn(2, ':').collect();
if parts.len() == 2 {
let name = parts[0].trim().to_string();
let value = parts[1].trim().to_string();
if !name.is_empty() && !value.is_empty() {
Some((name, value))
} else {
None
}
} else {
None
}
})
.collect()
})
.unwrap_or_default()
};
}
/// AWS Bedrock do not handle structured output query param, so we use a tool for structured output. Same for every Claude models.
pub fn should_use_structured_output_tool(provider: &AIProvider, model: &str) -> bool {
model.contains("claude") || provider == &AIProvider::AWSBedrock
}
/// Extract text content from OpenAIContent, joining parts with space if multiple
pub fn extract_text_content(content: &OpenAIContent) -> String {
match content {
OpenAIContent::Text(text) => text.clone(),
OpenAIContent::Parts(parts) => parts
.iter()
.filter_map(|p| {
if let ContentPart::Text { text } = p {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join(""),
}
}
+5 -1
View File
@@ -8,7 +8,11 @@ use anyhow::anyhow;
pub async fn validate_license_key(
_license_key: String,
_db: Option<&windmill_common::DB>,
) -> anyhow::Result<(String, bool)> {
) -> anyhow::Result<(
String,
bool,
Option<windmill_common::ee_oss::OfflineMetadata>,
)> {
// Implementation is not open source
Err(anyhow!("License can't be validated in Windmill CE"))
}
+51 -2
View File
@@ -37,13 +37,13 @@ use axum::{
use serde_json::json;
use serde::{Deserialize, Serialize};
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
#[cfg(feature = "enterprise")]
use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel};
#[cfg(all(feature = "private", feature = "enterprise"))]
use windmill_common::secret_backend::{
AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings,
};
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
use windmill_common::{
email_oss::send_email_plain_text,
error::{self, JsonResult, Result},
@@ -118,6 +118,8 @@ pub fn global_service() -> Router {
get(get_latest_key_renewal_attempt),
)
.route("/renew_license_key", post(renew_license_key))
.route("/offline_license_status", get(get_offline_license_status))
.route("/instance_hash", get(get_instance_hash))
.route("/customer_portal", post(create_customer_portal_session))
.route("/test_critical_channels", post(test_critical_channels))
.route("/critical_alerts", get(get_critical_alerts))
@@ -340,7 +342,7 @@ pub async fn test_license_key(
Json(TestKey { license_key }): Json<TestKey>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
let (_, expired) = validate_license_key(license_key, Some(&db)).await?;
let (_, expired, _offline_meta) = validate_license_key(license_key, Some(&db)).await?;
if expired {
Err(error::Error::BadRequest("Expired license key".to_string()))
@@ -349,6 +351,53 @@ pub async fn test_license_key(
}
}
#[derive(serde::Serialize)]
pub struct InstanceHash {
pub instance_hash: Option<String>,
}
/// Returns the live cap status for an offline license, or `null` when no
/// offline license is loaded. Used by the superadmin settings panel.
pub async fn get_offline_license_status(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<windmill_common::ee_oss::OfflineCapStatus>> {
require_super_admin(&db, &authed.email).await?;
let offline = (**windmill_common::ee_oss::LICENSE_OFFLINE_METADATA.load()).clone();
let is_offline = matches!(&offline, Some(m) if m.is_offline());
if !is_offline {
return Ok(Json(None));
}
#[cfg(feature = "enterprise")]
let cap = windmill_common::ee_oss::enforce_offline_caps(&db)
.await
.map_err(|e| error::Error::internal_err(format!("enforce_offline_caps: {e:#}")))?;
#[cfg(not(feature = "enterprise"))]
let cap: Option<windmill_common::ee_oss::OfflineCapStatus> = None;
Ok(Json(cap))
}
/// Returns the per-instance binding hash that goes into offline license keys.
/// Admin invokes via `curl` with their personal token when requesting a key
/// from support.
pub async fn get_instance_hash(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<InstanceHash> {
require_super_admin(&db, &authed.email).await?;
#[cfg(feature = "enterprise")]
let hash = windmill_common::ee_oss::compute_instance_hash(&db)
.await
.map_err(|e| error::Error::internal_err(format!("compute_instance_hash: {e:#}")))?;
#[cfg(not(feature = "enterprise"))]
let hash: Option<String> = None;
Ok(Json(InstanceHash { instance_hash: hash }))
}
pub async fn get_local_settings(
Extension(db): Extension<DB>,
authed: ApiAuthed,
@@ -5233,6 +5233,13 @@ async fn invite_user(
nu.email = nu.email.to_lowercase();
#[cfg(feature = "enterprise")]
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
{
return Err(Error::BadRequest(msg));
}
let mut tx = db.begin().await?;
let already_in_workspace = sqlx::query_scalar!(
@@ -5306,6 +5313,13 @@ async fn add_user(
nu.email = nu.email.to_lowercase();
#[cfg(feature = "enterprise")]
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
{
return Err(Error::BadRequest(msg));
}
let mut tx = db.begin().await?;
let already_exists_email = sqlx::query_scalar!(
@@ -7351,6 +7365,7 @@ async fn list_ws_specific(
WHERE v.workspace_id = s.workspace_id AND v.path = s.path
))
)
ORDER BY s.item_kind, s.path
"#,
&w_id
)
+60
View File
@@ -1779,6 +1779,63 @@ paths:
schema:
type: string
/settings/offline_license_status:
get:
summary: get cap-usage status for the currently-loaded offline license
description: |
Returns the live cap status (seats used vs cap, current CU vs cap) for
the offline license key currently in use. Returns `null` if no offline
license is loaded. Super-admin only.
operationId: getOfflineLicenseStatus
tags:
- setting
responses:
"200":
description: cap status (or null when no offline license)
content:
application/json:
schema:
type: object
nullable: true
properties:
seats_used:
type: number
description: Author-equivalent seats consumed (authors + 0.5 × operators)
seats_cap:
type: integer
author_count:
type: integer
operator_count:
type: integer
current_cu:
type: number
description: Sum of CU rate across workers that pinged in the last 2 minutes.
cu_cap:
type: number
cu_over_cap:
type: boolean
/settings/instance_hash:
get:
summary: per-instance binding hash for offline license issuance
description: |
Returns the hash a superadmin shares with Windmill support when
requesting an offline license. Super-admin only.
operationId: getInstanceHash
tags:
- setting
responses:
'200':
description: instance hash
content:
application/json:
schema:
type: object
properties:
instance_hash:
type: string
nullable: true
/settings/customer_portal:
post:
summary: create customer portal session
@@ -20963,6 +21020,9 @@ components:
jwt_role:
type: string
description: Vault JWT auth role name for Windmill (optional, if not provided token auth is used)
jwt_mount_path:
type: string
description: Mount path for the JWT auth method in Vault (optional, defaults to "jwt"). Set this when the JWT auth method is mounted at a non-default path, e.g. via `vault auth enable -path=<mount> jwt`.
namespace:
type: string
description: Vault Enterprise namespace (optional)
+2 -27
View File
@@ -15,11 +15,12 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, value::RawValue};
use std::collections::HashMap;
use std::time::Duration;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_ai::ai_cache::current_instance_ai_config_revision;
use windmill_ai::ai_providers::{
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
};
use windmill_ai::utils::AI_HTTP_HEADERS;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::db::UserDB;
use windmill_common::error::{to_anyhow, Error, Result};
use windmill_common::utils::configure_client;
@@ -101,32 +102,6 @@ lazy_static::lazy_static! {
pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringAIRequestConfig> = Cache::new(500);
/// Parse AI_HTTP_HEADERS environment variable into a vector of (header_name, header_value) tuples
/// Format: "header1: value1, header2: value2"
static ref AI_HTTP_HEADERS: Vec<(String, String)> = {
std::env::var("AI_HTTP_HEADERS")
.ok()
.map(|headers_str| {
headers_str
.split(',')
.filter_map(|header| {
let parts: Vec<&str> = header.splitn(2, ':').collect();
if parts.len() == 2 {
let name = parts[0].trim().to_string();
let value = parts[1].trim().to_string();
if !name.is_empty() && !value.is_empty() {
Some((name, value))
} else {
None
}
} else {
None
}
})
.collect()
})
.unwrap_or_default()
};
}
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
+5 -1
View File
@@ -10,7 +10,11 @@ use anyhow::anyhow;
pub async fn validate_license_key(
_license_key: String,
_db: Option<&crate::db::DB>,
) -> anyhow::Result<(String, bool)> {
) -> anyhow::Result<(
String,
bool,
Option<windmill_common::ee_oss::OfflineMetadata>,
)> {
// Implementation is not open source
Err(anyhow!("License can't be validated in Windmill CE"))
}
+8 -3
View File
@@ -1067,10 +1067,15 @@ impl<'a> GetQuery<'a> {
.ok()
.inspect(|data| job.raw_flow = Some(sqlx::types::Json(data.raw_flow.clone())));
}
if self.with_code && job.job_kind() == &JobKind::Preview {
if self.with_code
&& matches!(
job.job_kind(),
JobKind::Preview | JobKind::FlowScript | JobKind::AppScript
)
{
// Try to fetch the code from the cache, fallback to the preview code.
// NOTE: This could check for the job kinds instead of the `or_else` but it's not
// necessary as `fetch_script` return early if the job kind is not a preview one.
// `fetch_script` resolves FlowScript / AppScript via their runnable_id; for
// Preview jobs it returns early and we fall through to `fetch_preview_script`.
let conn = Connection::from(db.clone());
cache::job::fetch_script(db.clone(), job.job_kind(), hash)
.or_else(|_| cache::job::fetch_preview_script(&conn, &id, raw_lock, raw_code))
+54
View File
@@ -18,6 +18,60 @@ lazy_static::lazy_static! {
pub static ref LICENSE_KEY_VALID: AtomicBool = AtomicBool::new(true);
pub static ref LICENSE_KEY_ID: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
pub static ref LICENSE_KEY: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
pub static ref LICENSE_OFFLINE_METADATA: arc_swap::ArcSwap<Option<OfflineMetadata>> = arc_swap::ArcSwap::from_pointee(None);
pub static ref LICENSE_OFFLINE_OVER_CU_CAP: AtomicBool = AtomicBool::new(false);
pub static ref LICENSE_OFFLINE_LAST_STATUS: arc_swap::ArcSwap<Option<OfflineCapStatus>> = arc_swap::ArcSwap::from_pointee(None);
pub static ref LICENSE_OFFLINE_LAST_CHECKED_AT: arc_swap::ArcSwap<Option<chrono::DateTime<chrono::Utc>>> = arc_swap::ArcSwap::from_pointee(None);
}
#[cfg(not(feature = "private"))]
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
pub struct OfflineMetadata {
pub v: u32,
pub kind: String,
pub hash: String,
pub seats: i64,
pub cu_limit: f64,
}
#[cfg(not(feature = "private"))]
impl OfflineMetadata {
pub fn is_offline(&self) -> bool {
self.kind == "offline"
}
}
#[cfg(not(feature = "private"))]
#[derive(Clone, Debug, serde::Serialize)]
pub struct OfflineCapStatus {
pub seats_used: f64,
pub seats_cap: i64,
pub author_count: i64,
pub operator_count: i64,
pub current_cu: f64,
pub cu_cap: f64,
pub cu_over_cap: bool,
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn check_seat_cap_for_new_user(
_db: &DB,
_email: &str,
_new_user_is_operator: bool,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result<Option<String>> {
// Implementation is not open source
Ok(None)
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn enforce_offline_caps(_db: &DB) -> anyhow::Result<Option<OfflineCapStatus>> {
// Implementation is not open source
Ok(None)
}
#[cfg(not(feature = "private"))]
+30
View File
@@ -121,6 +121,36 @@ pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
pub const SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
/// Canonical form of a base URL, used as one of the inputs to the offline-license
/// instance hash (`compute_instance_hash`).
///
/// Rules: lowercase scheme and host, drop default ports (80/443), strip path/query/fragment,
/// strip trailing slash. If URL parsing fails, falls back to a best-effort lowercase +
/// trailing-slash strip so two semantically-equivalent inputs still produce the same
/// canonical form.
pub fn canonical_base_url(input: &str) -> String {
let trimmed = input.trim();
if trimmed.is_empty() {
return String::new();
}
match url::Url::parse(trimmed) {
Ok(u) => {
let scheme = u.scheme().to_ascii_lowercase();
let host = u
.host_str()
.map(|h| h.to_ascii_lowercase())
.unwrap_or_default();
let port = match (u.port(), scheme.as_str()) {
(Some(80), "http") | (Some(443), "https") => String::new(),
(Some(p), _) => format!(":{p}"),
(None, _) => String::new(),
};
format!("{scheme}://{host}{port}")
}
Err(_) => trimmed.trim_end_matches('/').to_ascii_lowercase(),
}
}
/// Checks if the user is allowed to preserve on_behalf_of values (admin or deployer).
pub fn can_preserve_on_behalf_of(authed: &impl db::Authable) -> bool {
authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP)
@@ -122,6 +122,11 @@ pub struct VaultSettings {
/// Optional - if not provided, token auth is used
#[serde(skip_serializing_if = "Option::is_none")]
pub jwt_role: Option<String>,
/// Mount path for the JWT auth method in Vault (defaults to "jwt").
/// Set this when the JWT auth method is mounted at a non-default path,
/// e.g. via `vault auth enable -path=my-mount jwt`.
#[serde(skip_serializing_if = "Option::is_none")]
pub jwt_mount_path: Option<String>,
/// Vault Enterprise namespace (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
@@ -26,6 +26,7 @@ mod tests {
address: "http://127.0.0.1:8200".to_string(),
mount_path: "windmill".to_string(),
jwt_role: Some("windmill-secrets".to_string()),
jwt_mount_path: None,
namespace: None,
token: Some("test-root-token".to_string()),
skip_ssl_verify: None,
+3 -1
View File
@@ -447,7 +447,9 @@ pub async fn get_license_id_or_uid<'c, E: sqlx::Executor<'c, Database = Postgres
}
}
async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(db: E) -> Result<String> {
pub async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(
db: E,
) -> Result<String> {
let uid_value = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = $1",
UNIQUE_ID_SETTING
@@ -91,6 +91,7 @@ mod tests {
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
mount_path: "windmill".to_string(),
jwt_role: None, // Static token mode
jwt_mount_path: None,
namespace: None,
token: Some(
std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()),
@@ -106,6 +107,7 @@ mod tests {
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
mount_path: "windmill".to_string(),
jwt_role: Some("windmill-secrets".to_string()), // JWT mode
jwt_mount_path: None,
namespace: None,
token: None, // No static token - use JWT
skip_ssl_verify: None,
@@ -203,7 +205,10 @@ mod tests {
println!("Testing Vault connection with JWT auth...");
println!(" Address: {}", settings.address);
println!(" JWT Role: {:?}", settings.jwt_role);
println!(" BASE_URL: {}", (**windmill_common::BASE_URL.load()).clone());
println!(
" BASE_URL: {}",
(**windmill_common::BASE_URL.load()).clone()
);
let result = test_vault_connection(&settings, Some(&db)).await;
assert!(
@@ -274,13 +279,15 @@ mod tests {
// Encrypt fixture placeholders with real workspace keys
encrypt_fixture_secrets(&db).await;
let secret_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM variable WHERE is_secret = true"
)
.fetch_one(&db)
.await
.expect("Failed to count secrets");
println!("Found {} secrets in database before migration", secret_count.unwrap_or(0));
let secret_count =
sqlx::query_scalar!("SELECT COUNT(*) FROM variable WHERE is_secret = true")
.fetch_one(&db)
.await
.expect("Failed to count secrets");
println!(
"Found {} secrets in database before migration",
secret_count.unwrap_or(0)
);
// Run migration
println!("Migrating secrets to Vault...");
@@ -288,8 +295,10 @@ mod tests {
.await
.expect("Migration to Vault failed");
println!("Migration report: total={}, migrated={}, failed={}",
report.total_secrets, report.migrated_count, report.failed_count);
println!(
"Migration report: total={}, migrated={}, failed={}",
report.total_secrets, report.migrated_count, report.failed_count
);
if !report.failures.is_empty() {
for f in &report.failures {
@@ -307,7 +316,11 @@ mod tests {
.get_secret(ws, path)
.await
.unwrap_or_else(|e| panic!("Failed to read {}/{} from Vault: {:?}", ws, path, e));
assert_eq!(value, expected_plaintext, "Vault value mismatch for {}/{}", ws, path);
assert_eq!(
value, expected_plaintext,
"Vault value mismatch for {}/{}",
ws, path
);
println!("{}/{} correct in Vault", ws, path);
}
@@ -346,8 +359,10 @@ mod tests {
.await
.expect("Migration to database failed");
println!("Migration report: total={}, migrated={}, failed={}",
report.total_secrets, report.migrated_count, report.failed_count);
println!(
"Migration report: total={}, migrated={}, failed={}",
report.total_secrets, report.migrated_count, report.failed_count
);
assert_eq!(report.failed_count, 0, "Migration had failures");
assert!(report.migrated_count > 0, "No secrets were migrated");
@@ -364,7 +379,11 @@ mod tests {
let mc = build_crypt(&db, ws).await.unwrap();
let decrypted = decrypt(&mc, row).expect("Failed to decrypt restored value");
assert_eq!(decrypted, expected_plaintext, "Restored value mismatch for {}/{}", ws, path);
assert_eq!(
decrypted, expected_plaintext,
"Restored value mismatch for {}/{}",
ws, path
);
println!("{}/{} correctly restored in DB", ws, path);
}
@@ -488,11 +507,19 @@ mod tests {
.await
.unwrap_or_else(|_| panic!("Secret {}/{} not found after round-trip", ws, path));
assert_ne!(encrypted, "ROUND_TRIP_CLEARED", "Secret {}/{} was not restored", ws, path);
assert_ne!(
encrypted, "ROUND_TRIP_CLEARED",
"Secret {}/{} was not restored",
ws, path
);
let mc = build_crypt(&db, ws).await.unwrap();
let decrypted = decrypt(&mc, encrypted).expect("Failed to decrypt");
assert_eq!(decrypted, expected_plaintext, "Round-trip value mismatch for {}/{}", ws, path);
assert_eq!(
decrypted, expected_plaintext,
"Round-trip value mismatch for {}/{}",
ws, path
);
println!("{}/{}: round-trip OK", ws, path);
}
@@ -522,10 +549,7 @@ mod tests {
.get_secret("test-workspace", "u/test-user/other_secret")
.await;
assert!(
cross_access.is_err(),
"Cross-workspace access should fail!"
);
assert!(cross_access.is_err(), "Cross-workspace access should fail!");
println!("✓ Cross-workspace access correctly denied");
// Verify own workspace access works
@@ -23,19 +23,21 @@
use sqlx::{Pool, Postgres};
use windmill_common::error::Result;
use windmill_common::secret_backend::{
vault_oss::{migrate_secrets_to_database, migrate_secrets_to_vault, test_vault_connection, VaultBackend},
vault_oss::{
migrate_secrets_to_database, migrate_secrets_to_vault, test_vault_connection, VaultBackend,
},
SecretBackend, VaultSettings,
};
fn test_vault_settings() -> VaultSettings {
VaultSettings {
address: std::env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
address: std::env::var("VAULT_ADDR")
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
mount_path: "windmill".to_string(),
jwt_role: Some("windmill-secrets".to_string()),
jwt_mount_path: None,
namespace: None,
token: Some(
std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()),
),
token: Some(std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string())),
skip_ssl_verify: None,
}
}
@@ -47,7 +49,11 @@ async fn test_vault_connection_works(db: Pool<Postgres>) {
let settings = test_vault_settings();
let result = test_vault_connection(&settings, Some(&db)).await;
assert!(result.is_ok(), "Failed to connect to Vault: {:?}", result.err());
assert!(
result.is_ok(),
"Failed to connect to Vault: {:?}",
result.err()
);
println!("✓ Successfully connected to Vault at {}", settings.address);
}
@@ -70,7 +76,10 @@ async fn test_migrate_db_to_vault(db: Pool<Postgres>) {
.await
.expect("Failed to query secrets");
println!("Found {} secrets in database before migration:", secrets_before.len());
println!(
"Found {} secrets in database before migration:",
secrets_before.len()
);
for s in &secrets_before {
println!(" - {}/{}: {} chars", s.workspace_id, s.path, s.value.len());
}
@@ -111,7 +120,10 @@ async fn test_migrate_db_to_vault(db: Pool<Postgres>) {
secret.path,
result.err()
);
println!("{}/{} exists in Vault", secret.workspace_id, secret.path);
println!(
" ✓ {}/{} exists in Vault",
secret.workspace_id, secret.path
);
}
println!("\n✓ Migration to Vault completed successfully");
@@ -133,8 +145,14 @@ async fn test_migrate_vault_to_db(db: Pool<Postgres>) {
let to_vault_report = migrate_secrets_to_vault(&db, &settings)
.await
.expect("Initial migration to Vault failed");
assert!(to_vault_report.migrated_count > 0, "No secrets to test with");
println!(" Migrated {} secrets to Vault", to_vault_report.migrated_count);
assert!(
to_vault_report.migrated_count > 0,
"No secrets to test with"
);
println!(
" Migrated {} secrets to Vault",
to_vault_report.migrated_count
);
// Clear the database values to simulate fresh migration back
println!("\nClearing database secret values...");
@@ -150,7 +168,10 @@ async fn test_migrate_vault_to_db(db: Pool<Postgres>) {
.fetch_one(&db)
.await
.expect("Failed to count cleared");
println!(" Cleared {} secret values in database", cleared.count.unwrap_or(0));
println!(
" Cleared {} secret values in database",
cleared.count.unwrap_or(0)
);
// Now migrate from Vault back to database
println!("\nMigrating secrets from Vault to database...");
@@ -206,15 +227,14 @@ async fn test_full_round_trip_migration(db: Pool<Postgres>) {
.expect("Failed to connect to Vault");
// Get original secrets
let original_secrets: std::collections::HashMap<(String, String), String> = sqlx::query!(
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true"
)
.fetch_all(&db)
.await
.expect("Failed to query original secrets")
.into_iter()
.map(|r| ((r.workspace_id, r.path), r.value))
.collect();
let original_secrets: std::collections::HashMap<(String, String), String> =
sqlx::query!("SELECT workspace_id, path, value FROM variable WHERE is_secret = true")
.fetch_all(&db)
.await
.expect("Failed to query original secrets")
.into_iter()
.map(|r| ((r.workspace_id, r.path), r.value))
.collect();
println!("Original secrets: {} entries", original_secrets.len());
@@ -243,21 +263,23 @@ async fn test_full_round_trip_migration(db: Pool<Postgres>) {
// Step 4: Verify round-trip integrity
println!("\n=== Step 4: Verify round-trip integrity ===");
let restored_secrets: std::collections::HashMap<(String, String), String> = sqlx::query!(
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true"
)
.fetch_all(&db)
.await
.expect("Failed to query restored secrets")
.into_iter()
.map(|r| ((r.workspace_id, r.path), r.value))
.collect();
let restored_secrets: std::collections::HashMap<(String, String), String> =
sqlx::query!("SELECT workspace_id, path, value FROM variable WHERE is_secret = true")
.fetch_all(&db)
.await
.expect("Failed to query restored secrets")
.into_iter()
.map(|r| ((r.workspace_id, r.path), r.value))
.collect();
// Compare original and restored
for ((ws, path), _original_value) in &original_secrets {
let restored_value = restored_secrets
.get(&(ws.clone(), path.clone()))
.expect(&format!("Secret {}/{} not found after round-trip", ws, path));
.expect(&format!(
"Secret {}/{} not found after round-trip",
ws, path
));
// Note: Values might differ slightly due to encryption/decryption
// but they should not be the cleared value
@@ -266,7 +288,12 @@ async fn test_full_round_trip_migration(db: Pool<Postgres>) {
"Secret {}/{} was not restored",
ws, path
);
println!("{}/{}: restored ({} chars)", ws, path, restored_value.len());
println!(
" ✓ {}/{}: restored ({} chars)",
ws,
path,
restored_value.len()
);
}
println!("\n✓ Full round-trip migration completed successfully!");
@@ -289,7 +316,10 @@ async fn test_workspace_isolation(db: Pool<Postgres>) {
.await
.expect("Migration failed");
println!("Migrated {} secrets across workspaces", report.migrated_count);
println!(
"Migrated {} secrets across workspaces",
report.migrated_count
);
// Verify workspace isolation in Vault
let vault_backend = VaultBackend::new(settings.clone());
@@ -309,13 +339,19 @@ async fn test_workspace_isolation(db: Pool<Postgres>) {
let ws1_result: Result<String> = vault_backend
.get_secret("test-workspace", "u/test-user/db_password")
.await;
assert!(ws1_result.is_ok(), "test-workspace secret should be accessible");
assert!(
ws1_result.is_ok(),
"test-workspace secret should be accessible"
);
println!("✓ test-workspace secrets accessible");
let ws2_result: Result<String> = vault_backend
.get_secret("test-workspace-2", "u/test-user/other_secret")
.await;
assert!(ws2_result.is_ok(), "test-workspace-2 secret should be accessible");
assert!(
ws2_result.is_ok(),
"test-workspace-2 secret should be accessible"
);
println!("✓ test-workspace-2 secrets accessible");
println!("\n✓ Workspace isolation verified!");
+3 -2
View File
@@ -28,9 +28,9 @@ deno_ast.workspace = true
deno_tls.workspace = true
deno_permissions.workspace = true
deno_io.workspace = true
deno_fs.workspace = true
deno_telemetry.workspace = true
deno_error.workspace = true
deno_runtime.workspace = true
winapi.workspace = true
itertools.workspace = true
@@ -60,6 +60,7 @@ deno_ast.workspace = true
deno_tls.workspace = true
deno_permissions.workspace = true
deno_io.workspace = true
deno_runtime.workspace = true
deno_fs.workspace = true
deno_telemetry.workspace = true
deno_error.workspace = true
winapi.workspace = true
+119 -22
View File
@@ -1,3 +1,6 @@
use deno_ast::{MediaType, ParseParams};
use deno_core::{ModuleCodeString, ModuleName, SourceMapData};
use deno_error::JsErrorBox;
use deno_fetch::FetchPermissions;
use deno_net::NetPermissions;
use deno_web::{BlobStore, TimersPermission};
@@ -22,10 +25,30 @@ impl FetchPermissions for PermissionsContainer {
#[inline(always)]
fn check_read<'a>(
&mut self,
_resolved: bool,
_p: &'a std::path::Path,
_path: Cow<'a, Path>,
_api_name: &str,
) -> Result<Cow<'a, std::path::Path>, deno_io::fs::FsError> {
_get_path: &'a dyn deno_fs::GetPath,
) -> Result<deno_fs::CheckedPath<'a>, deno_io::fs::FsError> {
unreachable!("snapshotting")
}
#[inline(always)]
fn check_write<'a>(
&mut self,
_path: Cow<'a, Path>,
_api_name: &str,
_get_path: &'a dyn deno_fs::GetPath,
) -> Result<deno_fs::CheckedPath<'a>, deno_io::fs::FsError> {
unreachable!("snapshotting")
}
#[inline(always)]
fn check_net_vsock(
&mut self,
_cid: u32,
_port: u32,
_api_name: &str,
) -> Result<(), deno_permissions::PermissionCheckError> {
unreachable!("snapshotting")
}
}
@@ -38,17 +61,17 @@ impl TimersPermission for PermissionsContainer {
}
impl NetPermissions for PermissionsContainer {
fn check_read<'a>(
fn check_read(
&mut self,
_p: &'a str,
_p: &str,
_api_name: &str,
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
unreachable!("snapshotting")
}
fn check_write<'a>(
fn check_write(
&mut self,
_p: &'a str,
_p: &str,
_api_name: &str,
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
unreachable!("snapshotting")
@@ -64,10 +87,19 @@ impl NetPermissions for PermissionsContainer {
fn check_write_path<'a>(
&mut self,
_: &'a Path,
_: &str,
_p: Cow<'a, Path>,
_api_name: &str,
) -> Result<Cow<'a, Path>, deno_permissions::PermissionCheckError> {
todo!()
unreachable!("snapshotting")
}
fn check_vsock(
&mut self,
_cid: u32,
_port: u32,
_api_name: &str,
) -> Result<(), deno_permissions::PermissionCheckError> {
unreachable!("snapshotting")
}
}
@@ -77,22 +109,87 @@ deno_core::extension!(
esm = ["src/runtime.js"],
);
// `extension_transpiler` callback for `deno_core::snapshot::create_snapshot`.
//
// Specialized to our snapshot's inputs. Of the seven deno_* extensions
// we register via `init()`, six ship pre-built `.js` files
// in their `esm` lists (webidl/url/console/web/fetch/net) — only
// `deno_telemetry`'s `extension!` macro lists `.ts` files
// (`telemetry.ts`, `util.ts`), so the TypeScript branch is needed
// solely for that crate. Our local `fetch` extension contributes
// `src/runtime.js` (pure JS). No `node:` imports happen at snapshot
// build time, no `.mjs`, no user-supplied modules. So:
// - `.js` → pass through.
// - `.ts` → transpile via deno_ast (deno_telemetry only).
// - anything else → build bug (deno shipping an unexpected file type
// or us mislabelling one), panic loudly rather than emit a broken
// snapshot.
//
// No source maps: the snapshot is a binary blob the runtime loads — source
// maps would never be consumed.
//
// The signature still returns `Result<_, JsErrorBox>` because that's what
// `extension_transpiler` expects, but we never construct one — parse and
// transpile failures are build-time bugs in deno's own .ts internals (or
// in our runtime.js, if we ever change its extension), so they panic.
//
// This replaces a call to `deno_runtime::transpile::maybe_transpile_source`
// from `deno_runtime 0.198.0`. The original is more general (handles
// `node:` modules, `.mjs`, emits source maps in debug builds, plumbs
// errors via `JsErrorBox`); none of that surface is reachable in our
// build. Dropping the `deno_runtime` dep eliminates a
// `deno_cache → rusqlite → libsqlite3-sys 0.35` transitive chain that
// collides with sqlx-sqlite's `libsqlite3-sys 0.30` (cargo's
// `links = "sqlite3"` rule).
fn maybe_transpile_source(
name: ModuleName,
source: ModuleCodeString,
) -> Result<(ModuleCodeString, Option<SourceMapData>), JsErrorBox> {
let media_type = MediaType::from_path(Path::new(&name));
match media_type {
MediaType::JavaScript => return Ok((source, None)),
MediaType::TypeScript => {}
_ => panic!("unexpected media type {media_type:?} for {name} during snapshot build"),
}
let parsed = deno_ast::parse_module(ParseParams {
specifier: deno_core::url::Url::parse(&name).unwrap(),
text: source.into(),
media_type,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})
.unwrap_or_else(|e| panic!("snapshot transpile: parse failed for {name}: {e}"));
let transpiled = parsed
.transpile(
&deno_ast::TranspileOptions {
imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Remove,
..Default::default()
},
&deno_ast::TranspileModuleOptions::default(),
&deno_ast::EmitOptions::default(),
)
.unwrap_or_else(|e| panic!("snapshot transpile: emit failed for {name}: {e}"))
.into_source();
Ok((transpiled.text.into(), None))
}
fn main() {
println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap());
println!("cargo:rustc-env=PROFILE={}", env::var("PROFILE").unwrap());
let exts = vec![
deno_telemetry::deno_telemetry::init_ops_and_esm(),
deno_webidl::deno_webidl::init_ops_and_esm(),
deno_url::deno_url::init_ops_and_esm(),
deno_console::deno_console::init_ops_and_esm(),
deno_web::deno_web::init_ops_and_esm::<PermissionsContainer>(
Arc::new(BlobStore::default()),
None,
),
deno_fetch::deno_fetch::init_ops_and_esm::<PermissionsContainer>(Default::default()),
deno_net::deno_net::init_ops_and_esm::<PermissionsContainer>(None, None),
fetch::init_ops_and_esm(),
deno_telemetry::deno_telemetry::init(),
deno_webidl::deno_webidl::init(),
deno_url::deno_url::init(),
deno_console::deno_console::init(),
deno_web::deno_web::init::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
deno_fetch::deno_fetch::init::<PermissionsContainer>(Default::default()),
deno_net::deno_net::init::<PermissionsContainer>(None, None),
fetch::init(),
];
// Build the file path to the snapshot.
@@ -105,7 +202,7 @@ fn main() {
cargo_manifest_dir: env!("CARGO_MANIFEST_DIR"),
startup_snapshot: None,
extension_transpiler: Some(std::rc::Rc::new(|specifier, source| {
deno_runtime::transpile::maybe_transpile_source(specifier, source)
maybe_transpile_source(specifier, source)
})),
extensions: exts,
with_runtime_cb: None,
+74 -19
View File
@@ -15,6 +15,9 @@
mod dedicated;
pub use dedicated::{ExecutingIsolate, PrewarmedIsolate, PrewarmedResult};
#[cfg(test)]
mod smoke_tests;
use std::{
borrow::Cow,
cell::RefCell,
@@ -47,6 +50,28 @@ use windmill_common::error::Error;
use windmill_common::result_stream::append_result_stream_db;
use windmill_common::worker::{write_file, Connection, WINDMILL_DIR};
// ── Snapshot-matched extensions ──────────────────────────────────────
//
// `deno_core` 0.352 validates that the snapshot's extension list is a
// *prefix* of the runtime's extension list (snapshot does not need an
// exact match — runtime is allowed to add extensions at the tail, but
// must not reorder or omit any that the snapshot baked in).
//
// Our snapshot (in build.rs) is the same eight deno_* extensions ending
// with this local `fetch` ext. The runtime adds one extra entry at the
// end — the windmill `ext` carrying our own ops — which is fine because
// it's after the snapshot prefix.
//
// This local `fetch` extension declaration must be present in both
// build.rs and lib.rs so the type passes through the `init()` macro.
// The ESM is already in the snapshot, so this `init()` call at runtime
// is a no-op for esm — the registration just records the ext.
deno_core::extension!(
fetch,
esm_entry_point = "ext:fetch/src/runtime.js",
esm = ["src/runtime.js"],
);
// ── Permission container ─────────────────────────────────────────────
pub struct PermissionsContainer;
@@ -64,11 +89,31 @@ impl FetchPermissions for PermissionsContainer {
#[inline(always)]
fn check_read<'a>(
&mut self,
_resolved: bool,
p: &'a std::path::Path,
path: Cow<'a, std::path::Path>,
_api_name: &str,
) -> Result<Cow<'a, std::path::Path>, deno_io::fs::FsError> {
Ok(Cow::Borrowed(p))
_get_path: &'a dyn deno_fs::GetPath,
) -> Result<deno_fs::CheckedPath<'a>, deno_io::fs::FsError> {
Ok(deno_fs::CheckedPath::Unresolved(path))
}
#[inline(always)]
fn check_write<'a>(
&mut self,
path: Cow<'a, std::path::Path>,
_api_name: &str,
_get_path: &'a dyn deno_fs::GetPath,
) -> Result<deno_fs::CheckedPath<'a>, deno_io::fs::FsError> {
Ok(deno_fs::CheckedPath::Unresolved(path))
}
#[inline(always)]
fn check_net_vsock(
&mut self,
_cid: u32,
_port: u32,
_api_name: &str,
) -> Result<(), deno_permissions::PermissionCheckError> {
Ok(())
}
}
@@ -80,17 +125,17 @@ impl TimersPermission for PermissionsContainer {
}
impl NetPermissions for PermissionsContainer {
fn check_read<'a>(
fn check_read(
&mut self,
p: &'a str,
p: &str,
_api_name: &str,
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
Ok(PathBuf::from(p))
}
fn check_write<'a>(
fn check_write(
&mut self,
p: &'a str,
p: &str,
_api_name: &str,
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
Ok(PathBuf::from(p))
@@ -106,10 +151,19 @@ impl NetPermissions for PermissionsContainer {
fn check_write_path<'a>(
&mut self,
p: &'a std::path::Path,
p: Cow<'a, std::path::Path>,
_api_name: &str,
) -> Result<std::borrow::Cow<'a, std::path::Path>, deno_permissions::PermissionCheckError> {
Ok(Cow::Borrowed(p))
) -> Result<Cow<'a, std::path::Path>, deno_permissions::PermissionCheckError> {
Ok(p)
}
fn check_vsock(
&mut self,
_cid: u32,
_port: u32,
_api_name: &str,
) -> Result<(), deno_permissions::PermissionCheckError> {
Ok(())
}
}
@@ -381,7 +435,7 @@ pub(crate) fn create_nativets_runtime(
let fetch_options = deno_fetch::Options {
root_cert_store_provider: None,
user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()),
proxy: ann.proxy.map(|x| deno_tls::Proxy {
proxy: ann.proxy.map(|x| deno_tls::Proxy::Http {
url: x.0,
basic_auth: x
.1
@@ -391,13 +445,14 @@ pub(crate) fn create_nativets_runtime(
};
let exts: Vec<Extension> = vec![
deno_telemetry::deno_telemetry::init_ops(),
deno_webidl::deno_webidl::init_ops(),
deno_url::deno_url::init_ops(),
deno_console::deno_console::init_ops(),
deno_web::deno_web::init_ops::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
deno_fetch::deno_fetch::init_ops::<PermissionsContainer>(fetch_options),
deno_net::deno_net::init_ops::<PermissionsContainer>(None, None),
deno_telemetry::deno_telemetry::init(),
deno_webidl::deno_webidl::init(),
deno_url::deno_url::init(),
deno_console::deno_console::init(),
deno_web::deno_web::init::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
deno_fetch::deno_fetch::init::<PermissionsContainer>(fetch_options),
deno_net::deno_net::init::<PermissionsContainer>(None, None),
fetch::init(),
ext,
];
@@ -0,0 +1,280 @@
//! Opt-in smoke tests for the nativets V8 runtime.
//!
//! Exercise the deno_core / deno_ast / swc surface (TypeScript transpile,
//! fetch, timers, URL, structuredClone, error propagation, concurrent
//! isolates, large payload roundtrip) that the standard worker-level
//! nativets tests in `backend/tests/worker.rs` don't reach — those tests
//! validate value passing through the job queue, but not the JS API
//! surface a deno_core bump would actually move.
//!
//! These tests are `#[ignore]`'d so the regular `cargo test` flow doesn't
//! pay their cost (each spawns a V8 isolate; some hit the network). Run
//! when changing the `deno_core` / `deno_ast` / `deno_runtime` / `swc_*`
//! pins in `backend/Cargo.toml`:
//!
//! cargo test -p windmill-runtime-nativets smoke -- --ignored
//!
//! Tests prefixed `smoke_net_` hit the public internet (httpbin.org,
//! example.com) and will fail if the runner has no egress. Skip them
//! locally with `cargo test -p windmill-runtime-nativets smoke -- --ignored --skip smoke_net_`.
use crate::{transpile_ts, NativeAnnotation, PrewarmedIsolate, PrewarmedResult};
/// Compile a TS snippet, run it through a fresh isolate with the given
/// positional args, and return the isolate's result + captured logs.
async fn run_ts(ts: &str, arg_names: &[&str], args: serde_json::Value) -> PrewarmedResult {
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
let ann = NativeAnnotation { useragent: None, proxy: None };
let arg_names: Vec<String> = arg_names.iter().map(|s| s.to_string()).collect();
let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, arg_names, None);
iso.wait_ready().await.expect("isolate failed to pre-warm");
iso.start_execution(args.to_string())
.wait()
.await
.expect("isolate execution panicked")
}
fn unwrap_value(r: &PrewarmedResult) -> serde_json::Value {
let raw = r.result.as_ref().expect("script returned an error");
serde_json::from_str(raw.get()).expect("result not valid JSON")
}
// -----------------------------------------------------------------------------
// Local (no network) — these still need V8 / deno_core ops to be wired.
// -----------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_basic_value_passing() {
let ts = r#"
export async function main(x: number): Promise<number> {
return x + 1;
}
"#;
let r = run_ts(ts, &["x"], serde_json::json!({"x": 41})).await;
assert_eq!(unwrap_value(&r), serde_json::json!(42));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_transpile_enum_and_union() {
// Enums + discriminated union + as-cast exercise the swc_ecma_ast +
// swc_ecma_parser TS-syntax paths the bare value tests don't.
let ts = r#"
enum Direction { Up = "U", Down = "D" }
type Msg = { kind: "move"; dir: Direction } | { kind: "stop" };
export async function main(): Promise<string> {
const msgs: Msg[] = [
{ kind: "move", dir: Direction.Up },
{ kind: "stop" },
{ kind: "move", dir: Direction.Down },
];
return msgs.map(m => m.kind === "move" ? m.dir : "_").join(",");
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(unwrap_value(&r), serde_json::json!("U,_,D"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_set_timeout_and_promise_all() {
// setTimeout lives in deno_web; Promise.all hits the V8 microtask
// queue. A bump that breaks timer-op registration or microtask drain
// would surface here (script would hang or return wrong order).
let ts = r#"
export async function main(): Promise<number[]> {
const delays = [40, 10, 20, 30];
return await Promise.all(delays.map(d =>
new Promise<number>(resolve => setTimeout(() => resolve(d), d))
));
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
// Promise.all preserves input order regardless of resolution order.
assert_eq!(unwrap_value(&r), serde_json::json!([40, 10, 20, 30]));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_url_and_searchparams() {
// deno_url surface: URL ctor, URLSearchParams parsing + iteration.
let ts = r#"
export async function main(): Promise<{ host: string; pairs: [string, string][] }> {
const u = new URL("https://example.com:8443/path?b=2&a=1&a=3");
const pairs: [string, string][] = [];
for (const [k, v] of u.searchParams) pairs.push([k, v]);
return { host: u.host, pairs };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({
"host": "example.com:8443",
"pairs": [["b", "2"], ["a", "1"], ["a", "3"]],
}),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_blob_btoa_atob() {
// deno_web surface: Blob, atob/btoa. `structuredClone` is *not* wired
// into the nativets global (the deno_web binding doesn't expose it
// here) — if that's ever changed, extend this test to cover it.
let ts = r#"
export async function main(): Promise<{ b64: string; round_trip: string; size: number }> {
const blob = new Blob(["hello"], { type: "text/plain" });
const b64 = btoa("hello");
const round_trip = atob(b64);
return { b64, round_trip, size: blob.size };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({
"b64": "aGVsbG8=",
"round_trip": "hello",
"size": 5,
}),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_large_payload_roundtrip() {
// ~512 KB string in and out — exercises arg encoding + result
// serialization through the deno_core <-> host op boundary at sizes
// an op-table change could break.
let big_in: String = "a".repeat(512 * 1024);
let ts = r#"
export async function main(s: string): Promise<{ in_len: number; out: string }> {
if (typeof s !== "string") throw new Error(`expected string, got ${typeof s}`);
return { in_len: s.length, out: "b".repeat(512 * 1024) };
}
"#;
let r = run_ts(ts, &["s"], serde_json::json!({"s": big_in})).await;
let v = unwrap_value(&r);
assert_eq!(v.get("in_len").and_then(|x| x.as_u64()), Some(512 * 1024));
let out_len = v
.get("out")
.and_then(|x| x.as_str())
.map(|s| s.len())
.unwrap_or(0);
assert_eq!(out_len, 512 * 1024);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_error_propagation_with_message() {
// Throwing a typed Error must surface as PrewarmedResult::Err with
// the original message. A deno_core bump that changes the host-side
// error wrapping would lose this contract.
let ts = r#"
export async function main(): Promise<void> {
throw new Error("nativets_smoke_marker_xyz");
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let err = r.result.expect_err("expected script to fail");
assert!(
err.contains("nativets_smoke_marker_xyz"),
"thrown error message did not reach result: {err}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_concurrent_isolates() {
// Spawn N isolates in parallel from the same tokio runtime. Each
// PrewarmedIsolate uses spawn_blocking + a fresh V8 isolate.
// Catches isolate-setup races (V8_ISOLATE_CREATE_LOCK ordering) and
// any per-isolate state that a deno_core bump could break under
// concurrency.
let ts = r#"
export async function main(i: number): Promise<number> {
return i * 10;
}
"#;
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
const N: i64 = 8;
let mut handles = Vec::with_capacity(N as usize);
for i in 0..N {
let js = js.clone();
let h = tokio::spawn(async move {
let ann = NativeAnnotation { useragent: None, proxy: None };
let mut iso =
PrewarmedIsolate::spawn(String::new(), js, ann, vec!["i".to_string()], None);
iso.wait_ready().await.expect("pre-warm failed");
let res = iso
.start_execution(serde_json::json!({"i": i}).to_string())
.wait()
.await
.expect("isolate panicked");
res.result.expect("script errored")
});
handles.push(h);
}
let mut got: Vec<i64> = Vec::with_capacity(N as usize);
for h in handles {
let raw = h.await.expect("join failed");
let v: serde_json::Value = serde_json::from_str(raw.get()).expect("not JSON");
got.push(v.as_i64().unwrap_or(-1));
}
got.sort();
let expected: Vec<i64> = (0..N).map(|i| i * 10).collect();
assert_eq!(got, expected);
}
// -----------------------------------------------------------------------------
// Network — actually exercise deno_fetch end-to-end. Skip in air-gapped CI
// with `--skip smoke_net_`.
// -----------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke (network); run with --ignored"]
async fn smoke_net_fetch_example_com() {
// example.com is one of the most stable hosts on the internet and
// returns a tiny known-text body, so we can both assert "fetch works"
// and "the response body parses correctly through deno_fetch".
let ts = r#"
export async function main(): Promise<{ status: number; has_marker: boolean }> {
const r = await fetch("https://example.com/");
const body = await r.text();
return { status: r.status, has_marker: body.includes("Example Domain") };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let v = unwrap_value(&r);
assert_eq!(v.get("status").and_then(|x| x.as_u64()), Some(200));
assert_eq!(v.get("has_marker"), Some(&serde_json::json!(true)));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke (network); run with --ignored"]
async fn smoke_net_fetch_json_and_headers() {
// httpbin.org/anything echoes request metadata back as JSON, so we
// can verify: deno_fetch sends custom headers, parses JSON response,
// and propagates query params end-to-end.
let ts = r#"
export async function main(): Promise<{ ua: string; arg: string }> {
const r = await fetch("https://httpbin.org/anything?nativets=ok", {
headers: { "x-windmill-smoke": "1" },
});
if (!r.ok) throw new Error(`status ${r.status}`);
const j: any = await r.json();
return {
ua: j.headers["X-Windmill-Smoke"] ?? "",
arg: j.args.nativets ?? "",
};
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let v = unwrap_value(&r);
assert_eq!(v.get("ua").and_then(|x| x.as_str()), Some("1"));
assert_eq!(v.get("arg").and_then(|x| x.as_str()), Some("ok"));
}
+33 -2
View File
@@ -1238,10 +1238,39 @@ async fn delete_resources_bulk(
.await?;
if let Some(res_data) = trash_resource {
// Per-resource linked vars so each resource's trash entry carries
// exactly the variables that vanished with it (matching the
// single-delete shape: trash_data["linked_variables"]).
let mut this_linked: Vec<String> = Vec::new();
if let Some(value) = res_data.get("value") {
collect_var_refs(value, &mut linked_var_paths);
collect_var_refs(value, &mut this_linked);
}
this_linked.sort();
this_linked.dedup();
let trash_linked_vars: Vec<serde_json::Value> = if this_linked.is_empty() {
Vec::new()
} else {
let placeholders: Vec<String> = this_linked
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 2))
.collect();
let query = format!(
"SELECT to_jsonb(t) FROM variable t WHERE workspace_id = $1 AND path IN ({})",
placeholders.join(", ")
);
let mut q = sqlx::query_scalar::<_, serde_json::Value>(&query).bind(&w_id);
for var_path in &this_linked {
q = q.bind(var_path);
}
q.fetch_all(&mut *tx).await?
};
let mut trash_data = serde_json::json!({"row": res_data});
if !trash_linked_vars.is_empty() {
trash_data["linked_variables"] = serde_json::Value::Array(trash_linked_vars);
}
let trash_data = serde_json::json!({"row": res_data});
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
@@ -1251,6 +1280,8 @@ async fn delete_resources_bulk(
&authed.username,
)
.await?;
linked_var_paths.extend(this_linked);
}
}
linked_var_paths.sort();
+25
View File
@@ -755,6 +755,17 @@ async fn delete_variables_bulk(
)
.fetch_all(&mut *tx)
.await?;
// Mirror single delete_variable: clean the linked-resource ws_specific
// markers BEFORE deleting the resource rows so they don't survive as
// orphans. A resource later created at the same path would otherwise
// inherit a stale ws_specific flag.
sqlx::query!(
"DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = ANY($2)",
w_id,
&deleted_paths
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2",
&deleted_paths,
@@ -1019,6 +1030,20 @@ async fn update_variable(
)
.execute(&mut *tx)
.await?;
// The linked resource at the same path is renamed above; move
// its ws_specific 'resource' marker too so an explicitly-flagged
// resource doesn't lose its ws_specific status on rename and
// doesn't leave a stale marker at the old path. Symmetric with
// update_resource's rename block.
sqlx::query!(
"UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'resource' AND path = $3",
npath,
w_id,
path
)
.execute(&mut *tx)
.await?;
}
}
-3
View File
@@ -1,10 +1,7 @@
// AI executor module structure
// This module will contain all AI-related execution logic
pub mod image_handler;
pub mod providers;
pub mod query_builder;
pub mod sse;
pub mod tools;
pub mod types;
pub mod utils;
@@ -1,16 +1,16 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use windmill_ai::{ai_google::parse_data_url, ai_providers::AIProvider};
use windmill_common::{client::AuthedClient, error::Error};
use crate::ai::{
use windmill_ai::{
ai_google::parse_data_url,
ai_providers::AIProvider,
image_handler::prepare_messages_for_api,
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
sse::{AnthropicSSEParser, SSEParser},
types::*,
utils::{extract_text_content, should_use_structured_output_tool},
};
use windmill_common::{client::AuthedClient, error::Error};
/// Anthropic API version for standard API
const ANTHROPIC_VERSION_STANDARD: &str = "2023-06-01";
@@ -6,25 +6,22 @@
//! - Stream event parsing
//! - Helper utilities
use crate::ai::{
use std::collections::HashMap;
use windmill_ai::{
image_handler::prepare_messages_for_api,
query_builder::{ParsedResponse, StreamEventSink},
types::StreamingEvent,
types::TokenUsage,
types::{OpenAIMessage, ToolDef},
types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef},
};
use std::collections::HashMap;
use windmill_common::{client::AuthedClient, error::Error};
// Re-export from shared module for use by other parts of the worker
// Import shared Bedrock helpers for worker-specific orchestration.
use windmill_ai::ai_bedrock::{
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta,
bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config,
format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai,
StreamingToolCall,
BedrockClient, StreamingToolCall,
};
pub use windmill_ai::ai_bedrock::{check_env_credentials, BedrockClient};
// ============================================================================
// Query Builder (Worker-specific orchestration)
@@ -1,17 +1,16 @@
use async_trait::async_trait;
use windmill_ai::ai_google::{
openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig, GeminiImageContent,
GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent,
GeminiTextRequest, GeminiTool,
};
use windmill_common::{client::AuthedClient, error::Error};
use crate::ai::{
use windmill_ai::{
ai_google::{
openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig,
GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart,
GeminiPredictContent, GeminiTextRequest, GeminiTool,
},
image_handler::{download_and_encode_s3_image, prepare_messages_for_api},
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
sse::{GeminiSSEParser, SSEParser},
types::*,
};
use windmill_common::{client::AuthedClient, error::Error};
// ============================================================================
// Query Builder Implementation
@@ -1,17 +1,16 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use windmill_ai::ai_providers::AIProvider;
use windmill_ai::ai_types::OpenAIToolCall;
use windmill_common::{client::AuthedClient, error::Error};
use crate::ai::{
use windmill_ai::{
ai_providers::AIProvider,
ai_types::OpenAIToolCall,
image_handler::{prepare_messages_for_api, s3_object_to_content_part},
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
sse::{OpenAIResponsesSSEParser, SSEParser},
types::*,
utils::extract_text_content,
};
use windmill_common::{client::AuthedClient, error::Error};
// Responses API structures
#[derive(Deserialize)]
@@ -1,15 +1,15 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json;
use windmill_ai::ai_providers::AIProvider;
use windmill_common::{client::AuthedClient, error::Error};
use crate::ai::{
use windmill_ai::{
ai_providers::AIProvider,
image_handler::prepare_messages_for_api,
providers::other::OtherQueryBuilder,
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
types::*,
};
use windmill_common::{client::AuthedClient, error::Error};
use crate::ai::providers::other::OtherQueryBuilder;
// OpenRouter-specific types
#[derive(Serialize)]
@@ -1,16 +1,15 @@
use async_trait::async_trait;
use serde::Serialize;
use serde_json;
use windmill_ai::ai_providers::AIProvider;
use windmill_common::{client::AuthedClient, error::Error};
use crate::ai::{
use windmill_ai::{
ai_providers::AIProvider,
image_handler::prepare_messages_for_api,
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
sse::{OpenAISSEParser, SSEParser},
types::*,
utils::should_use_structured_output_tool,
};
use windmill_common::{client::AuthedClient, error::Error};
#[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "lowercase")]
@@ -1,24 +1,19 @@
use async_trait::async_trait;
use windmill_ai::{
query_builder::{QueryBuilder, StreamEventSink},
types::*,
};
use windmill_common::{error::Error, worker::Connection};
use windmill_queue::MiniPulledJob;
use crate::{
ai::{
providers::{
anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder,
openai::OpenAIQueryBuilder, openrouter::OpenRouterQueryBuilder,
other::OtherQueryBuilder,
},
types::*,
ai::providers::{
anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder,
openai::OpenAIQueryBuilder, openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder,
},
job_logger::append_result_stream,
};
// Re-export from windmill_ai
pub use windmill_ai::query_builder::{
BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink,
};
/// Factory function to create the appropriate query builder for a provider
pub fn create_query_builder(provider: &ProviderWithResource) -> Box<dyn QueryBuilder> {
use windmill_ai::ai_providers::AIProvider;
+2 -4
View File
@@ -1,6 +1,4 @@
use crate::ai::query_builder::{StreamEventProcessor, StreamEventSink};
use crate::ai::types::McpToolSource;
use crate::ai::types::*;
use crate::ai::query_builder::StreamEventProcessor;
use crate::ai::utils::{
add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow,
is_completed_input_transform, update_flow_status_module_with_actions,
@@ -20,7 +18,7 @@ use mappable_rc::Marc;
use serde_json::value::RawValue;
use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
use windmill_ai::ai_types::OpenAIToolCall;
use windmill_ai::{ai_types::OpenAIToolCall, query_builder::StreamEventSink, types::*};
use windmill_common::jobs::JobPayload;
#[cfg(feature = "mcp")]
-2
View File
@@ -1,2 +0,0 @@
// Re-export all types from windmill_ai::types
pub use windmill_ai::types::*;
+2 -27
View File
@@ -1,5 +1,3 @@
pub use crate::ai::types::McpToolSource;
use crate::ai::types::ToolDef;
use anyhow::Context;
use serde_json::value::RawValue;
use sqlx::types::Json;
@@ -8,7 +6,7 @@ use std::{
sync::Arc,
};
use uuid::Uuid;
use windmill_ai::ai_providers::AIProvider;
use windmill_ai::types::*;
use windmill_common::flows::FlowModuleValue;
use windmill_common::{
db::DB,
@@ -24,7 +22,7 @@ use windmill_common::{
use windmill_mcp::{McpClient, McpResource, McpTool};
use windmill_queue::{flow_status::get_step_of_flow_status, MiniPulledJob};
use crate::{ai::types::*, parse_sig_of_lang};
use crate::parse_sig_of_lang;
pub fn parse_raw_script_schema(
content: &str,
@@ -323,11 +321,6 @@ pub fn get_step_name_from_flow(
)
}
/// AWS Bedrock do not handle structured output query param, so we use a tool for structured output. Same for every Claude models.
pub fn should_use_structured_output_tool(provider: &AIProvider, model: &str) -> bool {
model.contains("claude") || provider == &AIProvider::AWSBedrock
}
/// Cleanup MCP clients by gracefully shutting down connections
#[cfg(feature = "mcp")]
pub async fn cleanup_mcp_clients(mcp_clients: HashMap<String, Arc<McpClient>>) {
@@ -713,21 +706,3 @@ pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
false
})
}
/// Extract text content from OpenAIContent, joining parts with space if multiple
pub fn extract_text_content(content: &OpenAIContent) -> String {
match content {
OpenAIContent::Text(text) => text.clone(),
OpenAIContent::Parts(parts) => parts
.iter()
.filter_map(|p| {
if let ContentPart::Text { text } = p {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join(""),
}
}
+15 -41
View File
@@ -1,12 +1,10 @@
#[cfg(feature = "bedrock")]
use crate::ai::providers::bedrock::check_env_credentials;
use crate::ai::tools::{execute_tool_calls, ToolAbortHandles, ToolExecutionContext};
use crate::ai::utils::{
add_message_to_conversation, any_tool_needs_previous_result, cleanup_mcp_clients,
filter_schema_by_input_transforms, find_unique_tool_name, get_flow_context,
get_flow_job_runnable_and_raw_flow, get_step_name_from_flow, load_mcp_tools,
parse_raw_script_schema, should_use_structured_output_tool,
update_flow_status_module_with_actions, update_flow_status_module_with_actions_success,
parse_raw_script_schema, update_flow_status_module_with_actions,
update_flow_status_module_with_actions_success,
};
use crate::memory_oss::{read_from_memory, write_to_memory};
use crate::worker_flow::{get_previous_job_result, get_transform_context};
@@ -15,12 +13,20 @@ use regex::Regex;
use serde_json::value::RawValue;
use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
#[cfg(feature = "bedrock")]
use windmill_ai::ai_bedrock::check_env_credentials;
#[cfg(feature = "mcp")]
use windmill_mcp::McpClient;
#[cfg(not(feature = "mcp"))]
use crate::ai::tools::McpClientStub as McpClient;
use windmill_ai::ai_providers::AIProvider;
use windmill_ai::{
ai_providers::AIProvider,
image_handler::upload_image_to_s3,
query_builder::{BuildRequestArgs, ParsedResponse},
types::*,
utils::{should_use_structured_output_tool, AI_HTTP_HEADERS},
};
use windmill_common::{
cache,
client::AuthedClient,
@@ -38,13 +44,7 @@ use windmill_common::{
use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob};
use crate::{
ai::{
image_handler::upload_image_to_s3,
query_builder::{
create_query_builder, BuildRequestArgs, ParsedResponse, StreamEventProcessor,
},
types::*,
},
ai::query_builder::{create_query_builder, StreamEventProcessor},
common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier},
handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome},
};
@@ -52,33 +52,6 @@ use crate::{
lazy_static::lazy_static! {
static ref TOOL_NAME_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_]+$").unwrap();
/// Parse AI_HTTP_HEADERS environment variable into a vector of (header_name, header_value) tuples
/// Format: "header1: value1, header2: value2"
static ref AI_HTTP_HEADERS: Vec<(String, String)> = {
std::env::var("AI_HTTP_HEADERS")
.ok()
.map(|headers_str| {
headers_str
.split(',')
.filter_map(|header| {
let parts: Vec<&str> = header.splitn(2, ':').collect();
if parts.len() == 2 {
let name = parts[0].trim().to_string();
let value = parts[1].trim().to_string();
if !name.is_empty() && !value.is_empty() {
Some((name, value))
} else {
None
}
} else {
None
}
})
.collect()
})
.unwrap_or_default()
};
static ref AI_AGENT_TOOL_SCHEMA: Box<RawValue> = to_raw_value(&serde_json::json!({
"type": "object",
"properties": {
@@ -791,7 +764,7 @@ pub async fn run_agent(
let mut actions = vec![];
let mut content = None;
let mut final_usage: Option<crate::ai::types::TokenUsage> = None;
let mut final_usage: Option<TokenUsage> = None;
// Check if this provider supports tools with the current output type
let supports_tools = query_builder.supports_tools_with_output_type(output_type);
@@ -1231,7 +1204,8 @@ pub async fn run_agent(
}
ParsedResponse::Image { base64_data } => {
// For image output, upload to S3 and track in conversation
let s3_object = upload_image_to_s3(&base64_data, job, client).await?;
let s3_object =
upload_image_to_s3(&base64_data, &job.workspace_id, &job.id, client).await?;
let content = to_raw_value(&s3_object);
+1 -1
View File
@@ -1,5 +1,5 @@
use crate::ai::types::OpenAIMessage;
use uuid::Uuid;
use windmill_ai::types::OpenAIMessage;
use windmill_common::{db::DB, error::Error};
pub const MAX_MEMORY_SIZE_BYTES: usize = 100_000; // 100KB per memory entry in database
+3 -1
View File
@@ -3,7 +3,9 @@
pub use crate::memory_ee::*;
#[cfg(not(all(feature = "private", feature = "enterprise")))]
use {crate::ai::types::OpenAIMessage, crate::memory_common, uuid::Uuid, windmill_common::db::DB};
use {
crate::memory_common, uuid::Uuid, windmill_ai::types::OpenAIMessage, windmill_common::db::DB,
};
/// Read AI agent memory from storage
/// In OSS: always reads from database
File diff suppressed because one or more lines are too long
+69 -8
View File
@@ -23,6 +23,58 @@ windmill-worker → windmill-ai
windmill-common does **NOT** re-export from windmill-ai (would be circular). All consumers update imports.
## Reviewer Note: Keep the Next PR Small
The first merged PR established the crate boundary; it did not yet remove the duplicated API-vs-worker provider paths. The remaining work should stay split by dependency risk, not by the final desired module layout.
Do not jump directly from the current state to provider moves, proxy unification, and credential unification in one PR. The riskiest part is the API proxy because it combines request transformation, endpoint selection, auth headers, custom headers, OAuth user injection, Azure URL handling, Anthropic Vertex handling, Bedrock SDK calls, and SSE keepalive behavior.
Pull the shared plumbing forward before moving provider implementations:
- Move tiny shared utilities first, including `AI_HTTP_HEADERS`, `extract_text_content`, and `should_use_structured_output_tool`.
- Move SSE parsers next, using the existing `StreamEventSink` abstraction, and update callers to import from `windmill_ai` directly.
- Leave provider implementations, image upload/download handling, API proxy changes, and credential unification out of that PR.
Avoid adding modules whose only purpose is to re-export moved code. Direct imports from `windmill_ai` make ownership and dependency direction clearer at each call site.
Also do not make `build_proxy_request(raw_body, path)` too narrow. The proxy path needs method, incoming headers, resolved credentials, base URL/platform, organization/user fields, custom headers, and Bedrock/Azure/Vertex-specific context. Introduce a structured `ProxyBuildArgs`/`ProviderCredentials` shape before deleting `AIRequestConfig::prepare_request`, `google.rs`, or `bedrock.rs`.
## Next Phase PR: Shared Plumbing Only
Goal: make `windmill-ai` own the provider-independent helper code that later provider moves will need, without changing API proxy behavior or agent request behavior.
Suggested PR title: `refactor(ai): move shared SSE plumbing into windmill-ai`.
Scope:
- Add `windmill-ai/src/utils.rs`.
- Move the duplicated `AI_HTTP_HEADERS` parsing into `windmill_ai::utils` with identical parsing behavior.
- Move `extract_text_content` and `should_use_structured_output_tool` from `windmill-worker/src/ai/utils.rs` to `windmill_ai::utils`.
- Move `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`.
- Delete `windmill-worker/src/ai/sse.rs` and update callers to import parser types from `windmill_ai::sse`.
- Update callers of moved utility functions to import from `windmill_ai::utils` directly.
- Add the minimal new `windmill-ai` dependencies required by `sse.rs` (`eventsource-stream`, `tokio-stream`) and avoid adding worker/queue dependencies.
Out of scope:
- Do not move provider implementations.
- Do not move `image_handler`.
- Do not change `QueryBuilder` method signatures.
- Do not add `build_proxy_request`.
- Do not change API proxy routing, request preparation, credential resolution, audit logging, cache behavior, or Bedrock/Google special cases.
- Do not remove `windmill-api/src/google.rs`, `windmill-api/src/bedrock.rs`, or `AIRequestConfig::prepare_request`.
Implementation checklist:
1. Add `utils.rs` to `windmill-ai` and export it from `lib.rs`.
2. Move `AI_HTTP_HEADERS` exactly once, then update `windmill-api/src/ai.rs` and `windmill-worker/src/ai_executor.rs` to import it.
3. Move the two provider-independent helper functions into `windmill_ai::utils`; leave worker-specific flow/MCP/conversation utilities in `windmill-worker/src/ai/utils.rs`.
4. Move `sse.rs` into `windmill-ai`, change imports from `crate::ai::{query_builder, types}` to `crate::{query_builder, types}`, and keep behavior unchanged.
5. Remove worker `ai/sse.rs` and update provider imports to use `windmill_ai::sse` directly.
6. Run focused grep checks for duplicate `AI_HTTP_HEADERS`, old local helper definitions, and accidental `windmill_queue`/worker dependencies from `windmill-ai`.
7. Validate with `cargo check -p windmill-ai`, `cargo check -p windmill-worker`, and `cargo check -p windmill-api`. For `bedrock` builds, also check the existing bedrock feature path.
Review expectations:
- The diff should be mostly moved code and import updates.
- The behavior should be byte-for-byte equivalent where practical.
- Tests are only needed if helper behavior changes. For a pure move, existing backend checks plus manual AI streaming verification are enough.
## Step-by-Step Plan
Each step produces a compiling, working backend.
@@ -131,18 +183,27 @@ This is the key unification step. Add a new method to the `QueryBuilder` trait:
/// Used by the API chat proxy. Handles format conversion for non-OpenAI providers.
fn build_proxy_request(
&self,
raw_body: &[u8],
path: &str,
args: &ProxyBuildArgs<'_>,
) -> Result<ProxyRequest, Error>;
```
Where `ProxyRequest` contains the transformed body, endpoint URL, and auth headers:
Where `ProxyBuildArgs` carries the API proxy context that provider implementations need:
```rust
pub struct ProxyBuildArgs<'a> {
pub method: http::Method,
pub path: &'a str,
pub headers: &'a http::HeaderMap,
pub body: &'a [u8],
pub credentials: &'a ProviderCredentials,
}
```
And `ProxyRequest` contains the transformed request:
```rust
pub struct ProxyRequest {
pub url: String,
pub body: Vec<u8>,
pub auth_headers: Vec<(String, String)>,
pub is_sse: bool,
pub headers: Vec<(String, String)>,
}
```
@@ -153,9 +214,9 @@ pub struct ProxyRequest {
- **Bedrock**: Convert OpenAI format → Bedrock SDK calls. Replaces `windmill-api/src/bedrock.rs`.
**Refactor API proxy** (`windmill-api/src/ai.rs`):
1. Parse provider from headers, resolve credentials → `ProviderWithResource`
1. Parse provider from headers, resolve credentials → `ProviderCredentials`
2. Create `QueryBuilder` via `create_query_builder`
3. Call `query_builder.build_proxy_request(body, path)``ProxyRequest`
3. Call `query_builder.build_proxy_request(&proxy_args)``ProxyRequest`
4. Send the request, return response with SSE keepalive injection
**Remove** from windmill-api:
@@ -166,7 +227,7 @@ pub struct ProxyRequest {
- `supports_native_fim`, `transform_fim_to_chat_completions` — moved to windmill-ai
**Keep** in API:
- `AIRequestConfig::new` credential resolution (or refactor to produce `ProviderWithResource`)
- `AIRequestConfig::new` credential resolution until it is refactored to produce `ProviderCredentials`
- HTTP routes, audit logging, request caching
- `inject_keepalives`, `is_sse_response` helpers
- `AIConfig`, `ExpiringAIRequestConfig` caching types
+2 -2
View File
@@ -79,11 +79,11 @@
# ---------------------------------------------------------------
rustyV8Archive = let
version = "130.0.7";
version = "137.1.0";
target = stdenv.hostPlatform.rust.rustcTarget;
sha256 = {
x86_64-linux =
"sha256-pkdsuU6bAkcIHEZUJOt5PXdzK424CEgTLXjLtQ80t10=";
"sha256-Tiscfy2bzYGR3s0T+SC1IB3xWvTVpVcSEdjq3MCRoRw=";
aarch64-linux = lib.fakeHash;
x86_64-darwin = lib.fakeHash;
aarch64-darwin = lib.fakeHash;
@@ -7,6 +7,7 @@
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
@@ -103,14 +104,15 @@
class="max-w-lg"
/>
</label>
<label class="flex flex-col gap-1">
<label for="auth0_client_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"
>Client Secret <Tooltip>Client Secret of the auth0 service configuration</Tooltip></span
>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
class="max-w-lg"
<Password
id="auth0_client_secret"
small
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
<CollapseLink text="Instructions">
@@ -14,6 +14,7 @@
import ZitadelSetting from '$lib/components/ZitadelSetting.svelte'
import NextcloudSetting from '$lib/components/NextcloudSetting.svelte'
import CustomOauth from './CustomOauth.svelte'
import Password from './Password.svelte'
import { capitalize, type Item } from '$lib/utils'
import ClipboardPanel from './details/ClipboardPanel.svelte'
import Toggle from './Toggle.svelte'
@@ -297,12 +298,12 @@
<span class="text-primary font-semibold text-xs">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={oauths[k]['id']} />
</label>
<label class="block pb-6">
<label for="{k}_client_secret_sso" class="block pb-6">
<span class="text-primary font-semibold text-xs">Client Secret</span>
<input
type="text"
<Password
id="{k}_client_secret_sso"
placeholder="Client Secret"
bind:value={oauths[k]['secret']}
bind:password={oauths[k]['secret']}
/>
</label>
{#if !windmillBuiltins.includes(k) && k != 'slack'}
@@ -394,9 +395,13 @@
<span class="text-primary font-semibold text-xs">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={oauths[k]['id']} />
</label>
<label>
<label for="{k}_client_secret_oauth">
<span class="text-primary font-semibold text-xs">Client Secret</span>
<input type="text" placeholder="Client Secret" bind:value={oauths[k]['secret']} />
<Password
id="{k}_client_secret_oauth"
placeholder="Client Secret"
bind:password={oauths[k]['secret']}
/>
</label>
{#if k === 'visma' || !windmillBuiltins.includes(k)}
<div class="mb-8">
@@ -1,21 +1,20 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { run } from 'svelte/legacy'
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
value: any;
value: any
}
let { value = $bindable() }: Props = $props();
let { value = $bindable() }: Props = $props()
let org = $state('')
function changeOrg(org) {
if (value) {
value = {
@@ -37,7 +36,7 @@
let enabled = $derived(value != undefined)
run(() => {
changeOrg(org)
});
})
</script>
<div class="flex flex-col gap-1">
@@ -77,11 +76,12 @@
bind:value={value['id']}
/>
</label>
<label class="flex flex-col gap-1">
<label for="authelia_client_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
<Password
id="authelia_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
</SettingCard>
@@ -1,18 +1,16 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { run } from 'svelte/legacy'
import IconedResourceType from './IconedResourceType.svelte'
import Password from './Password.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
value: any;
value: any
}
let { value = $bindable() }: Props = $props();
let { value = $bindable() }: Props = $props()
function changeOrg(org) {
if (value && org) {
@@ -34,10 +32,12 @@
}
let enabled = $derived(value != undefined)
// Initialize org from existing auth_url
let org = $derived(value?.connect_config?.auth_url?.replace('/application/o/authorize/', '') ?? '')
let org = $derived(
value?.connect_config?.auth_url?.replace('/application/o/authorize/', '') ?? ''
)
run(() => {
changeOrg(org)
});
})
</script>
<div class="flex flex-col gap-1">
@@ -71,9 +71,13 @@
<span class="text-emphasis font-semibold text-xs">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
</label>
<label>
<label for="authentik_client_secret">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<Password
id="authentik_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
</SettingCard>
{/if}
@@ -5,6 +5,7 @@
import { json } from 'svelte-highlight/languages'
import { copyToClipboard, parseS3Object, roughSizeOfObject } from '$lib/utils'
import { base } from '$lib/base'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { Button, Drawer, DrawerContent } from './common'
import {
ClipboardCopy,
@@ -173,6 +174,25 @@
let largeObject: boolean | undefined = $state(undefined)
let resultApiPath = $derived(
workspaceId && jobId
? nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
: undefined
)
let resultDownloadHref = $derived(
resultApiPath
? `${base}/api${resultApiPath}`
: `data:text/json;charset=utf-8,${encodeURIComponent(toJsonStr(result))}`
)
let resultDownloadName = $derived(`${filename ?? 'result'}.json`)
async function onResultDownload(e: MouseEvent) {
if (!resultApiPath || !shouldDownloadViaClient()) return
e.preventDefault()
await downloadViaClient(resultApiPath, resultDownloadName)
}
function checkIfS3(result: any, keys: string[]) {
return keys.includes('s3') && typeof result.s3 === 'string'
}
@@ -1001,12 +1021,9 @@
{#if largeObject}
<div class="text-xs text-emphasis"
><a
download="{filename ?? 'result'}.json"
href={workspaceId && jobId
? nodeId
? `${base}/api/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `${base}/api/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
: `data:text/json;charset=utf-8,${encodeURIComponent(toJsonStr(result))}`}
download={resultDownloadName}
href={resultDownloadHref}
onclick={onResultDownload}
>
Download {filename ? '' : 'as JSON'}
</a>
@@ -1074,19 +1091,26 @@
<DrawerContent title="Expanded Result" on:close={jsonViewer.closeDrawer}>
{#snippet actions()}
{#if customUi?.disableDownload !== true}
<Button
download="{filename ?? 'result'}.json"
href={workspaceId && jobId
? nodeId
? `${base}/api/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `${base}/api/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
: `data:text/json;charset=utf-8,${encodeURIComponent(toJsonStr(result))}`}
startIcon={{ icon: Download }}
variant="subtle"
unifiedSize="md"
>
Download
</Button>
{#if resultApiPath && shouldDownloadViaClient()}
<Button
on:click={() => downloadViaClient(resultApiPath!, resultDownloadName)}
startIcon={{ icon: Download }}
variant="subtle"
unifiedSize="md"
>
Download
</Button>
{:else}
<Button
download={resultDownloadName}
href={resultDownloadHref}
startIcon={{ icon: Download }}
variant="subtle"
unifiedSize="md"
>
Download
</Button>
{/if}
{/if}
<Button
on:click={() => copyToClipboard(toJsonStr(result))}
@@ -27,6 +27,7 @@
import { type DurationStatus, type FlowStatusViewerContext, type GraphModuleState } from './graph'
import ModuleStatus from './ModuleStatus.svelte'
import { clone, isScriptPreview, msToSec, readFieldsRecursively, truncateRev } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import JobArgs from './JobArgs.svelte'
import { ChevronDown, Download, ExternalLink, Hourglass } from 'lucide-svelte'
import { deepEqual } from 'fast-equals'
@@ -1838,16 +1839,29 @@
style="min-height: {minTabHeight}px"
>
{#if !hideDownloadLogs && !isReplay && job?.id}
{@const logsApiPath = `/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`}
{@const logsName = `windmill_flow_logs_${job.id}.txt`}
<div class="flex justify-end p-1">
<Button
href="{base}/api/w/{workspace}/jobs_u/get_flow_all_logs/{job.id}"
download="windmill_flow_logs_{job.id}.txt"
color="light"
size="xs"
startIcon={{ icon: Download }}
>
Download all logs
</Button>
{#if shouldDownloadViaClient()}
<Button
on:click={() => downloadViaClient(logsApiPath, logsName)}
color="light"
size="xs"
startIcon={{ icon: Download }}
>
Download all logs
</Button>
{:else}
<Button
href="{base}/api{logsApiPath}"
download={logsName}
color="light"
size="xs"
startIcon={{ icon: Download }}
>
Download all logs
</Button>
{/if}
</div>
{/if}
<FlowLogViewerWrapper
@@ -56,6 +56,16 @@
attempted_at: string
} | null = $state(null)
let offlineCapStatus: {
seats_used: number
seats_cap: number
author_count: number
operator_count: number
current_cu: number
cu_cap: number
cu_over_cap: boolean
} | null = $state(null)
function showSetting(setting: string, values: Record<string, any>) {
if (setting == 'dev_instance') {
if (values['license_key'] == undefined) {
@@ -72,6 +82,14 @@
latestKeyRenewalAttempt = await SettingService.getLatestKeyRenewalAttempt()
}
async function reloadLicenseStatus() {
try {
offlineCapStatus = (await SettingService.getOfflineLicenseStatus()) as any
} catch {
offlineCapStatus = null
}
}
async function reloadLicenseKey() {
$values['license_key'] = await SettingService.getGlobal({
key: 'license_key'
@@ -80,7 +98,10 @@
$effect(() => {
if (setting.key == 'license_key') {
untrack(() => reloadKeyrenewalAttemptInfo())
untrack(() => {
reloadKeyrenewalAttemptInfo()
reloadLicenseStatus()
})
}
})
@@ -430,7 +451,7 @@
</div>
{/if}
{/if}
{#if latestKeyRenewalAttempt}
{#if latestKeyRenewalAttempt && !offlineCapStatus}
{@const attemptedAt = new Date(latestKeyRenewalAttempt.attempted_at).toLocaleString()}
{@const isTrial = latestKeyRenewalAttempt.result.startsWith('error: trial:')}
<div class="relative">
@@ -500,11 +521,41 @@
</div>
{/if}
{#if offlineCapStatus}
{@const cap = offlineCapStatus}
{@const seatsOver = cap.seats_used > cap.seats_cap}
{@const cuOver = cap.cu_over_cap}
<div class="mt-1 flex flex-row items-center gap-2 text-xs">
<div class="flex flex-row items-center gap-1">
{#if seatsOver}
<BadgeX class="text-red-600" size={12} />
{:else}
<BadgeCheck class="text-green-600" size={12} />
{/if}
<span class={seatsOver ? 'text-red-600' : 'text-green-600'}>
Seats: {cap.seats_used.toFixed(1)} / {cap.seats_cap}
</span>
</div>
<div class="flex flex-row items-center gap-1">
{#if cuOver}
<BadgeX class="text-red-600" size={12} />
{:else}
<BadgeCheck class="text-green-600" size={12} />
{/if}
<span class={cuOver ? 'text-red-600' : 'text-green-600'}>
CUs: {cap.current_cu.toFixed(2)} / {cap.cu_cap.toFixed(2)}
</span>
</div>
</div>
{/if}
{#if valid || expiration}
<div class="flex flex-row gap-2 mt-1">
<Button on:click={renewLicenseKey} loading={renewing} size="xs" variant="accent"
>Renew key
</Button>
{#if !offlineCapStatus}
<Button on:click={renewLicenseKey} loading={renewing} size="xs" variant="accent"
>Renew key
</Button>
{/if}
<Button variant="accent" size="xs" loading={opening} on:click={openCustomerPortal}>
Open customer portal
</Button>
@@ -1,23 +1,22 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { run } from 'svelte/legacy'
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
value: any;
value: any
}
let { value = $bindable() }: Props = $props();
let { value = $bindable() }: Props = $props()
const AUTH_URL_SUFFIX = '/ui/oauth2'
let proxyUrlValue = $state(undefined)
function changeValues({ baseUrl, id }) {
if (value) {
value = {
@@ -47,7 +46,7 @@
let baseUrl = $derived(proxyUrlValue ?? derivedBaseUrl ?? '')
run(() => {
changeValues({ baseUrl, id: value?.id ?? '' })
});
})
</script>
<div class="flex flex-col gap-1">
@@ -85,11 +84,12 @@
bind:value={value['id']}
/>
</label>
<label class="flex flex-col gap-1">
<label for="kanidm_client_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
<Password
id="kanidm_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
</SettingCard>
@@ -2,6 +2,7 @@
import { untrack } from 'svelte'
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
@@ -82,11 +83,12 @@
bind:value={value['id']}
/>
</label>
<label class="flex flex-col gap-1">
<label for="keycloak_client_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
<Password
id="keycloak_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
</SettingCard>
+35 -15
View File
@@ -16,6 +16,7 @@
import { copyToClipboard } from '$lib/utils'
import { base } from '$lib/base'
import { withExternalDomain } from '$lib/externalDomain'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { workspaceStore } from '$lib/stores'
import { AnsiUp } from 'ansi_up'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
@@ -204,9 +205,14 @@
scroll = true
}
})
let downloadHref = $derived(
withExternalDomain(`${base}/api/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`)
)
let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`)
let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`))
let downloadName = $derived(`windmill_logs_${jobId}.txt`)
async function onDownloadClick(e: MouseEvent) {
if (!shouldDownloadViaClient()) return
e.preventDefault()
await downloadViaClient(logsApiPath, downloadName)
}
let truncatedContent = $derived(truncateContent(content, loadedFromObjectStore, LOG_LIMIT))
let prefixInfo = $derived(findPrefixInfo(truncatedContent))
let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo))
@@ -246,17 +252,30 @@
<DrawerContent title="Expanded Logs" on:close={logViewer.closeDrawer}>
{#snippet actions()}
{#if jobId && download}
<Button
href={downloadHref}
download="windmill_logs_{jobId}.txt"
color="light"
size="xs"
startIcon={{
icon: Download
}}
>
Download
</Button>
{#if shouldDownloadViaClient()}
<Button
on:click={() => downloadViaClient(logsApiPath, downloadName)}
color="light"
size="xs"
startIcon={{
icon: Download
}}
>
Download
</Button>
{:else}
<Button
href={downloadHref}
download={downloadName}
color="light"
size="xs"
startIcon={{
icon: Download
}}
>
Download
</Button>
{/if}
{/if}
<Button
@@ -342,7 +361,8 @@
class="text-primary pb-0.5"
target="_blank"
href={downloadHref}
download="windmill_logs_{jobId}.txt"
download={downloadName}
onclick={onDownloadClick}
><Download size="14" />
</a>
</div>
@@ -2,6 +2,7 @@
import CollapseLink from './CollapseLink.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
@@ -85,14 +86,15 @@
bind:value={value['id']}
/>
</label>
<label class="flex flex-col gap-1">
<label for="nextcloud_client_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<span class="text-secondary font-normal text-xs"
>Client Secret from your Nextcloud OAuth2 app configuration</span
>
<TextInput
inputProps={{ type: 'password', placeholder: 'Client Secret' }}
bind:value={value['secret']}
<Password
id="nextcloud_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
<CollapseLink text="Instructions">
@@ -7,6 +7,7 @@
import { enterpriseLicense } from '$lib/stores'
import Button from './common/button/Button.svelte'
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
@@ -121,11 +122,12 @@
bind:value={value['id']}
/>
</label>
<label class="flex flex-col gap-1">
<label for="{name}_client_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
<Password
id="{name}_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
{#if name == 'microsoft' || name == 'teams'}
@@ -5,6 +5,7 @@
import Toggle from './Toggle.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import Password from './Password.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
@@ -95,12 +96,16 @@
>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
</label>
<label class="flex flex-col gap-1">
<label for="okta_client_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<span class="text-secondary font-normal text-xs"
>from the CLIENT SECRETS section of the okta service configuration</span
>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<Password
id="okta_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
<CollapseLink text="Instructions">
<div class="text-xs text-primary border rounded-md p-4 space-y-3">
@@ -9,6 +9,7 @@
import DarkModeObserver from './DarkModeObserver.svelte'
import { HelpersService } from '$lib/gen'
import { base } from '$lib/base'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { Download } from 'lucide-svelte'
import { Loader2 } from 'lucide-svelte'
@@ -200,13 +201,17 @@
</div>
{/if}
{#if !disable_download && !s3resource.endsWith('.csv')}
{@const csvApiPath = `/w/${workspaceId}/job_helpers/download_s3_parquet_file_as_csv?file_key=${encodeURIComponent(s3resource)}${storage ? `&storage=${storage}` : ''}`}
{@const csvName = (s3resource.split('/').pop() ?? 'download') + '.csv'}
<a
target="_blank"
href="{base}/api/w/{workspaceId}/job_helpers/download_s3_parquet_file_as_csv?file_key={encodeURIComponent(
s3resource
)}{storage ? `&storage=${storage}` : ''}"
href="{base}/api{csvApiPath}"
class="text-secondary w-full text-right underline text-2xs whitespace-nowrap"
><div class="flex flex-row-reverse gap-2 items-center"><Download size={12} /> CSV</div></a
onclick={async (e) => {
if (!shouldDownloadViaClient()) return
e.preventDefault()
await downloadViaClient(csvApiPath, csvName)
}}><div class="flex flex-row-reverse gap-2 items-center"><Download size={12} /> CSV</div></a
>
{/if}
+13
View File
@@ -355,6 +355,19 @@
!dirty && (dirty = true)
}
$effect(() => {
if (
path !== undefined &&
path !== '' &&
initialPath &&
!initialPath.startsWith('tmp/') &&
path !== initialPath &&
!dirty
) {
dirty = true
}
})
const openSearchWithPrefilledText: (t?: string) => void = getContext(
'openSearchWithPrefilledText'
)
@@ -2,6 +2,7 @@
import { untrack } from 'svelte'
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
@@ -76,11 +77,12 @@
bind:value={value['id']}
/>
</label>
<label class="flex flex-col gap-1">
<label for="pocketid_client_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
<Password
id="pocketid_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
</SettingCard>
@@ -35,6 +35,7 @@
sendUserToast,
type S3Object
} from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { Alert, Button } from './common'
import Section from './Section.svelte'
import { createEventDispatcher, untrack, type Snippet } from 'svelte'
@@ -715,14 +716,27 @@
{#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)}
<div class="flex gap-2 shrink-0">
{#if !hideS3SpecificDetails}
<Button
title="Download file from S3"
variant="default"
href={`${base}/api/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}`}
download={fileMetadata?.fileKey.split('/').pop() ?? 'unnamed_download.file'}
startIcon={{ icon: Download }}
iconOnly={true}
/>
{@const downloadApiPath = `/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}`}
{@const downloadName =
fileMetadata?.fileKey.split('/').pop() ?? 'unnamed_download.file'}
{#if shouldDownloadViaClient()}
<Button
title="Download file from S3"
variant="default"
on:click={() => downloadViaClient(downloadApiPath, downloadName)}
startIcon={{ icon: Download }}
iconOnly={true}
/>
{:else}
<Button
title="Download file from S3"
variant="default"
href={`${base}/api${downloadApiPath}`}
download={downloadName}
startIcon={{ icon: Download }}
iconOnly={true}
/>
{/if}
{/if}
{#if !readOnlyMode}
<Button
@@ -581,6 +581,8 @@
let testIsLoading = $state(false)
let testJob: Job | undefined = $state()
let pastPreviews: CompletedJob[] = $state([])
let historyTabActive = false
let pastPreviewsRequest: ReturnType<typeof JobService.listCompletedJobs> | undefined
let validCode = $state(true)
// Recording
@@ -711,7 +713,9 @@
lastRecording = scriptRecording.stop()
setActiveRecording(undefined)
}
loadPastTests()
if (historyTabActive) {
loadPastTests()
}
},
doneError({ error }) {
if (scriptRecording.active) {
@@ -742,12 +746,29 @@
}
async function loadPastTests(): Promise<void> {
pastPreviews = await JobService.listCompletedJobs({
pastPreviewsRequest?.cancel()
const req = JobService.listCompletedJobs({
workspace: $workspaceStore!,
jobKinds: 'preview',
createdBy: $userStore?.username,
scriptPathExact: path
scriptPathExact: path,
hasNullParent: true
})
pastPreviewsRequest = req
try {
const result = await req
if (pastPreviewsRequest === req) {
pastPreviews = result
}
} catch (err) {
if (!(err instanceof Error) || err.name !== 'CancelError') {
throw err
}
} finally {
if (pastPreviewsRequest === req) {
pastPreviewsRequest = undefined
}
}
}
export async function inferSchema(
@@ -1148,7 +1169,6 @@
if (!validCode && code && lang) {
await inferSchema(code, { applyInitialArgs: true })
}
loadPastTests()
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.SCRIPT)
})
@@ -1229,6 +1249,8 @@
}
onDestroy(() => {
pastPreviewsRequest?.cancel()
pastPreviewsRequest = undefined
disableCollaboration()
aiChatManager.scriptEditorApplyCode = undefined
aiChatManager.scriptEditorShowDiffMode = undefined
@@ -1675,6 +1697,12 @@
} as any)
: testJob}
{pastPreviews}
onTabChange={(tab) => {
historyTabActive = tab === 'history'
if (historyTabActive) {
loadPastTests()
}
}}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
@@ -86,7 +86,7 @@
{#if deployTo}
<Label
label="Workspace specific"
tooltip="Prevents this variable from being deployed to prod/staging"
tooltip="Prevents this variable from being deployed to prod/staging. May have been enabled automatically because a workspace-specific resource references this variable via $var:. Disabling this toggle does not retroactively un-mark the resource that referenced it."
>
<Toggle bind:checked={wsSpecific} />
</Label>
@@ -1,21 +1,20 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { run } from 'svelte/legacy'
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import Toggle from './Toggle.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
interface Props {
value: any;
value: any
}
let { value = $bindable() }: Props = $props();
let { value = $bindable() }: Props = $props()
let org = $state('')
function changeOrg(org) {
if (value) {
value = {
@@ -37,7 +36,7 @@
let enabled = $derived(value != undefined)
run(() => {
changeOrg(org)
});
})
</script>
<div class="flex flex-col gap-1">
@@ -75,11 +74,12 @@
bind:value={value['id']}
/>
</label>
<label>
<label for="zitadel_client_secret">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
<Password
id="zitadel_client_secret"
placeholder="Client Secret"
bind:password={value['secret']}
/>
</label>
</SettingCard>
@@ -2,6 +2,7 @@
import { workspaceStore } from '$lib/stores'
import { Download } from 'lucide-svelte'
import { base } from '$lib/base'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
interface Props {
s3object: any
@@ -10,6 +11,26 @@
}
let { s3object, workspaceId = undefined, appPath = undefined }: Props = $props()
let workspace = $derived(workspaceId ?? $workspaceStore)
let filename = $derived(s3object?.s3?.split?.('/')?.pop() ?? 'unnamed_download.file')
let apiPath = $derived(
`${
appPath
? `/w/${workspace}/apps_u/download_s3_file/${appPath}`
: `/w/${workspace}/job_helpers/download_s3_file`
}?${appPath ? 's3' : 'file_key'}=${encodeURIComponent(s3object?.s3 ?? '')}${
s3object?.storage ? `&storage=${s3object.storage}` : ''
}${appPath && s3object?.presigned ? `&${s3object?.presigned}` : ''}`
)
let href = $derived(`${base}/api${apiPath}`)
async function onclick(e: MouseEvent) {
if (!shouldDownloadViaClient()) return
e.preventDefault()
await downloadViaClient(apiPath, filename)
}
</script>
{#if s3object && s3object?.s3}
@@ -18,12 +39,9 @@
border border-dashed border-gray-400 hover:border-blue-500
focus-within:border-blue-500 hover:bg-blue-50 dark:hover:bg-frost-900 focus-within:bg-blue-50
duration-200 rounded-lg p-1 gap-2"
href={`${base}/api/w/${workspaceId ?? $workspaceStore}${
appPath ? `/apps_u/download_s3_file/${appPath}` : '/job_helpers/download_s3_file'
}?${appPath ? 's3' : 'file_key'}=${encodeURIComponent(s3object?.s3 ?? '')}${
s3object?.storage ? `&storage=${s3object.storage}` : ''
}${appPath && s3object?.presigned ? `&${s3object?.presigned}` : ''}`}
download={s3object?.s3?.split?.('/')?.pop() ?? 'unnamed_download.file'}
{href}
download={filename}
{onclick}
>
<Download />
<span>
+4 -1
View File
@@ -14,7 +14,10 @@ export const forbiddenIds: string[] = [
'in',
'failure',
'preprocessor',
'as'
'as',
'Input',
'Result',
'Trigger'
]
export function numberToChars(n: number) {
@@ -70,6 +70,7 @@
address: $values['secret_backend']?.address ?? '',
mount_path: $values['secret_backend']?.mount_path ?? 'windmill',
jwt_role: $values['secret_backend']?.jwt_role ?? 'windmill-secrets',
jwt_mount_path: $values['secret_backend']?.jwt_mount_path ?? null,
namespace: $values['secret_backend']?.namespace ?? null,
token: $values['secret_backend']?.token ?? null,
skip_ssl_verify: $values['secret_backend']?.skip_ssl_verify ?? false
@@ -122,6 +123,7 @@
address: $values['secret_backend'].address,
mount_path: $values['secret_backend'].mount_path,
jwt_role: $values['secret_backend'].jwt_role,
jwt_mount_path: $values['secret_backend'].jwt_mount_path || undefined,
namespace: $values['secret_backend'].namespace || undefined,
token: $values['secret_backend'].token || undefined,
skip_ssl_verify: $values['secret_backend'].skip_ssl_verify || undefined
@@ -352,6 +354,10 @@
}
let baseUrl = $derived($values['base_url'] ?? 'https://your-windmill-instance.com')
let jwtMount = $derived(($values['secret_backend']?.jwt_mount_path?.trim() || 'jwt') as string)
let vaultAudience = $derived(
($values['secret_backend']?.address?.trim() || 'https://vault.example.com:8200') as string
)
</script>
<div class="space-y-6">
@@ -500,6 +506,24 @@
}}
bind:value={$values['secret_backend'].jwt_role}
/>
<label for="vault_jwt_mount_path" class="block text-xs font-semibold text-emphasis"
>JWT Auth Mount Path (optional)</label
>
<span class="text-2xs text-secondary"
>Mount path of the JWT auth method in Vault. Defaults to <code>jwt</code>. Set this
only if you mounted the JWT auth method at a non-default path (<code
>vault auth enable -path=&lt;mount&gt; jwt</code
>).</span
>
<TextInput
inputProps={{
type: 'text',
id: 'vault_jwt_mount_path',
placeholder: 'jwt',
disabled
}}
bind:value={$values['secret_backend'].jwt_mount_path}
/>
<details class="mt-2">
<summary class="text-xs font-medium text-secondary cursor-pointer hover:text-primary"
>Vault JWT Setup Instructions</summary
@@ -510,29 +534,32 @@
class="bg-gray-100 dark:bg-gray-800 p-2 rounded font-mono text-2xs overflow-x-auto"
>
<pre
># Enable JWT auth method
vault auth enable jwt
># Enable JWT auth method{jwtMount === 'jwt'
? ''
: ` at custom mount '${jwtMount}'`}
vault auth enable {jwtMount === 'jwt' ? 'jwt' : `-path=${jwtMount} jwt`}
# Configure JWT auth with Windmill's JWKS endpoint
vault write auth/jwt/config \
jwks_url="{baseUrl}/.well-known/jwks.json" \
bound_issuer="{baseUrl}"
vault write auth/{jwtMount}/config \
jwks_url="{baseUrl}/api/oidc/jwks" \
bound_issuer="{baseUrl}/api/oidc/"
# Create a policy for Windmill secrets
vault policy write windmill-secrets - &lt;&lt;EOF
path "windmill/data/*" &#123;
path "{$values['secret_backend']?.mount_path ?? 'windmill'}/data/*" &#123;
capabilities = ["create", "read", "update", "delete"]
&#125;
path "windmill/metadata/*" &#123;
path "{$values['secret_backend']?.mount_path ?? 'windmill'}/metadata/*" &#123;
capabilities = ["list", "delete"]
&#125;
EOF
# Create the JWT role
vault write auth/jwt/role/windmill-secrets \
# Create the JWT role. bound_audiences must match the Vault server
# address — Windmill signs the JWT with `aud` = your Vault address.
vault write auth/{jwtMount}/role/{$values['secret_backend']?.jwt_role || 'windmill-secrets'} \
role_type="jwt" \
bound_audiences="{baseUrl}" \
user_claim="email" \
bound_audiences="{vaultAudience}" \
user_claim="sub" \
policies="windmill-secrets" \
ttl="1h"</pre
>
@@ -2,6 +2,7 @@
import ObjectViewer from './ObjectViewer.svelte'
import { copyToClipboard, truncate } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import {
createEventDispatcher,
@@ -407,12 +408,17 @@
<div class="flex">
<span class="text-primary">{closeBracket}</span>
{#if getTypeAsString(jsonFiltered) === 's3object'}
{@const s3DownloadApiPath = `/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(jsonFiltered?.s3 ?? '')}${jsonFiltered?.storage ? `&storage=${jsonFiltered.storage}` : ''}`}
{@const s3DownloadName = jsonFiltered?.s3.split('/').pop() ?? 'unnamed_download.file'}
<a
class="text-secondary underline font-semibold text-2xs whitespace-nowrap ml-1 w-fit"
href={`/api/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(
jsonFiltered?.s3 ?? ''
)}${jsonFiltered?.storage ? `&storage=${jsonFiltered.storage}` : ''}`}
download={jsonFiltered?.s3.split('/').pop() ?? 'unnamed_download.file'}
href={`/api${s3DownloadApiPath}`}
download={s3DownloadName}
onclick={async (e) => {
if (!shouldDownloadViaClient()) return
e.preventDefault()
await downloadViaClient(s3DownloadApiPath, s3DownloadName)
}}
>
<span class="flex items-center gap-1"><Download size={12} />download</span>
</a>
@@ -49,6 +49,7 @@
capturesTab?: import('svelte').Snippet
customResultPanel?: import('svelte').Snippet
showCustomResultPanel?: boolean
onTabChange?: (tab: string) => void
}
let {
@@ -65,7 +66,8 @@
children,
capturesTab,
customResultPanel,
showCustomResultPanel = false
showCustomResultPanel = false,
onTabChange
}: Props = $props()
type DContent = {
@@ -78,6 +80,10 @@
let drawerOpen: boolean = $state(false)
let drawerContent: DContent | undefined = $state(undefined)
$effect(() => {
onTabChange?.(selectedTab)
})
export function setFocusToLogs() {
selectedTab = 'logs'
}
@@ -17,7 +17,7 @@
schedules: true,
resources: true,
variables: true,
assets: false,
assets: true,
triggers: true,
audit_logs: true,
groups: true,
@@ -4,6 +4,7 @@
superadmin,
usedTriggerKinds,
userStore,
userWorkspaces,
workspaceStore,
isCriticalAlertsUIOpen,
enterpriseLicense,
@@ -11,6 +12,7 @@
tutorialsToDo,
skippedAll
} from '$lib/stores'
import { findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
import {
@@ -123,12 +125,28 @@
}
}
if (deleteForkedChildren && forkedDescendants.length > 0) {
for (const child of forkedDescendants) {
try {
await WorkspaceService.deleteWorkspace({ workspace: child.id })
} catch (err) {
sendUserToast(`Failed to delete forked child ${child.id}: ${err}`, true)
return
}
}
}
await WorkspaceService.deleteWorkspace({ workspace })
sendUserToast('You deleted the workspace')
clearStores()
goto('/user/workspaces')
}
let deleteForkedChildren = $state(false)
const forkedDescendants = $derived(
$workspaceStore ? findWorkspaceDescendants($workspaceStore, $userWorkspaces ?? []) : []
)
let hasNewChangelogs = $state(false)
let recentChangelogs: Changelog[] = $state([])
let lastOpened = localStorage.getItem('changelogsLastOpened')
@@ -285,7 +303,6 @@
label: 'Assets',
href: `${base}/assets`,
icon: Pyramid,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
},
@@ -492,6 +509,7 @@
label: 'Delete Forked Workspace',
action: async () => {
await loadForkedDatatables()
deleteForkedChildren = false
deleteWorkspaceForkModal = true
},
icon: Trash2,
@@ -807,6 +825,30 @@
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to delete this workspace fork? (deleting {$workspaceStore})</span>
{#if forkedDescendants.length > 0}
<div class="border rounded-md divide-y">
<div class="px-4 py-2 flex items-center justify-between gap-2">
<div class="flex flex-col min-w-0">
<span class="text-xs font-semibold text-secondary">Forked children</span>
<span class="text-3xs text-hint">
This fork has {forkedDescendants.length} forked
{forkedDescendants.length === 1 ? 'child' : 'children'} (transitively).
</span>
</div>
<Toggle
class="shrink-0"
size="xs"
bind:checked={deleteForkedChildren}
options={{ right: 'Also delete children' }}
/>
</div>
<ul class="px-4 py-2 text-3xs text-hint max-h-32 overflow-y-auto">
{#each forkedDescendants as child}
<li class="font-mono truncate" title={child.id}>{child.id}</li>
{/each}
</ul>
</div>
{/if}
{#if forkedDatatables.length > 0}
<div class="border rounded-md divide-y">
<div class="px-4 py-2 text-xs font-semibold text-secondary"> Forked databases </div>
+48
View File
@@ -0,0 +1,48 @@
import { OpenAPI } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
async function resolveToken(): Promise<string | undefined> {
const t = OpenAPI.TOKEN
if (!t) return undefined
return typeof t === 'string' ? t : await t({} as any)
}
/**
* When OpenAPI.TOKEN is set we cannot rely on a plain `<a href>` browser navigation
* because the browser does not attach the Authorization header. In that case fetch
* the file via the OpenAPI client (which uses OpenAPI.BASE and the Bearer token) and
* trigger a download from a blob URL. Otherwise let the default link behavior happen.
*
* `apiPath` should be the path relative to OpenAPI.BASE, starting with `/`
* (e.g. `/w/foo/job_helpers/download_s3_file?file_key=...`).
*/
export function shouldDownloadViaClient(): boolean {
return Boolean(OpenAPI.TOKEN)
}
export async function downloadViaClient(apiPath: string, filename: string): Promise<void> {
const token = await resolveToken()
const url = `${OpenAPI.BASE}${apiPath}`
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
let response: Response
try {
response = await fetch(url, { headers, credentials: OpenAPI.CREDENTIALS })
} catch (e) {
sendUserToast(`Download failed: ${e}`, true)
return
}
if (!response.ok) {
sendUserToast(`Download failed: ${response.status} ${response.statusText}`, true)
return
}
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(blobUrl)
}
@@ -12,7 +12,13 @@
type AssetKind,
type ListAssetsResponse
} from '$lib/gen'
import { userStore, workspaceStore, userWorkspaces, globalDbManagerDrawer } from '$lib/stores'
import {
userStore,
workspaceStore,
userWorkspaces,
globalDbManagerDrawer,
superadmin
} from '$lib/stores'
import { parseDbInputFromAssetSyntax, pluralize, truncate } from '$lib/utils'
import ExploreAssetButton, {
assetCanBeExplored
@@ -194,15 +200,17 @@
>
See documentation
</Button>
<Button
wrapperClasses="h-fit"
variant={props.data.current?.length === 0 && !props.data.loading
? 'accent'
: 'subtle'}
iconOnly
endIcon={{ icon: SettingsIcon }}
href={props.settingsHref}
/>
{#if !($userStore?.operator || (!$userStore?.is_admin && !$superadmin))}
<Button
wrapperClasses="h-fit"
variant={props.data.current?.length === 0 && !props.data.loading
? 'accent'
: 'subtle'}
iconOnly
endIcon={{ icon: SettingsIcon }}
href={props.settingsHref}
/>
{/if}
</div>
</div>
{#if props.data.current?.length}
@@ -194,19 +194,6 @@
}
}
let defaultTagPerWorkspace: boolean | undefined = $state(undefined)
let defaultTagWorkspaces: string[] = $state([])
async function loadDefaultTagsPerWorkspace() {
try {
defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace()
defaultTagWorkspaces = (await SettingService.getGlobal({
key: DEFAULT_TAGS_WORKSPACES_SETTING
})) as any
} catch (err) {
sendUserToast(`Could not load default tag per workspace setting: ${err}`, true)
}
}
function parseLicenseKey(key: string): {
valid: boolean
expiration?: Date
@@ -247,13 +234,11 @@
const { valid, expiration } = parseLicenseKey(licenseKey)
if (!valid && expiration) {
// License is expired
sendUserToast(
`Enterprise license key expired on ${expiration.toLocaleDateString()}. Please renew your license key to continue using Windmill.`,
true
)
} else if (expiration) {
// Check if expires within 7 days
const daysUntilExpiration = Math.floor(
(expiration.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
)
@@ -266,11 +251,23 @@
}
}
} catch (err) {
// Silently fail - don't show errors for license check
console.error('Failed to check license expiration:', err)
}
}
let defaultTagPerWorkspace: boolean | undefined = $state(undefined)
let defaultTagWorkspaces: string[] = $state([])
async function loadDefaultTagsPerWorkspace() {
try {
defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace()
defaultTagWorkspaces = (await SettingService.getGlobal({
key: DEFAULT_TAGS_WORKSPACES_SETTING
})) as any
} catch (err) {
sendUserToast(`Could not load default tag per workspace setting: ${err}`, true)
}
}
onMount(() => {
intervalId = setInterval(() => {
loadWorkers()
@@ -36,6 +36,7 @@
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { Slack } from 'lucide-svelte'
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
@@ -1499,13 +1500,26 @@
<div class="text-xs font-semibold text-emphasis mt-6 mb-1">Export workspace</div>
<div class="flex justify-start">
<Button
size="sm"
href="{base}/api/w/{$workspaceStore ?? ''}/workspaces/tarball?archive_type=zip"
target="_blank"
>
Export workspace as zip file
</Button>
{#if shouldDownloadViaClient()}
<Button
size="sm"
on:click={() =>
downloadViaClient(
`/w/${$workspaceStore ?? ''}/workspaces/tarball?archive_type=zip`,
`${$workspaceStore ?? 'workspace'}.zip`
)}
>
Export workspace as zip file
</Button>
{:else}
<Button
size="sm"
href="{base}/api/w/{$workspaceStore ?? ''}/workspaces/tarball?archive_type=zip"
target="_blank"
>
Export workspace as zip file
</Button>
{/if}
</div>
<div class="mt-12"></div>
+6
View File
@@ -1053,6 +1053,12 @@ components:
- 0.0 = deterministic, focused responses
- 0.7 = balanced (common default)
- 1.0+ = more creative/random
max_iterations:
allOf:
- $ref: '#/components/schemas/InputTransform'
description: |
Number. Limits how many times the agent can loop through reasoning and tool use.
Range: 1-1000.
required:
- provider
- user_message
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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