mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-18 16:02:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 --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,32 @@
|
||||
# Changelog
|
||||
|
||||
## [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`
|
||||
|
||||
+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
-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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+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.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15789,7 +15789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15802,7 +15802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15940,7 +15940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15963,7 +15963,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15976,7 +15976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16002,7 +16002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -16012,7 +16012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16029,7 +16029,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.642.0"
|
||||
version = "1.643.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.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16075,7 +16075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16091,7 +16091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16111,7 +16111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16131,7 +16131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16145,7 +16145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16171,7 +16171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16196,7 +16196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"flate2",
|
||||
@@ -16213,7 +16213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16234,7 +16234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16254,7 +16254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16284,7 +16284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16311,7 +16311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16323,7 +16323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.7.9",
|
||||
@@ -16346,7 +16346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16360,7 +16360,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16390,7 +16390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16404,7 +16404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16423,7 +16423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -16522,7 +16522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -16541,7 +16541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16556,7 +16556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16580,7 +16580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16597,7 +16597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16613,7 +16613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16634,7 +16634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16665,7 +16665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-oauth2",
|
||||
@@ -16689,7 +16689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -16723,7 +16723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16741,7 +16741,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16750,7 +16750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16762,7 +16762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16774,7 +16774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16786,7 +16786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16798,7 +16798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16810,7 +16810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16821,7 +16821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16832,7 +16832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16845,7 +16845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16869,7 +16869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16883,7 +16883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16900,7 +16900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16915,7 +16915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16934,7 +16934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16945,7 +16945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16982,7 +16982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -17020,7 +17020,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
@@ -17030,7 +17030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17059,7 +17059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -17082,7 +17082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17115,7 +17115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17135,7 +17135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17169,7 +17169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17204,7 +17204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17227,7 +17227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17251,7 +17251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -17275,7 +17275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17310,7 +17310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17338,7 +17338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17361,7 +17361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.642.0"
|
||||
version = "1.643.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.4",
|
||||
@@ -17379,7 +17379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.642.0"
|
||||
version = "1.643.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.643.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.643.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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.642.0
|
||||
version: 1.643.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,10 @@ 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
|
||||
schema:
|
||||
$ref: "#/components/schemas/JobTriggerKind"
|
||||
type: string
|
||||
OrderDesc:
|
||||
name: order_desc
|
||||
description: order by desc order (default true)
|
||||
@@ -17270,19 +17334,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 +17412,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 +17424,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 +17545,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 +17589,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 +19894,8 @@ components:
|
||||
format: date-time
|
||||
format_extension:
|
||||
type: string
|
||||
is_fileset:
|
||||
type: boolean
|
||||
required:
|
||||
- name
|
||||
|
||||
@@ -19841,6 +19905,8 @@ components:
|
||||
schema: {}
|
||||
description:
|
||||
type: string
|
||||
is_fileset:
|
||||
type: boolean
|
||||
|
||||
Schedule:
|
||||
type: object
|
||||
|
||||
@@ -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.643.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+132
-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,
|
||||
);
|
||||
|
||||
@@ -2587,6 +2675,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 +2740,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;
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
+1
-1
@@ -65,7 +65,7 @@ export {
|
||||
workspaceAdd,
|
||||
};
|
||||
|
||||
export const VERSION = "1.642.0";
|
||||
export const VERSION = "1.643.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);
|
||||
}
|
||||
|
||||
@@ -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.643.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-components",
|
||||
"version": "1.642.0",
|
||||
"version": "1.643.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.642.0",
|
||||
"version": "1.643.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>
|
||||
@@ -0,0 +1,735 @@
|
||||
<script lang="ts" module>
|
||||
import { z } from 'zod'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { formatDatePretty, parsePrettyDate, type IconType } from '$lib/utils'
|
||||
|
||||
export type FilterSchemaRec = Record<string, FilterSchema>
|
||||
export type FilterSchema = (
|
||||
| {
|
||||
type: 'string' | 'number' | 'boolean'
|
||||
allowMultiple?: boolean
|
||||
format?: 'json'
|
||||
}
|
||||
| {
|
||||
type: 'date'
|
||||
mode?: 'single' | 'end' | 'start'
|
||||
otherField?: string // For range display
|
||||
allowMultiple?: undefined
|
||||
}
|
||||
| {
|
||||
type: 'oneof'
|
||||
options: { value: string; label?: string; description?: string }[]
|
||||
allowCustomValue?: boolean
|
||||
allowNegative?: boolean
|
||||
allowMultiple?: boolean
|
||||
}
|
||||
) & {
|
||||
label?: string
|
||||
description?: string
|
||||
icon?: IconType
|
||||
}
|
||||
export type FilterInstanceRec<T extends FilterSchemaRec> = {
|
||||
[K in keyof T]: FilterInstance<T[K]>
|
||||
}
|
||||
export type FilterInstance<T extends FilterSchema> = T extends { type: 'string' }
|
||||
? string
|
||||
: T extends { type: 'number' }
|
||||
? number
|
||||
: T extends { type: 'boolean' }
|
||||
? boolean
|
||||
: T extends { type: 'date' }
|
||||
? Date
|
||||
: T extends { type: 'oneof'; options: any; allowCustomValue?: infer A }
|
||||
? A extends true
|
||||
? string
|
||||
: T['options'] extends { value: string }[]
|
||||
? _NegativeFilterInstance<T>
|
||||
: never
|
||||
: never
|
||||
|
||||
type _NegativeFilterInstance<T extends { options: { value: string }[] }> = T extends {
|
||||
allowNegative: true
|
||||
}
|
||||
? T['options'][number]['value'] | `!${T['options'][number]['value']}`
|
||||
: T['options'][number]['value']
|
||||
|
||||
/**
|
||||
* Converts a FilterSchemaRec to a Zod schema for validation
|
||||
*/
|
||||
export function filterSchemaRecToZodSchema<T extends FilterSchemaRec>(
|
||||
schemaRec: T
|
||||
): z.ZodObject<{
|
||||
[K in keyof T]: z.ZodType<FilterInstance<T[K]>>
|
||||
}> {
|
||||
const zodSchemaShape: Record<string, z.ZodType> = {}
|
||||
|
||||
for (const [key, filterSchema] of Object.entries(schemaRec)) {
|
||||
let fieldSchema: z.ZodType
|
||||
|
||||
if (filterSchema.type === 'string') {
|
||||
fieldSchema = z.string().nullable().default(null)
|
||||
} else if (filterSchema.type === 'number') {
|
||||
fieldSchema = z.number().nullable().default(null)
|
||||
} else if (filterSchema.type === 'boolean') {
|
||||
fieldSchema = z.boolean().nullable().default(null)
|
||||
} else if (filterSchema.type === 'date') {
|
||||
fieldSchema = z.string().nullable().default(null)
|
||||
} else if (filterSchema.type === 'oneof') {
|
||||
if (filterSchema.allowCustomValue) {
|
||||
// If custom values are allowed, accept any string
|
||||
fieldSchema = z.string().nullable().default(null)
|
||||
} else {
|
||||
// Extract the enum values from options
|
||||
const values = filterSchema.options.map((o) => o.value) as [string, ...string[]]
|
||||
fieldSchema = z.enum(values).nullable().default(null)
|
||||
}
|
||||
} else {
|
||||
// Fallback for unknown types
|
||||
fieldSchema = z.any().nullable().default(null)
|
||||
}
|
||||
|
||||
zodSchemaShape[key] = fieldSchema
|
||||
}
|
||||
|
||||
return z.object(zodSchemaShape) as any
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a URL-synced filter instance that automatically syncs with URL search parameters
|
||||
*/
|
||||
export function useUrlSyncedFilterInstance<T extends FilterSchemaRec>(
|
||||
schemaRec: T
|
||||
): { val: Partial<FilterInstanceRec<T>> } {
|
||||
// Build the Zod schema from the filter schema
|
||||
const zodSchema = filterSchemaRecToZodSchema(schemaRec)
|
||||
|
||||
// Create URL-synced search params
|
||||
const urlFilter = useSearchParams(zodSchema) as Record<string, unknown>
|
||||
|
||||
// Create the filter instance object
|
||||
const filterInstance: { val: Partial<FilterInstanceRec<T>> } = $state({ val: {} })
|
||||
|
||||
// Sync URL params to filter instance on initialization and when URL changes
|
||||
for (const key of Object.keys(schemaRec)) {
|
||||
let urlValue = urlFilter[key]
|
||||
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
|
||||
const d = new Date(urlValue)
|
||||
urlValue = isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
if (urlValue !== undefined && urlValue !== null) {
|
||||
;(filterInstance.val as any)[key] = urlValue
|
||||
}
|
||||
}
|
||||
|
||||
// Sync filter instance changes back to URL params
|
||||
for (const key of Object.keys(schemaRec)) {
|
||||
$effect(() => {
|
||||
let filterValue = (filterInstance.val as any)[key]
|
||||
if (schemaRec[key].type === 'date' && filterValue instanceof Date) {
|
||||
// Convert Date to ISO string for URL
|
||||
filterValue = filterValue.toISOString()
|
||||
}
|
||||
if (untrack(() => urlFilter[key]) == filterValue) return // Avoid unnecessary updates
|
||||
if (filterValue !== undefined && filterValue !== null) {
|
||||
urlFilter[key] = filterValue
|
||||
} else {
|
||||
urlFilter[key] = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return filterInstance
|
||||
}
|
||||
|
||||
function filterToText<F extends FilterSchema>(filter: FilterInstance<F>, schema: F): string {
|
||||
if (schema.type === 'date') {
|
||||
const date =
|
||||
typeof filter === 'string'
|
||||
? new Date(filter)
|
||||
: typeof filter === 'number'
|
||||
? new Date(filter)
|
||||
: (filter as Date)
|
||||
return formatDatePretty(date)
|
||||
}
|
||||
return String(filter)
|
||||
}
|
||||
|
||||
function textToFilter(text: string, schema: FilterSchema): FilterInstance<FilterSchema> | null {
|
||||
if (schema.type === 'string') return text
|
||||
if (schema.type === 'number') {
|
||||
const num = Number(text)
|
||||
return isNaN(num) ? null : (num as any)
|
||||
}
|
||||
if (schema.type === 'boolean') {
|
||||
if (text.toLowerCase() === 'true') return true as any
|
||||
if (text.toLowerCase() === 'false') return false as any
|
||||
return null
|
||||
}
|
||||
if (schema.type === 'date') {
|
||||
const date = parsePrettyDate(text)
|
||||
return date ? (date as any) : null
|
||||
}
|
||||
if (schema.type === 'oneof') {
|
||||
return text
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type FilterValidationError = { fields: string[]; error: string }
|
||||
|
||||
/**
|
||||
* Validates a filter instance against its schema.
|
||||
* Returns a list of validation errors, each with the affected fields and an error message.
|
||||
*/
|
||||
export function validateFilterInstance<T extends FilterSchemaRec>(
|
||||
schemaRec: T,
|
||||
instance: Partial<FilterInstanceRec<T>>
|
||||
): FilterValidationError[] {
|
||||
const errors: FilterValidationError[] = []
|
||||
|
||||
for (const [key, rawValue] of Object.entries(instance)) {
|
||||
const schema = schemaRec[key]
|
||||
if (!schema) continue
|
||||
|
||||
if (schema.type === 'date') {
|
||||
if (!rawValue || !((rawValue as any) instanceof Date) || isNaN(rawValue.getTime())) {
|
||||
errors.push({ fields: [key], error: `Invalid date format` })
|
||||
}
|
||||
} else if (schema.type === 'oneof') {
|
||||
const strValue = String(rawValue)
|
||||
const elements = schema.allowMultiple ? strValue.split(',') : [strValue]
|
||||
const validValues = schema.options.map((o) => o.value)
|
||||
|
||||
if (schema.allowMultiple && schema.allowNegative) {
|
||||
const hasPositive = elements.some((v) => !v.startsWith('!'))
|
||||
const hasNegative = elements.some((v) => v.startsWith('!'))
|
||||
if (hasPositive && hasNegative) {
|
||||
errors.push({
|
||||
fields: [key],
|
||||
error: `Cannot mix positive and negative values`
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!schema.allowCustomValue) {
|
||||
const invalid = elements
|
||||
.map((v) => v.replace(/^!/, ''))
|
||||
.filter((v) => !validValues.includes(v))
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
fields: [key],
|
||||
error: `Invalid value${invalid.length > 1 ? 's' : ''}: ${invalid.join(', ')}`
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (schema.type === 'string' && schema.format === 'json') {
|
||||
try {
|
||||
JSON.parse(String(rawValue))
|
||||
} catch (e) {
|
||||
errors.push({ fields: [key], error: `Invalid JSON format` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { inputBaseClass, inputBorderClass, inputSizeClasses } from './text_input/TextInput.svelte'
|
||||
import { MinusIcon, SearchIcon } from 'lucide-svelte'
|
||||
import { assignObjInPlace, clone } from '$lib/utils'
|
||||
import GenericDropdown from './select/GenericDropdown.svelte'
|
||||
import SimpleEditor from './SimpleEditor.svelte'
|
||||
import TaggedTextInput from './TaggedTextInput.svelte'
|
||||
import { DebouncedTempValue, useTransformedSyncedValue } from '$lib/svelte5Utils.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import CloseButton from './common/CloseButton.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import InlineCalendarInput, {
|
||||
fromCalendarDate,
|
||||
toCalendarDate
|
||||
} from './common/InlineCalendarInput.svelte'
|
||||
import { ButtonType } from './common'
|
||||
|
||||
type Props<SchemaT extends FilterSchemaRec> = {
|
||||
schema: SchemaT
|
||||
value: Partial<FilterInstanceRec<SchemaT>>
|
||||
presets?: { name: string; value: string }[]
|
||||
class?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
type SchemaT = FilterSchemaRec // TODO: Generic
|
||||
let {
|
||||
schema,
|
||||
value: valueInput = $bindable(),
|
||||
presets: _presets = [],
|
||||
class: className,
|
||||
placeholder = 'Filter...'
|
||||
}: Props<SchemaT> = $props()
|
||||
|
||||
let _value = new DebouncedTempValue(
|
||||
() => clone(valueInput),
|
||||
(v) => !errors.length && (valueInput = clone(v)),
|
||||
(t) => Object.entries(t)
|
||||
)
|
||||
let value = $derived(_value.current)
|
||||
let errors = $derived(validateFilterInstance(schema, value))
|
||||
|
||||
let currentTag: keyof SchemaT | undefined = $state()
|
||||
let currentTextSegment = $state({ text: '', start: 0, end: 0 })
|
||||
let open = $state(false)
|
||||
let inputElement: HTMLDivElement | undefined = $state()
|
||||
let highlightedIndex = $state(0)
|
||||
let taggedTextInput: TaggedTextInput | undefined = $state()
|
||||
|
||||
let tags = $derived(
|
||||
Object.entries(schema).map(([key, filterSchema]) => ({
|
||||
regex: new RegExp(`\\b${key}:(?:\\\\.|[^\\s])*`, 'g'),
|
||||
id: key,
|
||||
onClear: () => (delete value[key], asText.reparse())
|
||||
}))
|
||||
)
|
||||
|
||||
let keyHighlightRegex = $derived(new RegExp(`\\b(${Object.keys(schema).join('|')}):`, 'g'))
|
||||
|
||||
let errorKeys = $derived(new Set(errors.flatMap((e) => e.fields)))
|
||||
let errorHighlights = $derived(
|
||||
[...errorKeys].map((key) => ({
|
||||
regex: new RegExp(`(?<=\\b${key}:)(?:\\\\.|[^\\s])+`),
|
||||
classes: 'text-red-500 dark:text-red-400'
|
||||
}))
|
||||
)
|
||||
|
||||
let menuItems = $derived.by(() => {
|
||||
if (!currentTag) {
|
||||
const searchText = currentTextSegment.text.trim().toLowerCase()
|
||||
return Object.entries(schema)
|
||||
.filter(([k, _]) => !(k in value))
|
||||
.filter(([k, filterSchema]) => {
|
||||
if (!searchText) return true
|
||||
const label = (filterSchema.label || k).toLowerCase()
|
||||
const key = k.toLowerCase()
|
||||
return label.includes(searchText) || key.includes(searchText)
|
||||
})
|
||||
.map(([key, filterSchema]) => ({
|
||||
type: 'filter' as const,
|
||||
key,
|
||||
filterSchema,
|
||||
onClick: () => {
|
||||
// Replace the text segment with the new filter tag
|
||||
const before = asText.val.slice(0, currentTextSegment.start)
|
||||
const after = asText.val.slice(currentTextSegment.end)
|
||||
asText.val =
|
||||
`${before}${before && !before.endsWith(' ') ? ' ' : ''}${key}:\\\u00A0${after}`.trim() +
|
||||
'\u00A0'
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
const filter = schema[currentTag]
|
||||
if (filter.type === 'oneof') {
|
||||
// When allowMultiple, split on comma and match against the last segment
|
||||
const currentVal = String(value[currentTag!] ?? '')
|
||||
let searchSuffix: string
|
||||
if (filter.allowMultiple) {
|
||||
const parts = currentVal.split(',')
|
||||
searchSuffix = parts[parts.length - 1].replace(/^!/, '').trim()
|
||||
} else {
|
||||
searchSuffix = currentVal
|
||||
}
|
||||
|
||||
// Already-selected values (for allowMultiple, to avoid suggesting duplicates)
|
||||
const selectedValues = filter.allowMultiple
|
||||
? currentVal
|
||||
.split(',')
|
||||
.slice(0, -1)
|
||||
.map((v) => v.replace(/^!/, '').trim())
|
||||
: []
|
||||
|
||||
return filter.options
|
||||
.filter((o) => {
|
||||
if (selectedValues.includes(o.value)) return false
|
||||
if (!searchSuffix) return true
|
||||
return o.value.includes(searchSuffix)
|
||||
})
|
||||
.map((option) => ({
|
||||
type: 'option' as const,
|
||||
option,
|
||||
onClick: () =>
|
||||
appendOrSetValueForCurrentTag((currentVal.includes('!') ? '!' : '') + option.value),
|
||||
onNegativeClick: filter.allowNegative
|
||||
? () => appendOrSetValueForCurrentTag('!' + option.value)
|
||||
: undefined
|
||||
}))
|
||||
} else if (filter.type === 'boolean') {
|
||||
return [
|
||||
{
|
||||
type: 'boolean' as const,
|
||||
value: true,
|
||||
label: 'True',
|
||||
onClick: () => setValueForCurrentTag(true)
|
||||
},
|
||||
{
|
||||
type: 'boolean' as const,
|
||||
value: false,
|
||||
label: 'False',
|
||||
onClick: () => setValueForCurrentTag(false)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Reset highlighted index when menu items change
|
||||
$effect(() => {
|
||||
menuItems
|
||||
open
|
||||
highlightedIndex = 0
|
||||
})
|
||||
|
||||
const kvRegex = /\b(\w+):((?:[^\s\\]|\\.)*)/g
|
||||
|
||||
function parseFromText(text: string): Partial<FilterInstanceRec<SchemaT>> {
|
||||
const parsed: Record<string, string> = {}
|
||||
let match
|
||||
while ((match = kvRegex.exec(text)) !== null) {
|
||||
let [_, key, val] = match
|
||||
if (key in schema) {
|
||||
val ??= ''
|
||||
val = val.replace(/\\(.)/g, (_: string, c: string) => {
|
||||
if (c === 'n') return '\n'
|
||||
if (c === 'r') return '\r'
|
||||
return c
|
||||
}) // Unescape escaped characters
|
||||
val = val.trim()
|
||||
parsed[key] = textToFilter(val, schema[key]) as any
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseToText(v: Partial<FilterInstanceRec<SchemaT>>): string {
|
||||
return (
|
||||
Object.entries(v)
|
||||
.map(([key, val]) =>
|
||||
`${key}: ${filterToText(val as any, schema[key])}`
|
||||
.replace(/ /g, '\\ ')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
)
|
||||
.join(' ') + '\u00A0'
|
||||
)
|
||||
}
|
||||
|
||||
let asText = useTransformedSyncedValue(
|
||||
[() => (Object.entries(value), value), (v) => assignObjInPlace(value, v)],
|
||||
parseToText,
|
||||
parseFromText
|
||||
)
|
||||
|
||||
function setValueForCurrentTag(val: any) {
|
||||
if (!currentTag) return
|
||||
value[currentTag!] = val
|
||||
asText.reparse()
|
||||
}
|
||||
|
||||
/**
|
||||
* For allowMultiple fields: appends a new value to the existing comma-separated list,
|
||||
* replacing the last (in-progress) segment. For non-allowMultiple fields, behaves like setValueForCurrentTag.
|
||||
*/
|
||||
function appendOrSetValueForCurrentTag(val: string) {
|
||||
if (!currentTag) return
|
||||
const filter = schema[currentTag]
|
||||
if (filter.allowMultiple) {
|
||||
const existing = String(value[currentTag!] ?? '')
|
||||
const parts = existing.split(',')
|
||||
// If any existing part is negative, force the new value to be negative too
|
||||
const isNegativeContext = parts.slice(0, -1).some((p) => p.startsWith('!'))
|
||||
if (isNegativeContext && !val.startsWith('!')) val = '!' + val
|
||||
// Replace the last in-progress segment with the selected value
|
||||
parts[parts.length - 1] = val
|
||||
value[currentTag!] = parts.join(',')
|
||||
} else {
|
||||
value[currentTag!] = val
|
||||
}
|
||||
asText.reparse()
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!open) return
|
||||
if (e.key === 'Escape') {
|
||||
open = false
|
||||
return
|
||||
}
|
||||
|
||||
if (menuItems.length && e.key === 'ArrowDown') {
|
||||
highlightedIndex = (highlightedIndex + 1) % menuItems.length
|
||||
} else if (menuItems.length && e.key === 'ArrowUp') {
|
||||
highlightedIndex = (highlightedIndex - 1 + menuItems.length) % menuItems.length
|
||||
} else if (e.key === 'Enter') {
|
||||
if (menuItems[highlightedIndex]) {
|
||||
menuItems[highlightedIndex].onClick()
|
||||
} else {
|
||||
setValueForCurrentTag(value[currentTag!])
|
||||
taggedTextInput?.focusAtEnd()
|
||||
}
|
||||
const currTagSchema = currentTag ? schema[currentTag] : undefined
|
||||
if (currTagSchema && 'format' in currTagSchema && currTagSchema.format === 'json') {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
type Preset = { name: string; value: string }
|
||||
let presets: Preset[] = $derived(
|
||||
_presets.filter((p) => {
|
||||
// Only show presets that aren't already applied in asText
|
||||
return !asText.val.includes(p.value)
|
||||
})
|
||||
)
|
||||
|
||||
function appendFilterAsText(presetValue: string) {
|
||||
if (!asText.val.endsWith('\u00A0') && !asText.val.endsWith(' ')) asText.val += ' '
|
||||
asText.val += presetValue + '\u00A0'
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onmousedown={() => (open = false)} onkeydown={handleKeyDown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-center rounded-md bg-surface-input overflow-clip',
|
||||
inputBorderClass({ error: errors.length > 0, forceFocus: open }),
|
||||
ButtonType.UnifiedHeightClasses.md,
|
||||
className
|
||||
)}
|
||||
onmousedown={(e) => {
|
||||
if (!open) {
|
||||
e.preventDefault()
|
||||
if (!asText.val.endsWith('\u00A0') && !asText.val.endsWith(' ')) asText.val += '\u00A0'
|
||||
taggedTextInput?.focusAtEnd()
|
||||
}
|
||||
open = true
|
||||
e.stopPropagation()
|
||||
}}
|
||||
bind:this={inputElement}
|
||||
>
|
||||
<TaggedTextInput
|
||||
bind:this={taggedTextInput}
|
||||
bind:value={asText.val}
|
||||
{tags}
|
||||
highlights={[
|
||||
{ regex: /![a-zA-Z0-9_\-\/]+/, classes: 'text-yellow-600 dark:text-yellow-500' },
|
||||
{ regex: keyHighlightRegex, classes: 'text-hint' },
|
||||
{ regex: /,/, classes: 'text-hint mr-0.5' },
|
||||
...errorHighlights
|
||||
]}
|
||||
onCurrentTagChange={(tag) => (currentTag = tag ? (tag.id as keyof SchemaT) : undefined)}
|
||||
onTextSegmentAtCursorChange={(segment) => (currentTextSegment = segment)}
|
||||
class={twMerge(
|
||||
'overflow-x-auto !pr-24 bg-surface-input outline-none scrollbar-hidden text-nowrap flex-1 mr-2 mt-0.5',
|
||||
inputBaseClass,
|
||||
inputSizeClasses.md
|
||||
)}
|
||||
{placeholder}
|
||||
/>
|
||||
{#if asText.val}
|
||||
<CloseButton small class="mr-1.5" onClick={() => (_value.current = {})} />
|
||||
{:else}
|
||||
<div class="mr-3">
|
||||
<SearchIcon size={16} class="text-hint" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<GenericDropdown
|
||||
{open}
|
||||
getInputRect={() => inputElement?.getBoundingClientRect() ?? new DOMRect()}
|
||||
innerClass="!max-h-[30rem]"
|
||||
strictWidth
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="py-1 p-2 overflow-y-auto" onmousedown={(e) => e.stopPropagation()}>
|
||||
{#if !currentTag || !schema[currentTag]}
|
||||
{#if presets.length}
|
||||
<div class="text-xs px-2 my-2 font-bold">Presets</div>
|
||||
<div class="mb-3 px-2 flex gap-2 flex-wrap">
|
||||
{#each presets as preset}
|
||||
{@render presetTag(preset)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-xs px-2 my-2 font-bold">Filters</div>
|
||||
{#each menuItems as item, index}
|
||||
{#if item.type === 'filter' && item.filterSchema}
|
||||
{@render menuItem({
|
||||
Icon: item.filterSchema.icon || SearchIcon,
|
||||
onClick: item.onClick,
|
||||
label: item.filterSchema.label || item.key,
|
||||
description: item.filterSchema.description,
|
||||
highlighted: index === highlightedIndex
|
||||
})}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{#key currentTag}
|
||||
{@render suggestion(schema[currentTag])}
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</GenericDropdown>
|
||||
|
||||
{#snippet suggestion(filter: FilterSchema)}
|
||||
{#if filter.description}
|
||||
<div class="text-xs text-secondary px-2 my-2">{filter.description}</div>
|
||||
{/if}
|
||||
{#if filter.allowMultiple && (filter.type === 'string' || filter.type === 'oneof')}
|
||||
<div class="text-2xs text-hint px-2 -mt-1 mb-2">Separate multiple values with commas</div>
|
||||
{/if}
|
||||
{#if filter.type === 'oneof'}
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
{#each menuItems as item, index}
|
||||
{#if item.type === 'option' && item.option}
|
||||
{@render menuItem({
|
||||
onClick: item.onClick,
|
||||
label: item.option.label || item.option.value,
|
||||
highlighted: index === highlightedIndex,
|
||||
onNegativeClick: item.onNegativeClick
|
||||
})}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filter.type === 'boolean'}
|
||||
{#each menuItems as item, index}
|
||||
{#if item.type === 'boolean' && item.label}
|
||||
{@render menuItem({
|
||||
onClick: item.onClick,
|
||||
label: item.label,
|
||||
highlighted: index === highlightedIndex
|
||||
})}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else if filter.type === 'date'}
|
||||
{@const filterMode = filter.mode}
|
||||
<div class="p-3 mb-1">
|
||||
{#if !filterMode || filterMode === 'single'}
|
||||
<InlineCalendarInput
|
||||
bind:value={
|
||||
() => toCalendarDate(value[currentTag!]),
|
||||
(v) => {
|
||||
setValueForCurrentTag(fromCalendarDate(v))
|
||||
taggedTextInput?.preventCursorMoveOnNextSync()
|
||||
}
|
||||
}
|
||||
/>
|
||||
{:else}
|
||||
{@const curr = toCalendarDate(value[currentTag!])}
|
||||
{@const obj =
|
||||
filterMode === 'end'
|
||||
? { start: toCalendarDate(value[filter.otherField as keyof SchemaT]), end: curr }
|
||||
: { end: toCalendarDate(value[filter.otherField as keyof SchemaT]), start: curr }}
|
||||
<InlineCalendarInput
|
||||
mode="range"
|
||||
onClickBehavior={`set-${filterMode}`}
|
||||
infiniteRange
|
||||
bind:value={
|
||||
() => obj,
|
||||
(v) => {
|
||||
setValueForCurrentTag(fromCalendarDate(v[filterMode]))
|
||||
taggedTextInput?.preventCursorMoveOnNextSync()
|
||||
}
|
||||
}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if filter.type === 'string' && filter.format === 'json'}
|
||||
<div class="px-2 pb-2">
|
||||
<SimpleEditor
|
||||
autofocus={String(value[currentTag!] ?? '').length === 0}
|
||||
lang="json"
|
||||
autoHeight
|
||||
small
|
||||
bind:code={
|
||||
() => String(value[currentTag!] ?? ''),
|
||||
(v) => {
|
||||
setValueForCurrentTag(v ?? '')
|
||||
taggedTextInput?.preventCursorMoveOnNextSync()
|
||||
}
|
||||
}
|
||||
class="border border-border-light rounded min-h-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet menuItem({
|
||||
Icon,
|
||||
onClick,
|
||||
label,
|
||||
description,
|
||||
highlighted = false,
|
||||
onNegativeClick
|
||||
}: {
|
||||
Icon?: IconType
|
||||
onClick: () => void
|
||||
label: string
|
||||
description?: string
|
||||
highlighted?: boolean
|
||||
onNegativeClick?: () => void
|
||||
})}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'py-1.5 px-2 rounded-md hover:bg-surface-hover cursor-pointer text-sm flex items-center gap-3',
|
||||
highlighted && 'bg-surface-hover'
|
||||
)}
|
||||
onclick={onClick}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon size={16} class="inline" />
|
||||
{/if}
|
||||
<div class="inline flex-1 relative min-w-0">
|
||||
<div class="text-sm ellipsize">{label}</div>
|
||||
{#if description}
|
||||
<div class="text-xs text-hint">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if onNegativeClick}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<Popover openOnHover portal={null}>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
onClick={(e) => (e?.stopPropagation(), onNegativeClick?.())}
|
||||
iconOnly
|
||||
endIcon={{ icon: MinusIcon }}
|
||||
unifiedSize="xs"
|
||||
destructive
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="text-xs">Exclude {label}</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet presetTag({ name, value }: Preset)}
|
||||
<Badge onclick={() => appendFilterAsText(value)} clickable>
|
||||
{name}
|
||||
</Badge>
|
||||
{/snippet}
|
||||
@@ -1115,7 +1115,7 @@
|
||||
<div
|
||||
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
|
||||
>
|
||||
<div class="flex w-full max-w-md gap-8 items-center">
|
||||
<div class="flex w-full gap-8 items-center min-w-0">
|
||||
<SummaryPathDisplay
|
||||
bind:summary={flowStore.val.summary}
|
||||
bind:path={$pathStore}
|
||||
@@ -1124,7 +1124,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="gap-4 flex-row hidden md:flex w-full whitespace-nowrap max-w-md">
|
||||
<div class="gap-4 flex-row hidden md:flex whitespace-nowrap">
|
||||
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
|
||||
{@const primaryScheduleIndex = triggersState.triggers.findIndex((t) => t.isPrimary)}
|
||||
{@const scheduleIndex = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
|
||||
|
||||
@@ -104,14 +104,13 @@
|
||||
filters: {
|
||||
show_skipped: false,
|
||||
path: runnableId,
|
||||
success: 'running',
|
||||
arg: searchArgs ? JSON.stringify(searchArgs) : '',
|
||||
per_page: 5
|
||||
status: 'running',
|
||||
arg: searchArgs ? JSON.stringify(searchArgs) : ''
|
||||
},
|
||||
perPage: 5,
|
||||
jobKinds: getJobKinds(runnableType),
|
||||
syncQueuedRunsCount: false,
|
||||
refreshRate: 10000,
|
||||
computeMinAndMax: undefined,
|
||||
currentWorkspace: $workspaceStore ?? '',
|
||||
skip: !runnableId
|
||||
}) satisfies UseJobLoaderArgs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { FileText } from 'lucide-svelte'
|
||||
import { FileText, FolderOpen } from 'lucide-svelte'
|
||||
import { APP_TO_ICON_COMPONENT } from './icons'
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
@@ -11,6 +11,7 @@
|
||||
* @property {boolean} [center]
|
||||
* @property {boolean} [isSelected]
|
||||
* @property {any} [formatExtension]
|
||||
* @property {boolean} [isFileset]
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
@@ -22,7 +23,8 @@
|
||||
width = '24px',
|
||||
center = false,
|
||||
isSelected = false,
|
||||
formatExtension = undefined
|
||||
formatExtension = undefined,
|
||||
isFileset = false
|
||||
} = $props()
|
||||
|
||||
let iconComponent = $derived(
|
||||
@@ -45,6 +47,10 @@
|
||||
<span class={isSelected ? 'text-secondary' : 'text-secondary'}>
|
||||
<SvelteComponent {height} {width} size={widthInPixels} />
|
||||
</span>
|
||||
{:else if isFileset}
|
||||
<span class={isSelected ? 'text-secondary' : 'text-secondary grayscale'}>
|
||||
<FolderOpen {height} {width} />
|
||||
</span>
|
||||
{:else if formatExtension}
|
||||
<span class={isSelected ? 'text-secondary' : 'text-secondary grayscale'}>
|
||||
<FileText {height} {width} />
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
otherArgs?: Record<string, InputTransform>
|
||||
helperScript?: DynamicInputTypes.HelperScript | undefined
|
||||
isAgentTool?: boolean
|
||||
allowedAiTransforms?: string[] | undefined
|
||||
s3StorageConfigured?: boolean
|
||||
chatInputEnabled?: boolean
|
||||
}
|
||||
@@ -92,6 +93,7 @@
|
||||
otherArgs = {},
|
||||
helperScript = undefined,
|
||||
isAgentTool = false,
|
||||
allowedAiTransforms = isAgentTool ? undefined : [],
|
||||
s3StorageConfigured = true,
|
||||
chatInputEnabled = false
|
||||
}: Props = $props()
|
||||
@@ -135,6 +137,11 @@
|
||||
)
|
||||
)
|
||||
|
||||
// Whether this specific field is allowed to use AI transforms
|
||||
let fieldAllowsAi = $derived(
|
||||
allowedAiTransforms === undefined || allowedAiTransforms.includes(argName)
|
||||
)
|
||||
|
||||
let propertyType = $state(getPropertyType(arg))
|
||||
|
||||
function setExpr() {
|
||||
@@ -167,7 +174,7 @@
|
||||
function getPropertyType(arg: InputTransform | any): InputTransform['type'] {
|
||||
// For agent tools, if static with undefined/empty value, treat as 'ai', meaning the field will be filled by the AI agent dynamically.
|
||||
if (
|
||||
isAgentTool &&
|
||||
fieldAllowsAi &&
|
||||
((arg?.type === 'static' && arg?.value === undefined) || arg?.type === 'ai')
|
||||
) {
|
||||
if (arg?.type === 'static') {
|
||||
@@ -645,7 +652,7 @@
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
{#if isAgentTool}
|
||||
{#if fieldAllowsAi}
|
||||
<ToggleButton
|
||||
small
|
||||
label="AI"
|
||||
@@ -733,8 +740,8 @@
|
||||
<div
|
||||
class="text-sm text-tertiary italic p-3 bg-surface-secondary rounded-md border border-gray-200"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<InfoIcon size={16} />
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
<InfoIcon size={13} />
|
||||
This field will be filled by the AI agent dynamically
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
class?: string
|
||||
helperScript?: DynamicInputTypes.HelperScript
|
||||
isAgentTool?: boolean
|
||||
allowedAiTransforms?: string[] | undefined
|
||||
chatInputEnabled?: boolean
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
class: clazz = '',
|
||||
helperScript = undefined,
|
||||
isAgentTool = false,
|
||||
allowedAiTransforms = isAgentTool ? undefined : [],
|
||||
chatInputEnabled = false
|
||||
}: Props = $props()
|
||||
|
||||
@@ -141,6 +143,7 @@
|
||||
{enableAi}
|
||||
{helperScript}
|
||||
{isAgentTool}
|
||||
{allowedAiTransforms}
|
||||
{s3StorageConfigured}
|
||||
{chatInputEnabled}
|
||||
otherArgs={Object.fromEntries(
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import SchemaForm from './SchemaForm.svelte'
|
||||
import SimpleEditor from './SimpleEditor.svelte'
|
||||
import FilesetEditor from './FilesetEditor.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import TestConnection from './TestConnection.svelte'
|
||||
@@ -128,7 +129,7 @@
|
||||
resourceSchema.order =
|
||||
resourceSchema.order ?? Object.keys(resourceSchema.properties).sort()
|
||||
}
|
||||
if (resourceTypeInfo?.format_extension) {
|
||||
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
|
||||
textFileContent = args.content
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -165,7 +166,7 @@
|
||||
rawCode = JSON.stringify(args, null, 2)
|
||||
} else {
|
||||
parseJson()
|
||||
if (resourceTypeInfo?.format_extension) {
|
||||
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
|
||||
textFileContent = args.content
|
||||
}
|
||||
}
|
||||
@@ -294,9 +295,14 @@
|
||||
<div>
|
||||
{#if loadingSchema}
|
||||
<Skeleton layout={[[4]]} />
|
||||
{:else if !viewJsonSchema && resourceTypeInfo?.is_fileset}
|
||||
<h5 class="mt-1 inline-flex items-center gap-4">
|
||||
Fileset
|
||||
</h5>
|
||||
<FilesetEditor bind:args />
|
||||
{:else if !viewJsonSchema && resourceSchema && resourceSchema?.properties}
|
||||
{#if resourceTypeInfo?.format_extension}
|
||||
<h5 class="mt-4 inline-flex items-center gap-4 pb-2">
|
||||
<h5 class="mt-1 inline-flex items-center gap-4">
|
||||
File content ({resourceTypeInfo.format_extension})
|
||||
</h5>
|
||||
<div class="">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { clickOutside } from '$lib/utils'
|
||||
import { fly } from 'svelte/transition'
|
||||
import Portal from './Portal.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
const { children }: Props = $props()
|
||||
|
||||
let _isOpen = $state(false)
|
||||
let mousePos = $state({ x: 0, y: 0 })
|
||||
export function open(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
_isOpen = true
|
||||
mousePos = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
export function close() {
|
||||
_isOpen = false
|
||||
}
|
||||
export function isOpen() {
|
||||
return _isOpen
|
||||
}
|
||||
</script>
|
||||
|
||||
<Portal>
|
||||
{#if _isOpen}
|
||||
<div
|
||||
in:fly={{ x: 0, y: -10, duration: 120 }}
|
||||
use:clickOutside={{
|
||||
onClickOutside: (e) => {
|
||||
_isOpen = false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
class="absolute left-0 top-0 z-[9999] w-fit"
|
||||
style="transform: translate({mousePos.x + 2}px, {mousePos.y + 2}px)"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</Portal>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import 'chartjs-adapter-date-fns'
|
||||
import zoomPlugin from 'chartjs-plugin-zoom'
|
||||
import Tooltip2 from '$lib/components/Tooltip.svelte'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
@@ -17,7 +16,6 @@
|
||||
} from 'chart.js'
|
||||
import type { CompletedJob } from '$lib/gen'
|
||||
import { getDbClockNow } from '$lib/forLater'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { Scatter } from '$lib/components/chartjs-wrappers/chartJs'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
|
||||
@@ -28,10 +26,7 @@
|
||||
maxTimeSet?: string | null
|
||||
selectedIds?: string[]
|
||||
canSelect?: boolean
|
||||
lastFetchWentToEnd?: boolean
|
||||
totalRowsFetched: number
|
||||
onPointClicked: (ids: string[]) => void
|
||||
onLoadExtra: () => void
|
||||
onZoom: (zoom: { min: Date; max: Date }) => void
|
||||
}
|
||||
|
||||
@@ -42,10 +37,7 @@
|
||||
maxTimeSet = null,
|
||||
selectedIds = $bindable([]),
|
||||
canSelect = true,
|
||||
lastFetchWentToEnd = false,
|
||||
totalRowsFetched,
|
||||
onPointClicked,
|
||||
onLoadExtra,
|
||||
onZoom
|
||||
}: Props = $props()
|
||||
|
||||
@@ -299,23 +291,6 @@
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<!-- {JSON.stringify(minTime)}
|
||||
{JSON.stringify(maxTime)}
|
||||
|
||||
{JSON.stringify(jobs?.map((x) => x.started_at))} -->
|
||||
<!-- {minTime}
|
||||
{maxTime} -->
|
||||
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
|
||||
<div class="relative max-h-40">
|
||||
{#if !lastFetchWentToEnd}
|
||||
<div class="absolute top-[-28px] left-[220px]">
|
||||
<Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}>
|
||||
Load more
|
||||
<Tooltip2>
|
||||
There are more jobs to load but only the first {totalRowsFetched} were fetched
|
||||
</Tooltip2>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="relative h-44">
|
||||
<Scatter {data} options={scatterOptions} />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,15 @@
|
||||
const bubble = createBubbler()
|
||||
import { IndexSearchService, ServiceLogsService } from '$lib/gen'
|
||||
|
||||
import ManuelDatePicker from './runs/ManuelDatePicker.svelte'
|
||||
import TimeframeSelect, {
|
||||
serviceLogsTimeframes,
|
||||
useUrlSyncedTimeframe
|
||||
} from './runs/TimeframeSelect.svelte'
|
||||
import CalendarPicker from './common/calendarPicker/CalendarPicker.svelte'
|
||||
import LogViewer from './LogViewer.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import { onDestroy, tick } from 'svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
|
||||
import LogSnippetViewer from './LogSnippetViewer.svelte'
|
||||
@@ -20,6 +23,7 @@
|
||||
import Select from './select/Select.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { watch } from 'runed'
|
||||
|
||||
interface Props {
|
||||
searchTerm: string
|
||||
@@ -32,9 +36,6 @@
|
||||
let minTs: undefined | string = $state(undefined)
|
||||
let maxTs: undefined | string = $state(undefined)
|
||||
|
||||
let minTsManual: undefined | string = $state($page.url.searchParams.get('minTs') ?? undefined)
|
||||
let maxTsManual: undefined | string = $state($page.url.searchParams.get('maxTs') ?? undefined)
|
||||
|
||||
let max_lines: undefined | number = $state(undefined)
|
||||
|
||||
// let lastSeen: undefined | string = undefined
|
||||
@@ -58,15 +59,17 @@
|
||||
let timeout: number | undefined = $state(undefined)
|
||||
|
||||
let allLogs: ByMode | undefined = $state(undefined)
|
||||
let manualPicker: ManuelDatePicker | undefined = $state(undefined)
|
||||
|
||||
let _timeframe = useUrlSyncedTimeframe(serviceLogsTimeframes)
|
||||
let timeframe = $derived(_timeframe.timeframe)
|
||||
|
||||
let [minTsManual, maxTsManual] = $derived(
|
||||
timeframe.type === 'manual' ? [timeframe.minTs ?? undefined, timeframe.maxTs ?? undefined] : []
|
||||
)
|
||||
|
||||
let upTo: undefined | string = $state(undefined)
|
||||
let upToIsLatest = $state(true)
|
||||
|
||||
function onManualChanges() {
|
||||
getAllLogs(minTsManual ?? maxTs, maxTsManual)
|
||||
}
|
||||
|
||||
function getAllLogs(queryMinTs: string | undefined, queryMaxTs: string | undefined) {
|
||||
timeout && clearTimeout(timeout)
|
||||
loading = true
|
||||
@@ -151,11 +154,6 @@
|
||||
if (autoRefresh && searchTerm === '' && !maxTsManual) {
|
||||
timeout = setTimeout(() => {
|
||||
if (searchTerm !== '') return
|
||||
let minMax = manualPicker?.computeMinMax()
|
||||
if (minMax) {
|
||||
maxTsManual = minMax?.maxTs ?? undefined
|
||||
minTsManual = minMax?.minTs ?? undefined
|
||||
}
|
||||
let maxTsPlus1 = maxTs ? new Date(new Date(maxTs).getTime() + 1000) : undefined
|
||||
getAllLogs(maxTsPlus1?.toISOString(), undefined)
|
||||
}, 5000)
|
||||
@@ -315,8 +313,6 @@
|
||||
) {
|
||||
const params = new URLSearchParams()
|
||||
if (searchTerm) params.set('searchTerm', searchTerm)
|
||||
if (minTs) params.set('minTs', minTs)
|
||||
if (maxTs) params.set('maxTs', maxTs)
|
||||
if (selected?.mode) params.set('mode', selected.mode)
|
||||
if (selected?.workerGroup) params.set('workerGroup', selected.workerGroup)
|
||||
if (selected?.hostname) params.set('hostname', selected.hostname)
|
||||
@@ -435,13 +431,22 @@
|
||||
)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
minTsManual || maxTsManual || untrack(() => onManualChanges())
|
||||
})
|
||||
$effect(() => {
|
||||
;[searchTerm, selected, minTsManual, maxTsManual, allLogs]
|
||||
untrack(() => searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs))
|
||||
})
|
||||
watch(
|
||||
() => timeframe,
|
||||
() => {
|
||||
const ts = timeframe.computeMinMax()
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => [searchTerm, selected, timeframe, allLogs],
|
||||
() => {
|
||||
searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={logDrawer} bind:open={logDrawerOpen} size="1400px">
|
||||
@@ -477,71 +482,19 @@
|
||||
class="flex flex-col lg:flex-row gap-y-1 justify-between w-full relative pb-4 gap-x-0.5"
|
||||
id="service-logs-date-pickers"
|
||||
>
|
||||
<div class="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={minTsManual
|
||||
? new Date(minTsManual).toLocaleTimeString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'min datetime'}
|
||||
disabled
|
||||
/>
|
||||
<CalendarPicker
|
||||
label="min datetime"
|
||||
date={minTsManual}
|
||||
on:change={({ detail }) => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
minTsManual = detail
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
}}
|
||||
placement="top-start"
|
||||
/>
|
||||
</div>
|
||||
<ManuelDatePicker
|
||||
bind:minTs={() => minTsManual ?? null, (v) => (minTsManual = v ?? undefined)}
|
||||
bind:maxTs={() => maxTsManual ?? null, (v) => (maxTsManual = v ?? undefined)}
|
||||
bind:this={manualPicker}
|
||||
<TimeframeSelect
|
||||
items={serviceLogsTimeframes}
|
||||
bind:value={timeframe}
|
||||
{loading}
|
||||
on:loadJobs={() => {
|
||||
wrapperClasses="w-full"
|
||||
onClick={() => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
const ts = timeframe.computeMinMax()
|
||||
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
|
||||
}}
|
||||
serviceLogsChoices
|
||||
loadText={searchTerm === '' ? 'Last 1000 logfiles' : 'All time'}
|
||||
/>
|
||||
<div class="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={maxTsManual
|
||||
? new Date(maxTsManual).toLocaleTimeString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'max datetime'}
|
||||
disabled
|
||||
/>
|
||||
<CalendarPicker
|
||||
label="max datetime"
|
||||
date={maxTsManual}
|
||||
on:change={({ detail }) => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
maxTsManual = detail
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row-reverse pb-4 -mt-2 gap-2"
|
||||
><Toggle
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
let popoverOpen = $state(false)
|
||||
let own = $state(false)
|
||||
let onBehalfOfEmail = $state<string | undefined>(undefined)
|
||||
let summaryInput: ReturnType<typeof TextInput> | undefined = $state()
|
||||
let hasChanges = $derived(editSummary !== (summary ?? '') || (own && dirtyPath))
|
||||
|
||||
$effect(() => {
|
||||
@@ -70,16 +71,21 @@
|
||||
|
||||
{#if editable || onSaved}
|
||||
<Popover
|
||||
class="min-w-0 max-w-full"
|
||||
placement="bottom-start"
|
||||
contentClasses="p-4"
|
||||
usePointerDownOutside
|
||||
excludeSelectors=".drawer"
|
||||
disableFocusTrap
|
||||
openFocus={() => {
|
||||
summaryInput?.focus()
|
||||
return null
|
||||
}}
|
||||
bind:isOpen={popoverOpen}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
class={'min-w-24 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'}
|
||||
class={'min-w-0 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'}
|
||||
>
|
||||
<span class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
|
||||
>{path}</span
|
||||
@@ -98,6 +104,7 @@
|
||||
{#if onSaved}
|
||||
<Label label="Summary">
|
||||
<TextInput
|
||||
bind:this={summaryInput}
|
||||
inputProps={{
|
||||
type: 'text',
|
||||
placeholder: 'Short summary',
|
||||
@@ -146,6 +153,7 @@
|
||||
<label class="block text-primary">
|
||||
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
|
||||
<TextInput
|
||||
bind:this={summaryInput}
|
||||
inputProps={{
|
||||
type: 'text',
|
||||
placeholder: 'Short summary',
|
||||
@@ -177,7 +185,7 @@
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<div class="min-w-24 truncate flex flex-col px-2">
|
||||
<div class="min-w-0 truncate flex flex-col px-2">
|
||||
{#if !emptyString(summary)}
|
||||
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
tags,
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
highlights,
|
||||
onCurrentTagChange,
|
||||
onTextSegmentAtCursorChange,
|
||||
class: className = ''
|
||||
}: {
|
||||
tags: { regex: RegExp; id: string; onClear?: () => void }[]
|
||||
value?: string
|
||||
placeholder?: string
|
||||
highlights?: { regex: RegExp; classes: string }[]
|
||||
onCurrentTagChange?: (tag: { id: string } | null) => void
|
||||
onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
let contentEditableDiv: HTMLDivElement
|
||||
let isUpdating = false
|
||||
|
||||
$effect(() => {
|
||||
if (!value.trim() && value !== '') value = ''
|
||||
})
|
||||
|
||||
let _preventCursorMoveOnNextSync = false
|
||||
// Update the displayed HTML when value changes externally
|
||||
$effect(() => {
|
||||
if (contentEditableDiv && !isUpdating) {
|
||||
const currentText = getTextContent()
|
||||
if (currentText !== value) {
|
||||
updateDisplay(value)
|
||||
if (!_preventCursorMoveOnNextSync) {
|
||||
restoreCursor(value.length)
|
||||
const cursorPos = getCursorPosition()
|
||||
updateCurrentTag(cursorPos)
|
||||
}
|
||||
}
|
||||
}
|
||||
_preventCursorMoveOnNextSync = false
|
||||
})
|
||||
|
||||
export function preventCursorMoveOnNextSync() {
|
||||
_preventCursorMoveOnNextSync = true
|
||||
}
|
||||
|
||||
function getTextContent(): string {
|
||||
if (!contentEditableDiv) return ''
|
||||
return contentEditableDiv.textContent || ''
|
||||
}
|
||||
|
||||
function updateDisplay(text: string) {
|
||||
if (!contentEditableDiv) return
|
||||
|
||||
const html = highlightText(text)
|
||||
contentEditableDiv.innerHTML = html
|
||||
}
|
||||
|
||||
/** Apply secondary highlight spans within a raw-text chunk. Returns HTML. */
|
||||
function applyHighlightsToChunk(rawText: string): string {
|
||||
if (!highlights || highlights.length === 0) return escapeHtml(rawText)
|
||||
|
||||
// Find all highlight matches in the raw text
|
||||
const hlMatches: Array<{ start: number; end: number; classes: string }> = []
|
||||
for (const hl of highlights) {
|
||||
const regex = new RegExp(hl.regex, 'g')
|
||||
let m
|
||||
while ((m = regex.exec(rawText)) !== null) {
|
||||
hlMatches.push({ start: m.index, end: m.index + m[0].length, classes: hl.classes })
|
||||
}
|
||||
}
|
||||
if (hlMatches.length === 0) return escapeHtml(rawText)
|
||||
|
||||
// Sort and deduplicate (keep first on overlap)
|
||||
hlMatches.sort((a, b) => a.start - b.start)
|
||||
const filtered: typeof hlMatches = []
|
||||
let lastEnd = -1
|
||||
for (const m of hlMatches) {
|
||||
if (m.start >= lastEnd) {
|
||||
filtered.push(m)
|
||||
lastEnd = m.end
|
||||
}
|
||||
}
|
||||
|
||||
let result = ''
|
||||
let idx = 0
|
||||
for (const m of filtered) {
|
||||
if (m.start > idx) {
|
||||
result += escapeHtml(rawText.slice(idx, m.start))
|
||||
}
|
||||
result += `<span class="${m.classes}">${escapeHtml(rawText.slice(m.start, m.end))}</span>`
|
||||
idx = m.end
|
||||
}
|
||||
if (idx < rawText.length) {
|
||||
result += escapeHtml(rawText.slice(idx))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function highlightText(text: string): string {
|
||||
if (!text) return ''
|
||||
|
||||
// Create a list of all matches with their positions
|
||||
const matches: Array<{ start: number; end: number; tagIndex: number }> = []
|
||||
|
||||
tags.forEach((tag, tagIndex) => {
|
||||
const regex = new RegExp(tag.regex, 'g')
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
matches.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
tagIndex
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sort matches by start position
|
||||
matches.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Remove overlapping matches (keep the first one)
|
||||
const filteredMatches: Array<{ start: number; end: number; tagIndex: number }> = []
|
||||
let lastEnd = -1
|
||||
for (const match of matches) {
|
||||
if (match.start >= lastEnd) {
|
||||
filteredMatches.push(match)
|
||||
lastEnd = match.end
|
||||
}
|
||||
}
|
||||
|
||||
// Build HTML with highlighted segments
|
||||
let html = ''
|
||||
let lastIndex = 0
|
||||
|
||||
for (const match of filteredMatches) {
|
||||
// Add text before the match (apply secondary highlights)
|
||||
if (match.start > lastIndex) {
|
||||
html += applyHighlightsToChunk(text.slice(lastIndex, match.start))
|
||||
}
|
||||
|
||||
// Add highlighted match (with secondary highlights applied inside)
|
||||
const matchedText = text.slice(match.start, match.end)
|
||||
const tagId = tags[match.tagIndex].id
|
||||
const hasClear = !!tags[match.tagIndex].onClear
|
||||
const clearBtn = hasClear
|
||||
? `<span data-clear-tag="${tagId}" class="inline-flex w-2.5 h-3 ml-1 cursor-pointer opacity-50 hover:opacity-100" style="vertical-align: middle;"><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>`
|
||||
: ''
|
||||
html += `<span class="bg-surface-sunken/50 border border-border-light py-0.5 px-1.5 rounded" data-tag-id="${tagId}">${applyHighlightsToChunk(matchedText)}${clearBtn}</span>`
|
||||
|
||||
lastIndex = match.end
|
||||
}
|
||||
|
||||
// Add remaining text (apply secondary highlights)
|
||||
if (lastIndex < text.length) {
|
||||
html += applyHighlightsToChunk(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
let html = div.innerHTML
|
||||
html = html.replace(/\\(n|r|.)/g, (match, c) => {
|
||||
const display = c === 'n' ? '↵' : c === 'r' ? '↵' : c
|
||||
return (
|
||||
'<span style="display: inline; width: 0; height: 0; overflow: hidden; position: absolute;">\\</span>' +
|
||||
display
|
||||
)
|
||||
})
|
||||
return html
|
||||
}
|
||||
|
||||
let lastText = ''
|
||||
|
||||
function applyTextUpdate(newText: string, newCursorPos: number) {
|
||||
value = newText
|
||||
updateDisplay(newText)
|
||||
restoreCursor(newCursorPos)
|
||||
updateCurrentTag(newCursorPos)
|
||||
lastText = newText
|
||||
isUpdating = false
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
isUpdating = true
|
||||
const cursorPos = getCursorPosition()
|
||||
let newText = getTextContent()
|
||||
|
||||
// Remove any "\." sequences that were added by browser smart punctuation
|
||||
// These would only be created by macOS/browser when user double-presses space
|
||||
if (newText.includes('\\.')) {
|
||||
const cleanedText = newText.replace(/\\\./g, '')
|
||||
const removedCount = (newText.length - cleanedText.length) / 2 // Each "\." is 2 chars
|
||||
applyTextUpdate(cleanedText, cursorPos - removedCount * 2)
|
||||
return
|
||||
}
|
||||
|
||||
// Escape any literal newlines (e.g. from Shift+Enter or IME input)
|
||||
if (newText.includes('\n') || newText.includes('\r')) {
|
||||
const before = newText.slice(0, cursorPos)
|
||||
const newlinesBefore = (before.match(/[\n\r]/g) || []).length
|
||||
const cleanedText = newText.replace(/\r\n/g, '\\n').replace(/[\n\r]/g, '\\n')
|
||||
// Each newline becomes 2 chars (\n), so cursor shifts by +1 per newline before it
|
||||
applyTextUpdate(cleanedText, cursorPos + newlinesBefore)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user just typed an escaped character
|
||||
if (
|
||||
newText.length > lastText.length &&
|
||||
(newText[cursorPos - 1] === ' ' ||
|
||||
newText[cursorPos - 1] === '\u00A0' ||
|
||||
newText[cursorPos - 1] === '\\')
|
||||
) {
|
||||
// Check if there's already an escaped space right before the cursor (e.g., "tag\ |")
|
||||
// If user types another space, just remove the backslash instead of adding "\ \"
|
||||
if (
|
||||
(newText[cursorPos - 1] === ' ' || newText[cursorPos - 1] === '\u00A0') &&
|
||||
newText[cursorPos - 3] === '\\' &&
|
||||
(newText[cursorPos - 2] === ' ' || newText[cursorPos - 2] === '\u00A0')
|
||||
) {
|
||||
// Remove the backslash before the existing space
|
||||
newText = newText.slice(0, cursorPos - 3) + newText.slice(cursorPos - 2)
|
||||
applyTextUpdate(newText, cursorPos - 1)
|
||||
return
|
||||
}
|
||||
|
||||
// Escape the space/backslash by adding backslash before it
|
||||
newText = newText.slice(0, cursorPos - 1) + '\\' + newText.slice(cursorPos - 1)
|
||||
applyTextUpdate(newText, cursorPos + 1)
|
||||
return
|
||||
}
|
||||
|
||||
applyTextUpdate(newText, cursorPos)
|
||||
}
|
||||
|
||||
function getTextSegmentAtCursor(cursorPos: number): {
|
||||
text: string
|
||||
start: number
|
||||
end: number
|
||||
} | null {
|
||||
// Find all tag positions
|
||||
const tagPositions: Array<{ start: number; end: number }> = []
|
||||
for (const tag of tags) {
|
||||
const regex = new RegExp(tag.regex, 'g')
|
||||
let match
|
||||
while ((match = regex.exec(value)) !== null) {
|
||||
tagPositions.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start position
|
||||
tagPositions.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Find the text segment containing the cursor
|
||||
let segmentStart = 0
|
||||
let segmentEnd = value.length
|
||||
|
||||
for (const tag of tagPositions) {
|
||||
if (cursorPos <= tag.start) {
|
||||
// Cursor is before this tag
|
||||
segmentEnd = tag.start
|
||||
break
|
||||
} else if (cursorPos > tag.end) {
|
||||
// Cursor is after this tag
|
||||
segmentStart = tag.end
|
||||
} else {
|
||||
// Cursor is inside a tag
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: value.slice(segmentStart, segmentEnd).trim(),
|
||||
start: segmentStart,
|
||||
end: segmentEnd
|
||||
}
|
||||
}
|
||||
|
||||
function updateCurrentTag(cursorPos: number) {
|
||||
let currentTag: { id: string } | null = null
|
||||
|
||||
for (const tag of tags) {
|
||||
const regex = new RegExp(tag.regex, 'g')
|
||||
let match
|
||||
while ((match = regex.exec(value)) !== null) {
|
||||
const start = match.index
|
||||
const end = match.index + match[0].length
|
||||
if (cursorPos >= start && cursorPos <= end) {
|
||||
currentTag = { id: tag.id }
|
||||
|
||||
onCurrentTagChange?.(currentTag)
|
||||
onTextSegmentAtCursorChange?.({ text: '', start: 0, end: 0 })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCurrentTagChange?.(null)
|
||||
|
||||
// Get text segment at cursor when not in a tag
|
||||
const textSegment = getTextSegmentAtCursor(cursorPos)
|
||||
if (textSegment) {
|
||||
onTextSegmentAtCursorChange?.(textSegment)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
// Check if the click landed on a clear button
|
||||
const target = e.target as HTMLElement | null
|
||||
const clearTarget = target?.closest<HTMLElement>('[data-clear-tag]')
|
||||
if (clearTarget) {
|
||||
const tagId = clearTarget.dataset.clearTag!
|
||||
const tag = tags.find((t) => t.id === tagId)
|
||||
tag?.onClear?.()
|
||||
return
|
||||
}
|
||||
const cursorPos = getCursorPosition()
|
||||
updateCurrentTag(cursorPos)
|
||||
}
|
||||
|
||||
function handleKeyup(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
|
||||
const cursorPos = getCursorPosition()
|
||||
updateCurrentTag(cursorPos)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
|
||||
const cursorPos = getCursorPosition()
|
||||
const text = getTextContent()
|
||||
|
||||
// Handle Backspace key to remove escape sequences
|
||||
if (e.key === 'Backspace') {
|
||||
// Check if we're right after an escaped character (e.g., "abc\ |def")
|
||||
// We want to remove both the backslash and the escaped character
|
||||
if (cursorPos >= 2 && text[cursorPos - 2] === '\\') {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
const newText = text.slice(0, cursorPos - 2) + text.slice(cursorPos)
|
||||
applyTextUpdate(newText, cursorPos - 2)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Delete key to remove escape sequences
|
||||
if (e.key === 'Delete') {
|
||||
// Check if the character at cursor position is a backslash (escape character)
|
||||
if (cursorPos < text.length && text[cursorPos] === '\\' && cursorPos + 1 < text.length) {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
const newText = text.slice(0, cursorPos) + text.slice(cursorPos + 2)
|
||||
applyTextUpdate(newText, cursorPos)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrow key navigation to skip escape sequences
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
if (e.key === 'ArrowLeft' && cursorPos > 0) {
|
||||
// Moving left: check if we're right after an escaped character (e.g., "abc\ |def")
|
||||
// We want to skip over the backslash and the escaped character
|
||||
if (cursorPos >= 2 && text[cursorPos - 2] === '\\') {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
restoreCursor(cursorPos - 2)
|
||||
updateCurrentTag(cursorPos - 2)
|
||||
isUpdating = false
|
||||
return
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
// Moving right: check if we're at a backslash (e.g., "abc|\ def")
|
||||
// We want to skip over the backslash and the escaped character
|
||||
if (cursorPos < text.length && text[cursorPos] === '\\' && cursorPos + 1 < text.length) {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
restoreCursor(cursorPos + 2)
|
||||
updateCurrentTag(cursorPos + 2)
|
||||
isUpdating = false
|
||||
return
|
||||
}
|
||||
|
||||
// If user pressed right arrow and is at the end, add a space if needed
|
||||
if (
|
||||
cursorPos === text.length &&
|
||||
text.length > 0 &&
|
||||
((text[text.length - 1] !== ' ' && text[text.length - 1] !== '\u00A0') ||
|
||||
text[text.length - 2] === '\\')
|
||||
) {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
const newText = text + '\u00A0'
|
||||
applyTextUpdate(newText, newText.length)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCursorPosition(): number {
|
||||
if (!contentEditableDiv) return 0
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return 0
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const preCaretRange = range.cloneRange()
|
||||
preCaretRange.selectNodeContents(contentEditableDiv)
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset)
|
||||
|
||||
return preCaretRange.toString().length
|
||||
}
|
||||
|
||||
function restoreCursor(position: number) {
|
||||
if (!contentEditableDiv) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
let currentPos = 0
|
||||
let node: Node | null = null
|
||||
let offset = 0
|
||||
|
||||
function traverse(n: Node): boolean {
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
const textLength = n.textContent?.length || 0
|
||||
if (currentPos + textLength >= position) {
|
||||
node = n
|
||||
offset = position - currentPos
|
||||
return true
|
||||
}
|
||||
currentPos += textLength
|
||||
} else {
|
||||
for (let i = 0; i < n.childNodes.length; i++) {
|
||||
if (traverse(n.childNodes[i])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
traverse(contentEditableDiv)
|
||||
|
||||
if (node) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
// Ensure cursor is visible by scrolling if needed
|
||||
ensureCursorVisible()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCursorVisible() {
|
||||
if (!contentEditableDiv) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const rect = range.getBoundingClientRect()
|
||||
const containerRect = contentEditableDiv.getBoundingClientRect()
|
||||
|
||||
// Check if cursor is outside the visible area horizontally
|
||||
if (rect.left < containerRect.left) {
|
||||
// Cursor is to the left of visible area
|
||||
contentEditableDiv.scrollLeft -= containerRect.left - rect.left + 10
|
||||
} else if (rect.right > containerRect.right) {
|
||||
// Cursor is to the right of visible area
|
||||
contentEditableDiv.scrollLeft += rect.right - containerRect.right + 10
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault()
|
||||
let text = e.clipboardData?.getData('text/plain') || ''
|
||||
// Escape backslashes, spaces, and newlines
|
||||
text = text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/ /g, '\\ ')
|
||||
.replace(/\r\n/g, '\\n')
|
||||
.replace(/[\n\r]/g, '\\n')
|
||||
document.execCommand('insertText', false, text)
|
||||
}
|
||||
|
||||
export function focusAtEnd() {
|
||||
if (!contentEditableDiv) return
|
||||
contentEditableDiv.focus()
|
||||
restoreCursor(value.length)
|
||||
updateCurrentTag(value.length)
|
||||
contentEditableDiv.scrollLeft = contentEditableDiv.scrollWidth
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={contentEditableDiv}
|
||||
contenteditable="true"
|
||||
oninput={handleInput}
|
||||
onpaste={handlePaste}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeyDown}
|
||||
onkeyup={handleKeyup}
|
||||
class="outline-none text-nowrap pt-[0.45rem] {className}"
|
||||
class:text-hint={value === ''}
|
||||
data-placeholder={placeholder}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
spellcheck="false"
|
||||
></div>
|
||||
|
||||
<style>
|
||||
[contenteditable][data-placeholder]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,7 @@
|
||||
markdownTooltip?: string | undefined
|
||||
customSize?: string
|
||||
class?: string
|
||||
Icon?: typeof InfoIcon
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@
|
||||
markdownTooltip = undefined,
|
||||
customSize = '100%',
|
||||
class: classNames = '',
|
||||
Icon = InfoIcon,
|
||||
children
|
||||
}: Props = $props()
|
||||
const plugins = [gfmPlugin()]
|
||||
@@ -53,7 +55,7 @@
|
||||
? 'text-primary-inverse'
|
||||
: 'text-primary'} {classNames} relative"
|
||||
>
|
||||
<InfoIcon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
|
||||
<Icon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
|
||||
</div>
|
||||
{#snippet text()}
|
||||
{#if markdownTooltip}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FileCode, FolderIcon, Box, Braces } from 'lucide-svelte'
|
||||
import type { FilterSchemaRec } from '../FilterSearchbar.svelte'
|
||||
|
||||
export function buildAssetsFilterSchema({
|
||||
paths,
|
||||
assetKinds
|
||||
}: {
|
||||
paths: string[]
|
||||
assetKinds: string[]
|
||||
}) {
|
||||
return {
|
||||
asset_path: {
|
||||
type: 'string' as const,
|
||||
label: 'Asset path pattern',
|
||||
icon: FolderIcon,
|
||||
description: 'Filter by asset path pattern (case-insensitive)'
|
||||
},
|
||||
asset_kinds: {
|
||||
type: 'oneof' as const,
|
||||
options: assetKinds.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: false,
|
||||
allowNegative: false,
|
||||
allowMultiple: true,
|
||||
label: 'Asset kind',
|
||||
icon: Box,
|
||||
description: 'Filter by asset kind (s3object, resource, variable, etc.)'
|
||||
},
|
||||
usage_path: {
|
||||
type: 'string' as const,
|
||||
label: 'Usage path pattern',
|
||||
icon: FileCode,
|
||||
description: 'Filter by usage path pattern (case-insensitive)'
|
||||
},
|
||||
path: {
|
||||
type: 'oneof' as const,
|
||||
options: paths.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: true,
|
||||
allowNegative: false,
|
||||
allowMultiple: false,
|
||||
label: 'Asset path',
|
||||
icon: FileCode,
|
||||
description: 'Filter by exact asset path'
|
||||
},
|
||||
columns: {
|
||||
type: 'string' as const,
|
||||
label: 'Columns',
|
||||
icon: Braces,
|
||||
description: 'Filter by comma-separated column names (e.g., col1,col2,col3)'
|
||||
}
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user