mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
Merge branch 'main' into feat/asset-graph-view
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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.
|
||||
|
||||
-28
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
-58
@@ -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"
|
||||
}
|
||||
-23
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
-23
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+32
@@ -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"
|
||||
}
|
||||
Generated
+540
-2865
File diff suppressed because it is too large
Load Diff
+60
-23
@@ -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 @@
|
||||
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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+18
-10
@@ -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,
|
||||
@@ -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>,
|
||||
@@ -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(""),
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export all types from windmill_ai::types
|
||||
pub use windmill_ai::types::*;
|
||||
@@ -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(""),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=<mount> 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 - <<EOF
|
||||
path "windmill/data/*" {
|
||||
path "{$values['secret_backend']?.mount_path ?? 'windmill'}/data/*" {
|
||||
capabilities = ["create", "read", "update", "delete"]
|
||||
}
|
||||
path "windmill/metadata/*" {
|
||||
path "{$values['secret_backend']?.mount_path ?? 'windmill'}/metadata/*" {
|
||||
capabilities = ["list", "delete"]
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
+13
-13
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
Reference in New Issue
Block a user