mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-16 08:02:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a06485f51 | ||
|
|
27571457a1 | ||
|
|
d4e711e337 | ||
|
|
55c172cc59 | ||
|
|
d883f647ed | ||
|
|
6a7811bdd0 | ||
|
|
8ff2340c0c | ||
|
|
835db5d290 | ||
|
|
b59d60378c | ||
|
|
8869fde737 | ||
|
|
90a6db72a2 | ||
|
|
3aba0ed250 | ||
|
|
207dcdb4f7 | ||
|
|
b97216cf37 | ||
|
|
b3ac0249de | ||
|
|
9ac07897cf | ||
|
|
c15b9abe5e | ||
|
|
1abfeea81a | ||
|
|
97c163bb33 | ||
|
|
7f3ddd7edd | ||
|
|
5bac8b093d | ||
|
|
9c513b2c62 | ||
|
|
753c05a030 | ||
|
|
1b4489acac | ||
|
|
302fea683c | ||
|
|
4c06d74bd0 | ||
|
|
680cac7084 | ||
|
|
cee3198c9b | ||
|
|
9b28c85469 | ||
|
|
32c4b474f9 | ||
|
|
6ba0da3ee5 | ||
|
|
de6fd160d5 | ||
|
|
705e186f3d | ||
|
|
0935bf9fc4 | ||
|
|
26270d8cd1 | ||
|
|
9a7a0135f7 | ||
|
|
0604600b8b |
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve _ee.rs symlinks to actual files so Claude can read them
|
||||
# This script runs before each user prompt is processed
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
|
||||
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
|
||||
|
||||
# Find all _ee.rs symlinks and store their targets
|
||||
find "$PROJECT_DIR" -name "*_ee.rs" -type l 2>/dev/null | while read -r symlink; do
|
||||
target=$(readlink -f "$symlink" 2>/dev/null) || continue
|
||||
|
||||
# Only process if target file exists
|
||||
if [[ -f "$target" ]]; then
|
||||
# Store symlink path and target in manifest
|
||||
echo "$symlink|$target" >> "$MANIFEST_FILE.tmp"
|
||||
|
||||
# Replace symlink with actual file content
|
||||
rm "$symlink"
|
||||
cp "$target" "$symlink"
|
||||
fi
|
||||
done
|
||||
|
||||
# Atomically replace manifest
|
||||
if [[ -f "$MANIFEST_FILE.tmp" ]]; then
|
||||
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore _ee.rs symlinks after Claude finishes processing
|
||||
# This script runs when Claude stops
|
||||
# IMPORTANT: Copies any modifications back to the target before restoring symlinks
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
|
||||
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
|
||||
|
||||
# Check if manifest exists
|
||||
if [[ ! -f "$MANIFEST_FILE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read manifest and restore symlinks
|
||||
while IFS='|' read -r symlink target; do
|
||||
if [[ -n "$symlink" && -n "$target" ]]; then
|
||||
# If the file exists (not a symlink) and target exists, copy changes back
|
||||
if [[ -f "$symlink" && ! -L "$symlink" && -e "$target" ]]; then
|
||||
# Copy the potentially modified file back to the target
|
||||
cp "$symlink" "$target"
|
||||
fi
|
||||
|
||||
# Remove the regular file (which was a copy)
|
||||
rm -f "$symlink" 2>/dev/null || true
|
||||
|
||||
# Recreate the symlink
|
||||
ln -s "$target" "$symlink" 2>/dev/null || true
|
||||
fi
|
||||
done < "$MANIFEST_FILE"
|
||||
|
||||
# Clean up manifest
|
||||
rm -f "$MANIFEST_FILE"
|
||||
|
||||
exit 0
|
||||
+4
-34
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"additionalDirectories": [
|
||||
"../windmill-ee-private"
|
||||
],
|
||||
"allow": [
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
@@ -63,39 +66,6 @@
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/resolve-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
@@ -130,4 +100,4 @@
|
||||
"typescript-lsp@claude-plugins-official": true,
|
||||
"code-review@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,4 +226,93 @@ When generating Svelte 5 code, prioritize frontend performance by applying the f
|
||||
Hello
|
||||
</div>
|
||||
```
|
||||
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
|
||||
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
|
||||
|
||||
## Windmill UI Component Rules (MUST follow)
|
||||
|
||||
Always use Windmill's own design-system components instead of raw HTML elements. Using raw HTML elements produces inconsistent styling and breaks the design language.
|
||||
|
||||
### Icons — use `lucide-svelte`
|
||||
|
||||
**Never** write inline SVGs. Import icons from `lucide-svelte`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-svelte'
|
||||
</script>
|
||||
|
||||
<ChevronLeft size={16} />
|
||||
```
|
||||
|
||||
### Buttons — use `<Button>`
|
||||
|
||||
**Never** use `<button>`. Import and use `Button` from `$lib/components/common`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
|
||||
</script>
|
||||
|
||||
<!-- Regular button -->
|
||||
<Button variant="default" onclick={handleClick}>Label</Button>
|
||||
|
||||
<!-- Icon-only button (no label) -->
|
||||
<Button startIcon={{ icon: ChevronLeft }} iconOnly onclick={prevMonth} />
|
||||
<Button startIcon={{ icon: ChevronRight }} iconOnly onclick={nextMonth} />
|
||||
```
|
||||
|
||||
Key `Button` props:
|
||||
- `variant?: 'accent' | 'accent-secondary' | 'default' | 'subtle'`
|
||||
- `unifiedSize?: 'sm' | 'md' | 'lg'`
|
||||
- `startIcon?: { icon: SvelteComponent }` — renders an icon before the label
|
||||
- `iconOnly?: boolean` — renders icon with no surrounding label text
|
||||
- `disabled?: boolean`
|
||||
|
||||
### Text inputs — use `<TextInput>`
|
||||
|
||||
**Never** use `<input>`. Import and use `TextInput` from `$lib/components/common`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { TextInput } from '$lib/components/common'
|
||||
let val = $state('')
|
||||
</script>
|
||||
|
||||
<TextInput bind:value={val} placeholder="Enter value" />
|
||||
```
|
||||
|
||||
Key `TextInput` props:
|
||||
- `value?: string | number` (bindable)
|
||||
- `placeholder?: string`
|
||||
- `disabled?: boolean`
|
||||
- `error?: string | boolean`
|
||||
- `size?: 'sm' | 'md' | 'lg'`
|
||||
- `inputProps?` — forwarded to the underlying `<input>`
|
||||
|
||||
### Selects — use `<Select>`
|
||||
|
||||
**Never** use `<select>`. Import and use `Select` from `$lib/components/select/Select.svelte`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
|
||||
const monthItems = [
|
||||
{ label: 'January', value: 1 },
|
||||
{ label: 'February', value: 2 },
|
||||
// ...
|
||||
]
|
||||
let selectedMonth = $state(1)
|
||||
</script>
|
||||
|
||||
<Select items={monthItems} bind:value={selectedMonth} />
|
||||
```
|
||||
|
||||
Key `Select` props:
|
||||
- `items?: Array<{ label?: string; value: any; subtitle?: string; disabled?: boolean }>`
|
||||
- `value` (bindable) — the currently selected `.value`
|
||||
- `placeholder?: string`
|
||||
- `clearable?: boolean`
|
||||
- `disabled?: boolean`
|
||||
- `size?: 'sm' | 'md' | 'lg'`
|
||||
@@ -19,7 +19,7 @@ defaults:
|
||||
|
||||
jobs:
|
||||
cargo_test:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: ubicloud-standard-16
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
@@ -86,22 +86,8 @@ jobs:
|
||||
working-directory: /
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.93.0
|
||||
- name: Cache cargo target directory
|
||||
uses: useblacksmith/stickydisk@v1
|
||||
with:
|
||||
key: cargo-target
|
||||
path: ./backend/target
|
||||
- name: Cache cargo registry
|
||||
uses: useblacksmith/cache@v1
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: cargo-registry-${{ hashFiles('backend/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-registry-
|
||||
- name: Read EE repo commit hash
|
||||
run: |
|
||||
echo "ee_repo_ref=$(cat ./ee-repo-ref.txt)" >> "$GITHUB_ENV"
|
||||
@@ -229,7 +215,7 @@ jobs:
|
||||
fi
|
||||
echo "Verified: Package requires authentication for @windmill-test/private-pkg"
|
||||
- name: Cache DuckDB FFI module build
|
||||
uses: useblacksmith/cache@v1
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ./backend/windmill-duckdb-ffi-internal/target
|
||||
key: ${{ runner.os }}-duckdb-ffi-${{ hashFiles('./backend/windmill-duckdb-ffi-internal/src/**/*.rs', './backend/windmill-duckdb-ffi-internal/Cargo.toml', './backend/windmill-duckdb-ffi-internal/Cargo.lock') }}
|
||||
@@ -245,7 +231,6 @@ jobs:
|
||||
RUST_LOG_STYLE: never
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
||||
CARGO_BUILD_JOBS: 12
|
||||
CARGO_INCREMENTAL: 1
|
||||
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
|
||||
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
|
||||
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
|
||||
|
||||
@@ -17,6 +17,9 @@ rust-client/Cargo.toml
|
||||
# Worktree-generated port isolation
|
||||
.env.local
|
||||
|
||||
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Symlinked cache directories (for git worktrees)
|
||||
backend/target
|
||||
frontend/node_modules
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
"svelte": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
services:
|
||||
- name: BE
|
||||
portEnv: BACKEND_PORT
|
||||
- name: FE
|
||||
portEnv: FRONTEND_PORT
|
||||
|
||||
profiles:
|
||||
default:
|
||||
name: default
|
||||
|
||||
sandbox:
|
||||
name: sandbox
|
||||
systemPrompt: >
|
||||
You are running inside a sandboxed container with full permissions.
|
||||
This worktree is configured with the following ports:
|
||||
|
||||
- Backend: port ${BACKEND_PORT}.
|
||||
Start with: cd backend && PORT=${BACKEND_PORT}
|
||||
DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill
|
||||
cargo watch -x run
|
||||
|
||||
- Frontend: port ${FRONTEND_PORT}.
|
||||
Start with: cd frontend && REMOTE=http://localhost:${BACKEND_PORT}
|
||||
npm run dev -- --port ${FRONTEND_PORT} --host 0.0.0.0
|
||||
|
||||
--- Screenshots ---
|
||||
You can take screenshots of the frontend UI and upload them to R2
|
||||
for use in PR descriptions.
|
||||
1) Take a screenshot:
|
||||
bunx playwright screenshot --browser chromium
|
||||
http://localhost:${FRONTEND_PORT}/path/to/page /tmp/screenshot.png
|
||||
2) Upload to R2:
|
||||
aws s3 cp /tmp/screenshot.png
|
||||
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/screenshot.png"
|
||||
--endpoint-url "$(printenv R2_ENDPOINT)"
|
||||
3) The public URL will be:
|
||||
$(printenv R2_PUBLIC_URL)/<branch>/screenshot.png
|
||||
4) Include screenshots in PR descriptions as markdown images:
|
||||
/<branch>/screenshot.png)
|
||||
|
||||
--- Terminal Recordings (asciinema) ---
|
||||
You can record terminal sessions and upload them for sharing.
|
||||
asciinema is pre-installed at /usr/local/bin/asciinema.
|
||||
|
||||
1) Write a shell script with the commands to demo. Add sleep
|
||||
delays for readable pacing:
|
||||
- 0.5s after printing a "$ command" line (lets viewer read it)
|
||||
- 1.5-2s after command output (lets viewer absorb the result)
|
||||
- Set GIT_PAGER=cat and PAGER=cat to prevent pager hangs
|
||||
|
||||
2) Record headlessly:
|
||||
asciinema rec --headless --overwrite \
|
||||
-c "bash /tmp/demo.sh" \
|
||||
--window-size 120x50 \
|
||||
--title "Description of demo" \
|
||||
/tmp/demo.cast
|
||||
|
||||
3) Upload to asciinema.org:
|
||||
XDG_DATA_HOME=/tmp/.local/share \
|
||||
asciinema upload --server-url https://asciinema.org /tmp/demo.cast
|
||||
envPassthrough:
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
- R2_ENDPOINT
|
||||
- R2_BUCKET
|
||||
- R2_PUBLIC_URL
|
||||
+14
-2
@@ -46,11 +46,20 @@ pre_remove:
|
||||
- ./scripts/worktree-cleanup
|
||||
|
||||
panes:
|
||||
- command: <agent>
|
||||
- command: >-
|
||||
claude --dangerously-skip-permissions --append-system-prompt
|
||||
"You are running inside a tmux session with other panes running services.\n
|
||||
Pane layout (current window):\n
|
||||
- Pane 0: this pane (claude agent)\n
|
||||
- Pane 1: backend (cargo watch -x run)\n
|
||||
- Pane 2: frontend (npm run dev)\n\n
|
||||
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).\n
|
||||
When restarting backend or frontend, make sure to use the ports listed in .env.local.\n
|
||||
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check."
|
||||
focus: true
|
||||
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/backend" && PORT=${BACKEND_PORT:-8000} cargo watch -x run'
|
||||
split: horizontal
|
||||
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm install && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000}'
|
||||
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm install && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0'
|
||||
split: vertical
|
||||
|
||||
files:
|
||||
@@ -61,3 +70,6 @@ files:
|
||||
sandbox:
|
||||
enabled: false
|
||||
toolchain: off
|
||||
# image, host_commands, and extra_mounts configured in global
|
||||
# ~/.config/workmux/config.yaml — see README_WORKMUX_DEV.md for required
|
||||
# extra_mounts (windmill-ee-private access in sandbox)
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## [1.644.0](https://github.com/windmill-labs/windmill/compare/v1.643.0...v1.644.0) (2026-02-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cli:** detect missing folders on sync push and add 'wmill folder add-missing' ([#8011](https://github.com/windmill-labs/windmill/issues/8011)) ([835db5d](https://github.com/windmill-labs/windmill/commit/835db5d290a151f38f4e879ed7ffbda5d1c4b24f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent concurrent index migrations from re-running on every startup ([#8069](https://github.com/windmill-labs/windmill/issues/8069)) ([8ff2340](https://github.com/windmill-labs/windmill/commit/8ff2340c0c08ce49a809c8958a9862ffb1681642))
|
||||
|
||||
## [1.643.0](https://github.com/windmill-labs/windmill/compare/v1.642.0...v1.643.0) (2026-02-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add fileset resource type support ([32c4b47](https://github.com/windmill-labs/windmill/commit/32c4b474f92f3dbbd2077fab70bdf9e407581626))
|
||||
* add fileset resource type support ([#8063](https://github.com/windmill-labs/windmill/issues/8063)) ([c15b9ab](https://github.com/windmill-labs/windmill/commit/c15b9abe5eb2a1566a7ce4b18784c961d178a669))
|
||||
* add light mode for navigation sidebar ([#8057](https://github.com/windmill-labs/windmill/issues/8057)) ([0935bf9](https://github.com/windmill-labs/windmill/commit/0935bf9fc460c03c6d8469b93036e43714517ef2))
|
||||
* **aiagent:** handle ai agent as tool ([#8031](https://github.com/windmill-labs/windmill/issues/8031)) ([de6fd16](https://github.com/windmill-labs/windmill/commit/de6fd160d56c1037adbbe785f195483c25982e1c))
|
||||
* Unified filters and new runs page ([#8027](https://github.com/windmill-labs/windmill/issues/8027)) ([9b28c85](https://github.com/windmill-labs/windmill/commit/9b28c85469d6b2a8590810b313b030d9f00ee9e3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address code review findings for fileset feature ([1b4489a](https://github.com/windmill-labs/windmill/commit/1b4489acac3b050f0a783548bacfc9bdf33ee593))
|
||||
* address second round of review findings ([753c05a](https://github.com/windmill-labs/windmill/commit/753c05a03089b95b4ade68d3bf61c8818de422ce))
|
||||
* **backend:** decimal between 0 and -1 in mssql ([#8051](https://github.com/windmill-labs/windmill/issues/8051)) ([9686608](https://github.com/windmill-labs/windmill/commit/9686608355615a50c8395f6e2fd51dcc25498226))
|
||||
* **backend:** use filename instead of content_type to detect file fields in multipart form data ([#8054](https://github.com/windmill-labs/windmill/issues/8054)) ([0aa885d](https://github.com/windmill-labs/windmill/commit/0aa885db67d77202205fc1609e841b8ffd9a8121))
|
||||
* exclude app_theme resources from workspace tab ([9c513b2](https://github.com/windmill-labs/windmill/commit/9c513b2c62acc369179fb9e404e1f4007cd854c6))
|
||||
* fileset editor takes full height with matching header ([9ac0789](https://github.com/windmill-labs/windmill/commit/9ac07897cf99f3af27801e435c7376a46ef760c9))
|
||||
* prevent iframe from overriding file selection after file creation ([7f3ddd7](https://github.com/windmill-labs/windmill/commit/7f3ddd7edd3ea993642aadd55cdba0ac2ea1eb9f))
|
||||
* resolve svelte warnings and type error in fileset components ([4c06d74](https://github.com/windmill-labs/windmill/commit/4c06d74bd01ca2dda848be421d70dd5268520992))
|
||||
* restore full-width file tree items in raw app sidebar ([5bac8b0](https://github.com/windmill-labs/windmill/commit/5bac8b093dbe913a563b02573959c64dd405ff61))
|
||||
* suppress iframe setActiveDocument during file population ([1abfeea](https://github.com/windmill-labs/windmill/commit/1abfeea81a645c59934d62257ad869ed7b475634))
|
||||
* update git sync init script to hub version 28158 ([#8061](https://github.com/windmill-labs/windmill/issues/8061)) ([705e186](https://github.com/windmill-labs/windmill/commit/705e186f3d4c7d8f8a88fc84b379ed9fe800a6b2))
|
||||
* use correct column name completed_at instead of ended_at in count_completed_jobs_detail ([#8066](https://github.com/windmill-labs/windmill/issues/8066)) ([3aba0ed](https://github.com/windmill-labs/windmill/commit/3aba0ed2508debdc78a6631e49b074a97635f21d))
|
||||
|
||||
## [1.642.0](https://github.com/windmill-labs/windmill/compare/v1.641.0...v1.642.0) (2026-02-22)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
git \
|
||||
iptables \
|
||||
gosu \
|
||||
sudo \
|
||||
unzip \
|
||||
# Rust native build deps (for cargo check)
|
||||
pkg-config \
|
||||
cmake \
|
||||
clang \
|
||||
mold \
|
||||
libtool \
|
||||
libssl-dev \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxslt1-dev \
|
||||
libffi-dev \
|
||||
zlib1g-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libclang-dev \
|
||||
libkrb5-dev \
|
||||
libsasl2-dev \
|
||||
# PostgreSQL (for local DB during development)
|
||||
postgresql \
|
||||
postgresql-client \
|
||||
# Node.js 22 (for npm run check / frontend dev)
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
# Container runs as arbitrary UIDs (--user uid:gid). These three lines make
|
||||
# sudo work for any UID:
|
||||
# 1) NOPASSWD rule so sudo never prompts for a password
|
||||
# 2) Writable passwd/group so the entrypoint can register the dynamic UID
|
||||
# 3) Writable shadow so unix_chkpwd can validate the account (without this,
|
||||
# sudo fails with "account validation failure, is your account locked?")
|
||||
&& echo "ALL ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/sandbox \
|
||||
&& chmod 0440 /etc/sudoers.d/sandbox \
|
||||
&& chmod 666 /etc/passwd /etc/group /etc/shadow
|
||||
|
||||
# ── GitHub CLI (for PR creation) ──────────────────────────────────────────────
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Rust toolchain ────────────────────────────────────────────────────────────
|
||||
# Install under /usr/local/lib/ so bins are world-readable with default umask.
|
||||
# CARGO_HOME is overridden to /tmp/.cargo at the end for mutable runtime state.
|
||||
ENV RUSTUP_HOME=/usr/local/lib/rustup CARGO_HOME=/usr/local/lib/cargo
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --default-toolchain stable --profile minimal && \
|
||||
ln -s /usr/local/lib/cargo/bin/* /usr/local/bin/
|
||||
RUN cargo install sqlx-cli --no-default-features --features native-tls,postgres && \
|
||||
cargo install cargo-watch && \
|
||||
cargo install --locked --git https://github.com/asciinema/asciinema && \
|
||||
ln -sf /usr/local/lib/cargo/bin/sqlx /usr/local/bin/sqlx && \
|
||||
ln -sf /usr/local/lib/cargo/bin/cargo-watch /usr/local/bin/cargo-watch && \
|
||||
ln -sf /usr/local/lib/cargo/bin/asciinema /usr/local/bin/asciinema
|
||||
|
||||
# ── Register dynamic runtime users ───────────────────────────────────────────
|
||||
RUN cat <<'SCRIPT' > /usr/local/bin/register-dynamic-user.sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
uid="${1:-}"
|
||||
gid="${2:-}"
|
||||
|
||||
if [ -z "$uid" ] || [ -z "$gid" ]; then
|
||||
echo "register-dynamic-user: usage: register-dynamic-user <uid> <gid>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! getent group "$gid" >/dev/null 2>&1; then
|
||||
echo "sandbox:x:${gid}:" >> /etc/group
|
||||
fi
|
||||
|
||||
if ! getent passwd "$uid" >/dev/null 2>&1; then
|
||||
echo "sandbox:x:${uid}:${gid}:sandbox:/tmp:/bin/sh" >> /etc/passwd
|
||||
fi
|
||||
|
||||
# Add a shadow entry ("*" = no password) so unix_chkpwd doesn't reject sudo.
|
||||
if ! grep -q "^sandbox:" /etc/shadow 2>/dev/null; then
|
||||
echo "sandbox:*:19000:0:99999:7:::" >> /etc/shadow
|
||||
fi
|
||||
SCRIPT
|
||||
RUN chmod +x /usr/local/bin/register-dynamic-user.sh
|
||||
|
||||
# ── Network init script (iptables firewall + privilege drop) ──────────────────
|
||||
RUN cat <<'SCRIPT' > /usr/local/bin/network-init.sh
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${WM_PROXY_HOST:-}" ] && [ -n "${WM_PROXY_PORT:-}" ]; then
|
||||
# Resolve hostnames to ALL IPs (multi-A records, round-robin DNS)
|
||||
PROXY_IPS=$(getent ahostsv4 "$WM_PROXY_HOST" | awk '{print $1}' | sort -u)
|
||||
RPC_HOST="${WM_RPC_HOST:-$WM_PROXY_HOST}"
|
||||
RPC_IPS=$(getent ahostsv4 "$RPC_HOST" | awk '{print $1}' | sort -u)
|
||||
|
||||
if [ -z "$PROXY_IPS" ] || [ -z "$RPC_IPS" ]; then
|
||||
echo "network-init: failed to resolve proxy/RPC host" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# IPv4: default deny outbound
|
||||
iptables -P OUTPUT DROP
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Allow DNS (UDP/TCP 53) to configured nameservers.
|
||||
if [ -f /etc/resolv.conf ]; then
|
||||
grep '^nameserver' /etc/resolv.conf | awk '{print $2}' | while read -r ns; do
|
||||
iptables -A OUTPUT -d "$ns" -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -d "$ns" -p tcp --dport 53 -j ACCEPT
|
||||
done
|
||||
fi
|
||||
|
||||
# Allow ALL resolved proxy IPs (handles multi-A DNS)
|
||||
for ip in $PROXY_IPS; do
|
||||
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_PROXY_PORT" -j ACCEPT
|
||||
done
|
||||
|
||||
# Allow ALL resolved RPC IPs
|
||||
if [ -n "${WM_RPC_PORT:-}" ]; then
|
||||
for ip in $RPC_IPS; do
|
||||
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_RPC_PORT" -j ACCEPT
|
||||
done
|
||||
fi
|
||||
|
||||
# Reject (not drop) everything else to fail fast instead of hanging
|
||||
iptables -A OUTPUT -j REJECT
|
||||
|
||||
# IPv6: block entirely to prevent leaks (fail closed)
|
||||
if ip6tables -L -n >/dev/null 2>&1; then
|
||||
ip6tables -P OUTPUT DROP
|
||||
ip6tables -A OUTPUT -o lo -j ACCEPT
|
||||
ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
ip6tables -A OUTPUT -j REJECT
|
||||
else
|
||||
if ! sysctl -w net.ipv6.conf.all.disable_ipv6=1 2>/dev/null; then
|
||||
echo "network-init: failed to block IPv6 (neither ip6tables nor sysctl available)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add sandbox user/group so sudo works after dropping privileges.
|
||||
if [ -z "${WM_TARGET_UID:-}" ] || [ -z "${WM_TARGET_GID:-}" ]; then
|
||||
echo "network-init: WM_TARGET_UID and WM_TARGET_GID are required" >&2
|
||||
exit 1
|
||||
fi
|
||||
/usr/local/bin/register-dynamic-user.sh "${WM_TARGET_UID}" "${WM_TARGET_GID}"
|
||||
|
||||
# Fix PTY ownership so the unprivileged user can read/write the terminal.
|
||||
if [ -t 0 ]; then
|
||||
chown "${WM_TARGET_UID}:${WM_TARGET_GID}" "$(tty)"
|
||||
fi
|
||||
|
||||
# Drop privileges and exec the user command.
|
||||
exec gosu "${WM_TARGET_UID}:${WM_TARGET_GID}" env HOME=/tmp "$@"
|
||||
SCRIPT
|
||||
RUN chmod +x /usr/local/bin/network-init.sh
|
||||
|
||||
# ── workmux (sandbox RPC) ────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://raw.githubusercontent.com/raine/workmux/main/scripts/install.sh | bash
|
||||
|
||||
# ── Claude Code ───────────────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash && \
|
||||
target="$(readlink -f /root/.local/bin/claude)" && \
|
||||
mv /root/.local/share/claude /usr/local/lib/claude && \
|
||||
ln -s "/usr/local/lib/claude/versions/$(basename "$target")" /usr/local/bin/claude && \
|
||||
mkdir -p /tmp/.local/bin && \
|
||||
ln -s /usr/local/bin/claude /tmp/.local/bin/claude && \
|
||||
chmod -R a+rwX /tmp/.local
|
||||
|
||||
# ── Codex ─────────────────────────────────────────────────────────────────────
|
||||
RUN npm i -g @openai/codex
|
||||
|
||||
# ── Bun ───────────────────────────────────────────────────────────────────────
|
||||
ENV BUN_INSTALL=/usr/local/lib/bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||
ln -s /usr/local/lib/bun/bin/bun /usr/local/bin/bun && \
|
||||
ln -s /usr/local/lib/bun/bin/bunx /usr/local/bin/bunx
|
||||
|
||||
# ── Playwright + Chromium (for screenshots) ──────────────────────────────────
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/lib/playwright-browsers
|
||||
RUN bun add -g @playwright/test \
|
||||
&& bunx playwright install chromium --with-deps \
|
||||
&& chmod -R a+rwX /usr/local/lib/playwright-browsers \
|
||||
&& chmod -R a+rwX /usr/local/lib/bun/install \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/bunx-*
|
||||
|
||||
# ── AWS CLI (for S3-compatible uploads to R2) ─────────────────────────────────
|
||||
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
|
||||
&& unzip -q /tmp/awscliv2.zip -d /tmp \
|
||||
&& /tmp/aws/install \
|
||||
&& rm -rf /tmp/aws /tmp/awscliv2.zip
|
||||
|
||||
ENV AWS_DEFAULT_REGION=auto
|
||||
|
||||
# ── Runtime env for arbitrary UID ─────────────────────────────────────────────
|
||||
# Mutable state goes to /tmp (writable by any UID). Toolchains stay read-only.
|
||||
ENV CARGO_HOME=/tmp/.cargo BUN_TMPDIR=/tmp
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────────────
|
||||
RUN cat <<'ENTRY' > /usr/local/bin/entrypoint.sh
|
||||
#!/bin/sh
|
||||
/usr/local/bin/register-dynamic-user.sh "$(id -u)" "$(id -g)"
|
||||
|
||||
# Start PostgreSQL (unix socket in /tmp, owned by postgres user)
|
||||
mkdir -p /tmp/pgdata && sudo chown postgres:postgres /tmp/pgdata
|
||||
if [ ! -f /tmp/pgdata/PG_VERSION ]; then
|
||||
sudo -u postgres /usr/lib/postgresql/15/bin/initdb -D /tmp/pgdata --auth=trust
|
||||
fi
|
||||
sudo -u postgres /usr/lib/postgresql/15/bin/pg_ctl -D /tmp/pgdata -l /tmp/pg.log start -o "-k /tmp"
|
||||
sudo -u postgres psql -h /tmp -c "CREATE ROLE sandbox SUPERUSER LOGIN" 2>/dev/null || true
|
||||
sudo -u postgres createdb -h /tmp windmill 2>/dev/null || true
|
||||
|
||||
# Run database migrations so sqlx compile-time checks work
|
||||
if [ -d "$PWD/backend/migrations" ]; then
|
||||
DATABASE_URL="postgres://sandbox@localhost/windmill?host=/tmp" \
|
||||
sqlx migrate run --source "$PWD/backend/migrations" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install frontend dependencies and generate backend client
|
||||
if [ -d "$PWD/frontend" ]; then
|
||||
(cd "$PWD/frontend" && npm install && npm run generate-backend-client) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
ENTRY
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -172,6 +172,81 @@ The setup is defined in `.workmux.yaml` at the repo root. Key sections:
|
||||
- **`files.copy`**: Copies `backend/.env` and `scripts/` into each worktree
|
||||
- **`files.symlink`**: Symlinks `node_modules` and `.svelte-kit` to avoid reinstalling per worktree
|
||||
|
||||
## Enterprise (EE) Code Access
|
||||
|
||||
The enterprise source code lives in the `windmill-ee-private` repository (sibling to this repo). When you create a worktree, `scripts/worktree-env` automatically creates a matching EE worktree on the same branch and configures Claude Code's `additionalDirectories` to grant access.
|
||||
|
||||
### Sandbox setup
|
||||
|
||||
When using sandbox mode, the container needs explicit mounts to access the EE repo. Add the following to your global workmux config (`~/.config/workmux/config.yaml`):
|
||||
|
||||
```yaml
|
||||
sandbox:
|
||||
extra_mounts:
|
||||
- host_path: ~/windmill-ee-private
|
||||
writable: true
|
||||
- host_path: ~/windmill-ee-private__worktrees
|
||||
writable: true
|
||||
```
|
||||
|
||||
This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container.
|
||||
|
||||
|
||||
## Cursor SSH Integration (`wmc`)
|
||||
|
||||
`wm-cursor` (aliased as `wmc`) gives each worktree its own Cursor SSH remote window with an independently-focused tmux session. All windows are visible in the status bar across all Cursor terminals, but each one is focused on its own worktree.
|
||||
|
||||
This uses **grouped tmux sessions** — multiple sessions that share the same window list but track focus independently:
|
||||
|
||||
```
|
||||
tmux session: main <-- your main Cursor terminal
|
||||
tmux session: cursor-feat-a <-- Cursor window for feat-a (focused on wm-feat-a)
|
||||
tmux session: cursor-feat-b <-- Cursor window for feat-b (focused on wm-feat-b)
|
||||
\__ all three share the same windows in the status bar
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
Run once from inside tmux on the remote:
|
||||
|
||||
```bash
|
||||
./scripts/wm-cursor setup /home/hugo/projects/windmill
|
||||
```
|
||||
|
||||
This:
|
||||
|
||||
1. **Merges `.vscode/settings.json`** — adds the `wm-tmux` terminal profile (auto-attaches to the `main` tmux session), disables auto port forwarding, configures forwarding for ports 8000/3000/5432, and stops rust-analyzer from auto-starting. Existing settings are preserved.
|
||||
2. **Creates `.vscode/tasks.json`** — auto-starts the dev database (`start-dev-db.sh`) when the folder opens.
|
||||
3. **Adds `wmc` alias to `~/.zshrc`** — so you can use `wmc` from any tmux window.
|
||||
|
||||
After setup, reopen Cursor's terminal to pick up the new profile.
|
||||
|
||||
### Usage
|
||||
|
||||
All commands run from inside a tmux session (i.e., from Cursor's integrated terminal after setup).
|
||||
|
||||
**Create a new worktree + open Cursor:**
|
||||
|
||||
```bash
|
||||
wmc add -A -p "implement feature X"
|
||||
```
|
||||
|
||||
This runs `workmux add`, creates a grouped tmux session, writes `.vscode/settings.json` in the worktree (with port forwarding matching the worktree's assigned ports), and opens a new Cursor window.
|
||||
|
||||
**Open Cursor for an existing worktree:**
|
||||
|
||||
```bash
|
||||
wmc open my-feature
|
||||
```
|
||||
|
||||
**Close a worktree's Cursor window and tmux window (keeps the worktree):**
|
||||
|
||||
```bash
|
||||
wmc close my-feature
|
||||
```
|
||||
|
||||
This kills the grouped tmux session and calls `workmux close` to close the tmux window. The worktree and branch are preserved. Grouped sessions are also automatically cleaned up when you `workmux rm` a worktree (via `scripts/worktree-cleanup`).
|
||||
|
||||
## Login
|
||||
|
||||
Default credentials: `admin@windmill.dev` / `changeme`
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -52,7 +57,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "03d63d2e64b012f624d2731b5bcb8849c74a9474777be61edf0ed43ddda07ef3"
|
||||
|
||||
+1
-2
@@ -43,8 +43,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1c2157ce14e90f0751d7f0a9f2dbb3c5a5789a32423e75260098a5300a4af986"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT schema, description, format_extension\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2",
|
||||
"query": "SELECT schema, description, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,6 +17,11 @@
|
||||
"ordinal": 2,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -28,8 +33,9 @@
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7bc9fc05dbd162866bef1fdd3e7faeb50429881ed1bc962903f06e4b3d5f8d44"
|
||||
"hash": "2768622b76ad92c05f4f44d997aff285707e1a43ce85e5bb8e87849d78a0637f"
|
||||
}
|
||||
+1
-2
@@ -42,8 +42,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -38,8 +38,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, now())",
|
||||
"query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -10,10 +10,11 @@
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ffedbb3a2676a6d7b71f81f89109a02a8dba90d40144e942527f8a3fc36dfbc1"
|
||||
"hash": "5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00"
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
+1
-2
@@ -77,8 +77,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH prev_sd AS (\n DELETE FROM debounce_stale_data WHERE job_id = $1 RETURNING to_relock\n ) INSERT INTO debounce_stale_data (job_id, to_relock)\n VALUES ($2, array_cat((SELECT to_relock FROM prev_sd), $3))\n ON CONFLICT (job_id) DO UPDATE SET to_relock = EXCLUDED.to_relock\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "61b37cb4db6e60c2d35f7d23db5afbe04e040a8dcd1d93afaaaa320665c8779a"
|
||||
}
|
||||
+16
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\" FROM v2_job WHERE id = $1",
|
||||
"query": "SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -42,12 +42,21 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "flow_step_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -58,8 +67,10 @@
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "805d633de90fee335f1726284eda0dbc200d45960fb8dea867492c8c7dd096d5"
|
||||
"hash": "7aaa5b0bd873c2029e2201d287ea0aaae04678ac105374bbe387e534a6cb6333"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension\n FROM resource_type\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7abd579d3ec97853ac36cc8dad29013eb133a28cd848bf8fdf9571b2ee402a3e"
|
||||
}
|
||||
+7
-1
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -51,7 +56,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7b1239ad6460e8f5fb41bfe12f662a779528784ec8cf3f6dcce5545ab90bf234"
|
||||
|
||||
+1
-2
@@ -44,8 +44,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT name, format_extension, is_fileset FROM resource_type WHERE (format_extension IS NOT NULL OR is_fileset = true) AND (workspace_id = $1 OR workspace_id = 'admins')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "842775bcf91d747abb11ffe9c98fa1208595e012590606ef6667ea3a78105883"
|
||||
}
|
||||
+1
-2
@@ -102,8 +102,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM _sqlx_migrations WHERE\n version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR\n version=20250201145631 OR version=20250201145632 OR version=20251006143821 OR\n version=20260207000001 OR version=20260207000002 OR version=20260207000003 OR version=20260207000004",
|
||||
"query": "DELETE FROM _sqlx_migrations WHERE\n version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR\n version=20250201145631 OR version=20250201145632 OR version=20251006143821",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c6bcf0d9e211bc03e3338682295f4995e1d622917367c478742addd073245ad5"
|
||||
"hash": "8d4ad4ee75fb149c36a9f6a0c4cf5fd981473f45d1b71b4b8236e021f7c8682d"
|
||||
}
|
||||
+1
-2
@@ -32,8 +32,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -72,8 +72,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -77,8 +77,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -102,8 +102,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -72,8 +72,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -51,7 +56,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b8d392ccfcccafe0c19511b3567bc11779b1052b0948c410468a8aeba1d26d33"
|
||||
|
||||
+1
-2
@@ -41,8 +41,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT name, format_extension FROM resource_type WHERE format_extension IS NOT NULL AND (workspace_id = $1 OR workspace_id = 'admins')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cf1cef7e0fe2e7e3db96b0ec005360361b9eec023a6fc2a4a7a917f59d86af4d"
|
||||
}
|
||||
+1
-2
@@ -41,8 +41,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -31,8 +31,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -37,8 +37,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -77,8 +77,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -49,7 +54,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "eb1f7f01461f5a7540c273b37e5d578c31cf151ab3ef813f7aada76533761e12"
|
||||
|
||||
+1
-2
@@ -32,8 +32,7 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
"unassigned_singlestepflow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+88
-88
@@ -2259,9 +2259,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -5588,7 +5588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -5804,7 +5804,7 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
|
||||
dependencies = [
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -8092,9 +8092,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.23"
|
||||
version = "1.1.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
|
||||
checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -8116,9 +8116,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
@@ -9729,9 +9729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.2.3"
|
||||
version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
|
||||
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||
|
||||
[[package]]
|
||||
name = "p224"
|
||||
@@ -11598,14 +11598,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -13838,14 +13838,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
version = "3.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -13864,7 +13864,7 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
|
||||
dependencies = [
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
@@ -15725,7 +15725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15789,7 +15789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15802,7 +15802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15940,7 +15940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15963,7 +15963,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15976,7 +15976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16002,7 +16002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -16012,7 +16012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16029,7 +16029,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -16052,7 +16052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16075,7 +16075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16091,7 +16091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16111,7 +16111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16131,7 +16131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16145,7 +16145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16171,7 +16171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16196,7 +16196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"flate2",
|
||||
@@ -16213,7 +16213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16234,7 +16234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16254,7 +16254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16284,7 +16284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16311,7 +16311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16323,7 +16323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.7.9",
|
||||
@@ -16346,7 +16346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16360,7 +16360,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16390,7 +16390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16404,7 +16404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16423,7 +16423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -16522,7 +16522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -16541,7 +16541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16556,7 +16556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16580,7 +16580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16597,7 +16597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16613,7 +16613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16634,7 +16634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16665,7 +16665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-oauth2",
|
||||
@@ -16689,7 +16689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -16723,7 +16723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16741,7 +16741,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16750,7 +16750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16762,7 +16762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16774,7 +16774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16786,7 +16786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16798,7 +16798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16810,7 +16810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16821,7 +16821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16832,7 +16832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16845,7 +16845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16869,7 +16869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16883,7 +16883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16900,7 +16900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16915,7 +16915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16934,7 +16934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16945,7 +16945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16982,7 +16982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -17020,7 +17020,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
@@ -17030,7 +17030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17059,7 +17059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -17082,7 +17082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17115,7 +17115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17135,7 +17135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17169,7 +17169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17204,7 +17204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17227,7 +17227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17251,7 +17251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -17275,7 +17275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17310,7 +17310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17338,7 +17338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17361,7 +17361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.4",
|
||||
@@ -17379,7 +17379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -18252,7 +18252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -76,7 +76,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.642.0"
|
||||
version = "1.644.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE resource_type DROP COLUMN is_fileset;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE resource_type ADD COLUMN is_fileset BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -137,7 +137,7 @@ raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char)
|
||||
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), approver(char), resume_id(int), approved(bool)
|
||||
FK: (flow) -> v2_job_queue(id)
|
||||
|
||||
@@ -27,9 +27,13 @@ struct ListAssetsQuery {
|
||||
per_page: i64,
|
||||
cursor_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
cursor_id: Option<i64>,
|
||||
asset_path: Option<String>,
|
||||
usage_path: Option<String>,
|
||||
asset_kinds: Option<String>,
|
||||
pub asset_path: Option<String>,
|
||||
pub usage_path: Option<String>,
|
||||
pub asset_kinds: Option<String>,
|
||||
// Exact path match filter
|
||||
pub path: Option<String>,
|
||||
// Filter by matching a subset of the columns using base64 encoded json subset
|
||||
pub columns: Option<String>,
|
||||
}
|
||||
|
||||
fn default_per_page() -> i64 {
|
||||
@@ -75,12 +79,24 @@ async fn list_assets(
|
||||
|
||||
let mut param_count = 2; // $1 = workspace_id, $2 = limit
|
||||
|
||||
// Asset path filter
|
||||
// Asset path filter (ILIKE pattern match)
|
||||
if query.asset_path.is_some() {
|
||||
param_count += 1;
|
||||
asset_summary_filters.push(format!("asset.path ILIKE ${}", param_count));
|
||||
}
|
||||
|
||||
// Exact path filter
|
||||
if query.path.is_some() {
|
||||
param_count += 1;
|
||||
asset_summary_filters.push(format!("asset.path = ${}", param_count));
|
||||
}
|
||||
|
||||
// Columns filter (check if JSONB has all specified keys)
|
||||
if query.columns.is_some() {
|
||||
param_count += 1;
|
||||
asset_summary_filters.push(format!("asset.columns ?& ${}", param_count));
|
||||
}
|
||||
|
||||
// Usage path filter - for jobs, also check runnable_path
|
||||
let needs_job_join_in_cte = query.usage_path.is_some();
|
||||
if query.usage_path.is_some() {
|
||||
@@ -211,6 +227,20 @@ async fn list_assets(
|
||||
query_builder = query_builder.bind(format!("%{}%", asset_path));
|
||||
}
|
||||
|
||||
if let Some(ref path) = query.path {
|
||||
query_builder = query_builder.bind(path);
|
||||
}
|
||||
|
||||
if let Some(ref columns) = query.columns {
|
||||
// Columns is a comma-separated string, split into array for ?& operator
|
||||
let columns_array: Vec<String> = columns
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
query_builder = query_builder.bind(columns_array);
|
||||
}
|
||||
|
||||
if let Some(ref usage_path) = query.usage_path {
|
||||
query_builder = query_builder.bind(format!("%{}%", usage_path));
|
||||
}
|
||||
|
||||
@@ -54,6 +54,21 @@ INSERT INTO resource (workspace_id, path, value, description, resource_type, ext
|
||||
VALUES ('test-workspace', 'u/test-user/scalar_var_resource', '"$var:u/test-user/db_password"',
|
||||
'Scalar var ref', 'string', '{}', 'test-user');
|
||||
|
||||
-- === fileset resource type test data ===
|
||||
|
||||
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, is_fileset)
|
||||
VALUES ('test-workspace', 'test_fileset', '{}',
|
||||
'Test fileset type', 'test-user', true);
|
||||
|
||||
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension)
|
||||
VALUES ('test-workspace', 'test_file', '{"type": "object", "properties": {"content": {"type": "string"}}}',
|
||||
'Test file type', 'test-user', 'txt');
|
||||
|
||||
INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'u/test-user/fileset_resource',
|
||||
'{"config.yaml": "key: value", "data/input.json": "{\"items\": []}"}',
|
||||
'A fileset resource', 'test_fileset', '{}', 'test-user');
|
||||
|
||||
-- === mcp_tools test data ===
|
||||
|
||||
INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
|
||||
|
||||
@@ -69,8 +69,12 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
// --- get_value_interpolated ---
|
||||
let resp =
|
||||
authed_get(port, "get_value_interpolated", "u/test-user/simple_resource").await;
|
||||
let resp = authed_get(
|
||||
port,
|
||||
"get_value_interpolated",
|
||||
"u/test-user/simple_resource",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?,
|
||||
@@ -78,8 +82,12 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// $var: interpolation
|
||||
let resp =
|
||||
authed_get(port, "get_value_interpolated", "u/test-user/resource_with_var").await;
|
||||
let resp = authed_get(
|
||||
port,
|
||||
"get_value_interpolated",
|
||||
"u/test-user/resource_with_var",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?,
|
||||
@@ -87,8 +95,12 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// $res: interpolation
|
||||
let resp =
|
||||
authed_get(port, "get_value_interpolated", "u/test-user/resource_with_res").await;
|
||||
let resp = authed_get(
|
||||
port,
|
||||
"get_value_interpolated",
|
||||
"u/test-user/resource_with_res",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?,
|
||||
@@ -96,8 +108,7 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// mixed $var: and $res: refs
|
||||
let resp =
|
||||
authed_get(port, "get_value_interpolated", "u/test-user/resource_mixed").await;
|
||||
let resp = authed_get(port, "get_value_interpolated", "u/test-user/resource_mixed").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?,
|
||||
@@ -105,8 +116,12 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// chained $res: -> $var:
|
||||
let resp =
|
||||
authed_get(port, "get_value_interpolated", "u/test-user/chained_resource").await;
|
||||
let resp = authed_get(
|
||||
port,
|
||||
"get_value_interpolated",
|
||||
"u/test-user/chained_resource",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?,
|
||||
@@ -114,8 +129,7 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// null value
|
||||
let resp =
|
||||
authed_get(port, "get_value_interpolated", "u/test-user/null_resource").await;
|
||||
let resp = authed_get(port, "get_value_interpolated", "u/test-user/null_resource").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?,
|
||||
@@ -123,8 +137,7 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// not found
|
||||
let resp =
|
||||
authed_get(port, "get_value_interpolated", "u/test-user/nonexistent").await;
|
||||
let resp = authed_get(port, "get_value_interpolated", "u/test-user/nonexistent").await;
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
// array passthrough
|
||||
@@ -162,7 +175,9 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
"expected at least 10 resources from fixture, got {}",
|
||||
list.len()
|
||||
);
|
||||
assert!(list.iter().any(|r| r["path"] == "u/test-user/simple_resource"));
|
||||
assert!(list
|
||||
.iter()
|
||||
.any(|r| r["path"] == "u/test-user/simple_resource"));
|
||||
|
||||
// list with resource_type filter
|
||||
let resp = authed(client().get(format!("{base}/list?resource_type=mcp_server")))
|
||||
@@ -259,9 +274,11 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(body["description"], "Updated description");
|
||||
|
||||
// --- update_value ---
|
||||
let resp = authed(
|
||||
client().post(resource_url(port, "update_value", "u/test-user/new_resource")),
|
||||
)
|
||||
let resp = authed(client().post(resource_url(
|
||||
port,
|
||||
"update_value",
|
||||
"u/test-user/new_resource",
|
||||
)))
|
||||
.json(&json!({"value": {"url": "https://final.com"}}))
|
||||
.send()
|
||||
.await
|
||||
@@ -275,35 +292,44 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// --- delete ---
|
||||
let resp = authed(
|
||||
client().delete(resource_url(port, "delete", "u/test-user/new_resource")),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().delete(resource_url(port, "delete", "u/test-user/new_resource")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let resp = authed_get(port, "exists", "u/test-user/new_resource").await;
|
||||
assert_eq!(resp.json::<bool>().await?, false);
|
||||
|
||||
// delete nonexistent -> 404
|
||||
let resp = authed(
|
||||
client().delete(resource_url(port, "delete", "u/test-user/new_resource")),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().delete(resource_url(port, "delete", "u/test-user/new_resource")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
// --- file_resource_type_to_file_ext_map ---
|
||||
let resp = authed(client().get(format!(
|
||||
"{base}/file_resource_type_to_file_ext_map"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{base}/file_resource_type_to_file_ext_map")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
resp.json::<serde_json::Value>().await?;
|
||||
let ext_map = resp.json::<serde_json::Value>().await?;
|
||||
// Verify the map includes fileset type info with is_fileset flag (no format_extension)
|
||||
let fileset_info = &ext_map["test_fileset"];
|
||||
assert_eq!(fileset_info["format_extension"], serde_json::Value::Null);
|
||||
assert_eq!(fileset_info["is_fileset"], true);
|
||||
// Verify non-fileset file type
|
||||
let file_info = &ext_map["test_file"];
|
||||
assert_eq!(file_info["format_extension"], "txt");
|
||||
assert_eq!(file_info["is_fileset"], false);
|
||||
|
||||
// --- fileset resource value ---
|
||||
let resp = authed_get(port, "get_value", "u/test-user/fileset_resource").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let fileset_val = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(fileset_val["config.yaml"], "key: value");
|
||||
assert_eq!(fileset_val["data/input.json"], "{\"items\": []}");
|
||||
|
||||
// --- resource types ---
|
||||
|
||||
@@ -384,17 +410,68 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(body["description"], "Updated type desc");
|
||||
|
||||
// type/delete
|
||||
let resp = authed(
|
||||
client().delete(resource_url(port, "type/delete", "new_test_type")),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().delete(resource_url(port, "type/delete", "new_test_type")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let resp = authed_get(port, "type/exists", "new_test_type").await;
|
||||
assert_eq!(resp.json::<bool>().await?, false);
|
||||
|
||||
// --- fileset resource type CRUD ---
|
||||
|
||||
// type/get for fileset type - verify is_fileset is returned
|
||||
let resp = authed_get(port, "type/get", "test_fileset").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["name"], "test_fileset");
|
||||
assert_eq!(body["is_fileset"], true);
|
||||
assert_eq!(body["format_extension"], serde_json::Value::Null);
|
||||
|
||||
// type/get for non-fileset type - verify is_fileset is false
|
||||
let resp = authed_get(port, "type/get", "test_db").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["is_fileset"], false);
|
||||
|
||||
// type/create fileset type (no format_extension needed)
|
||||
let resp = authed(client().post(format!("{base}/type/create")))
|
||||
.json(&json!({
|
||||
"name": "new_fileset_type",
|
||||
"description": "A fileset type",
|
||||
"schema": {},
|
||||
"is_fileset": true
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
|
||||
let resp = authed_get(port, "type/get", "new_fileset_type").await;
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["is_fileset"], true);
|
||||
assert_eq!(body["format_extension"], serde_json::Value::Null);
|
||||
|
||||
// type/update - set is_fileset on existing type
|
||||
let resp = authed(client().post(resource_url(port, "type/update", "new_fileset_type")))
|
||||
.json(&json!({"is_fileset": false}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let resp = authed_get(port, "type/get", "new_fileset_type").await;
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["is_fileset"], false);
|
||||
|
||||
// cleanup
|
||||
let resp = authed(client().delete(resource_url(port, "type/delete", "new_fileset_type")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ pub mod concurrency_groups;
|
||||
pub mod execution;
|
||||
pub mod job_metrics;
|
||||
pub mod jobs_export;
|
||||
pub mod negated_filter;
|
||||
pub mod query;
|
||||
pub mod types;
|
||||
|
||||
pub use execution::*;
|
||||
pub use negated_filter::{NegatedFilter, NegatedListFilter};
|
||||
pub use query::*;
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Author: Windmill Labs, Inc
|
||||
* Copyright: Windmill Labs, Inc 2024
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Filter wrappers that support an optional `!` negation prefix.
|
||||
//!
|
||||
//! - [`NegatedFilter<T>`] — a single value, e.g. `"schedule"` or `"!schedule"`.
|
||||
//! - [`NegatedListFilter<T>`] — comma-separated values, e.g. `"!schedule,!email"` or `"http,webhook"`.
|
||||
//! Every item in the list shares the same negated/non-negated sense; mixing is not supported
|
||||
|
||||
use serde::{
|
||||
de::{self, DeserializeOwned},
|
||||
Deserializer,
|
||||
};
|
||||
use std::{fmt, marker::PhantomData};
|
||||
|
||||
// ── NegatedFilter<T> ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A single filter value optionally prefixed with `!` to indicate negation.
|
||||
///
|
||||
/// Deserializes `"schedule"` → `NegatedFilter { value: Schedule, negated: false }`
|
||||
/// Deserializes `"!schedule"` → `NegatedFilter { value: Schedule, negated: true }`
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NegatedFilter<T> {
|
||||
pub value: T,
|
||||
pub negated: bool,
|
||||
}
|
||||
|
||||
impl<T> NegatedFilter<T> {
|
||||
pub fn positive(value: T) -> Self {
|
||||
Self { value, negated: false }
|
||||
}
|
||||
|
||||
pub fn negated(value: T) -> Self {
|
||||
Self { value, negated: true }
|
||||
}
|
||||
}
|
||||
|
||||
struct NegatedFilterVisitor<T>(PhantomData<T>);
|
||||
|
||||
impl<'de, T: DeserializeOwned> de::Visitor<'de> for NegatedFilterVisitor<T> {
|
||||
type Value = NegatedFilter<T>;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "a string optionally prefixed with '!'")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
|
||||
let (negated, raw) = match s.strip_prefix('!') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, s),
|
||||
};
|
||||
let value = serde_json::from_value(serde_json::Value::String(raw.to_owned()))
|
||||
.map_err(|e| E::custom(format!("invalid filter value {:?}: {}", raw, e)))?;
|
||||
Ok(NegatedFilter { value, negated })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: DeserializeOwned> de::Deserialize<'de> for NegatedFilter<T> {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
deserializer.deserialize_str(NegatedFilterVisitor(PhantomData))
|
||||
}
|
||||
}
|
||||
|
||||
// ── NegatedListFilter<T> ──────────────────────────────────────────────────────
|
||||
|
||||
/// A comma-separated list of filter values, all sharing the same negation sense.
|
||||
///
|
||||
/// Deserializes `"schedule,email"` → `NegatedListFilter { values: [Schedule, Email], negated: false }`
|
||||
/// Deserializes `"!schedule,!email"` → `NegatedListFilter { values: [Schedule, Email], negated: true }`
|
||||
///
|
||||
/// The `!` is read from the **first** item only; subsequent items may or may not carry
|
||||
/// `!` and it is stripped regardless, keeping the API forgiving.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NegatedListFilter<T> {
|
||||
pub values: Vec<T>,
|
||||
pub negated: bool,
|
||||
}
|
||||
|
||||
impl<T> NegatedListFilter<T> {
|
||||
pub fn positive(values: Vec<T>) -> Self {
|
||||
Self { values, negated: false }
|
||||
}
|
||||
}
|
||||
|
||||
struct NegatedListFilterVisitor<T>(PhantomData<T>);
|
||||
|
||||
impl<'de, T: DeserializeOwned> de::Visitor<'de> for NegatedListFilterVisitor<T> {
|
||||
type Value = NegatedListFilter<T>;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "a comma-separated string optionally prefixed with '!'")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
|
||||
let mut negated = false;
|
||||
let values = s
|
||||
.split(',')
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let raw = match item.strip_prefix('!') {
|
||||
Some(rest) => {
|
||||
if i == 0 {
|
||||
negated = true;
|
||||
}
|
||||
rest
|
||||
}
|
||||
None => item,
|
||||
};
|
||||
serde_json::from_value::<T>(serde_json::Value::String(raw.to_owned()))
|
||||
.map_err(|e| E::custom(format!("invalid filter value {:?}: {}", raw, e)))
|
||||
})
|
||||
.collect::<Result<Vec<T>, E>>()?;
|
||||
Ok(NegatedListFilter { values, negated })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: DeserializeOwned> de::Deserialize<'de> for NegatedListFilter<T> {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
deserializer.deserialize_str(NegatedListFilterVisitor(PhantomData))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,17 @@ use windmill_common::utils::{paginate_without_limits, Pagination};
|
||||
|
||||
use crate::types::{ListCompletedQuery, ListQueueQuery};
|
||||
|
||||
/// Build a `NOT IN (...)` clause that also includes `OR col IS NULL`, so that
|
||||
/// rows where the nullable column is NULL are not silently excluded.
|
||||
fn not_in_nullable(col: &str, quoted: &[String]) -> String {
|
||||
format!(
|
||||
"({} IS NULL OR {} NOT IN ({}))",
|
||||
col,
|
||||
col,
|
||||
quoted.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn filter_list_queue_query(
|
||||
mut sqlb: SqlBuilder,
|
||||
lq: &ListQueueQuery,
|
||||
@@ -33,18 +44,62 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(w) = &lq.worker {
|
||||
let quoted: Vec<_> = w.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
sqlb.and_where_like_left("v2_job_queue.worker", w.replace("*", "%"));
|
||||
let clauses: Vec<_> = w
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if w.negated {
|
||||
format!("v2_job_queue.worker NOT LIKE '{p}'")
|
||||
} else {
|
||||
format!("v2_job_queue.worker LIKE '{p}'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if w.negated { " AND " } else { " OR " };
|
||||
let inner = clauses.join(sep);
|
||||
if w.negated {
|
||||
sqlb.and_where(format!("(v2_job_queue.worker IS NULL OR ({inner}))"));
|
||||
} else {
|
||||
sqlb.and_where(format!("({inner})"));
|
||||
}
|
||||
} else if w.negated {
|
||||
sqlb.and_where(not_in_nullable("v2_job_queue.worker", "ed));
|
||||
} else {
|
||||
sqlb.and_where_eq("v2_job_queue.worker", "?".bind(w));
|
||||
sqlb.and_where_in("v2_job_queue.worker", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ps) = &lq.script_path_start {
|
||||
sqlb.and_where_like_left("runnable_path", ps);
|
||||
let clauses: Vec<_> = ps
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let e = v.replace("'", "''");
|
||||
if ps.negated {
|
||||
format!("runnable_path NOT LIKE '{e}%'")
|
||||
} else {
|
||||
format!("runnable_path LIKE '{e}%'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if ps.negated { " AND " } else { " OR " };
|
||||
let inner = clauses.join(sep);
|
||||
if ps.negated {
|
||||
sqlb.and_where(format!("(runnable_path IS NULL OR ({inner}))"));
|
||||
} else {
|
||||
sqlb.and_where(format!("({inner})"));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &lq.script_path_exact {
|
||||
sqlb.and_where_eq("runnable_path", "?".bind(p));
|
||||
let quoted: Vec<_> = p.values.iter().map(|v| quote(v)).collect();
|
||||
if p.negated {
|
||||
sqlb.and_where(not_in_nullable("runnable_path", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("runnable_path", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(p) = &lq.schedule_path {
|
||||
sqlb.and_where_eq("trigger", "?".bind(p));
|
||||
@@ -54,13 +109,34 @@ pub fn filter_list_queue_query(
|
||||
sqlb.and_where_eq("runnable_id", "?".bind(h));
|
||||
}
|
||||
if let Some(cb) = &lq.created_by {
|
||||
sqlb.and_where_eq("created_by", "?".bind(cb));
|
||||
let quoted: Vec<_> = cb.values.iter().map(|v| quote(v)).collect();
|
||||
if cb.negated {
|
||||
sqlb.and_where_not_in("created_by", "ed);
|
||||
} else {
|
||||
sqlb.and_where_in("created_by", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(t) = &lq.tag {
|
||||
let quoted: Vec<_> = t.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
sqlb.and_where_like_left("v2_job.tag", t.replace("*", "%"));
|
||||
let clauses: Vec<_> = t
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if t.negated {
|
||||
format!("v2_job.tag NOT LIKE '{p}'")
|
||||
} else {
|
||||
format!("v2_job.tag LIKE '{p}'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if t.negated { " AND " } else { " OR " };
|
||||
sqlb.and_where(format!("({})", clauses.join(sep)));
|
||||
} else if t.negated {
|
||||
sqlb.and_where_not_in("v2_job.tag", "ed);
|
||||
} else {
|
||||
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
|
||||
sqlb.and_where_in("v2_job.tag", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +191,12 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(jk) = &lq.job_kinds {
|
||||
sqlb.and_where_in(
|
||||
"kind",
|
||||
&jk.split(',').into_iter().map(quote).collect::<Vec<_>>(),
|
||||
);
|
||||
let quoted: Vec<_> = jk.values.iter().map(|v| quote(v)).collect();
|
||||
if jk.negated {
|
||||
sqlb.and_where_not_in("kind", "ed);
|
||||
} else {
|
||||
sqlb.and_where_in("kind", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(args) = &lq.args {
|
||||
@@ -134,11 +212,21 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(tk) = &lq.trigger_kind {
|
||||
sqlb.and_where_eq("trigger_kind", "?".bind(&format!("{}", tk)));
|
||||
let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect();
|
||||
if tk.negated {
|
||||
sqlb.and_where(not_in_nullable("trigger_kind", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("trigger_kind", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tp) = &lq.trigger_path {
|
||||
sqlb.and_where_eq("trigger", "?".bind(tp));
|
||||
let quoted: Vec<_> = tp.values.iter().map(|v| quote(v)).collect();
|
||||
if tp.negated {
|
||||
sqlb.and_where(not_in_nullable("trigger", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("trigger", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
sqlb
|
||||
@@ -187,25 +275,71 @@ pub fn filter_list_completed_query(
|
||||
|
||||
if let Some(label) = &lq.label {
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
let wh = format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') label WHERE jsonb_typeof(result->'wm_labels') = 'array' AND label LIKE '{}')",
|
||||
&label.replace("*", "%").replace("'", "''")
|
||||
);
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
sqlb.and_where(&wh);
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if label.negated {
|
||||
format!(
|
||||
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if label.negated { " AND " } else { " OR " };
|
||||
if !label.negated {
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
}
|
||||
sqlb.and_where(format!("({})", clauses.join(sep)));
|
||||
} else if label.negated {
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("NOT (result->'wm_labels' ? '{}')", v.replace("'", "''")))
|
||||
.collect();
|
||||
sqlb.and_where(format!("({})", clauses.join(" AND ")));
|
||||
} else {
|
||||
let mut wh = format!("result->'wm_labels' ? ");
|
||||
wh.push_str(&format!("'{}'", &label.replace("'", "''")));
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''")))
|
||||
.collect();
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
sqlb.and_where(&wh);
|
||||
sqlb.and_where(format!("({})", clauses.join(" OR ")));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(worker) = &lq.worker {
|
||||
let quoted: Vec<_> = worker.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
sqlb.and_where_like_left("v2_job_completed.worker", worker.replace("*", "%"));
|
||||
let clauses: Vec<_> = worker
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if worker.negated {
|
||||
format!("v2_job_completed.worker NOT LIKE '{p}'")
|
||||
} else {
|
||||
format!("v2_job_completed.worker LIKE '{p}'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if worker.negated { " AND " } else { " OR " };
|
||||
let inner = clauses.join(sep);
|
||||
if worker.negated {
|
||||
sqlb.and_where(format!("(v2_job_completed.worker IS NULL OR ({inner}))"));
|
||||
} else {
|
||||
sqlb.and_where(format!("({inner})"));
|
||||
}
|
||||
} else if worker.negated {
|
||||
sqlb.and_where(not_in_nullable("v2_job_completed.worker", "ed));
|
||||
} else {
|
||||
sqlb.and_where_eq("v2_job_completed.worker", "?".bind(worker));
|
||||
sqlb.and_where_in("v2_job_completed.worker", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,24 +354,68 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
|
||||
if let Some(ps) = &lq.script_path_start {
|
||||
sqlb.and_where_like_left("runnable_path", ps);
|
||||
let clauses: Vec<_> = ps
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let e = v.replace("'", "''");
|
||||
if ps.negated {
|
||||
format!("runnable_path NOT LIKE '{e}%'")
|
||||
} else {
|
||||
format!("runnable_path LIKE '{e}%'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if ps.negated { " AND " } else { " OR " };
|
||||
let inner = clauses.join(sep);
|
||||
if ps.negated {
|
||||
sqlb.and_where(format!("(runnable_path IS NULL OR ({inner}))"));
|
||||
} else {
|
||||
sqlb.and_where(format!("({inner})"));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &lq.script_path_exact {
|
||||
sqlb.and_where_eq("runnable_path", "?".bind(p));
|
||||
let quoted: Vec<_> = p.values.iter().map(|v| quote(v)).collect();
|
||||
if p.negated {
|
||||
sqlb.and_where(not_in_nullable("runnable_path", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("runnable_path", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(h) = &lq.script_hash {
|
||||
sqlb.and_where_eq("runnable_id", "?".bind(h));
|
||||
}
|
||||
if let Some(t) = &lq.tag {
|
||||
let quoted: Vec<_> = t.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
sqlb.and_where_like_left("v2_job.tag", t.replace("*", "%"));
|
||||
let clauses: Vec<_> = t
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if t.negated {
|
||||
format!("v2_job.tag NOT LIKE '{p}'")
|
||||
} else {
|
||||
format!("v2_job.tag LIKE '{p}'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if t.negated { " AND " } else { " OR " };
|
||||
sqlb.and_where(format!("({})", clauses.join(sep)));
|
||||
} else if t.negated {
|
||||
sqlb.and_where_not_in("v2_job.tag", "ed);
|
||||
} else {
|
||||
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
|
||||
sqlb.and_where_in("v2_job.tag", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cb) = &lq.created_by {
|
||||
sqlb.and_where_eq("created_by", "?".bind(cb));
|
||||
let quoted: Vec<_> = cb.values.iter().map(|v| quote(v)).collect();
|
||||
if cb.negated {
|
||||
sqlb.and_where_not_in("created_by", "ed);
|
||||
} else {
|
||||
sqlb.and_where_in("created_by", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(r) = &lq.success {
|
||||
if *r {
|
||||
@@ -308,10 +486,12 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
}
|
||||
if let Some(jk) = &lq.job_kinds {
|
||||
sqlb.and_where_in(
|
||||
"kind",
|
||||
&jk.split(',').into_iter().map(quote).collect::<Vec<_>>(),
|
||||
);
|
||||
let quoted: Vec<_> = jk.values.iter().map(|v| quote(v)).collect();
|
||||
if jk.negated {
|
||||
sqlb.and_where_not_in("kind", "ed);
|
||||
} else {
|
||||
sqlb.and_where_in("kind", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(args) = &lq.args {
|
||||
@@ -327,11 +507,21 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
|
||||
if let Some(tk) = &lq.trigger_kind {
|
||||
sqlb.and_where_eq("trigger_kind", "?".bind(&format!("{}", tk)));
|
||||
let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect();
|
||||
if tk.negated {
|
||||
sqlb.and_where(not_in_nullable("trigger_kind", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("trigger_kind", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tp) = &lq.trigger_path {
|
||||
sqlb.and_where_eq("trigger", "?".bind(tp));
|
||||
let quoted: Vec<_> = tp.values.iter().map(|v| quote(v)).collect();
|
||||
if tp.negated {
|
||||
sqlb.and_where(not_in_nullable("trigger", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("trigger", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
sqlb
|
||||
@@ -375,6 +565,7 @@ pub fn list_completed_jobs_query(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::negated_filter::NegatedListFilter;
|
||||
|
||||
fn empty_queue_query() -> ListQueueQuery {
|
||||
ListQueueQuery {
|
||||
@@ -478,7 +669,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_script_path_start() {
|
||||
let lq = ListQueueQuery {
|
||||
script_path_start: Some("f/test".to_string()),
|
||||
script_path_start: Some(NegatedListFilter::positive(vec!["f/test".to_string()])),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -495,7 +686,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_script_path_exact() {
|
||||
let lq = ListQueueQuery {
|
||||
script_path_exact: Some("f/test/script".to_string()),
|
||||
script_path_exact: Some(NegatedListFilter::positive(vec![
|
||||
"f/test/script".to_string()
|
||||
])),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -510,10 +703,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_running() {
|
||||
let lq = ListQueueQuery {
|
||||
running: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { running: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -527,7 +717,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_job_kinds() {
|
||||
let lq = ListQueueQuery {
|
||||
job_kinds: Some("script,flow".to_string()),
|
||||
job_kinds: Some(NegatedListFilter::positive(vec![
|
||||
"script".to_string(),
|
||||
"flow".to_string(),
|
||||
])),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -543,10 +736,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_suspended() {
|
||||
let lq = ListQueueQuery {
|
||||
suspended: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { suspended: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -559,10 +749,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_is_not_schedule() {
|
||||
let lq = ListQueueQuery {
|
||||
is_not_schedule: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { is_not_schedule: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -575,10 +762,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_has_null_parent() {
|
||||
let lq = ListQueueQuery {
|
||||
has_null_parent: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { has_null_parent: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -591,10 +775,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_is_flow_step_true() {
|
||||
let lq = ListQueueQuery {
|
||||
is_flow_step: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { is_flow_step: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -607,10 +788,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_is_flow_step_false() {
|
||||
let lq = ListQueueQuery {
|
||||
is_flow_step: Some(false),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { is_flow_step: Some(false), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -623,10 +801,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_admins_all_workspaces() {
|
||||
let lq = ListQueueQuery {
|
||||
all_workspaces: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { all_workspaces: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -639,10 +814,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_non_admins_ignores_all_workspaces() {
|
||||
let lq = ListQueueQuery {
|
||||
all_workspaces: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { all_workspaces: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -695,10 +867,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_completed_filter_success_true() {
|
||||
let lq = ListCompletedQuery {
|
||||
success: Some(true),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let lq = ListCompletedQuery { success: Some(true), ..empty_completed_query() };
|
||||
let sqlb = filter_list_completed_query(
|
||||
SqlBuilder::select_from("v2_job_completed").clone(),
|
||||
&lq,
|
||||
@@ -711,10 +880,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_completed_filter_success_false() {
|
||||
let lq = ListCompletedQuery {
|
||||
success: Some(false),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let lq = ListCompletedQuery { success: Some(false), ..empty_completed_query() };
|
||||
let sqlb = filter_list_completed_query(
|
||||
SqlBuilder::select_from("v2_job_completed").clone(),
|
||||
&lq,
|
||||
@@ -739,7 +905,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_completed_filter_label() {
|
||||
let lq = ListCompletedQuery {
|
||||
label: Some("deploy".to_string()),
|
||||
label: Some(NegatedListFilter::positive(vec!["deploy".to_string()])),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let sqlb = filter_list_completed_query(
|
||||
@@ -754,10 +920,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_completed_filter_is_skipped() {
|
||||
let lq = ListCompletedQuery {
|
||||
is_skipped: Some(true),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let lq = ListCompletedQuery { is_skipped: Some(true), ..empty_completed_query() };
|
||||
let sqlb = filter_list_completed_query(
|
||||
SqlBuilder::select_from("v2_job_completed").clone(),
|
||||
&lq,
|
||||
|
||||
@@ -27,6 +27,8 @@ use windmill_common::{
|
||||
|
||||
use windmill_api_sse::{Job, JobExtended};
|
||||
|
||||
use crate::negated_filter::NegatedListFilter;
|
||||
|
||||
// ------------ RunJobQuery ------------
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
@@ -89,10 +91,10 @@ impl RunJobQuery {
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ListQueueQuery {
|
||||
pub script_path_start: Option<String>,
|
||||
pub script_path_exact: Option<String>,
|
||||
pub script_path_start: Option<NegatedListFilter<String>>,
|
||||
pub script_path_exact: Option<NegatedListFilter<String>>,
|
||||
pub script_hash: Option<String>,
|
||||
pub created_by: Option<String>,
|
||||
pub created_by: Option<NegatedListFilter<String>>,
|
||||
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
@@ -103,12 +105,12 @@ pub struct ListQueueQuery {
|
||||
pub schedule_path: Option<String>,
|
||||
pub parent_job: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub job_kinds: Option<String>,
|
||||
pub job_kinds: Option<NegatedListFilter<String>>,
|
||||
pub suspended: Option<bool>,
|
||||
pub worker: Option<String>,
|
||||
pub worker: Option<NegatedListFilter<String>>,
|
||||
// filter by matching a subset of the args using base64 encoded json subset
|
||||
pub args: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub tag: Option<NegatedListFilter<String>>,
|
||||
pub scheduled_for_before_now: Option<bool>,
|
||||
pub all_workspaces: Option<bool>,
|
||||
pub is_flow_step: Option<bool>,
|
||||
@@ -116,17 +118,17 @@ pub struct ListQueueQuery {
|
||||
pub is_not_schedule: Option<bool>,
|
||||
pub concurrency_key: Option<String>,
|
||||
pub allow_wildcards: Option<bool>,
|
||||
pub trigger_kind: Option<JobTriggerKind>,
|
||||
pub trigger_path: Option<String>,
|
||||
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub include_args: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ListCompletedQuery {
|
||||
pub script_path_start: Option<String>,
|
||||
pub script_path_exact: Option<String>,
|
||||
pub script_path_start: Option<NegatedListFilter<String>>,
|
||||
pub script_path_exact: Option<NegatedListFilter<String>>,
|
||||
pub script_hash: Option<String>,
|
||||
pub created_by: Option<String>,
|
||||
pub created_by: Option<NegatedListFilter<String>>,
|
||||
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
@@ -142,7 +144,7 @@ pub struct ListCompletedQuery {
|
||||
pub running: Option<bool>,
|
||||
pub parent_job: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub job_kinds: Option<String>,
|
||||
pub job_kinds: Option<NegatedListFilter<String>>,
|
||||
pub is_skipped: Option<bool>,
|
||||
pub is_flow_step: Option<bool>,
|
||||
pub suspended: Option<bool>,
|
||||
@@ -151,17 +153,17 @@ pub struct ListCompletedQuery {
|
||||
pub args: Option<String>,
|
||||
// filter by matching a subset of the result using base64 encoded json subset
|
||||
pub result: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub tag: Option<NegatedListFilter<String>>,
|
||||
pub scheduled_for_before_now: Option<bool>,
|
||||
pub all_workspaces: Option<bool>,
|
||||
pub has_null_parent: Option<bool>,
|
||||
pub label: Option<String>,
|
||||
pub label: Option<NegatedListFilter<String>>,
|
||||
pub is_not_schedule: Option<bool>,
|
||||
pub concurrency_key: Option<String>,
|
||||
pub worker: Option<String>,
|
||||
pub worker: Option<NegatedListFilter<String>>,
|
||||
pub allow_wildcards: Option<bool>,
|
||||
pub trigger_kind: Option<JobTriggerKind>,
|
||||
pub trigger_path: Option<String>,
|
||||
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub include_args: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -578,8 +580,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_decode_payload_valid() {
|
||||
let payload = base64::engine::general_purpose::STANDARD
|
||||
.encode(r#"{"key": "value"}"#);
|
||||
let payload = base64::engine::general_purpose::STANDARD.encode(r#"{"key": "value"}"#);
|
||||
let result: HashMap<String, serde_json::Value> = decode_payload(payload).unwrap();
|
||||
assert_eq!(result["key"], json!("value"));
|
||||
}
|
||||
@@ -644,22 +645,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_run_job_query_payload_as_args_valid() {
|
||||
let encoded = base64::engine::general_purpose::STANDARD
|
||||
.encode(r#"{"x": 42}"#);
|
||||
let q = RunJobQuery {
|
||||
payload: Some(encoded),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(r#"{"x": 42}"#);
|
||||
let q = RunJobQuery { payload: Some(encoded), ..Default::default() };
|
||||
let result = q.payload_as_args().unwrap();
|
||||
assert!(result.contains_key("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_job_query_payload_as_args_invalid() {
|
||||
let q = RunJobQuery {
|
||||
payload: Some("invalid!!!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let q = RunJobQuery { payload: Some("invalid!!!".to_string()), ..Default::default() };
|
||||
assert!(q.payload_as_args().is_err());
|
||||
}
|
||||
|
||||
@@ -668,10 +662,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_list_completed_to_queue_query_conversion() {
|
||||
let lcq = ListCompletedQuery {
|
||||
script_path_start: Some("f/test".to_string()),
|
||||
script_path_start: Some(NegatedListFilter::positive(vec!["f/test".to_string()])),
|
||||
script_path_exact: None,
|
||||
script_hash: None,
|
||||
created_by: Some("admin".to_string()),
|
||||
created_by: Some(NegatedListFilter::positive(vec!["admin".to_string()])),
|
||||
started_before: None,
|
||||
started_after: None,
|
||||
created_before: Some(chrono::Utc::now()),
|
||||
@@ -687,14 +681,17 @@ mod tests {
|
||||
running: Some(true),
|
||||
parent_job: None,
|
||||
order_desc: Some(true),
|
||||
job_kinds: Some("script,flow".to_string()),
|
||||
job_kinds: Some(NegatedListFilter::positive(vec![
|
||||
"script".to_string(),
|
||||
"flow".to_string(),
|
||||
])),
|
||||
is_skipped: None,
|
||||
is_flow_step: None,
|
||||
suspended: None,
|
||||
schedule_path: None,
|
||||
args: None,
|
||||
result: None,
|
||||
tag: Some("custom".to_string()),
|
||||
tag: Some(NegatedListFilter::positive(vec!["custom".to_string()])),
|
||||
scheduled_for_before_now: None,
|
||||
all_workspaces: None,
|
||||
has_null_parent: None,
|
||||
@@ -709,11 +706,24 @@ mod tests {
|
||||
};
|
||||
|
||||
let lqq: ListQueueQuery = lcq.into();
|
||||
assert_eq!(lqq.script_path_start, Some("f/test".to_string()));
|
||||
assert_eq!(lqq.created_by, Some("admin".to_string()));
|
||||
assert_eq!(
|
||||
lqq.script_path_start
|
||||
.as_ref()
|
||||
.and_then(|f| f.values.first().cloned()),
|
||||
Some("f/test".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lqq.created_by
|
||||
.as_ref()
|
||||
.and_then(|f| f.values.first().cloned()),
|
||||
Some("admin".to_string())
|
||||
);
|
||||
assert_eq!(lqq.running, Some(true));
|
||||
assert_eq!(lqq.job_kinds, Some("script,flow".to_string()));
|
||||
assert_eq!(lqq.tag, Some("custom".to_string()));
|
||||
assert_eq!(lqq.job_kinds.as_ref().map(|f| f.values.len()), Some(2));
|
||||
assert_eq!(
|
||||
lqq.tag.as_ref().and_then(|f| f.values.first().cloned()),
|
||||
Some("custom".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_super_admin, ApiAuthed};
|
||||
use windmill_common::DB;
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
routing::{delete, get, post},
|
||||
@@ -18,8 +16,10 @@ use serde::{Deserialize, Serialize};
|
||||
use sql_builder::{prelude::Bind, SqlBuilder};
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use std::str::FromStr;
|
||||
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_super_admin, ApiAuthed};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
@@ -486,8 +486,15 @@ pub struct ListScheduleQuery {
|
||||
pub per_page: Option<usize>,
|
||||
pub path: Option<String>,
|
||||
pub is_flow: Option<bool>,
|
||||
// filter by matching a subset of the args using base64 encoded json subset
|
||||
pub args: Option<String>,
|
||||
pub path_start: Option<String>,
|
||||
// exact match on schedule path
|
||||
pub schedule_path: Option<String>,
|
||||
// filter on description (pattern match)
|
||||
pub description: Option<String>,
|
||||
// filter on summary (pattern match)
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -543,6 +550,18 @@ async fn list_schedule(
|
||||
if let Some(path_start) = &lsq.path_start {
|
||||
sqlb.and_where_like_left("path", path_start);
|
||||
}
|
||||
if let Some(schedule_path) = &lsq.schedule_path {
|
||||
sqlb.and_where_eq("path", "?".bind(schedule_path));
|
||||
}
|
||||
if let Some(description) = &lsq.description {
|
||||
sqlb.and_where(&format!(
|
||||
"description ILIKE '%{}%'",
|
||||
description.replace("'", "''")
|
||||
));
|
||||
}
|
||||
if let Some(summary) = &lsq.summary {
|
||||
sqlb.and_where(&format!("summary ILIKE '%{}%'", summary.replace("'", "''")));
|
||||
}
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let rows = sqlx::query_as::<_, ScheduleLight>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
|
||||
@@ -30,7 +30,6 @@ use uuid::Uuid;
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_types::s3::LargeFileStorage;
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE};
|
||||
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
|
||||
@@ -55,6 +54,7 @@ use windmill_dep_map::scoped_dependency_map::{
|
||||
DependencyDependent, DependencyMap, ScopedDependencyMap,
|
||||
};
|
||||
use windmill_git_sync::{handle_deployment_metadata, handle_fork_branch_creation, DeployedObject};
|
||||
use windmill_types::s3::LargeFileStorage;
|
||||
|
||||
use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -3010,8 +3010,8 @@ async fn clone_resource_types(
|
||||
target_workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension)
|
||||
SELECT $2, name, schema, description, edited_at, created_by, format_extension
|
||||
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)
|
||||
SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset
|
||||
FROM resource_type
|
||||
WHERE workspace_id = $1",
|
||||
source_workspace_id,
|
||||
@@ -5254,7 +5254,7 @@ async fn compare_two_resource_types(
|
||||
) -> Result<ItemComparison> {
|
||||
// Get resource type from each workspace
|
||||
let source_resource_type = sqlx::query!(
|
||||
"SELECT schema, description, format_extension
|
||||
"SELECT schema, description, format_extension, is_fileset
|
||||
FROM resource_type
|
||||
WHERE workspace_id = $1 AND name = $2",
|
||||
source_workspace_id,
|
||||
@@ -5264,7 +5264,7 @@ async fn compare_two_resource_types(
|
||||
.await?;
|
||||
|
||||
let target_resource_type = sqlx::query!(
|
||||
"SELECT schema, description, format_extension
|
||||
"SELECT schema, description, format_extension, is_fileset
|
||||
FROM resource_type
|
||||
WHERE workspace_id = $1 AND name = $2",
|
||||
fork_workspace_id,
|
||||
@@ -5280,6 +5280,7 @@ async fn compare_two_resource_types(
|
||||
if source.schema != target.schema
|
||||
|| source.description != target.description
|
||||
|| source.format_extension != target.format_extension
|
||||
|| source.is_fileset != target.is_fileset
|
||||
{
|
||||
has_changes = true;
|
||||
}
|
||||
|
||||
@@ -28049,6 +28049,7 @@ components:
|
||||
name: trigger_kind
|
||||
description: trigger kind (schedule, http, websocket...)
|
||||
in: query
|
||||
x-go-name: JobTriggerKindParam
|
||||
schema: *ref_160
|
||||
OrderDesc:
|
||||
name: order_desc
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.642.0
|
||||
version: 1.644.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -4091,6 +4091,21 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: path
|
||||
description: exact path match filter
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: description
|
||||
description: pattern match filter for description field (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: value
|
||||
description: pattern match filter for non-secret variable values (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
responses:
|
||||
@@ -5090,6 +5105,21 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: path
|
||||
description: exact path match filter
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: description
|
||||
description: pattern match filter for description field (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: value
|
||||
description: JSONB subset match filter using base64 encoded JSON
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: resource list
|
||||
@@ -5214,10 +5244,19 @@ paths:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: map from resource type to file ext
|
||||
description: map from resource type to file resource info
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
format_extension:
|
||||
type: string
|
||||
nullable: true
|
||||
is_fileset:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/resources/type/delete/{path}:
|
||||
delete:
|
||||
@@ -11120,7 +11159,7 @@ paths:
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- $ref: "#/components/parameters/ArgsFilter"
|
||||
- name: path
|
||||
description: filter by path
|
||||
description: filter by path (script path)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -11134,6 +11173,21 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: schedule_path
|
||||
description: exact match on the schedule's path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: description
|
||||
description: pattern match filter for description field (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: summary
|
||||
description: pattern match filter for summary field (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: schedule list
|
||||
@@ -16885,6 +16939,16 @@ paths:
|
||||
description: Filter by asset kinds (multiple values allowed)
|
||||
schema:
|
||||
type: string
|
||||
- name: path
|
||||
in: query
|
||||
description: exact path match filter
|
||||
schema:
|
||||
type: string
|
||||
- name: columns
|
||||
in: query
|
||||
description: JSONB subset match filter for columns using base64 encoded JSON
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: paginated assets in the workspace
|
||||
@@ -17258,10 +17322,11 @@ components:
|
||||
type: integer
|
||||
JobTriggerKind:
|
||||
name: trigger_kind
|
||||
description: trigger kind (schedule, http, websocket...)
|
||||
description: "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')"
|
||||
in: query
|
||||
x-go-name: JobTriggerKindParam
|
||||
schema:
|
||||
$ref: "#/components/schemas/JobTriggerKind"
|
||||
type: string
|
||||
OrderDesc:
|
||||
name: order_desc
|
||||
description: order by desc order (default true)
|
||||
@@ -17270,19 +17335,19 @@ components:
|
||||
type: boolean
|
||||
CreatedBy:
|
||||
name: created_by
|
||||
description: mask to filter exact matching user creator
|
||||
description: "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
Label:
|
||||
name: label
|
||||
description: mask to filter exact matching job's label (job labels are completed jobs with as a result an object containing a string in the array at key 'wm_labels')
|
||||
description: "filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
Worker:
|
||||
name: worker
|
||||
description: worker this job was ran on
|
||||
description: "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17348,7 +17413,7 @@ components:
|
||||
type: string
|
||||
ScriptStartPath:
|
||||
name: script_path_start
|
||||
description: mask to filter matching starting path
|
||||
description: "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17360,13 +17425,13 @@ components:
|
||||
type: string
|
||||
TriggerPath:
|
||||
name: trigger_path
|
||||
description: mask to filter by trigger path
|
||||
description: "filter by trigger path. Supports comma-separated list (e.g. 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
ScriptExactPath:
|
||||
name: script_path_exact
|
||||
description: mask to filter exact matching path
|
||||
description: "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17481,7 +17546,7 @@ components:
|
||||
type: string
|
||||
Tag:
|
||||
name: tag
|
||||
description: filter on jobs with a given tag/worker group
|
||||
description: "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17525,9 +17590,7 @@ components:
|
||||
enum: [Create, Update, Delete, Execute]
|
||||
JobKinds:
|
||||
name: job_kinds
|
||||
description:
|
||||
filter on job kind (values 'preview', 'script', 'dependencies', 'flow')
|
||||
separated by,
|
||||
description: "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -19832,6 +19895,8 @@ components:
|
||||
format: date-time
|
||||
format_extension:
|
||||
type: string
|
||||
is_fileset:
|
||||
type: boolean
|
||||
required:
|
||||
- name
|
||||
|
||||
@@ -19841,6 +19906,8 @@ components:
|
||||
schema: {}
|
||||
description:
|
||||
type: string
|
||||
is_fileset:
|
||||
type: boolean
|
||||
|
||||
Schedule:
|
||||
type: object
|
||||
|
||||
@@ -15,7 +15,10 @@ use sqlx::{
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
pub use windmill_common::db::DB;
|
||||
use windmill_common::{error::Error, utils::{generate_lock_id, GIT_VERSION}};
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
utils::{generate_lock_id, GIT_VERSION},
|
||||
};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use windmill_api_auth::{ApiAuthed, OptJobAuthed};
|
||||
@@ -255,8 +258,7 @@ pub async fn migrate(
|
||||
if let Err(err) = sqlx::query!(
|
||||
"DELETE FROM _sqlx_migrations WHERE
|
||||
version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR
|
||||
version=20250201145631 OR version=20250201145632 OR version=20251006143821 OR
|
||||
version=20260207000001 OR version=20260207000002 OR version=20260207000003 OR version=20260207000004"
|
||||
version=20250201145631 OR version=20250201145632 OR version=20251006143821"
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
@@ -264,6 +266,31 @@ pub async fn migrate(
|
||||
tracing::info!("Could not remove sqlx migrations: {err:#}");
|
||||
}
|
||||
|
||||
// For migrations that were replaced (same version, new content), only delete if
|
||||
// the stored checksum doesn't match the current file — i.e., it's a stale record
|
||||
// from the old broken version. Once the new migration is applied, the checksum
|
||||
// matches and the record is kept, avoiding expensive re-application on every start.
|
||||
let migrator = sqlx::migrate!("../migrations");
|
||||
let potentially_stale: &[i64] = &[
|
||||
20260207000001,
|
||||
20260207000002,
|
||||
20260207000003,
|
||||
20260207000004,
|
||||
];
|
||||
for m in migrator.migrations.iter() {
|
||||
if potentially_stale.contains(&m.version) {
|
||||
if let Err(err) =
|
||||
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = $1 AND checksum != $2")
|
||||
.bind(m.version)
|
||||
.bind(&*m.checksum)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::info!("Could not clean up stale migration {}: {err:#}", m.version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = killpill_rx.recv() => {
|
||||
tracing::info!("Killpill received, stopping migration");
|
||||
@@ -309,12 +336,11 @@ pub async fn wait_for_migrations(
|
||||
let mut attempts = 0;
|
||||
|
||||
loop {
|
||||
let is_applied: Result<Option<bool>, sqlx::Error> = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = $1)",
|
||||
)
|
||||
.bind(latest_version)
|
||||
.fetch_one(db)
|
||||
.await;
|
||||
let is_applied: Result<Option<bool>, sqlx::Error> =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = $1)")
|
||||
.bind(latest_version)
|
||||
.fetch_one(db)
|
||||
.await;
|
||||
|
||||
match is_applied {
|
||||
Ok(Some(true)) => {
|
||||
|
||||
@@ -1933,7 +1933,7 @@ async fn count_completed_jobs_detail(
|
||||
|
||||
if let Some(after_s_ago) = query.completed_after_s_ago {
|
||||
let after = Utc::now() - chrono::Duration::seconds(after_s_ago);
|
||||
sqlb.and_where_gt("ended_at", "?".bind(&after.to_rfc3339()));
|
||||
sqlb.and_where_gt("completed_at", "?".bind(&after.to_rfc3339()));
|
||||
}
|
||||
|
||||
if let Some(success) = query.success {
|
||||
|
||||
@@ -98,6 +98,7 @@ pub struct ResourceType {
|
||||
pub created_by: Option<String>,
|
||||
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub format_extension: Option<String>,
|
||||
pub is_fileset: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -106,12 +107,14 @@ pub struct CreateResourceType {
|
||||
pub schema: Option<serde_json::Value>,
|
||||
pub description: Option<String>,
|
||||
pub format_extension: Option<String>,
|
||||
pub is_fileset: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditResourceType {
|
||||
pub schema: Option<serde_json::Value>,
|
||||
pub description: Option<String>,
|
||||
pub is_fileset: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
@@ -160,9 +163,13 @@ struct EditResource {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListResourceQuery {
|
||||
resource_type: Option<String>,
|
||||
resource_type_exclude: Option<String>,
|
||||
path_start: Option<String>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_type_exclude: Option<String>,
|
||||
pub path_start: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub description: Option<String>,
|
||||
// filter by matching a subset of the value using base64 encoded json subset
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -282,6 +289,18 @@ async fn list_resources(
|
||||
sqlb.and_where_like_left("resource.path", path_start);
|
||||
}
|
||||
|
||||
if let Some(path) = &lq.path {
|
||||
sqlb.and_where_eq("resource.path", "?".bind(path));
|
||||
}
|
||||
|
||||
if let Some(description) = &lq.description {
|
||||
sqlb.and_where("resource.description ILIKE ?".bind(&format!("%{}%", description)));
|
||||
}
|
||||
|
||||
if let Some(value) = &lq.value {
|
||||
sqlb.and_where("resource.value @> ?".bind(&value.replace("'", "''")));
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query_as::<_, ListableResource>(&sql)
|
||||
@@ -1193,29 +1212,38 @@ async fn update_resource_value(
|
||||
Ok(format!("value of resource {} updated", path))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FileResourceTypeInfo {
|
||||
pub format_extension: Option<String>,
|
||||
pub is_fileset: bool,
|
||||
}
|
||||
|
||||
async fn file_resource_ext_to_resource_type(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<HashMap<String, String>> {
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
) -> JsonResult<HashMap<String, FileResourceTypeInfo>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LocalFileResourceExtension {
|
||||
name: String,
|
||||
format_extension: Option<String>,
|
||||
is_fileset: bool,
|
||||
}
|
||||
|
||||
let r = sqlx::query_as!(LocalFileResourceExtension, "
|
||||
SELECT name, format_extension FROM resource_type WHERE format_extension IS NOT NULL AND (workspace_id = $1 OR workspace_id = 'admins')", w_id)
|
||||
SELECT name, format_extension, is_fileset FROM resource_type WHERE (format_extension IS NOT NULL OR is_fileset = true) AND (workspace_id = $1 OR workspace_id = 'admins')", w_id)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
let hashmap: HashMap<String, String> = r
|
||||
let hashmap: HashMap<String, FileResourceTypeInfo> = r
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
if let Some(format_extension) = entry.format_extension {
|
||||
Some((entry.name, format_extension))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.name,
|
||||
FileResourceTypeInfo {
|
||||
format_extension: entry.format_extension,
|
||||
is_fileset: entry.is_fileset,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1315,16 +1343,25 @@ async fn create_resource_type(
|
||||
|
||||
check_rt_path_conflict(&mut tx, &w_id, &resource_type.name).await?;
|
||||
|
||||
let is_fileset = resource_type.is_fileset.unwrap_or(false);
|
||||
|
||||
if is_fileset && resource_type.format_extension.is_some() {
|
||||
return Err(Error::BadRequest(
|
||||
"A fileset resource type cannot have a format_extension".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource_type
|
||||
(workspace_id, name, schema, description, created_by, format_extension, edited_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())",
|
||||
(workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
|
||||
w_id,
|
||||
resource_type.name,
|
||||
resource_type.schema,
|
||||
resource_type.description,
|
||||
authed.username,
|
||||
resource_type.format_extension,
|
||||
is_fileset,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1485,6 +1522,9 @@ async fn update_resource_type(
|
||||
if let Some(ndesc) = ns.description {
|
||||
sqlb.set_str("description", ndesc);
|
||||
}
|
||||
if let Some(is_fileset) = ns.is_fileset {
|
||||
sqlb.set("is_fileset", if is_fileset { "TRUE" } else { "FALSE" });
|
||||
}
|
||||
sqlb.set_str("edited_at", "now()");
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
@@ -96,7 +96,11 @@ async fn list_contextual_variables(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListVariableQuery {
|
||||
path_start: Option<String>,
|
||||
pub path_start: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub description: Option<String>,
|
||||
// filter by matching the non-encrypted value (for non-secrets only)
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_variables(
|
||||
@@ -106,33 +110,76 @@ async fn list_variables(
|
||||
Query(lq): Query<ListVariableQuery>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<ListableVariable>> {
|
||||
use sql_builder::{bind::Bind, SqlBuilder};
|
||||
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let mut sqlb = SqlBuilder::select_from("variable")
|
||||
.fields(&[
|
||||
"variable.workspace_id",
|
||||
"variable.path",
|
||||
"CASE WHEN is_secret IS TRUE THEN null ELSE variable.value::text END as value",
|
||||
"is_secret",
|
||||
"variable.description",
|
||||
"variable.extra_perms",
|
||||
"account",
|
||||
"is_oauth",
|
||||
"(now() > account.expires_at) as is_expired",
|
||||
"account.refresh_error",
|
||||
"resource.path IS NOT NULL as is_linked",
|
||||
"account.refresh_token != '' as is_refreshed",
|
||||
"variable.expires_at",
|
||||
])
|
||||
.left()
|
||||
.join("account")
|
||||
.on(&format!(
|
||||
"variable.account = account.id AND account.workspace_id = '{}'",
|
||||
w_id
|
||||
))
|
||||
.left()
|
||||
.join("resource")
|
||||
.on(&format!(
|
||||
"resource.path = variable.path AND resource.workspace_id = '{}'",
|
||||
w_id
|
||||
))
|
||||
.and_where("variable.workspace_id = ?".bind(&w_id))
|
||||
.and_where(&format!(
|
||||
"variable.path NOT LIKE 'u/' || '{}' || '/secret_arg/%'",
|
||||
authed.username
|
||||
))
|
||||
.order_by("path", false)
|
||||
.limit(per_page)
|
||||
.offset(offset)
|
||||
.clone();
|
||||
|
||||
let rows = sqlx::query_as::<_, ListableVariable>(
|
||||
"SELECT variable.workspace_id, variable.path, CASE WHEN is_secret IS TRUE THEN null ELSE variable.value::text END as value,
|
||||
is_secret, variable.description, variable.extra_perms, account, is_oauth, (now() > account.expires_at) as is_expired,
|
||||
account.refresh_error,
|
||||
resource.path IS NOT NULL as is_linked,
|
||||
account.refresh_token != '' as is_refreshed,
|
||||
variable.expires_at
|
||||
from variable
|
||||
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $1
|
||||
LEFT JOIN resource ON resource.path = variable.path AND resource.workspace_id = $1
|
||||
WHERE variable.workspace_id = $1 AND variable.path NOT LIKE 'u/' || $2 || '/secret_arg/%'
|
||||
AND variable.path LIKE $3 || '%'
|
||||
ORDER BY path
|
||||
LIMIT $4 OFFSET $5
|
||||
",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(&authed.username)
|
||||
.bind(&lq.path_start.unwrap_or_default())
|
||||
.bind(per_page as i32)
|
||||
.bind(offset as i32)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
if let Some(path_start) = &lq.path_start {
|
||||
sqlb.and_where_like_left("variable.path", path_start);
|
||||
}
|
||||
|
||||
if let Some(path) = &lq.path {
|
||||
sqlb.and_where_eq("variable.path", "?".bind(path));
|
||||
}
|
||||
|
||||
if let Some(description) = &lq.description {
|
||||
sqlb.and_where(&format!(
|
||||
"variable.description ILIKE '%{}%'",
|
||||
description.replace("'", "''")
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(value) = &lq.value {
|
||||
// Only filter on non-secret variables' value
|
||||
sqlb.and_where(&format!(
|
||||
"(is_secret = FALSE AND variable.value ILIKE '%{}%')",
|
||||
value.replace("'", "''")
|
||||
));
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query_as::<_, ListableVariable>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
|
||||
@@ -540,53 +540,48 @@ impl FlowModule {
|
||||
) -> anyhow::Result<()> {
|
||||
for module in modules {
|
||||
cb(module)?;
|
||||
match module
|
||||
let module_value = module
|
||||
.get_value()
|
||||
.map_err(|e| anyhow::anyhow!("Module '{}': {}", module.id, e))?
|
||||
{
|
||||
FlowModuleValue::ForloopFlow { modules, .. }
|
||||
| FlowModuleValue::WhileloopFlow { modules, .. } => {
|
||||
Self::traverse_modules(&modules, cb)?;
|
||||
}
|
||||
FlowModuleValue::BranchOne { branches, default, .. } => {
|
||||
for branch in branches {
|
||||
Self::traverse_modules(&branch.modules, cb)?;
|
||||
}
|
||||
Self::traverse_modules(&default, cb)?;
|
||||
}
|
||||
FlowModuleValue::BranchAll { branches, .. } => {
|
||||
for branch in branches {
|
||||
Self::traverse_modules(&branch.modules, cb)?;
|
||||
}
|
||||
}
|
||||
FlowModuleValue::AIAgent { tools, .. } => {
|
||||
for tool in tools {
|
||||
match &tool.value {
|
||||
ToolValue::FlowModule(module_value) => match module_value {
|
||||
FlowModuleValue::ForloopFlow { modules, .. }
|
||||
| FlowModuleValue::WhileloopFlow { modules, .. } => {
|
||||
Self::traverse_modules(&modules, cb)?;
|
||||
}
|
||||
FlowModuleValue::BranchOne { branches, default, .. } => {
|
||||
for branch in branches {
|
||||
Self::traverse_modules(&branch.modules, cb)?;
|
||||
}
|
||||
Self::traverse_modules(&default, cb)?;
|
||||
}
|
||||
FlowModuleValue::BranchAll { branches, .. } => {
|
||||
for branch in branches {
|
||||
Self::traverse_modules(&branch.modules, cb)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ToolValue::Mcp(_) => {}
|
||||
ToolValue::Websearch(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
.map_err(|e| anyhow::anyhow!("Module '{}': {}", module.id, e))?;
|
||||
Self::traverse_module_value(&module_value, cb)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn traverse_module_value<C: FnMut(&FlowModule) -> anyhow::Result<()>>(
|
||||
module_value: &FlowModuleValue,
|
||||
cb: &mut C,
|
||||
) -> anyhow::Result<()> {
|
||||
match module_value {
|
||||
FlowModuleValue::ForloopFlow { modules, .. }
|
||||
| FlowModuleValue::WhileloopFlow { modules, .. } => {
|
||||
Self::traverse_modules(modules, cb)?;
|
||||
}
|
||||
FlowModuleValue::BranchOne { branches, default, .. } => {
|
||||
for branch in branches {
|
||||
Self::traverse_modules(&branch.modules, cb)?;
|
||||
}
|
||||
Self::traverse_modules(default, cb)?;
|
||||
}
|
||||
FlowModuleValue::BranchAll { branches, .. } => {
|
||||
for branch in branches {
|
||||
Self::traverse_modules(&branch.modules, cb)?;
|
||||
}
|
||||
}
|
||||
FlowModuleValue::AIAgent { tools, .. } => {
|
||||
for tool in tools {
|
||||
let Some(tool_module) = Option::<FlowModule>::from(tool) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
cb(&tool_module)?;
|
||||
let tool_value = tool_module
|
||||
.get_value()
|
||||
.map_err(|e| anyhow::anyhow!("Tool module '{}': {}", tool_module.id, e))?;
|
||||
Self::traverse_module_value(&tool_value, cb)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1071,7 +1066,10 @@ impl Into<Box<RawValue>> for FlowModuleValue {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ordered_map<S>(value: &HashMap<String, InputTransform>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
pub fn ordered_map<S>(
|
||||
value: &HashMap<String, InputTransform>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
|
||||
@@ -3,8 +3,8 @@ use crate::ai::types::McpToolSource;
|
||||
use crate::ai::types::*;
|
||||
use crate::ai::utils::{
|
||||
add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow,
|
||||
update_flow_status_module_with_actions, update_flow_status_module_with_actions_success,
|
||||
FlowContext,
|
||||
is_completed_input_transform, update_flow_status_module_with_actions,
|
||||
update_flow_status_module_with_actions_success, FlowContext,
|
||||
};
|
||||
use crate::common::OccupancyMetrics;
|
||||
use crate::result_processor::handle_non_flow_job_error;
|
||||
@@ -21,7 +21,6 @@ use serde_json::value::RawValue;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::ai_types::OpenAIToolCall;
|
||||
use windmill_common::flows::InputTransform;
|
||||
use windmill_common::jobs::JobPayload;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
@@ -35,15 +34,15 @@ type McpClient = McpClientStub;
|
||||
use windmill_common::{
|
||||
client::AuthedClient,
|
||||
db::DB,
|
||||
error::{to_anyhow, Error},
|
||||
error::Error,
|
||||
flow_conversations::MessageType,
|
||||
flow_status::AgentAction,
|
||||
flows::FlowModuleValue,
|
||||
worker::{to_raw_value, Connection},
|
||||
};
|
||||
use windmill_queue::{
|
||||
get_mini_pulled_job, push, JobCompleted, MiniCompletedJob, MiniPulledJob, PushArgs,
|
||||
PushIsolationLevel,
|
||||
add_completed_job, add_completed_job_error, get_mini_pulled_job, push, MiniCompletedJob,
|
||||
MiniPulledJob, PushArgs, PushIsolationLevel,
|
||||
};
|
||||
|
||||
/// Context for tool execution containing all required references and state
|
||||
@@ -54,8 +53,9 @@ pub struct ToolExecutionContext<'a> {
|
||||
|
||||
// Job context
|
||||
pub job: &'a MiniPulledJob,
|
||||
pub parent_job: &'a Uuid,
|
||||
pub parent_job: Option<&'a Uuid>,
|
||||
pub summary: &'a Option<&'a str>,
|
||||
pub flow_step_id_override: Option<&'a str>,
|
||||
|
||||
// Execution parameters
|
||||
pub client: &'a AuthedClient,
|
||||
@@ -66,7 +66,6 @@ pub struct ToolExecutionContext<'a> {
|
||||
|
||||
// Runtime state
|
||||
pub occupancy_metrics: &'a mut OccupancyMetrics,
|
||||
pub job_completed_tx: &'a JobCompletedSender,
|
||||
pub killpill_rx: &'a mut tokio::sync::broadcast::Receiver<()>,
|
||||
|
||||
// Optional streaming & chat
|
||||
@@ -283,7 +282,9 @@ async fn execute_windmill_tool(
|
||||
module_id: tool_module.id.clone(),
|
||||
});
|
||||
|
||||
update_flow_status_module_with_actions(ctx.db, ctx.parent_job, actions).await?;
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions(ctx.db, parent_job, actions).await?;
|
||||
}
|
||||
|
||||
let raw_tool_call_args = if tool_call.function.arguments.is_empty() {
|
||||
"{}".to_string()
|
||||
@@ -301,11 +302,14 @@ async fn execute_windmill_tool(
|
||||
)
|
||||
})?;
|
||||
|
||||
let tool_value = tool_module.get_value()?;
|
||||
|
||||
// Get input transforms given by the user and merge them with AI given args
|
||||
let input_transforms = match tool_module.get_value()? {
|
||||
FlowModuleValue::Script { input_transforms, .. } => input_transforms,
|
||||
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
|
||||
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
|
||||
let input_transforms = match &tool_value {
|
||||
FlowModuleValue::Script { input_transforms, .. }
|
||||
| FlowModuleValue::RawScript { input_transforms, .. }
|
||||
| FlowModuleValue::FlowScript { input_transforms, .. }
|
||||
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
|
||||
_ => {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Unsupported tool: {}",
|
||||
@@ -331,17 +335,8 @@ async fn execute_windmill_tool(
|
||||
// Evaluate each input transform and merge with AI-provided args
|
||||
for (key, transform) in input_transforms.iter() {
|
||||
// We skip static empty / null values, those are the one the AI will fill in
|
||||
match transform {
|
||||
InputTransform::Static { value } => {
|
||||
let val = value.get().trim();
|
||||
if val.is_empty() || val == "null" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
InputTransform::Ai => {
|
||||
continue;
|
||||
}
|
||||
_ => (),
|
||||
if !is_completed_input_transform(transform) {
|
||||
continue;
|
||||
}
|
||||
let result = evaluate_input_transform::<Box<RawValue>>(
|
||||
transform,
|
||||
@@ -356,7 +351,7 @@ async fn execute_windmill_tool(
|
||||
tool_call_args.insert(key.clone(), result);
|
||||
}
|
||||
|
||||
let job_payload = match tool_module.get_value()? {
|
||||
let job_payload = match tool_value {
|
||||
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
|
||||
script_to_payload(
|
||||
script_hash,
|
||||
@@ -380,7 +375,6 @@ async fn execute_windmill_tool(
|
||||
} => {
|
||||
let path = path
|
||||
.unwrap_or_else(|| format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id));
|
||||
|
||||
raw_script_to_payload(
|
||||
path,
|
||||
content,
|
||||
@@ -394,8 +388,7 @@ async fn execute_windmill_tool(
|
||||
}
|
||||
FlowModuleValue::FlowScript { id, language, concurrency_settings, tag, .. } => {
|
||||
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
|
||||
|
||||
let payload = JobPayloadWithTag {
|
||||
JobPayloadWithTag {
|
||||
payload: JobPayload::FlowScript {
|
||||
id,
|
||||
language,
|
||||
@@ -409,8 +402,29 @@ async fn execute_windmill_tool(
|
||||
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
};
|
||||
payload
|
||||
}
|
||||
}
|
||||
FlowModuleValue::AIAgent { tools: sub_tools, .. } => {
|
||||
let has_nested_agent_tools = sub_tools.iter().any(|t| {
|
||||
matches!(
|
||||
t.value,
|
||||
windmill_common::flows::ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
|
||||
)
|
||||
});
|
||||
if has_nested_agent_tools {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent tools cannot be nested beyond 2 levels. The nested agent tool contains \
|
||||
AIAgent sub-tools, which would exceed the maximum nesting depth.".to_string()
|
||||
));
|
||||
}
|
||||
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
|
||||
JobPayloadWithTag {
|
||||
payload: JobPayload::AIAgent { path },
|
||||
tag: None,
|
||||
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::internal_err(format!(
|
||||
@@ -452,8 +466,8 @@ async fn execute_windmill_tool(
|
||||
None,
|
||||
ctx.job.schedule_path(),
|
||||
Some(ctx.job.id),
|
||||
None,
|
||||
None,
|
||||
ctx.job.root_job.or(Some(ctx.job.id)),
|
||||
ctx.job.flow_innermost_root_job.or(Some(ctx.job.id)),
|
||||
Some(job_id),
|
||||
false,
|
||||
false,
|
||||
@@ -544,7 +558,6 @@ async fn execute_windmill_tool(
|
||||
ctx.occupancy_metrics.total_duration_of_running_jobs =
|
||||
updated_occupancy.total_duration_of_running_jobs;
|
||||
|
||||
// Continue with match on handle_result
|
||||
match handle_result {
|
||||
Err(err) => {
|
||||
handle_tool_execution_error(
|
||||
@@ -627,7 +640,9 @@ async fn handle_tool_execution_error(
|
||||
.await?;
|
||||
}
|
||||
|
||||
update_flow_status_module_with_actions_success(ctx.db, ctx.parent_job, false).await?;
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
|
||||
}
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled (error case)
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false).await;
|
||||
@@ -649,23 +664,50 @@ async fn handle_tool_execution_success(
|
||||
let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok();
|
||||
|
||||
let result = if let Some(SendResult {
|
||||
result: SendResultPayload::JobCompleted(JobCompleted { result, .. }),
|
||||
..
|
||||
}) = send_result.as_ref()
|
||||
result: SendResultPayload::JobCompleted(ref jc), ..
|
||||
}) = send_result
|
||||
{
|
||||
let result = result.clone();
|
||||
ctx.job_completed_tx
|
||||
.send(send_result.unwrap().result, true)
|
||||
let result = jc.result.clone();
|
||||
// Write tool completion to the DB inline instead of forwarding through
|
||||
// the parent channel. Forwarding would deadlock for nested agents: the
|
||||
// sub-tool result would fill the parent's bounded(1) channel, leaving
|
||||
// no room for the agent's own completion from process_result.
|
||||
if jc.success {
|
||||
add_completed_job(
|
||||
ctx.db,
|
||||
&jc.job,
|
||||
true,
|
||||
false,
|
||||
sqlx::types::Json(&*jc.result),
|
||||
jc.result_columns.clone(),
|
||||
jc.mem_peak,
|
||||
jc.canceled_by.clone(),
|
||||
false,
|
||||
jc.duration,
|
||||
jc.from_cache.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
.map_err(|e| Error::internal_err(format!("Failed to add completed job: {e}")))?;
|
||||
} else {
|
||||
let error_value: serde_json::Value =
|
||||
serde_json::from_str(jc.result.get()).unwrap_or_else(|_| {
|
||||
serde_json::json!({ "message": format!("Non serializable error: {}", jc.result.get()) })
|
||||
});
|
||||
add_completed_job_error(
|
||||
ctx.db,
|
||||
&jc.job,
|
||||
jc.mem_peak,
|
||||
jc.canceled_by.clone(),
|
||||
error_value,
|
||||
ctx.worker_name,
|
||||
false,
|
||||
jc.duration,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?;
|
||||
}
|
||||
result
|
||||
} else {
|
||||
if let Some(send_result) = send_result {
|
||||
ctx.job_completed_tx
|
||||
.send(send_result.result, true)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
}
|
||||
return Err(Error::internal_err(
|
||||
"Tool job completed but no result".to_string(),
|
||||
));
|
||||
@@ -696,7 +738,9 @@ async fn handle_tool_execution_success(
|
||||
.await?;
|
||||
}
|
||||
|
||||
update_flow_status_module_with_actions_success(ctx.db, ctx.parent_job, success).await?;
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?;
|
||||
}
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled
|
||||
let content = if success {
|
||||
@@ -731,8 +775,10 @@ async fn add_tool_message_to_chat(
|
||||
.and_then(|fs| fs.memory_id)
|
||||
{
|
||||
let db_clone = ctx.db.clone();
|
||||
let step_name =
|
||||
get_step_name_from_flow(ctx.summary.as_deref(), ctx.job.flow_step_id.as_deref());
|
||||
let effective_step_id = ctx
|
||||
.flow_step_id_override
|
||||
.or(ctx.job.flow_step_id.as_deref());
|
||||
let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id);
|
||||
let content = content.to_string();
|
||||
|
||||
// Spawn task because we do not need to wait for the result
|
||||
|
||||
@@ -62,6 +62,17 @@ pub fn parse_raw_script_schema(
|
||||
Ok(to_raw_value(&schema))
|
||||
}
|
||||
|
||||
pub fn is_completed_input_transform(transform: &InputTransform) -> bool {
|
||||
match transform {
|
||||
InputTransform::Static { value } => {
|
||||
let val = value.get().trim();
|
||||
!val.is_empty() && val != "null"
|
||||
}
|
||||
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
|
||||
InputTransform::Ai => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters out properties from a JSON schema that have completed input transforms.
|
||||
/// This allows AI agents to only see and fill parameters that don't have user-configured values.
|
||||
pub fn filter_schema_by_input_transforms(
|
||||
@@ -77,14 +88,7 @@ pub fn filter_schema_by_input_transforms(
|
||||
let keys_to_remove: HashSet<String> = input_transforms
|
||||
.iter()
|
||||
.filter_map(|(key, transform)| {
|
||||
let is_completed = match transform {
|
||||
InputTransform::Static { value } => {
|
||||
let val = value.get().trim();
|
||||
!val.is_empty() && val != "null"
|
||||
}
|
||||
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
|
||||
InputTransform::Ai => false,
|
||||
};
|
||||
let is_completed = is_completed_input_transform(transform);
|
||||
if is_completed {
|
||||
Some(key.clone())
|
||||
} else {
|
||||
@@ -123,10 +127,13 @@ pub fn filter_schema_by_input_transforms(
|
||||
Ok(to_raw_value(&schema_value))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FlowJobRunnableIdAndRawFlow {
|
||||
pub runnable_id: Option<ScriptHash>,
|
||||
pub raw_flow: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
pub kind: JobKind,
|
||||
pub parent_job: Option<Uuid>,
|
||||
pub flow_step_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_flow_job_runnable_and_raw_flow(
|
||||
@@ -135,7 +142,7 @@ pub async fn get_flow_job_runnable_and_raw_flow(
|
||||
) -> windmill_common::error::Result<FlowJobRunnableIdAndRawFlow> {
|
||||
let job = sqlx::query_as!(
|
||||
FlowJobRunnableIdAndRawFlow,
|
||||
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\" FROM v2_job WHERE id = $1",
|
||||
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
|
||||
job_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
@@ -690,6 +697,7 @@ pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
|
||||
FlowModuleValue::Script { input_transforms, .. } => input_transforms,
|
||||
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
|
||||
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
|
||||
FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ use crate::{
|
||||
},
|
||||
common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier},
|
||||
handle_child::run_future_with_polling_update_job_poller,
|
||||
JobCompletedSender,
|
||||
};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -79,11 +78,57 @@ lazy_static::lazy_static! {
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
static ref AI_AGENT_TOOL_SCHEMA: Box<RawValue> = to_raw_value(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_message": { "type": "string" },
|
||||
},
|
||||
"required": ["user_message"],
|
||||
"additionalProperties": false,
|
||||
}));
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
||||
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
|
||||
|
||||
fn find_module_by_id(
|
||||
modules: &Vec<FlowModule>,
|
||||
target_id: &str,
|
||||
) -> Result<Option<FlowModule>, Error> {
|
||||
let mut found: Option<FlowModule> = None;
|
||||
FlowModule::traverse_modules(modules, &mut |module| {
|
||||
if found.is_none() && module.id == target_id {
|
||||
found = Some(module.clone());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| Error::internal_err(format!("Failed to traverse flow modules: {e}")))?;
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn find_ai_agent_tool_module_in_parent_agent(
|
||||
modules: &Vec<FlowModule>,
|
||||
parent_agent_step_id: &str,
|
||||
tool_module_id: &str,
|
||||
) -> Result<Option<FlowModule>, Error> {
|
||||
let Some(parent_agent_module) = find_module_by_id(modules, parent_agent_step_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let FlowModuleValue::AIAgent { tools, .. } = parent_agent_module.get_value()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for tool in tools {
|
||||
if tool.id == tool_module_id {
|
||||
return Ok(Option::<FlowModule>::from(&tool));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn handle_ai_agent_job(
|
||||
// connection
|
||||
conn: &Connection,
|
||||
@@ -97,7 +142,6 @@ pub async fn handle_ai_agent_job(
|
||||
canceled_by: &mut Option<CanceledBy>,
|
||||
mem_peak: &mut i32,
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
job_completed_tx: &JobCompletedSender,
|
||||
worker_dir: &str,
|
||||
base_internal_url: &str,
|
||||
worker_name: &str,
|
||||
@@ -117,26 +161,57 @@ pub async fn handle_ai_agent_job(
|
||||
return handle_credentials_check(&args.provider).await;
|
||||
}
|
||||
|
||||
let Some(flow_step_id) = &job.flow_step_id else {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent job has no flow step id".to_string(),
|
||||
));
|
||||
};
|
||||
// flow_step_id is set by the flow executor for top-level AI agents.
|
||||
// For nested AI agent tools, it's not set (to avoid triggering flow step
|
||||
// machinery on a parent that has no v2_job_status row), so we extract the
|
||||
// tool module ID from the runnable_path which has the form ".../tools/{id}".
|
||||
let flow_step_id = job
|
||||
.flow_step_id
|
||||
.as_deref()
|
||||
.or_else(|| job.runnable_path().rsplit_once("/tools/").map(|(_, id)| id))
|
||||
.ok_or_else(|| Error::internal_err("AI agent job has no flow step id".to_string()))?
|
||||
.to_string();
|
||||
let flow_step_id = &flow_step_id;
|
||||
|
||||
let Some(parent_job) = &job.parent_job else {
|
||||
let Some(immediate_parent_job) = &job.parent_job else {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent job has no parent job".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let flow_job = get_flow_job_runnable_and_raw_flow(db, &parent_job).await?;
|
||||
let mut flow_job_id = *immediate_parent_job;
|
||||
let mut flow_job = get_flow_job_runnable_and_raw_flow(db, &flow_job_id).await?;
|
||||
let direct_parent_job_kind = flow_job.kind;
|
||||
let direct_parent_job_flow_step_id = flow_job.flow_step_id.clone();
|
||||
|
||||
// If the direct parent is an AI agent (nested tool case), go one level up to the flow.
|
||||
if flow_job.kind == JobKind::AIAgent {
|
||||
let Some(parent_job_id) = flow_job.parent_job else {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent parent has no parent job".to_string(),
|
||||
));
|
||||
};
|
||||
flow_job_id = parent_job_id;
|
||||
flow_job = get_flow_job_runnable_and_raw_flow(db, &flow_job_id).await?;
|
||||
|
||||
if !matches!(
|
||||
flow_job.kind,
|
||||
JobKind::Flow | JobKind::FlowNode | JobKind::FlowPreview
|
||||
) {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent nesting beyond 2 levels is not supported. \
|
||||
Only flow → agent → nested agent tool is allowed."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let flow_data = match flow_job.kind {
|
||||
JobKind::Flow | JobKind::FlowNode => {
|
||||
cache::job::fetch_flow(db, &flow_job.kind, flow_job.runnable_id).await?
|
||||
}
|
||||
JobKind::FlowPreview => {
|
||||
cache::job::fetch_preview_flow(db, &parent_job, flow_job.raw_flow).await?
|
||||
cache::job::fetch_preview_flow(db, &flow_job_id, flow_job.raw_flow).await?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::internal_err(
|
||||
@@ -147,8 +222,18 @@ pub async fn handle_ai_agent_job(
|
||||
|
||||
let value = flow_data.value();
|
||||
|
||||
let module = value.modules.iter().find(|m| m.id == *flow_step_id);
|
||||
let summary = module.as_ref().and_then(|m| m.summary.clone());
|
||||
let module = if direct_parent_job_kind == JobKind::AIAgent {
|
||||
let parent_agent_step_id = direct_parent_job_flow_step_id.as_deref().ok_or_else(|| {
|
||||
Error::internal_err("Parent AI agent job has no flow_step_id".to_string())
|
||||
})?;
|
||||
find_ai_agent_tool_module_in_parent_agent(
|
||||
&value.modules,
|
||||
parent_agent_step_id,
|
||||
flow_step_id,
|
||||
)?
|
||||
} else {
|
||||
find_module_by_id(&value.modules, flow_step_id)?
|
||||
};
|
||||
|
||||
let Some(module) = module else {
|
||||
return Err(Error::internal_err(
|
||||
@@ -156,6 +241,8 @@ pub async fn handle_ai_agent_job(
|
||||
));
|
||||
};
|
||||
|
||||
let summary = module.summary.clone();
|
||||
|
||||
let FlowModuleValue::AIAgent { tools, .. } = module.get_value()? else {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent module is not an AI agent".to_string(),
|
||||
@@ -285,6 +372,16 @@ pub async fn handle_ai_agent_job(
|
||||
let schema = Some(parse_raw_script_schema(&content, &language)?);
|
||||
(schema, input_transforms)
|
||||
}
|
||||
FlowModuleValue::AIAgent { input_transforms, .. } => {
|
||||
// By convention for AIAgent tools, only user_message is expected to be AI-filled.
|
||||
(
|
||||
Some(
|
||||
RawValue::from_string(AI_AGENT_TOOL_SCHEMA.get().to_string())
|
||||
.expect("AI_AGENT_TOOL_SCHEMA should always be valid JSON"),
|
||||
),
|
||||
input_transforms,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Unsupported tool: {}",
|
||||
@@ -342,18 +439,24 @@ pub async fn handle_ai_agent_job(
|
||||
stream_notifier.update_flow_status_with_stream_job();
|
||||
}
|
||||
|
||||
let flow_status_job = if direct_parent_job_kind == JobKind::AIAgent {
|
||||
None
|
||||
} else {
|
||||
Some(flow_job_id)
|
||||
};
|
||||
|
||||
let agent_fut = run_agent(
|
||||
db,
|
||||
conn,
|
||||
job,
|
||||
parent_job,
|
||||
flow_status_job.as_ref(),
|
||||
Some(flow_step_id.as_str()),
|
||||
&args,
|
||||
&tools,
|
||||
&mcp_clients,
|
||||
summary.as_deref(),
|
||||
client,
|
||||
&mut inner_occupancy_metrics,
|
||||
job_completed_tx,
|
||||
worker_dir,
|
||||
base_internal_url,
|
||||
worker_name,
|
||||
@@ -391,7 +494,8 @@ pub async fn run_agent(
|
||||
|
||||
// agent job and flow data
|
||||
job: &MiniPulledJob,
|
||||
parent_job: &Uuid,
|
||||
parent_job: Option<&Uuid>,
|
||||
flow_step_id_override: Option<&str>,
|
||||
args: &AIAgentArgs,
|
||||
tools: &[Tool],
|
||||
mcp_clients: &HashMap<String, Arc<McpClient>>,
|
||||
@@ -400,7 +504,6 @@ pub async fn run_agent(
|
||||
// job execution context
|
||||
client: &AuthedClient,
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
job_completed_tx: &JobCompletedSender,
|
||||
worker_dir: &str,
|
||||
base_internal_url: &str,
|
||||
worker_name: &str,
|
||||
@@ -433,6 +536,10 @@ pub async fn run_agent(
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Effective flow_step_id: override for nested agents, otherwise from job
|
||||
let effective_flow_step_id: Option<&str> =
|
||||
flow_step_id_override.or(job.flow_step_id.as_deref());
|
||||
|
||||
// Fetch flow context for input transforms context, chat and memory
|
||||
let mut flow_context = get_flow_context(db, job).await;
|
||||
|
||||
@@ -479,7 +586,7 @@ pub async fn run_agent(
|
||||
}
|
||||
Some(Memory::Auto { context_length, .. }) => {
|
||||
// Auto mode: load from memory
|
||||
if let Some(step_id) = job.flow_step_id.as_deref() {
|
||||
if let Some(step_id) = effective_flow_step_id {
|
||||
if let Some(memory_id) = memory_id {
|
||||
// Read messages from memory
|
||||
match read_from_memory(db, &job.workspace_id, memory_id, step_id).await {
|
||||
@@ -534,9 +641,8 @@ pub async fn run_agent(
|
||||
let id_context = {
|
||||
if let Some(ref flow_status) = flow_context.flow_status {
|
||||
// Get the step ID from the AI agent's flow step
|
||||
let previous_id = job
|
||||
.flow_step_id
|
||||
.clone()
|
||||
let previous_id = effective_flow_step_id
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
Some(get_transform_context(job, &previous_id, flow_status))
|
||||
@@ -649,7 +755,7 @@ pub async fn run_agent(
|
||||
.and_then(|fs| fs.chat_input_enabled)
|
||||
.unwrap_or(false);
|
||||
|
||||
let step_name = get_step_name_from_flow(summary.as_deref(), job.flow_step_id.as_deref());
|
||||
let step_name = get_step_name_from_flow(summary.as_deref(), effective_flow_step_id);
|
||||
|
||||
let max_iterations = args
|
||||
.max_iterations
|
||||
@@ -881,8 +987,11 @@ pub async fn run_agent(
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
|
||||
update_flow_status_module_with_actions_success(db, parent_job, true).await?;
|
||||
if let Some(parent_job) = parent_job {
|
||||
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
|
||||
update_flow_status_module_with_actions_success(db, parent_job, true)
|
||||
.await?;
|
||||
}
|
||||
|
||||
content = Some(OpenAIContent::Text(response_content.clone()));
|
||||
|
||||
@@ -940,13 +1049,13 @@ pub async fn run_agent(
|
||||
job,
|
||||
parent_job,
|
||||
summary: &summary,
|
||||
flow_step_id_override,
|
||||
client,
|
||||
worker_dir,
|
||||
base_internal_url,
|
||||
worker_name,
|
||||
hostname,
|
||||
occupancy_metrics,
|
||||
job_completed_tx,
|
||||
killpill_rx,
|
||||
stream_event_processor: stream_event_processor.as_ref(),
|
||||
flow_context: &mut flow_context,
|
||||
@@ -1071,7 +1180,7 @@ pub async fn run_agent(
|
||||
// final_messages contains the complete history (old messages + new ones)
|
||||
if matches!(output_type, OutputType::Text) && !use_manual_messages {
|
||||
if let Some(Memory::Auto { context_length, .. }) = &args.memory {
|
||||
if let Some(step_id) = job.flow_step_id.as_deref() {
|
||||
if let Some(step_id) = effective_flow_step_id {
|
||||
// Extract OpenAIMessages from final_messages
|
||||
let all_messages: Vec<OpenAIMessage> =
|
||||
final_messages.iter().map(|m| m.message.clone()).collect();
|
||||
|
||||
@@ -3398,7 +3398,6 @@ pub async fn handle_queued_job(
|
||||
&mut canceled_by,
|
||||
&mut mem_peak,
|
||||
&mut *occupancy_metrics,
|
||||
&job_completed_tx,
|
||||
worker_dir,
|
||||
base_internal_url,
|
||||
worker_name,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.642.0";
|
||||
export const VERSION = "v1.644.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
+8
-32
@@ -112,39 +112,15 @@ source <(wmill completions zsh)
|
||||
|
||||
### Testing with a local `windmill-yaml-validator`
|
||||
|
||||
The CLI imports `windmill-yaml-validator` from npm (`npm:windmill-yaml-validator@1.1.0`).
|
||||
To test local changes to the validator before publishing, use the Deno compatibility
|
||||
script and import map override:
|
||||
|
||||
1. Make the validator sources Deno-compatible:
|
||||
To test local changes to the validator before publishing, use `npm link`:
|
||||
|
||||
```bash
|
||||
cd ../windmill-yaml-validator
|
||||
./deno-compat.sh
|
||||
```
|
||||
# In windmill-yaml-validator/
|
||||
npm run build
|
||||
npm link
|
||||
|
||||
2. Add the following entries to `cli/deno.json` imports:
|
||||
|
||||
```json
|
||||
"npm:windmill-yaml-validator@1.1.0": "../windmill-yaml-validator/src/index.ts",
|
||||
"ajv": "npm:ajv@^8.17.1",
|
||||
"@stoplight/yaml": "npm:@stoplight/yaml@^4.3.0"
|
||||
```
|
||||
|
||||
3. Run the CLI directly with Deno:
|
||||
|
||||
```bash
|
||||
deno run -A src/main.ts lint
|
||||
```
|
||||
|
||||
4. When done, restore everything:
|
||||
|
||||
```bash
|
||||
# Restore validator sources
|
||||
cd ../windmill-yaml-validator
|
||||
./deno-compat.sh -r
|
||||
|
||||
# Remove the 3 import map lines from cli/deno.json
|
||||
# In cli/
|
||||
npm link windmill-yaml-validator
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
@@ -156,13 +132,13 @@ cd ../windmill-yaml-validator
|
||||
**Run tests locally (full features):**
|
||||
|
||||
```bash
|
||||
deno test --allow-all --no-check
|
||||
bun test test/
|
||||
```
|
||||
|
||||
**Run tests in CI mode (minimal features, skips EE tests):**
|
||||
|
||||
```bash
|
||||
CI_MINIMAL_FEATURES=true deno test --allow-all --no-check
|
||||
CI_MINIMAL_FEATURES=true bun test test/
|
||||
```
|
||||
|
||||
| Variable | Description |
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"yaml": "^2.7.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
@@ -151,6 +152,8 @@
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/diff": ["@types/diff@5.2.3", "", {}, "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
@@ -183,6 +186,8 @@
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
Generated
+87
-96
@@ -6,16 +6,11 @@
|
||||
"": {
|
||||
"name": "wmill-dev",
|
||||
"dependencies": {
|
||||
"@ayonli/jsext": "^1.9.0",
|
||||
"@cliffy/ansi": "npm:@jsr/cliffy__ansi@1.0.0",
|
||||
"@cliffy/command": "npm:@jsr/cliffy__command@1.0.0",
|
||||
"@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0",
|
||||
"@cliffy/table": "npm:@jsr/cliffy__table@1.0.0",
|
||||
"@std/encoding": "npm:@jsr/std__encoding@1.0.10",
|
||||
"@std/log": "npm:@jsr/std__log@0.224.14",
|
||||
"@std/path": "npm:@jsr/std__path@1.1.4",
|
||||
"@std/yaml": "npm:@jsr/std__yaml@1.0.10",
|
||||
"@windmill-labs/shared-utils": "npm:@jsr/windmill-labs__shared-utils@1.0.12",
|
||||
"@windmill-labs/shared-utils": "^1.0.12",
|
||||
"diff": "^5.2.0",
|
||||
"esbuild": "0.24.2",
|
||||
"get-port": "7.1.0",
|
||||
@@ -23,6 +18,7 @@
|
||||
"minimatch": "^10.0.0",
|
||||
"open": "^10.0.0",
|
||||
"svelte": "^5.45.2",
|
||||
"tar-stream": "^3.1.7",
|
||||
"windmill-parser-wasm-csharp": "*",
|
||||
"windmill-parser-wasm-go": "*",
|
||||
"windmill-parser-wasm-java": "*",
|
||||
@@ -44,25 +40,11 @@
|
||||
"devDependencies": {
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ayonli/jsext": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@ayonli/jsext/-/jsext-1.9.0.tgz",
|
||||
"integrity": "sha512-hIu6lQhoLr5e26lmt+vzopuZffaAyb623r4+8HlN/rhXgm2ywHslzk7UHiATdfDbfPjBARkB6cfXjVEi3aav6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"ws": "^8.17.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@cliffy/ansi": {
|
||||
"name": "@jsr/cliffy__ansi",
|
||||
"version": "1.0.0",
|
||||
@@ -623,15 +605,6 @@
|
||||
"resolved": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.9.tgz",
|
||||
"integrity": "sha512-YFJJMozmORj2K91c5J9opWeh0VUwrd+Mwb7Pr0FkVCAKVLu2UhT4LyvJqWiyUT+eF+MdfqQ9F7RtQj4bXn9Smw=="
|
||||
},
|
||||
"node_modules/@jsr/std__fs": {
|
||||
"version": "1.0.23",
|
||||
"resolved": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.23.tgz",
|
||||
"integrity": "sha512-e8jspB3M44E5YhWiLCTqibBBTwVmxQaHN06WvFa/elAKm5E/LfAe8Hj5XGNC8P7a0MIPASlNJsnF1bgO/g+aqg==",
|
||||
"dependencies": {
|
||||
"@jsr/std__internal": "^1.0.12",
|
||||
"@jsr/std__path": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsr/std__internal": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz",
|
||||
@@ -671,38 +644,6 @@
|
||||
"@jsr/std__regexp": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@std/encoding": {
|
||||
"name": "@jsr/std__encoding",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz",
|
||||
"integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw=="
|
||||
},
|
||||
"node_modules/@std/log": {
|
||||
"name": "@jsr/std__log",
|
||||
"version": "0.224.14",
|
||||
"resolved": "https://npm.jsr.io/~/11/@jsr/std__log/0.224.14.tgz",
|
||||
"integrity": "sha512-EHT7E0plakyzk/gxMrwqUf3YGCCxN3Is25QrEh7toYA7qwj46R4qY7cIaDEKy8QqI5JHOFHwWXOClcPK6goIoQ==",
|
||||
"dependencies": {
|
||||
"@jsr/std__fmt": "^1.0.5",
|
||||
"@jsr/std__fs": "^1.0.11",
|
||||
"@jsr/std__io": "^0.225.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@std/path": {
|
||||
"name": "@jsr/std__path",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz",
|
||||
"integrity": "sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA==",
|
||||
"dependencies": {
|
||||
"@jsr/std__internal": "^1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@std/yaml": {
|
||||
"name": "@jsr/std__yaml",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://npm.jsr.io/~/11/@jsr/std__yaml/1.0.10.tgz",
|
||||
"integrity": "sha512-1WIM023Kvi48pvPE3UO5YcieambLgywUooLhAkkaObIcMB77F/YP2ILdl+vNfik+vElkl9znmuST9AZo8mbCpA=="
|
||||
},
|
||||
"node_modules/@stoplight/ordered-object-literal": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz",
|
||||
@@ -784,6 +725,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tar-stream": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.4.tgz",
|
||||
"integrity": "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -852,6 +803,20 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
||||
"integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react-native-b4a": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-b4a": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
|
||||
@@ -861,6 +826,20 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-abort-controller": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
@@ -1013,12 +992,27 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
},
|
||||
"node_modules/events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
@@ -1047,18 +1041,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -1263,12 +1245,6 @@
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-immediate-shim": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
|
||||
@@ -1278,6 +1254,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
|
||||
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events-universal": "^1.0.0",
|
||||
"fast-fifo": "^1.3.2",
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
@@ -1287,13 +1274,6 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sudo-prompt": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
|
||||
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.53.2",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.2.tgz",
|
||||
@@ -1321,6 +1301,26 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
|
||||
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4",
|
||||
"fast-fifo": "^1.2.0",
|
||||
"streamx": "^2.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -1484,15 +1484,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { stat, writeFile, mkdir } from "node:fs/promises";
|
||||
import { stat, readdir, writeFile, mkdir } from "node:fs/promises";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
@@ -6,17 +6,19 @@ import { Command } from "@cliffy/command";
|
||||
import { Table } from "@cliffy/table";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { sep as SEP } from "node:path";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace, validatePath } from "../../core/context.ts";
|
||||
import { resolveWorkspace } from "../../core/context.ts";
|
||||
import { GlobalOptions, isSuperset, parseFromFile } from "../../types.ts";
|
||||
import { Folder } from "../../../gen/types.gen.ts";
|
||||
|
||||
export interface FolderFile {
|
||||
summary: string | undefined;
|
||||
display_name: string | undefined;
|
||||
owners: Array<string> | undefined;
|
||||
extra_perms: { [record: string]: boolean } | undefined;
|
||||
display_name: string | undefined;
|
||||
}
|
||||
|
||||
async function list(opts: GlobalOptions & { json?: boolean }) {
|
||||
@@ -45,7 +47,7 @@ async function list(opts: GlobalOptions & { json?: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function newFolder(opts: GlobalOptions, name: string) {
|
||||
async function newFolder(opts: GlobalOptions & { summary?: string }, name: string) {
|
||||
const dirPath = `f${SEP}${name}`;
|
||||
const filePath = `${dirPath}${SEP}folder.meta.yaml`;
|
||||
try {
|
||||
@@ -54,7 +56,9 @@ async function newFolder(opts: GlobalOptions, name: string) {
|
||||
} catch (e: any) {
|
||||
if (e.message?.startsWith("File already exists")) throw e;
|
||||
}
|
||||
const template: Omit<FolderFile, "display_name"> = {
|
||||
const template: FolderFile = {
|
||||
summary: opts.summary ?? "",
|
||||
display_name: name,
|
||||
owners: [],
|
||||
extra_perms: {},
|
||||
};
|
||||
@@ -143,30 +147,72 @@ export async function pushFolder(
|
||||
}
|
||||
}
|
||||
|
||||
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
|
||||
async function push(opts: GlobalOptions, name: string) {
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
if (!validatePath(remotePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fstat = await stat(filePath);
|
||||
if (!fstat.isFile()) {
|
||||
throw new Error("file path must refer to a file.");
|
||||
const metaPath = `f${SEP}${name}${SEP}folder.meta.yaml`;
|
||||
try {
|
||||
await stat(metaPath);
|
||||
} catch {
|
||||
throw new Error(`Could not find ${metaPath}. Does the folder exist locally?`);
|
||||
}
|
||||
|
||||
console.log(colors.bold.yellow("Pushing folder..."));
|
||||
|
||||
await pushFolder(
|
||||
workspace.workspaceId,
|
||||
remotePath,
|
||||
name,
|
||||
undefined,
|
||||
parseFromFile(filePath)
|
||||
parseFromFile(metaPath)
|
||||
);
|
||||
console.log(colors.bold.underline.green("Folder pushed"));
|
||||
}
|
||||
|
||||
async function addMissing(opts: GlobalOptions & { yes?: boolean }) {
|
||||
const fDir = `f`;
|
||||
try {
|
||||
await stat(fDir);
|
||||
} catch {
|
||||
log.info("No 'f/' directory found. Nothing to do.");
|
||||
return;
|
||||
}
|
||||
const entries = await readdir(fDir, { withFileTypes: true });
|
||||
const missing: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const metaPath = `${fDir}${SEP}${entry.name}${SEP}folder.meta.yaml`;
|
||||
try {
|
||||
await stat(metaPath);
|
||||
} catch {
|
||||
missing.push(entry.name);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) {
|
||||
log.info("All folders already have a folder.meta.yaml. Nothing to do.");
|
||||
return;
|
||||
}
|
||||
log.info(`Missing folder.meta.yaml for:`);
|
||||
for (const name of missing) {
|
||||
log.info(` - ${name}`);
|
||||
}
|
||||
if (
|
||||
!opts.yes &&
|
||||
!(await Confirm.prompt({
|
||||
message: `Create ${missing.length} folder.meta.yaml file(s)?`,
|
||||
default: true,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const name of missing) {
|
||||
await newFolder(opts, name);
|
||||
}
|
||||
log.info(
|
||||
`\nCreated ${missing.length} folder.meta.yaml file(s). You can now run 'wmill sync push' to push them.`,
|
||||
);
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.description("folder related commands")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
@@ -180,12 +226,19 @@ const command = new Command()
|
||||
.action(get as any)
|
||||
.command("new", "create a new folder locally")
|
||||
.arguments("<name:string>")
|
||||
.option("--summary <summary:string>", "folder summary")
|
||||
.action(newFolder as any)
|
||||
.command(
|
||||
"push",
|
||||
"push a local folder spec. This overrides any remote versions."
|
||||
"push a local folder to the remote by name. This overrides any remote versions."
|
||||
)
|
||||
.arguments("<file_path:string> <remote_path:string>")
|
||||
.action(push as any);
|
||||
.arguments("<name:string>")
|
||||
.action(push as any)
|
||||
.command(
|
||||
"add-missing",
|
||||
"create default folder.meta.yaml for all subdirectories of f/ that are missing one"
|
||||
)
|
||||
.option("-y, --yes", "skip confirmation prompt")
|
||||
.action(addMissing as any);
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -16,6 +16,7 @@ interface HubResourceType {
|
||||
schema: string;
|
||||
app: string;
|
||||
description: string;
|
||||
is_fileset?: boolean;
|
||||
}
|
||||
|
||||
export async function pull(opts: GlobalOptions) {
|
||||
@@ -114,7 +115,8 @@ export async function pull(opts: GlobalOptions) {
|
||||
y.name === x.name &&
|
||||
typeof y.schema !== "string" &&
|
||||
deepEqual(y.schema, x.schema) &&
|
||||
y.description === x.description
|
||||
y.description === x.description &&
|
||||
(y.is_fileset ?? false) === (x.is_fileset ?? false)
|
||||
)
|
||||
) {
|
||||
log.info("skipping " + x.name + " (same as current)");
|
||||
|
||||
@@ -24,6 +24,7 @@ import { capitalize, toCamel } from "../../utils/utils.ts";
|
||||
export interface ResourceTypeFile {
|
||||
schema?: any;
|
||||
description?: string;
|
||||
is_fileset?: boolean;
|
||||
}
|
||||
|
||||
export async function pushResourceType(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { stat, writeFile } from "node:fs/promises";
|
||||
import { stat, writeFile, readdir, readFile } from "node:fs/promises";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import nodePath from "node:path";
|
||||
|
||||
import {
|
||||
GlobalOptions,
|
||||
@@ -27,6 +28,24 @@ export interface ResourceFile {
|
||||
is_oauth?: boolean; // deprecated
|
||||
}
|
||||
|
||||
async function readFilesetDirectory(dirPath: string): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
async function walk(currentPath: string, prefix: string) {
|
||||
const entries = await readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = nodePath.join(currentPath, entry.name);
|
||||
const relPath = prefix ? prefix + "/" + entry.name : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
await walk(entryPath, relPath);
|
||||
} else if (entry.isFile()) {
|
||||
result[relPath] = await readFile(entryPath, "utf-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(dirPath, "");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function pushResource(
|
||||
workspace: string,
|
||||
remotePath: string,
|
||||
@@ -46,7 +65,10 @@ export async function pushResource(
|
||||
|
||||
// Helper function to resolve inline content
|
||||
const resolveInlineContent = async () => {
|
||||
if (localResource.value["content"]?.startsWith("!inline ")) {
|
||||
if (typeof localResource.value === "string" && localResource.value.startsWith("!inline_fileset ")) {
|
||||
const dirPath = localResource.value.split(" ")[1];
|
||||
localResource.value = await readFilesetDirectory(dirPath.replaceAll("/", SEP));
|
||||
} else if (localResource.value["content"]?.startsWith("!inline ")) {
|
||||
const basePath = localResource.value["content"].split(" ")[1];
|
||||
|
||||
// If we're processing a branch-specific metadata file, read from branch-specific resource file
|
||||
|
||||
+172
-10
@@ -38,6 +38,7 @@ import {
|
||||
deepEqual,
|
||||
fetchRemoteVersion,
|
||||
isFileResource,
|
||||
isFilesetResource,
|
||||
isRawAppFile,
|
||||
isWorkspaceDependencies,
|
||||
} from "../../utils/utils.ts";
|
||||
@@ -484,11 +485,53 @@ export function extractInlineScriptsForApps(
|
||||
return [];
|
||||
}
|
||||
|
||||
type FileResourceTypeInfo = { format_extension: string | null; is_fileset: boolean };
|
||||
|
||||
function parseFileResourceTypeMap(
|
||||
raw: Record<string, string | FileResourceTypeInfo>,
|
||||
): { formatExtMap: Record<string, string>; filesetMap: Record<string, boolean> } {
|
||||
const formatExtMap: Record<string, string> = {};
|
||||
const filesetMap: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (typeof v === "string") {
|
||||
formatExtMap[k] = v;
|
||||
filesetMap[k] = false;
|
||||
} else {
|
||||
if (v.format_extension) {
|
||||
formatExtMap[k] = v.format_extension;
|
||||
}
|
||||
filesetMap[k] = v.is_fileset ?? false;
|
||||
}
|
||||
}
|
||||
return { formatExtMap, filesetMap };
|
||||
}
|
||||
|
||||
async function findFilesetResourceFile(changePath: string): Promise<string> {
|
||||
// Extract the base path before .fileset/
|
||||
const filesetIdx = changePath.indexOf(".fileset" + SEP);
|
||||
if (filesetIdx === -1) {
|
||||
throw new Error(`Not a fileset resource path: ${changePath}`);
|
||||
}
|
||||
const basePath = changePath.substring(0, filesetIdx);
|
||||
const candidates = [basePath + ".resource.json", basePath + ".resource.yaml"];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const s = await stat(candidate);
|
||||
if (s.isFile()) return candidate;
|
||||
} catch {
|
||||
// not found, try next
|
||||
}
|
||||
}
|
||||
throw new Error(`No resource metadata file found for fileset resource: ${changePath}`);
|
||||
}
|
||||
|
||||
function ZipFSElement(
|
||||
zip: JSZip,
|
||||
useYaml: boolean,
|
||||
defaultTs: "bun" | "deno",
|
||||
resourceTypeToFormatExtension: Record<string, string>,
|
||||
resourceTypeToIsFileset: Record<string, boolean>,
|
||||
ignoreCodebaseChanges: boolean,
|
||||
): DynFSElement {
|
||||
async function _internal_file(
|
||||
@@ -860,10 +903,17 @@ function ZipFSElement(
|
||||
log.error(`Failed to parse resource.yaml at path: ${p}`);
|
||||
throw error;
|
||||
}
|
||||
const resourceType = parsed["resource_type"];
|
||||
const formatExtension =
|
||||
resourceTypeToFormatExtension[parsed["resource_type"]];
|
||||
resourceTypeToFormatExtension[resourceType];
|
||||
const isFileset = resourceTypeToIsFileset[resourceType] ?? false;
|
||||
|
||||
if (formatExtension) {
|
||||
if (isFileset) {
|
||||
parsed["value"] =
|
||||
"!inline_fileset " +
|
||||
removeSuffix(p.replaceAll(SEP, "/"), ".resource.json") +
|
||||
".fileset";
|
||||
} else if (formatExtension) {
|
||||
parsed["value"]["content"] =
|
||||
"!inline " +
|
||||
removeSuffix(p.replaceAll(SEP, "/"), ".resource.json") +
|
||||
@@ -918,10 +968,37 @@ function ZipFSElement(
|
||||
log.error(`Failed to parse resource file content at path: ${p}`);
|
||||
throw error;
|
||||
}
|
||||
const resourceType = parsed["resource_type"];
|
||||
const formatExtension =
|
||||
resourceTypeToFormatExtension[parsed["resource_type"]];
|
||||
resourceTypeToFormatExtension[resourceType];
|
||||
const isFileset = resourceTypeToIsFileset[resourceType] ?? false;
|
||||
|
||||
if (formatExtension) {
|
||||
if (isFileset && typeof parsed["value"] === "object" && parsed["value"] !== null) {
|
||||
const filesetBasePath =
|
||||
removeSuffix(finalPath, ".resource.json") + ".fileset";
|
||||
// Push directory entry for the fileset
|
||||
r.push({
|
||||
isDirectory: true,
|
||||
path: filesetBasePath,
|
||||
async *getChildren() {
|
||||
for (const [relPath, fileContent] of Object.entries(parsed["value"])) {
|
||||
if (typeof fileContent === "string") {
|
||||
yield {
|
||||
isDirectory: false,
|
||||
path: path.join(filesetBasePath, relPath),
|
||||
async *getChildren() {},
|
||||
async getContentText() {
|
||||
return fileContent;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
async getContentText() {
|
||||
throw new Error("Cannot get content of directory");
|
||||
},
|
||||
});
|
||||
} else if (formatExtension) {
|
||||
const fileContent: string = parsed["value"]["content"];
|
||||
if (typeof fileContent === "string") {
|
||||
r.push({
|
||||
@@ -1058,6 +1135,7 @@ export async function elementsToMap(
|
||||
const path = entry.path;
|
||||
if (
|
||||
!isFileResource(path) &&
|
||||
!isFilesetResource(path) &&
|
||||
!isRawAppFile(path) &&
|
||||
!isWorkspaceDependencies(path)
|
||||
) {
|
||||
@@ -1103,7 +1181,7 @@ export async function elementsToMap(
|
||||
}
|
||||
}
|
||||
|
||||
if (skips.skipResources && isFileResource(path)) continue;
|
||||
if (skips.skipResources && (isFileResource(path) || isFilesetResource(path))) continue;
|
||||
|
||||
const ext = json ? ".json" : ".yaml";
|
||||
if (!skips.includeSchedules && path.endsWith(".schedule" + ext)) continue;
|
||||
@@ -1715,10 +1793,14 @@ export async function pull(
|
||||
);
|
||||
|
||||
let resourceTypeToFormatExtension: Record<string, string> = {};
|
||||
let resourceTypeToIsFileset: Record<string, boolean> = {};
|
||||
try {
|
||||
resourceTypeToFormatExtension = (await wmill.fileResourceTypeToFileExtMap({
|
||||
const raw = (await wmill.fileResourceTypeToFileExtMap({
|
||||
workspace: workspace.workspaceId,
|
||||
})) as Record<string, string>;
|
||||
})) as Record<string, string | FileResourceTypeInfo>;
|
||||
const parsed = parseFileResourceTypeMap(raw);
|
||||
resourceTypeToFormatExtension = parsed.formatExtMap;
|
||||
resourceTypeToIsFileset = parsed.filesetMap;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -1745,6 +1827,7 @@ export async function pull(
|
||||
!opts.json,
|
||||
opts.defaultTs ?? "bun",
|
||||
resourceTypeToFormatExtension,
|
||||
resourceTypeToIsFileset,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -2241,10 +2324,14 @@ export async function push(
|
||||
),
|
||||
);
|
||||
let resourceTypeToFormatExtension: Record<string, string> = {};
|
||||
let resourceTypeToIsFileset: Record<string, boolean> = {};
|
||||
try {
|
||||
resourceTypeToFormatExtension = (await wmill.fileResourceTypeToFileExtMap({
|
||||
const raw = (await wmill.fileResourceTypeToFileExtMap({
|
||||
workspace: workspace.workspaceId,
|
||||
})) as Record<string, string>;
|
||||
})) as Record<string, string | FileResourceTypeInfo>;
|
||||
const parsed = parseFileResourceTypeMap(raw);
|
||||
resourceTypeToFormatExtension = parsed.formatExtMap;
|
||||
resourceTypeToIsFileset = parsed.filesetMap;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -2269,6 +2356,7 @@ export async function push(
|
||||
!opts.json,
|
||||
opts.defaultTs ?? "bun",
|
||||
resourceTypeToFormatExtension,
|
||||
resourceTypeToIsFileset,
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -2392,6 +2480,45 @@ export async function push(
|
||||
log.info(
|
||||
`remote (${workspace.name}) <- local: ${changes.length} changes to apply`,
|
||||
);
|
||||
// Check that every folder referenced in the changeset has a local folder.meta.yaml
|
||||
const missingFolders: string[] = [];
|
||||
if (changes.length > 0) {
|
||||
const folderNames = new Set<string>();
|
||||
for (const change of changes) {
|
||||
const parts = change.path.split(SEP);
|
||||
if (parts.length >= 3 && parts[0] === "f" && change.name !== "deleted") {
|
||||
folderNames.add(parts[1]);
|
||||
}
|
||||
}
|
||||
for (const folderName of folderNames) {
|
||||
try {
|
||||
await stat(path.join("f", folderName, "folder.meta.yaml"));
|
||||
} catch {
|
||||
missingFolders.push(folderName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFolders.length > 0) {
|
||||
const folderList = missingFolders.map((f) => ` - ${f}`).join("\n");
|
||||
const user = await wmill.whoami({ workspace: workspace.workspaceId });
|
||||
const userIsAdmin = user.is_admin;
|
||||
const msg =
|
||||
`${userIsAdmin ? "Warning: " : ""}Missing folder.meta.yaml for:\n${folderList}\n` +
|
||||
`Run 'wmill folder add-missing' to create them locally, then push again.`;
|
||||
if (!userIsAdmin) {
|
||||
if (opts.jsonOutput) {
|
||||
console.log(JSON.stringify({ success: false, error: "missing_folders", missing_folders: missingFolders, message: msg }, null, 2));
|
||||
} else {
|
||||
log.error(msg);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (!opts.jsonOutput) {
|
||||
log.warn(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON output for dry-run
|
||||
if (opts.dryRun && opts.jsonOutput) {
|
||||
const result = {
|
||||
@@ -2423,6 +2550,7 @@ export async function push(
|
||||
if (!opts.jsonOutput) {
|
||||
prettyChanges(changes, specificItems, opts.branch);
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
log.info(colors.gray(`Dry run complete.`));
|
||||
return;
|
||||
@@ -2587,6 +2715,39 @@ export async function push(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (isFilesetResource(change.path)) {
|
||||
const resourceFilePath = await findFilesetResourceFile(change.path);
|
||||
if (!alreadySynced.includes(resourceFilePath)) {
|
||||
alreadySynced.push(resourceFilePath);
|
||||
|
||||
const newObj = parseFromPath(
|
||||
resourceFilePath,
|
||||
await readFile(resourceFilePath, "utf-8"),
|
||||
);
|
||||
|
||||
let serverPath = resourceFilePath;
|
||||
const currentBranch = cachedBranchForPush;
|
||||
|
||||
if (currentBranch && isBranchSpecificFile(resourceFilePath)) {
|
||||
serverPath = fromBranchSpecificPath(
|
||||
resourceFilePath,
|
||||
currentBranch,
|
||||
);
|
||||
}
|
||||
|
||||
await pushResource(
|
||||
workspace.workspaceId,
|
||||
serverPath,
|
||||
undefined,
|
||||
newObj,
|
||||
resourceFilePath,
|
||||
);
|
||||
if (stateTarget) {
|
||||
await writeFile(stateTarget, change.after, "utf-8");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const oldObj = parseFromPath(change.path, change.before);
|
||||
const newObj = parseFromPath(change.path, change.after);
|
||||
|
||||
@@ -2619,7 +2780,8 @@ export async function push(
|
||||
change.path.endsWith(".script.json") ||
|
||||
change.path.endsWith(".script.yaml") ||
|
||||
change.path.endsWith(".lock") ||
|
||||
isFileResource(change.path)
|
||||
isFileResource(change.path) ||
|
||||
isFilesetResource(change.path)
|
||||
) {
|
||||
continue;
|
||||
} else if (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { minimatch } from "minimatch";
|
||||
import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts";
|
||||
import { isFileResource } from "../utils/utils.ts";
|
||||
import { isFileResource, isFilesetResource } from "../utils/utils.ts";
|
||||
import { SyncOptions } from "./conf.ts";
|
||||
import { TRIGGER_TYPES } from "../types.ts";
|
||||
|
||||
@@ -165,7 +165,7 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC
|
||||
return specificItems.settings !== undefined;
|
||||
}
|
||||
|
||||
if (isFileResource(path)) {
|
||||
if (isFileResource(path) || isFilesetResource(path)) {
|
||||
return specificItems.resources !== undefined;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,14 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (isFilesetResource(path)) {
|
||||
const basePathMatch = path.match(/^(.+?)\.fileset[/\\]/);
|
||||
if (basePathMatch && specificItems.resources) {
|
||||
const basePath = basePathMatch[1] + '.resource.yaml';
|
||||
return matchesPatterns(basePath, specificItems.resources);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+40
-11
File diff suppressed because one or more lines are too long
Regular → Executable
+1
-1
@@ -65,7 +65,7 @@ export {
|
||||
workspaceAdd,
|
||||
};
|
||||
|
||||
export const VERSION = "1.642.0";
|
||||
export const VERSION = "1.644.0";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ import { pushResourceType } from "./commands/resource-type/resource-type.ts";
|
||||
import { pushVariable } from "./commands/variable/variable.ts";
|
||||
import { yamlOptions } from "./commands/sync/sync.ts";
|
||||
import { showDiffs } from "./core/conf.ts";
|
||||
import { deepEqual, isFileResource, isWorkspaceDependencies } from "./utils/utils.ts";
|
||||
import { deepEqual, isFileResource, isFilesetResource, isWorkspaceDependencies } from "./utils/utils.ts";
|
||||
import { pushSchedule } from "./commands/schedule/schedule.ts";
|
||||
import { pushWorkspaceUser } from "./commands/user/user.ts";
|
||||
import { pushGroup } from "./commands/user/user.ts";
|
||||
@@ -333,7 +333,7 @@ export function getTypeStrFromPath(
|
||||
) {
|
||||
return typeEnding;
|
||||
} else {
|
||||
if (isFileResource(p)) {
|
||||
if (isFileResource(p) || isFilesetResource(p)) {
|
||||
return "resource";
|
||||
}
|
||||
throw new Error("Could not infer type of path " + JSON.stringify(parsed));
|
||||
|
||||
@@ -154,6 +154,11 @@ export function isFileResource(path: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches children inside a .fileset/ directory, not the directory itself. */
|
||||
export function isFilesetResource(path: string): boolean {
|
||||
return path.includes(".fileset/") || path.includes(".fileset\\");
|
||||
}
|
||||
|
||||
export function isRawAppFile(path: string): boolean {
|
||||
return isRawAppPath(path);
|
||||
}
|
||||
|
||||
@@ -232,8 +232,9 @@ export class CargoBackend {
|
||||
*/
|
||||
private getBasePostgresUrl(): string {
|
||||
const url = new URL(this.config.postgresUrl);
|
||||
// Remove any existing database path
|
||||
// Remove any existing database path and query params (e.g. ?sslmode=disable)
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
return url.toString().replace(/\/$/, ""); // Remove trailing slash
|
||||
}
|
||||
|
||||
@@ -629,13 +630,13 @@ export class CargoBackend {
|
||||
/**
|
||||
* Create CLI command with proper authentication
|
||||
*/
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): { command: string, args: string[], cwd: string, env: Record<string, string> } {
|
||||
const workspace = workspaceName || this.config.workspace;
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { command: string, args: string[], cwd: string, env: Record<string, string> } {
|
||||
const workspace = opts?.workspace || this.config.workspace;
|
||||
const cliDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const fullArgs = [
|
||||
"--base-url", this.baseUrl,
|
||||
"--workspace", workspace,
|
||||
"--token", this.token,
|
||||
"--token", opts?.token || this.token,
|
||||
"--config-dir", this.config.testConfigDir,
|
||||
...args,
|
||||
];
|
||||
@@ -660,12 +661,12 @@ export class CargoBackend {
|
||||
/**
|
||||
* Run CLI command and return result
|
||||
*/
|
||||
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
|
||||
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}> {
|
||||
const cmd = this.createCLICommand(args, workingDir, workspaceName);
|
||||
const cmd = this.createCLICommand(args, workingDir, opts);
|
||||
const proc = Bun.spawn([cmd.command, ...cmd.args], {
|
||||
cwd: cmd.cwd,
|
||||
env: cmd.env,
|
||||
|
||||
@@ -1020,12 +1020,12 @@ export async function main(
|
||||
/**
|
||||
* Create CLI command with proper authentication
|
||||
*/
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): { cmd: string[], cwd: string } {
|
||||
const workspace = workspaceName || this.config.workspace;
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { cmd: string[], cwd: string } {
|
||||
const workspace = opts?.workspace || this.config.workspace;
|
||||
const fullArgs = [
|
||||
'--base-url', this.config.baseUrl,
|
||||
'--workspace', workspace,
|
||||
'--token', this.config.token,
|
||||
'--token', opts?.token || this.config.token,
|
||||
'--config-dir', this.config.testConfigDir,
|
||||
...args
|
||||
];
|
||||
@@ -1049,12 +1049,12 @@ export async function main(
|
||||
/**
|
||||
* Run CLI command and return result
|
||||
*/
|
||||
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
|
||||
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}> {
|
||||
const { cmd, cwd } = this.createCLICommand(args, workingDir, workspaceName);
|
||||
const { cmd, cwd } = this.createCLICommand(args, workingDir, opts);
|
||||
const proc = Bun.spawn(cmd, {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Tests for missing folder.meta.yaml detection during sync push,
|
||||
* the `folder add-missing` command, and the simplified `folder push` command.
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { writeFile, mkdir, readFile, rm, mkdtemp } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getTestBackend, createNonAdminUser } from "./test_backend.ts";
|
||||
|
||||
type IsolatedWorkspaceTestContext = {
|
||||
backend: any;
|
||||
tempDir: string;
|
||||
workspaceId: string;
|
||||
runCLICommand: (
|
||||
args: string[],
|
||||
opts?: { token?: string }
|
||||
) => Promise<{ stdout: string; stderr: string; code: number }>;
|
||||
apiRequest: (path: string, options?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
async function createWorkspace(backend: any, workspaceId: string): Promise<void> {
|
||||
const response = await backend.apiRequest!("/api/workspaces/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: workspaceId,
|
||||
// Workspace name has a 50-char DB limit; keep it identical to the short ID.
|
||||
name: workspaceId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
if (!error.includes("already exists") && !error.includes("duplicate")) {
|
||||
throw new Error(`Failed to create workspace ${workspaceId}: ${error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await response.text();
|
||||
}
|
||||
|
||||
async function withIsolatedWorkspace(
|
||||
testFn: (ctx: IsolatedWorkspaceTestContext) => Promise<void>
|
||||
): Promise<void> {
|
||||
const backend = await getTestBackend();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "windmill_cli_test_"));
|
||||
const workspaceId = `fmeta_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
let workspaceCreated = false;
|
||||
|
||||
try {
|
||||
await createWorkspace(backend, workspaceId);
|
||||
workspaceCreated = true;
|
||||
|
||||
await testFn({
|
||||
backend,
|
||||
tempDir,
|
||||
workspaceId,
|
||||
runCLICommand: (args: string[], opts?: { token?: string }) =>
|
||||
backend.runCLICommand(args, tempDir, {
|
||||
workspace: workspaceId,
|
||||
token: opts?.token,
|
||||
}),
|
||||
apiRequest: (path: string, options?: RequestInit) =>
|
||||
backend.apiRequest!(`/api/w/${workspaceId}${path}`, options),
|
||||
});
|
||||
} finally {
|
||||
if (workspaceCreated) {
|
||||
try {
|
||||
const archiveResponse = await backend.apiRequest!(
|
||||
`/api/w/${workspaceId}/workspaces/archive`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
await archiveResponse.text();
|
||||
} catch {
|
||||
// Best-effort cleanup to avoid exceeding non-enterprise workspace limits.
|
||||
}
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function wmillYaml(): string {
|
||||
return `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// folder new — creates folder.meta.yaml with summary and display_name
|
||||
// =============================================================================
|
||||
|
||||
describe("folder new", () => {
|
||||
test("creates folder.meta.yaml with summary and display_name", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const folderName = `newfolder${Date.now()}`;
|
||||
const result = await runCLICommand(
|
||||
["folder", "new", folderName, "--summary", "My summary"],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
|
||||
const metaPath = join(tempDir, "f", folderName, "folder.meta.yaml");
|
||||
const content = await readFile(metaPath, "utf-8");
|
||||
expect(content).toContain("summary: My summary");
|
||||
expect(content).toContain(`display_name: ${folderName}`);
|
||||
expect(content).toContain("owners:");
|
||||
expect(content).toContain("extra_perms:");
|
||||
});
|
||||
});
|
||||
|
||||
test("creates folder.meta.yaml with empty summary when none provided", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const folderName = `nosummary${Date.now()}`;
|
||||
const result = await runCLICommand(
|
||||
["folder", "new", folderName],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
|
||||
const content = await readFile(
|
||||
join(tempDir, "f", folderName, "folder.meta.yaml"),
|
||||
"utf-8"
|
||||
);
|
||||
expect(content).toContain('summary: ""');
|
||||
expect(content).toContain(`display_name: ${folderName}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("fails if folder.meta.yaml already exists", async () => {
|
||||
await withIsolatedWorkspace(async ({ runCLICommand }) => {
|
||||
const folderName = `dupfolder${Date.now()}`;
|
||||
// Create first
|
||||
await runCLICommand(["folder", "new", folderName]);
|
||||
|
||||
// Try again — should fail
|
||||
const result = await runCLICommand(
|
||||
["folder", "new", folderName],
|
||||
);
|
||||
expect(result.code).not.toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// folder add-missing — scaffolds missing folder.meta.yaml files
|
||||
// =============================================================================
|
||||
|
||||
describe("folder add-missing", () => {
|
||||
test("creates folder.meta.yaml for directories missing one", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
// Create two folders: one with meta, one without
|
||||
const withMeta = `withmeta${Date.now()}`;
|
||||
const withoutMeta = `withoutmeta${Date.now()}`;
|
||||
|
||||
await mkdir(join(tempDir, "f", withMeta), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", withMeta, "folder.meta.yaml"),
|
||||
'summary: ""\ndisplay_name: existing\nowners: []\nextra_perms: {}\n',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
await mkdir(join(tempDir, "f", withoutMeta), { recursive: true });
|
||||
// No folder.meta.yaml for withoutMeta
|
||||
|
||||
const result = await runCLICommand(
|
||||
["folder", "add-missing", "-y"],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
|
||||
// withoutMeta should now have a folder.meta.yaml
|
||||
const createdMeta = await readFile(
|
||||
join(tempDir, "f", withoutMeta, "folder.meta.yaml"),
|
||||
"utf-8"
|
||||
);
|
||||
expect(createdMeta).toContain(`display_name: ${withoutMeta}`);
|
||||
expect(createdMeta).toContain("owners:");
|
||||
|
||||
// withMeta should be unchanged
|
||||
const existingMeta = await readFile(
|
||||
join(tempDir, "f", withMeta, "folder.meta.yaml"),
|
||||
"utf-8"
|
||||
);
|
||||
expect(existingMeta).toContain("display_name: existing");
|
||||
});
|
||||
});
|
||||
|
||||
test("reports nothing to do when all folders have meta", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const folderName = `alldone${Date.now()}`;
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "folder.meta.yaml"),
|
||||
'summary: ""\ndisplay_name: done\nowners: []\nextra_perms: {}\n',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["folder", "add-missing", "-y"],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout + result.stderr).toContain("Nothing to do");
|
||||
});
|
||||
});
|
||||
|
||||
test("reports nothing to do when no f/ directory exists", async () => {
|
||||
await withIsolatedWorkspace(async ({ runCLICommand }) => {
|
||||
const result = await runCLICommand(
|
||||
["folder", "add-missing", "-y"],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout + result.stderr).toContain("Nothing to do");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// folder push — simplified single-arg signature
|
||||
// =============================================================================
|
||||
|
||||
describe("folder push", () => {
|
||||
test("pushes a folder by name", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand, apiRequest }) => {
|
||||
const folderName = `pushbyname${Date.now()}`;
|
||||
|
||||
// Create local folder meta
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "folder.meta.yaml"),
|
||||
`summary: "pushed"\ndisplay_name: "${folderName}"\nowners:\n - "admin@windmill.dev"\nextra_perms: {}\n`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["folder", "push", folderName],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout + result.stderr).toContain("Folder pushed");
|
||||
|
||||
// Verify via API
|
||||
const apiResp = await apiRequest(`/folders/get/${folderName}`);
|
||||
expect(apiResp.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
test("fails when folder does not exist locally", async () => {
|
||||
await withIsolatedWorkspace(async ({ runCLICommand }) => {
|
||||
const result = await runCLICommand(
|
||||
["folder", "push", "nonexistent"],
|
||||
);
|
||||
|
||||
expect(result.code).not.toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// sync push — missing folder.meta.yaml detection
|
||||
// =============================================================================
|
||||
|
||||
describe("sync push missing folder detection", () => {
|
||||
test("admin user gets warning but push succeeds", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const uniqueId = Date.now();
|
||||
const folderName = `nometaadmin${uniqueId}`;
|
||||
|
||||
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
|
||||
|
||||
// Create a script inside a folder WITHOUT folder.meta.yaml
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "test_script.ts"),
|
||||
'export async function main() { return "hello"; }',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
|
||||
);
|
||||
|
||||
// Admin should get a warning but push succeeds (exit 0)
|
||||
expect(result.code).toEqual(0);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("Missing folder.meta.yaml");
|
||||
expect(output).toContain(folderName);
|
||||
expect(output).toContain("wmill folder add-missing");
|
||||
});
|
||||
});
|
||||
|
||||
test("no warning when folder.meta.yaml exists", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const uniqueId = Date.now();
|
||||
const folderName = `withmeta${uniqueId}`;
|
||||
|
||||
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
|
||||
|
||||
// Create folder WITH folder.meta.yaml
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "folder.meta.yaml"),
|
||||
`summary: ""\ndisplay_name: "${folderName}"\nowners: []\nextra_perms: {}\n`,
|
||||
"utf-8"
|
||||
);
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "test_script.ts"),
|
||||
'export async function main() { return "hello"; }',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).not.toContain("Missing folder.meta.yaml");
|
||||
});
|
||||
});
|
||||
|
||||
test.skipIf(!process.env["EE_LICENSE_KEY"])("non-admin user gets error and exit code 1", async () => {
|
||||
await withIsolatedWorkspace(async ({ backend, tempDir, workspaceId, runCLICommand, apiRequest }) => {
|
||||
const nonAdminToken = await createNonAdminUser(backend, workspaceId);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const folderName = `nometanonadmin${uniqueId}`;
|
||||
|
||||
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
|
||||
|
||||
// Create a script inside a folder WITHOUT folder.meta.yaml
|
||||
// First create the folder on remote so the non-admin has somewhere to push
|
||||
await apiRequest(
|
||||
"/folders/create",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: folderName,
|
||||
extra_perms: { "g/all": true },
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "test_script.ts"),
|
||||
'export async function main() { return "hello"; }',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
|
||||
{ token: nonAdminToken }
|
||||
);
|
||||
|
||||
// Non-admin should get exit code 1
|
||||
expect(result.code).toEqual(1);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("Missing folder.meta.yaml");
|
||||
expect(output).toContain("wmill folder add-missing");
|
||||
});
|
||||
});
|
||||
|
||||
test("no warning for deleted changes without folder.meta.yaml", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand, apiRequest }) => {
|
||||
const uniqueId = Date.now();
|
||||
const folderName = `delfolder${uniqueId}`;
|
||||
|
||||
// Create folder and script on remote via API
|
||||
await apiRequest(
|
||||
"/folders/create",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: folderName }),
|
||||
}
|
||||
);
|
||||
|
||||
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
|
||||
|
||||
// Pull to get remote state, then delete the folder locally
|
||||
await runCLICommand(["sync", "pull", "--yes"]);
|
||||
|
||||
// Remove the folder locally to trigger a "deleted" change
|
||||
await rm(join(tempDir, "f", folderName), { recursive: true, force: true });
|
||||
|
||||
const result = await runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
|
||||
);
|
||||
|
||||
// Should not warn about missing meta for deleted items
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).not.toContain("Missing folder.meta.yaml");
|
||||
});
|
||||
});
|
||||
});
|
||||
+70
-10
@@ -40,8 +40,8 @@ export interface TestBackend {
|
||||
stop(): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any;
|
||||
runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any;
|
||||
runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
@@ -97,12 +97,12 @@ class CargoBackendAdapter implements TestBackend {
|
||||
await this.backend.reset();
|
||||
}
|
||||
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any {
|
||||
return this.backend.createCLICommand(args, workingDir, workspaceName);
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any {
|
||||
return this.backend.createCLICommand(args, workingDir, opts);
|
||||
}
|
||||
|
||||
async runCLICommand(args: string[], workingDir: string, workspaceName?: string) {
|
||||
return this.backend.runCLICommand(args, workingDir, workspaceName);
|
||||
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }) {
|
||||
return this.backend.runCLICommand(args, workingDir, opts);
|
||||
}
|
||||
|
||||
async apiRequest(path: string, options?: RequestInit): Promise<Response> {
|
||||
@@ -369,12 +369,12 @@ class ContainerizedBackendAdapter implements TestBackend {
|
||||
await this.backend.reset();
|
||||
}
|
||||
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any {
|
||||
return this.backend.createCLICommand(args, workingDir, workspaceName);
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any {
|
||||
return this.backend.createCLICommand(args, workingDir, opts);
|
||||
}
|
||||
|
||||
async runCLICommand(args: string[], workingDir: string, workspaceName?: string) {
|
||||
return this.backend.runCLICommand(args, workingDir, workspaceName);
|
||||
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }) {
|
||||
return this.backend.runCLICommand(args, workingDir, opts);
|
||||
}
|
||||
|
||||
async seedTestData(): Promise<void> {
|
||||
@@ -507,6 +507,66 @@ function registerCleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a non-admin user, add them to the workspace, and return their token.
|
||||
*/
|
||||
export async function createNonAdminUser(
|
||||
backend: TestBackend,
|
||||
workspaceId: string = backend.workspace
|
||||
): Promise<string> {
|
||||
if (!backend.apiRequest) {
|
||||
throw new Error("Backend does not support apiRequest");
|
||||
}
|
||||
|
||||
const email = `nonadmin_${Date.now()}@test.dev`;
|
||||
const password = "testpass123";
|
||||
|
||||
// Create user globally (as admin)
|
||||
const createResp = await backend.apiRequest("/api/users/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
super_admin: false,
|
||||
name: "Non-Admin Test User",
|
||||
}),
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
throw new Error(`Failed to create user: ${await createResp.text()}`);
|
||||
}
|
||||
await createResp.text();
|
||||
|
||||
// Add user to workspace as non-admin
|
||||
const addResp = await backend.apiRequest(
|
||||
`/api/w/${workspaceId}/workspaces/add_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
is_admin: false,
|
||||
operator: false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!addResp.ok) {
|
||||
throw new Error(`Failed to add user to workspace: ${await addResp.text()}`);
|
||||
}
|
||||
await addResp.text();
|
||||
|
||||
// Login as the non-admin user to get a token
|
||||
const loginResp = await fetch(`${backend.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!loginResp.ok) {
|
||||
throw new Error(`Failed to login as non-admin: ${await loginResp.text()}`);
|
||||
}
|
||||
return await loginResp.text();
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { CargoBackendConfig } from "./cargo_backend.ts";
|
||||
export type { ContainerConfig } from "./containerized_backend.ts";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { deepEqual, isFileResource, toCamel, capitalize } from "../src/utils/utils.ts";
|
||||
import { deepEqual, isFileResource, isFilesetResource, toCamel, capitalize } from "../src/utils/utils.ts";
|
||||
import {
|
||||
getTypeStrFromPath,
|
||||
removeType,
|
||||
@@ -156,6 +156,32 @@ describe("isFileResource", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// isFilesetResource
|
||||
// =============================================================================
|
||||
|
||||
describe("isFilesetResource", () => {
|
||||
test("detects fileset resource paths (unix separator)", () => {
|
||||
expect(isFilesetResource("f/test/my_config.fileset/config.yaml")).toBe(true);
|
||||
expect(isFilesetResource("u/admin/templates.fileset/path/to/file.txt")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects fileset resource paths (windows separator)", () => {
|
||||
expect(isFilesetResource("f\\test\\my_config.fileset\\config.yaml")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-fileset paths", () => {
|
||||
expect(isFilesetResource("f/test/my_resource.resource.yaml")).toBe(false);
|
||||
expect(isFilesetResource("f/test/my_file.resource.file.txt")).toBe(false);
|
||||
expect(isFilesetResource("f/test/my_script.ts")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects paths ending with .fileset (no child file)", () => {
|
||||
// The directory itself is not a fileset resource file - only children are
|
||||
expect(isFilesetResource("f/test/my_config.fileset")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// removeType
|
||||
// =============================================================================
|
||||
@@ -241,6 +267,11 @@ describe("getTypeStrFromPath", () => {
|
||||
expect(getTypeStrFromPath("devs.group.yaml")).toBe("group");
|
||||
});
|
||||
|
||||
test("detects fileset resource files as resource type", () => {
|
||||
expect(getTypeStrFromPath("f/test/my_config.fileset/config.yaml")).toBe("resource");
|
||||
expect(getTypeStrFromPath("u/admin/templates.fileset/path/to/file.txt")).toBe("resource");
|
||||
});
|
||||
|
||||
test("throws for unknown type", () => {
|
||||
expect(() => getTypeStrFromPath("f/test/unknown.xyz.yaml")).toThrow();
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.642.0",
|
||||
"version": "1.644.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-components",
|
||||
"version": "1.642.0",
|
||||
"version": "1.644.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.642.0",
|
||||
"version": "1.644.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { OauthService, type ResourceType } from '$lib/gen'
|
||||
import FilesetEditor from './FilesetEditor.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { emptySchema, emptyString } from '$lib/utils'
|
||||
import SchemaForm from './SchemaForm.svelte'
|
||||
@@ -79,7 +80,7 @@
|
||||
rawCode = JSON.stringify(args, null, 2)
|
||||
} else {
|
||||
parseJson()
|
||||
if (resourceTypeInfo?.format_extension) {
|
||||
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
|
||||
textFileContent = args.content
|
||||
}
|
||||
}
|
||||
@@ -237,6 +238,11 @@
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
{:else if resourceTypeInfo?.is_fileset}
|
||||
<h5 class="mt-1 inline-flex items-center gap-4">
|
||||
Fileset
|
||||
</h5>
|
||||
<FilesetEditor bind:args />
|
||||
{:else if resourceTypeInfo?.format_extension}
|
||||
<h5 class="mt-4 inline-flex items-center gap-4">
|
||||
File content ({resourceTypeInfo.format_extension})
|
||||
|
||||
@@ -260,6 +260,6 @@
|
||||
} as any)
|
||||
</script>
|
||||
|
||||
<div class="relative max-h-40">
|
||||
<div class="relative h-44">
|
||||
<Line {data} {options} />
|
||||
</div>
|
||||
|
||||
@@ -184,13 +184,13 @@
|
||||
unifiedSize="md"
|
||||
wrapperClasses="h-full"
|
||||
{disabled}
|
||||
iconOnly
|
||||
endIcon={{ icon: X }}
|
||||
on:click={() => {
|
||||
value = null
|
||||
dispatch('clear')
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
></Button>
|
||||
{/if}
|
||||
<!-- <div>
|
||||
<ToggleButtonGroup bind:selected={format} let:item>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
type Item = {
|
||||
label: string
|
||||
icon?: any
|
||||
right?: string
|
||||
onClick?: () => void
|
||||
onHover?: (hover: boolean) => void
|
||||
}
|
||||
export type Props = {
|
||||
closeCallback?: () => void
|
||||
items: Item[]
|
||||
}
|
||||
let { items, closeCallback }: Props = $props()
|
||||
</script>
|
||||
|
||||
<ul class="bg-surface-tertiary rounded-md border w-56 relative drop-shadow-base">
|
||||
{#each items as item}
|
||||
<li class="w-full">
|
||||
<button
|
||||
class="px-3 h-9 text-xs cursor-pointer hover:bg-surface-hover font-normal w-full text-left flex items-center gap-2.5"
|
||||
onclick={() => {
|
||||
item.onClick?.()
|
||||
item.onHover?.(false)
|
||||
closeCallback?.()
|
||||
}}
|
||||
onmouseenter={() => item.onHover?.(true)}
|
||||
onmouseleave={() => item.onHover?.(false)}
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon size="16"></item.icon>
|
||||
{/if}
|
||||
<span class="flex-1">
|
||||
{item.label}
|
||||
</span>
|
||||
{#if item.right}
|
||||
<span class="text-xs text-hint">{item.right}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#if items.length === 0}
|
||||
<li class="w-full">
|
||||
<div
|
||||
class="px-3 h-9 text-xs font-normal w-full text-left flex items-center gap-2.5 text-hint"
|
||||
>
|
||||
No actions available
|
||||
</div>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
items?: Item[]
|
||||
extraLabel?: import('svelte').Snippet
|
||||
selected: string
|
||||
selectedDisplayName?: string
|
||||
btnClasses?: string
|
||||
}
|
||||
|
||||
let { items = [], extraLabel, selected, selectedDisplayName, btnClasses }: Props = $props()
|
||||
|
||||
const filteredItems = $derived(items.filter((item) => item.id !== selected))
|
||||
</script>
|
||||
|
||||
<DropdownV2 items={filteredItems}>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
class={twMerge(
|
||||
'p-2 h-8 flex flex-row items-center gap-2 border hover:bg-surface-hover cursor-pointer rounded-md',
|
||||
btnClasses
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-1 pr-2 justify-between w-full">
|
||||
<span class="text-xs whitespace-nowrap">
|
||||
{selectedDisplayName ?? items.find((item) => item.id === selected)?.displayName ?? ''}
|
||||
</span>
|
||||
|
||||
{@render extraLabel?.()}
|
||||
</div>
|
||||
<ChevronDown size={12} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
@@ -157,7 +157,7 @@
|
||||
variant="subtle"
|
||||
startIcon={{ icon: EllipsisVertical }}
|
||||
btnClasses="bg-transparent"
|
||||
iconOnly
|
||||
iconOnly={!btnText}
|
||||
>
|
||||
{btnText}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { Plus, File, Folder, FolderOpen } from 'lucide-svelte'
|
||||
import FileTreeNode from './raw_apps/FileTreeNode.svelte'
|
||||
import type { TreeNode } from './raw_apps/fileTreeUtils'
|
||||
import { buildFileTree } from './raw_apps/fileTreeUtils'
|
||||
|
||||
interface Props {
|
||||
/** File path → content map. Keys use / prefix (e.g. /index.html). */
|
||||
files: Record<string, string>
|
||||
/** Currently selected path (/-prefixed). Read-only; changes via onSelectPath callback. */
|
||||
selectedPath?: string | undefined
|
||||
/** Called when user clicks a path (file or folder). */
|
||||
onSelectPath?: (path: string) => void
|
||||
/** Extra tree nodes appended after the main tree (e.g. read-only wmill.ts). */
|
||||
extraNodes?: TreeNode[]
|
||||
/** Show a root / entry at the top of the tree. */
|
||||
showRoot?: boolean
|
||||
/** Hide the built-in header (useful when parent provides its own). */
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
files = $bindable({}),
|
||||
selectedPath = undefined,
|
||||
onSelectPath,
|
||||
extraNodes,
|
||||
showRoot = false,
|
||||
hideHeader = false
|
||||
}: Props = $props()
|
||||
|
||||
let pendingNewFilePath: string | undefined = $state(undefined)
|
||||
let pathToEdit: string | undefined = $state(undefined)
|
||||
// Empty folders exist only in the UI until a file is created inside them
|
||||
let emptyFolders: string[] = $state([])
|
||||
|
||||
const fileTree = $derived(
|
||||
buildFileTree([
|
||||
...Object.keys(files ?? {}),
|
||||
...emptyFolders,
|
||||
...(pendingNewFilePath ? [pendingNewFilePath] : [])
|
||||
])
|
||||
)
|
||||
|
||||
function getUniquePath(basePath: string): string {
|
||||
const existingPaths = new Set(
|
||||
[...Object.keys(files ?? {}), ...emptyFolders, pendingNewFilePath].filter(Boolean)
|
||||
)
|
||||
|
||||
if (!existingPaths.has(basePath)) return basePath
|
||||
|
||||
const isFolder = basePath.endsWith('/')
|
||||
let pathWithoutTrailing = isFolder ? basePath.slice(0, -1) : basePath
|
||||
const lastSlash = pathWithoutTrailing.lastIndexOf('/')
|
||||
const parentPath = pathWithoutTrailing.substring(0, lastSlash + 1)
|
||||
const fileName = pathWithoutTrailing.substring(lastSlash + 1)
|
||||
|
||||
let nameWithoutExt: string
|
||||
let ext: string
|
||||
if (isFolder) {
|
||||
nameWithoutExt = fileName
|
||||
ext = ''
|
||||
} else {
|
||||
const dotIndex = fileName.lastIndexOf('.')
|
||||
nameWithoutExt = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName
|
||||
ext = dotIndex > 0 ? fileName.substring(dotIndex) : ''
|
||||
}
|
||||
|
||||
let counter = 1
|
||||
let candidate: string
|
||||
do {
|
||||
const newName = `${nameWithoutExt} (${counter})${ext}`
|
||||
candidate = isFolder ? `${parentPath}${newName}/` : `${parentPath}${newName}`
|
||||
counter++
|
||||
} while (existingPaths.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
function handleFileClick(path: string) {
|
||||
onSelectPath?.(path)
|
||||
}
|
||||
|
||||
function handleAddFile(folderPath: string) {
|
||||
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
|
||||
const basePath = normalizedFolder + 'newfile.txt'
|
||||
const newPath = getUniquePath(basePath)
|
||||
pendingNewFilePath = newPath
|
||||
pathToEdit = newPath
|
||||
}
|
||||
|
||||
export function handleAddRootFile() {
|
||||
let basePath: string
|
||||
if (selectedPath && selectedPath !== '/') {
|
||||
if (selectedPath.endsWith('/')) {
|
||||
basePath = selectedPath + 'newfile.txt'
|
||||
} else {
|
||||
const pathParts = selectedPath.split('/').filter(Boolean)
|
||||
const parentPath =
|
||||
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
|
||||
basePath = parentPath + 'newfile.txt'
|
||||
}
|
||||
} else {
|
||||
basePath = '/newfile.txt'
|
||||
}
|
||||
const newPath = getUniquePath(basePath)
|
||||
pendingNewFilePath = newPath
|
||||
pathToEdit = newPath
|
||||
}
|
||||
|
||||
function handleAddFolder(folderPath: string) {
|
||||
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
|
||||
const basePath = normalizedFolder + 'newfolder/'
|
||||
const newPath = getUniquePath(basePath)
|
||||
pendingNewFilePath = newPath
|
||||
pathToEdit = newPath
|
||||
}
|
||||
|
||||
export function handleAddRootFolder() {
|
||||
let basePath: string
|
||||
if (selectedPath && selectedPath !== '/') {
|
||||
if (selectedPath.endsWith('/')) {
|
||||
basePath = selectedPath + 'newfolder/'
|
||||
} else {
|
||||
const pathParts = selectedPath.split('/').filter(Boolean)
|
||||
const parentPath =
|
||||
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
|
||||
basePath = parentPath + 'newfolder/'
|
||||
}
|
||||
} else {
|
||||
basePath = '/newfolder/'
|
||||
}
|
||||
const newPath = getUniquePath(basePath)
|
||||
pendingNewFilePath = newPath
|
||||
pathToEdit = newPath
|
||||
}
|
||||
|
||||
function handleRename(oldPath: string, newName: string) {
|
||||
const isFolder = oldPath.endsWith('/')
|
||||
const pathParts = oldPath.split('/').filter(Boolean)
|
||||
const parentPath = '/' + pathParts.slice(0, -1).join('/')
|
||||
let newPath = parentPath === '/' ? '/' + newName : parentPath + '/' + newName
|
||||
if (isFolder && !newPath.endsWith('/')) {
|
||||
newPath = newPath + '/'
|
||||
}
|
||||
|
||||
const isPendingNew = pendingNewFilePath === oldPath
|
||||
|
||||
if (!isPendingNew && oldPath === newPath) {
|
||||
pathToEdit = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const nfiles = { ...files }
|
||||
|
||||
if (isFolder) {
|
||||
if (isPendingNew) {
|
||||
// New empty folder — track in UI until a file is created inside
|
||||
emptyFolders = [...emptyFolders, newPath]
|
||||
pendingNewFilePath = undefined
|
||||
} else {
|
||||
// Rename all children under old folder path
|
||||
for (const key of Object.keys(nfiles)) {
|
||||
if (key === oldPath || key.startsWith(oldPath)) {
|
||||
const newKey = newPath + key.substring(oldPath.length)
|
||||
nfiles[newKey] = nfiles[key]
|
||||
delete nfiles[key]
|
||||
}
|
||||
}
|
||||
// Also rename in emptyFolders
|
||||
emptyFolders = emptyFolders.map((f) =>
|
||||
f === oldPath || f.startsWith(oldPath)
|
||||
? newPath + f.substring(oldPath.length)
|
||||
: f
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (isPendingNew) {
|
||||
nfiles[newPath] = ''
|
||||
pendingNewFilePath = undefined
|
||||
} else {
|
||||
nfiles[newPath] = nfiles[oldPath]
|
||||
delete nfiles[oldPath]
|
||||
}
|
||||
// Remove empty folders that are now implicitly defined by this file path
|
||||
emptyFolders = emptyFolders.filter((f) => !newPath.startsWith(f))
|
||||
}
|
||||
|
||||
files = nfiles
|
||||
pathToEdit = undefined
|
||||
onSelectPath?.(newPath)
|
||||
}
|
||||
|
||||
function handleDelete(path: string) {
|
||||
const isFolder = path.endsWith('/')
|
||||
const nfiles = { ...files }
|
||||
|
||||
if (isFolder) {
|
||||
for (const key of Object.keys(nfiles)) {
|
||||
if (key === path || key.startsWith(path)) {
|
||||
delete nfiles[key]
|
||||
}
|
||||
}
|
||||
emptyFolders = emptyFolders.filter((f) => f !== path && !f.startsWith(path))
|
||||
} else {
|
||||
delete nfiles[path]
|
||||
}
|
||||
|
||||
files = nfiles
|
||||
|
||||
if (selectedPath === path || (isFolder && selectedPath?.startsWith(path))) {
|
||||
const remaining = Object.keys(nfiles)
|
||||
if (remaining.length > 0) {
|
||||
onSelectPath?.(remaining[0])
|
||||
} else {
|
||||
onSelectPath?.(showRoot ? '/' : '')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !hideHeader}
|
||||
<div class="p-2 border-b flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-emphasis">Files</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
onClick={handleAddRootFile}
|
||||
title="Add file"
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
btnClasses="px-1 gap-0.5"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<File size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRootFolder}
|
||||
title="Add folder"
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
btnClasses="px-1 gap-0.5"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<Folder size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1 overflow-y-auto py-1 w-full">
|
||||
{#if showRoot}
|
||||
<button
|
||||
onclick={() => onSelectPath?.('/')}
|
||||
class="w-full flex items-center gap-1 px-2 py-1 text-xs hover:bg-surface-hover transition-colors rounded text-left {selectedPath ===
|
||||
'/'
|
||||
? 'bg-surface-accent-selected'
|
||||
: ''}"
|
||||
>
|
||||
<FolderOpen size={12} class="flex-shrink-0 text-secondary" />
|
||||
<span
|
||||
class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
|
||||
>/</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
{#each fileTree as node (node.path)}
|
||||
<FileTreeNode
|
||||
{node}
|
||||
onFileClick={handleFileClick}
|
||||
onAddFile={handleAddFile}
|
||||
onAddFolder={handleAddFolder}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
{selectedPath}
|
||||
{pathToEdit}
|
||||
onRequestEdit={(path) => (pathToEdit = path)}
|
||||
onCancelEdit={() => {
|
||||
pathToEdit = undefined
|
||||
pendingNewFilePath = undefined
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#if extraNodes}
|
||||
{#each extraNodes as node (node.path)}
|
||||
<FileTreeNode
|
||||
{node}
|
||||
noEdit
|
||||
onFileClick={handleFileClick}
|
||||
onAddFile={handleAddFile}
|
||||
onAddFolder={handleAddFolder}
|
||||
{selectedPath}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import SimpleEditor from './SimpleEditor.svelte'
|
||||
import FileExplorer from './FileExplorer.svelte'
|
||||
|
||||
interface Props {
|
||||
args: Record<string, any>
|
||||
}
|
||||
|
||||
let { args = $bindable({}) }: Props = $props()
|
||||
|
||||
// Internal files map uses /-prefixed keys (matching tree node paths).
|
||||
// Compute initial files + selection together to avoid referencing $state outside reactive context.
|
||||
const initialFiles = Object.fromEntries(
|
||||
Object.entries(args ?? {}).map(([k, v]) => ['/' + k, String(v ?? '')])
|
||||
)
|
||||
const initialFile = Object.keys(initialFiles).find((k) => !k.endsWith('/'))
|
||||
|
||||
let files: Record<string, string> = $state(initialFiles)
|
||||
let selectedPath: string | undefined = $state(initialFile ?? '/')
|
||||
let editContent: string = $state(initialFile ? (initialFiles[initialFile] ?? '') : '')
|
||||
|
||||
// The selected file path (/-prefixed, not a folder)
|
||||
const selectedFileKey: string | undefined = $derived.by(() => {
|
||||
if (selectedPath != null && !selectedPath.endsWith('/')) {
|
||||
return selectedPath
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
// Display key without leading /
|
||||
const selectedDisplayKey: string | undefined = $derived(
|
||||
selectedFileKey?.replace(/^\//, '')
|
||||
)
|
||||
|
||||
function flushEditContent() {
|
||||
if (selectedFileKey != null && selectedFileKey in files && files[selectedFileKey] !== editContent) {
|
||||
files = { ...files, [selectedFileKey]: editContent }
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectPath(path: string) {
|
||||
flushEditContent()
|
||||
selectedPath = path
|
||||
if (!path.endsWith('/') && path !== '') {
|
||||
editContent = files[path] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
// Sync files → args, overlaying current editContent for the active file.
|
||||
// This avoids spreading a new files object on every keystroke.
|
||||
$effect(() => {
|
||||
const currentKey = selectedFileKey
|
||||
const currentContent = editContent
|
||||
const newArgs: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(files)) {
|
||||
if (!key.endsWith('/')) {
|
||||
const argKey = key.replace(/^\//, '')
|
||||
newArgs[argKey] = key === currentKey ? currentContent : value
|
||||
}
|
||||
}
|
||||
args = newArgs
|
||||
})
|
||||
|
||||
function inferLang(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'plaintext'
|
||||
const langMap: Record<string, string> = {
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
toml: 'toml',
|
||||
ini: 'ini',
|
||||
xml: 'xml',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
py: 'python',
|
||||
sh: 'shell',
|
||||
bash: 'shell',
|
||||
sql: 'sql',
|
||||
md: 'markdown',
|
||||
cfg: 'ini',
|
||||
conf: 'ini',
|
||||
j2: 'jinja',
|
||||
jinja: 'jinja'
|
||||
}
|
||||
return langMap[ext] ?? 'plaintext'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex border rounded-md overflow-hidden h-[60vh]">
|
||||
<div class="w-56 shrink-0 border-r flex flex-col bg-surface-secondary overflow-y-auto">
|
||||
<FileExplorer
|
||||
bind:files
|
||||
{selectedPath}
|
||||
onSelectPath={handleSelectPath}
|
||||
showRoot
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 overflow-y-auto">
|
||||
{#if selectedDisplayKey != null}
|
||||
<div class="px-2 border-b text-xs text-secondary bg-surface-secondary sticky top-0 z-10 flex items-center h-[36.5px]">
|
||||
{selectedDisplayKey}
|
||||
</div>
|
||||
{#key selectedFileKey}
|
||||
<SimpleEditor
|
||||
autoHeight
|
||||
lang={inferLang(selectedDisplayKey)}
|
||||
bind:code={editContent}
|
||||
fixedOverflowWidgets={false}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-xs text-secondary">
|
||||
Select a file or add a new one
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user