Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36aadbebec | ||
|
|
bedba3b75e | ||
|
|
771d67c849 | ||
|
|
46d486960a | ||
|
|
ca73267cbb | ||
|
|
6eabb8db63 | ||
|
|
5cc8f20cd2 | ||
|
|
ea5312e940 | ||
|
|
c62ba73ce4 | ||
|
|
98ac164ac8 | ||
|
|
ed6aaeeea3 | ||
|
|
5e415c0a12 | ||
|
|
35dded1347 | ||
|
|
aedf012c84 | ||
|
|
62fea97547 | ||
|
|
96c2d88d91 | ||
|
|
5eb308ad35 | ||
|
|
cfa04e1188 | ||
|
|
0d520f730b | ||
|
|
8cdc7d6e9e | ||
|
|
51077245c7 | ||
|
|
9e387c3559 | ||
|
|
7aea965803 | ||
|
|
8446e3b551 | ||
|
|
a6d6136d57 | ||
|
|
c93b2e287c | ||
|
|
93f927c1c1 | ||
|
|
83f21510f3 | ||
|
|
5c1f69ddcd | ||
|
|
3f2bd424c7 | ||
|
|
0d5f42e89e | ||
|
|
dfad07881d | ||
|
|
7b6ba7093a | ||
|
|
8042e33c38 | ||
|
|
57d23c92c5 | ||
|
|
f21140f7cd | ||
|
|
2800226bd4 | ||
|
|
2ae82796fc | ||
|
|
d1290ba777 | ||
|
|
9de0060884 | ||
|
|
3df8964fc6 | ||
|
|
fb0b2234ba | ||
|
|
4064ec3a3d | ||
|
|
0db6cbd10c | ||
|
|
ab91f78017 | ||
|
|
1e0245ca9a | ||
|
|
bead746bb8 | ||
|
|
268b5ee2a8 | ||
|
|
24f2571b37 | ||
|
|
ed1a655317 | ||
|
|
a4b440a20d | ||
|
|
b550be8711 | ||
|
|
071129f03b | ||
|
|
e9ac1ce9eb | ||
|
|
587142ddac | ||
|
|
d9ed0c318f | ||
|
|
4959a0553a | ||
|
|
c4323e40c1 | ||
|
|
696b8de1ed | ||
|
|
5f6dda9060 | ||
|
|
8490f4435d | ||
|
|
fa2f65e512 | ||
|
|
b9dec43d2a | ||
|
|
5227b76c2f | ||
|
|
1643d654a8 | ||
|
|
65a8789dfd | ||
|
|
6299e7a36a | ||
|
|
bcbfe4659d | ||
|
|
b2fac069df | ||
|
|
4ab08cb6a1 | ||
|
|
d6d4d85d8f | ||
|
|
81d386d365 | ||
|
|
a1b878842f | ||
|
|
00c3e9baf0 | ||
|
|
118dcb59af | ||
|
|
bd583be239 | ||
|
|
c1f7cb5d42 | ||
|
|
d502ef5029 | ||
|
|
bc7ca9982b |
@@ -100,4 +100,4 @@
|
||||
"typescript-lsp@claude-plugins-official": true,
|
||||
"code-review@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,93 +226,4 @@ 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.
|
||||
|
||||
## 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'`
|
||||
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
|
||||
21
.github/workflows/backend-test.yml
vendored
21
.github/workflows/backend-test.yml
vendored
@@ -19,7 +19,7 @@ defaults:
|
||||
|
||||
jobs:
|
||||
cargo_test:
|
||||
runs-on: ubicloud-standard-16
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
@@ -86,8 +86,22 @@ jobs:
|
||||
working-directory: /
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
cache: false
|
||||
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"
|
||||
@@ -215,7 +229,7 @@ jobs:
|
||||
fi
|
||||
echo "Verified: Package requires authentication for @windmill-test/private-pkg"
|
||||
- name: Cache DuckDB FFI module build
|
||||
uses: actions/cache@v3
|
||||
uses: useblacksmith/cache@v1
|
||||
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') }}
|
||||
@@ -231,6 +245,7 @@ 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
|
||||
|
||||
@@ -4,5 +4,11 @@
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
66
.wmdev.yaml
66
.wmdev.yaml
@@ -1,66 +0,0 @@
|
||||
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
|
||||
@@ -47,7 +47,7 @@ pre_remove:
|
||||
|
||||
panes:
|
||||
- command: >-
|
||||
claude --dangerously-skip-permissions --append-system-prompt
|
||||
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
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,44 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.644.0](https://github.com/windmill-labs/windmill/compare/v1.643.0...v1.644.0) (2026-02-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cli:** detect missing folders on sync push and add 'wmill folder add-missing' ([#8011](https://github.com/windmill-labs/windmill/issues/8011)) ([835db5d](https://github.com/windmill-labs/windmill/commit/835db5d290a151f38f4e879ed7ffbda5d1c4b24f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent concurrent index migrations from re-running on every startup ([#8069](https://github.com/windmill-labs/windmill/issues/8069)) ([8ff2340](https://github.com/windmill-labs/windmill/commit/8ff2340c0c08ce49a809c8958a9862ffb1681642))
|
||||
|
||||
## [1.643.0](https://github.com/windmill-labs/windmill/compare/v1.642.0...v1.643.0) (2026-02-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add fileset resource type support ([32c4b47](https://github.com/windmill-labs/windmill/commit/32c4b474f92f3dbbd2077fab70bdf9e407581626))
|
||||
* add fileset resource type support ([#8063](https://github.com/windmill-labs/windmill/issues/8063)) ([c15b9ab](https://github.com/windmill-labs/windmill/commit/c15b9abe5eb2a1566a7ce4b18784c961d178a669))
|
||||
* add light mode for navigation sidebar ([#8057](https://github.com/windmill-labs/windmill/issues/8057)) ([0935bf9](https://github.com/windmill-labs/windmill/commit/0935bf9fc460c03c6d8469b93036e43714517ef2))
|
||||
* **aiagent:** handle ai agent as tool ([#8031](https://github.com/windmill-labs/windmill/issues/8031)) ([de6fd16](https://github.com/windmill-labs/windmill/commit/de6fd160d56c1037adbbe785f195483c25982e1c))
|
||||
* Unified filters and new runs page ([#8027](https://github.com/windmill-labs/windmill/issues/8027)) ([9b28c85](https://github.com/windmill-labs/windmill/commit/9b28c85469d6b2a8590810b313b030d9f00ee9e3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address code review findings for fileset feature ([1b4489a](https://github.com/windmill-labs/windmill/commit/1b4489acac3b050f0a783548bacfc9bdf33ee593))
|
||||
* address second round of review findings ([753c05a](https://github.com/windmill-labs/windmill/commit/753c05a03089b95b4ade68d3bf61c8818de422ce))
|
||||
* **backend:** decimal between 0 and -1 in mssql ([#8051](https://github.com/windmill-labs/windmill/issues/8051)) ([9686608](https://github.com/windmill-labs/windmill/commit/9686608355615a50c8395f6e2fd51dcc25498226))
|
||||
* **backend:** use filename instead of content_type to detect file fields in multipart form data ([#8054](https://github.com/windmill-labs/windmill/issues/8054)) ([0aa885d](https://github.com/windmill-labs/windmill/commit/0aa885db67d77202205fc1609e841b8ffd9a8121))
|
||||
* exclude app_theme resources from workspace tab ([9c513b2](https://github.com/windmill-labs/windmill/commit/9c513b2c62acc369179fb9e404e1f4007cd854c6))
|
||||
* fileset editor takes full height with matching header ([9ac0789](https://github.com/windmill-labs/windmill/commit/9ac07897cf99f3af27801e435c7376a46ef760c9))
|
||||
* prevent iframe from overriding file selection after file creation ([7f3ddd7](https://github.com/windmill-labs/windmill/commit/7f3ddd7edd3ea993642aadd55cdba0ac2ea1eb9f))
|
||||
* resolve svelte warnings and type error in fileset components ([4c06d74](https://github.com/windmill-labs/windmill/commit/4c06d74bd01ca2dda848be421d70dd5268520992))
|
||||
* restore full-width file tree items in raw app sidebar ([5bac8b0](https://github.com/windmill-labs/windmill/commit/5bac8b093dbe913a563b02573959c64dd405ff61))
|
||||
* suppress iframe setActiveDocument during file population ([1abfeea](https://github.com/windmill-labs/windmill/commit/1abfeea81a645c59934d62257ad869ed7b475634))
|
||||
* update git sync init script to hub version 28158 ([#8061](https://github.com/windmill-labs/windmill/issues/8061)) ([705e186](https://github.com/windmill-labs/windmill/commit/705e186f3d4c7d8f8a88fc84b379ed9fe800a6b2))
|
||||
* use correct column name completed_at instead of ended_at in count_completed_jobs_detail ([#8066](https://github.com/windmill-labs/windmill/issues/8066)) ([3aba0ed](https://github.com/windmill-labs/windmill/commit/3aba0ed2508debdc78a6631e49b074a97635f21d))
|
||||
|
||||
## [1.642.0](https://github.com/windmill-labs/windmill/compare/v1.641.0...v1.642.0) (2026-02-22)
|
||||
|
||||
|
||||
|
||||
@@ -58,10 +58,8 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
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
|
||||
ln -sf /usr/local/lib/cargo/bin/cargo-watch /usr/local/bin/cargo-watch
|
||||
|
||||
# ── Register dynamic runtime users ───────────────────────────────────────────
|
||||
RUN cat <<'SCRIPT' > /usr/local/bin/register-dynamic-user.sh
|
||||
@@ -175,8 +173,7 @@ RUN curl -fsSL https://claude.ai/install.sh | bash && \
|
||||
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
|
||||
ln -s /usr/local/bin/claude /tmp/.local/bin/claude
|
||||
|
||||
# ── Codex ─────────────────────────────────────────────────────────────────────
|
||||
RUN npm i -g @openai/codex
|
||||
@@ -192,7 +189,6 @@ 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) ─────────────────────────────────
|
||||
@@ -235,4 +231,4 @@ fi
|
||||
exec "$@"
|
||||
ENTRY
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
@@ -191,62 +191,6 @@ sandbox:
|
||||
|
||||
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`
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable",
|
||||
"volume"
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
"ordinal": 6,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -57,8 +52,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "03d63d2e64b012f624d2731b5bcb8849c74a9474777be61edf0ed43ddda07ef3"
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -42,7 +42,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
16
backend/.sqlx/query-61b37cb4db6e60c2d35f7d23db5afbe04e040a8dcd1d93afaaaa320665c8779a.json
generated
Normal file
16
backend/.sqlx/query-61b37cb4db6e60c2d35f7d23db5afbe04e040a8dcd1d93afaaaa320665c8779a.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
15
backend/.sqlx/query-7abd579d3ec97853ac36cc8dad29013eb133a28cd848bf8fdf9571b2ee402a3e.json
generated
Normal file
15
backend/.sqlx/query-7abd579d3ec97853ac36cc8dad29013eb133a28cd848bf8fdf9571b2ee402a3e.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -37,11 +37,6 @@
|
||||
"ordinal": 6,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -56,8 +51,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7b1239ad6460e8f5fb41bfe12f662a779528784ec8cf3f6dcce5545ab90bf234"
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT schema, description, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2",
|
||||
"query": "SELECT schema, description, format_extension\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,11 +17,6 @@
|
||||
"ordinal": 2,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -33,9 +28,8 @@
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2768622b76ad92c05f4f44d997aff285707e1a43ce85e5bb8e87849d78a0637f"
|
||||
"hash": "7bc9fc05dbd162866bef1fdd3e7faeb50429881ed1bc962903f06e4b3d5f8d44"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"query": "SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\" FROM v2_job WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -42,21 +42,12 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "flow_step_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -67,10 +58,8 @@
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7aaa5b0bd873c2029e2201d287ea0aaae04678ac105374bbe387e534a6cb6333"
|
||||
"hash": "805d633de90fee335f1726284eda0dbc200d45960fb8dea867492c8c7dd096d5"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -102,7 +102,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable",
|
||||
"volume"
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
"ordinal": 6,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -56,8 +51,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b8d392ccfcccafe0c19511b3567bc11779b1052b0948c410468a8aeba1d26d33"
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM _sqlx_migrations WHERE\n version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR\n version=20250201145631 OR version=20250201145632 OR version=20251006143821",
|
||||
"query": "DELETE FROM _sqlx_migrations WHERE\n version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR\n version=20250201145631 OR version=20250201145632 OR version=20251006143821 OR\n version=20260207000001 OR version=20260207000002 OR version=20260207000003 OR version=20260207000004",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8d4ad4ee75fb149c36a9f6a0c4cf5fd981473f45d1b71b4b8236e021f7c8682d"
|
||||
"hash": "c6bcf0d9e211bc03e3338682295f4995e1d622917367c478742addd073245ad5"
|
||||
}
|
||||
28
backend/.sqlx/query-cf1cef7e0fe2e7e3db96b0ec005360361b9eec023a6fc2a4a7a917f59d86af4d.json
generated
Normal file
28
backend/.sqlx/query-cf1cef7e0fe2e7e3db96b0ec005360361b9eec023a6fc2a4a7a917f59d86af4d.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -41,7 +41,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
"ordinal": 6,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -54,8 +49,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "eb1f7f01461f5a7540c273b37e5d578c31cf151ab3ef813f7aada76533761e12"
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"aiagent",
|
||||
"unassigned_script",
|
||||
"unassigned_flow",
|
||||
"unassigned_singlestepflow"
|
||||
"unassigned_singlestepflow",
|
||||
"snapshotbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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())",
|
||||
"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())",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -10,11 +10,10 @@
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00"
|
||||
"hash": "ffedbb3a2676a6d7b71f81f89109a02a8dba90d40144e942527f8a3fc36dfbc1"
|
||||
}
|
||||
176
backend/Cargo.lock
generated
176
backend/Cargo.lock
generated
@@ -2259,9 +2259,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
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.4",
|
||||
"rustix 1.1.3",
|
||||
"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.4",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -8092,9 +8092,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.24"
|
||||
version = "1.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839"
|
||||
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -8116,9 +8116,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
@@ -9729,9 +9729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.3.0"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
|
||||
|
||||
[[package]]
|
||||
name = "p224"
|
||||
@@ -11598,14 +11598,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -13838,14 +13838,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.26.0"
|
||||
version = "3.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"rustix 1.1.3",
|
||||
"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.4",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
@@ -15725,7 +15725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15789,7 +15789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15802,7 +15802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15940,7 +15940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15963,7 +15963,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15976,7 +15976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16002,7 +16002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -16012,7 +16012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16029,7 +16029,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -16052,7 +16052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16075,7 +16075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16091,7 +16091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16111,7 +16111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16131,7 +16131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16145,7 +16145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16171,7 +16171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16196,7 +16196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"flate2",
|
||||
@@ -16213,7 +16213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16234,7 +16234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16254,7 +16254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16284,7 +16284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16311,7 +16311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16323,7 +16323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.7.9",
|
||||
@@ -16346,7 +16346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16360,7 +16360,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16390,7 +16390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16404,7 +16404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16423,7 +16423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -16522,7 +16522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -16541,7 +16541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16556,7 +16556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16580,7 +16580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16597,7 +16597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16613,7 +16613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16634,7 +16634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16665,7 +16665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-oauth2",
|
||||
@@ -16689,7 +16689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -16723,7 +16723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16741,7 +16741,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16750,7 +16750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16762,7 +16762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16774,7 +16774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16786,7 +16786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16798,7 +16798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16810,7 +16810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16821,7 +16821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16832,7 +16832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16845,7 +16845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16869,7 +16869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16883,7 +16883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16900,7 +16900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16915,7 +16915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16934,7 +16934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16945,7 +16945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16982,7 +16982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -17020,7 +17020,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
@@ -17030,7 +17030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17059,7 +17059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -17082,7 +17082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17115,7 +17115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17135,7 +17135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17169,7 +17169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17204,7 +17204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17227,7 +17227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17251,7 +17251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -17275,7 +17275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17310,7 +17310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17338,7 +17338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17361,7 +17361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.4",
|
||||
@@ -17379,7 +17379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.644.0"
|
||||
version = "1.642.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.4",
|
||||
"rustix 1.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -76,7 +76,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.644.0"
|
||||
version = "1.642.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE resource_type DROP COLUMN is_fileset;
|
||||
@@ -1 +0,0 @@
|
||||
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), is_fileset(bool)
|
||||
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char)
|
||||
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,13 +27,9 @@ struct ListAssetsQuery {
|
||||
per_page: i64,
|
||||
cursor_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
cursor_id: Option<i64>,
|
||||
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>,
|
||||
asset_path: Option<String>,
|
||||
usage_path: Option<String>,
|
||||
asset_kinds: Option<String>,
|
||||
}
|
||||
|
||||
fn default_per_page() -> i64 {
|
||||
@@ -79,24 +75,12 @@ async fn list_assets(
|
||||
|
||||
let mut param_count = 2; // $1 = workspace_id, $2 = limit
|
||||
|
||||
// Asset path filter (ILIKE pattern match)
|
||||
// Asset path filter
|
||||
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() {
|
||||
@@ -227,20 +211,6 @@ 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,21 +54,6 @@ 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,12 +69,8 @@ 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?,
|
||||
@@ -82,12 +78,8 @@ 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?,
|
||||
@@ -95,12 +87,8 @@ 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?,
|
||||
@@ -108,7 +96,8 @@ 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?,
|
||||
@@ -116,12 +105,8 @@ 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?,
|
||||
@@ -129,7 +114,8 @@ 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?,
|
||||
@@ -137,7 +123,8 @@ 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
|
||||
@@ -175,9 +162,7 @@ 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")))
|
||||
@@ -274,11 +259,9 @@ 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
|
||||
@@ -292,44 +275,35 @@ 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);
|
||||
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\": []}");
|
||||
resp.json::<serde_json::Value>().await?;
|
||||
|
||||
// --- resource types ---
|
||||
|
||||
@@ -410,68 +384,17 @@ 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,11 +10,9 @@ 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::*;
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* 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,17 +14,6 @@ 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,
|
||||
@@ -44,62 +33,18 @@ 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) {
|
||||
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));
|
||||
sqlb.and_where_like_left("v2_job_queue.worker", w.replace("*", "%"));
|
||||
} else {
|
||||
sqlb.and_where_in("v2_job_queue.worker", "ed);
|
||||
sqlb.and_where_eq("v2_job_queue.worker", "?".bind(w));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ps) = &lq.script_path_start {
|
||||
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})"));
|
||||
}
|
||||
sqlb.and_where_like_left("runnable_path", ps);
|
||||
}
|
||||
if let Some(p) = &lq.script_path_exact {
|
||||
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);
|
||||
}
|
||||
sqlb.and_where_eq("runnable_path", "?".bind(p));
|
||||
}
|
||||
if let Some(p) = &lq.schedule_path {
|
||||
sqlb.and_where_eq("trigger", "?".bind(p));
|
||||
@@ -109,34 +54,13 @@ pub fn filter_list_queue_query(
|
||||
sqlb.and_where_eq("runnable_id", "?".bind(h));
|
||||
}
|
||||
if let Some(cb) = &lq.created_by {
|
||||
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);
|
||||
}
|
||||
sqlb.and_where_eq("created_by", "?".bind(cb));
|
||||
}
|
||||
if let Some(t) = &lq.tag {
|
||||
let quoted: Vec<_> = t.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
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);
|
||||
sqlb.and_where_like_left("v2_job.tag", t.replace("*", "%"));
|
||||
} else {
|
||||
sqlb.and_where_in("v2_job.tag", "ed);
|
||||
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,12 +115,10 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(jk) = &lq.job_kinds {
|
||||
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);
|
||||
}
|
||||
sqlb.and_where_in(
|
||||
"kind",
|
||||
&jk.split(',').into_iter().map(quote).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(args) = &lq.args {
|
||||
@@ -212,21 +134,11 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(tk) = &lq.trigger_kind {
|
||||
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);
|
||||
}
|
||||
sqlb.and_where_eq("trigger_kind", "?".bind(&format!("{}", tk)));
|
||||
}
|
||||
|
||||
if let Some(tp) = &lq.trigger_path {
|
||||
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.and_where_eq("trigger", "?".bind(tp));
|
||||
}
|
||||
|
||||
sqlb
|
||||
@@ -275,71 +187,25 @@ pub fn filter_list_completed_query(
|
||||
|
||||
if let Some(label) = &lq.label {
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
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 clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''")))
|
||||
.collect();
|
||||
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(format!("({})", clauses.join(" OR ")));
|
||||
sqlb.and_where(&wh);
|
||||
} else {
|
||||
let mut wh = format!("result->'wm_labels' ? ");
|
||||
wh.push_str(&format!("'{}'", &label.replace("'", "''")));
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
sqlb.and_where(&wh);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(worker) = &lq.worker {
|
||||
let quoted: Vec<_> = worker.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
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));
|
||||
sqlb.and_where_like_left("v2_job_completed.worker", worker.replace("*", "%"));
|
||||
} else {
|
||||
sqlb.and_where_in("v2_job_completed.worker", "ed);
|
||||
sqlb.and_where_eq("v2_job_completed.worker", "?".bind(worker));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,68 +220,24 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
|
||||
if let Some(ps) = &lq.script_path_start {
|
||||
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})"));
|
||||
}
|
||||
sqlb.and_where_like_left("runnable_path", ps);
|
||||
}
|
||||
if let Some(p) = &lq.script_path_exact {
|
||||
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);
|
||||
}
|
||||
sqlb.and_where_eq("runnable_path", "?".bind(p));
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
sqlb.and_where_like_left("v2_job.tag", t.replace("*", "%"));
|
||||
} else {
|
||||
sqlb.and_where_in("v2_job.tag", "ed);
|
||||
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cb) = &lq.created_by {
|
||||
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);
|
||||
}
|
||||
sqlb.and_where_eq("created_by", "?".bind(cb));
|
||||
}
|
||||
if let Some(r) = &lq.success {
|
||||
if *r {
|
||||
@@ -486,12 +308,10 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
}
|
||||
if let Some(jk) = &lq.job_kinds {
|
||||
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);
|
||||
}
|
||||
sqlb.and_where_in(
|
||||
"kind",
|
||||
&jk.split(',').into_iter().map(quote).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(args) = &lq.args {
|
||||
@@ -507,21 +327,11 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
|
||||
if let Some(tk) = &lq.trigger_kind {
|
||||
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);
|
||||
}
|
||||
sqlb.and_where_eq("trigger_kind", "?".bind(&format!("{}", tk)));
|
||||
}
|
||||
|
||||
if let Some(tp) = &lq.trigger_path {
|
||||
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.and_where_eq("trigger", "?".bind(tp));
|
||||
}
|
||||
|
||||
sqlb
|
||||
@@ -565,7 +375,6 @@ pub fn list_completed_jobs_query(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::negated_filter::NegatedListFilter;
|
||||
|
||||
fn empty_queue_query() -> ListQueueQuery {
|
||||
ListQueueQuery {
|
||||
@@ -669,7 +478,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_script_path_start() {
|
||||
let lq = ListQueueQuery {
|
||||
script_path_start: Some(NegatedListFilter::positive(vec!["f/test".to_string()])),
|
||||
script_path_start: Some("f/test".to_string()),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -686,9 +495,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_script_path_exact() {
|
||||
let lq = ListQueueQuery {
|
||||
script_path_exact: Some(NegatedListFilter::positive(vec![
|
||||
"f/test/script".to_string()
|
||||
])),
|
||||
script_path_exact: Some("f/test/script".to_string()),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -703,7 +510,10 @@ 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,
|
||||
@@ -717,10 +527,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_job_kinds() {
|
||||
let lq = ListQueueQuery {
|
||||
job_kinds: Some(NegatedListFilter::positive(vec![
|
||||
"script".to_string(),
|
||||
"flow".to_string(),
|
||||
])),
|
||||
job_kinds: Some("script,flow".to_string()),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -736,7 +543,10 @@ 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,
|
||||
@@ -749,7 +559,10 @@ 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,
|
||||
@@ -762,7 +575,10 @@ 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,
|
||||
@@ -775,7 +591,10 @@ 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,
|
||||
@@ -788,7 +607,10 @@ 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,
|
||||
@@ -801,7 +623,10 @@ 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,
|
||||
@@ -814,7 +639,10 @@ 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,
|
||||
@@ -867,7 +695,10 @@ 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,
|
||||
@@ -880,7 +711,10 @@ 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,
|
||||
@@ -905,7 +739,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_completed_filter_label() {
|
||||
let lq = ListCompletedQuery {
|
||||
label: Some(NegatedListFilter::positive(vec!["deploy".to_string()])),
|
||||
label: Some("deploy".to_string()),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let sqlb = filter_list_completed_query(
|
||||
@@ -920,7 +754,10 @@ 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,8 +27,6 @@ use windmill_common::{
|
||||
|
||||
use windmill_api_sse::{Job, JobExtended};
|
||||
|
||||
use crate::negated_filter::NegatedListFilter;
|
||||
|
||||
// ------------ RunJobQuery ------------
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
@@ -91,10 +89,10 @@ impl RunJobQuery {
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ListQueueQuery {
|
||||
pub script_path_start: Option<NegatedListFilter<String>>,
|
||||
pub script_path_exact: Option<NegatedListFilter<String>>,
|
||||
pub script_path_start: Option<String>,
|
||||
pub script_path_exact: Option<String>,
|
||||
pub script_hash: Option<String>,
|
||||
pub created_by: Option<NegatedListFilter<String>>,
|
||||
pub created_by: Option<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>>,
|
||||
@@ -105,12 +103,12 @@ pub struct ListQueueQuery {
|
||||
pub schedule_path: Option<String>,
|
||||
pub parent_job: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub job_kinds: Option<NegatedListFilter<String>>,
|
||||
pub job_kinds: Option<String>,
|
||||
pub suspended: Option<bool>,
|
||||
pub worker: Option<NegatedListFilter<String>>,
|
||||
pub worker: Option<String>,
|
||||
// filter by matching a subset of the args using base64 encoded json subset
|
||||
pub args: Option<String>,
|
||||
pub tag: Option<NegatedListFilter<String>>,
|
||||
pub tag: Option<String>,
|
||||
pub scheduled_for_before_now: Option<bool>,
|
||||
pub all_workspaces: Option<bool>,
|
||||
pub is_flow_step: Option<bool>,
|
||||
@@ -118,17 +116,17 @@ pub struct ListQueueQuery {
|
||||
pub is_not_schedule: Option<bool>,
|
||||
pub concurrency_key: Option<String>,
|
||||
pub allow_wildcards: Option<bool>,
|
||||
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub trigger_kind: Option<JobTriggerKind>,
|
||||
pub trigger_path: Option<String>,
|
||||
pub include_args: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ListCompletedQuery {
|
||||
pub script_path_start: Option<NegatedListFilter<String>>,
|
||||
pub script_path_exact: Option<NegatedListFilter<String>>,
|
||||
pub script_path_start: Option<String>,
|
||||
pub script_path_exact: Option<String>,
|
||||
pub script_hash: Option<String>,
|
||||
pub created_by: Option<NegatedListFilter<String>>,
|
||||
pub created_by: Option<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>>,
|
||||
@@ -144,7 +142,7 @@ pub struct ListCompletedQuery {
|
||||
pub running: Option<bool>,
|
||||
pub parent_job: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub job_kinds: Option<NegatedListFilter<String>>,
|
||||
pub job_kinds: Option<String>,
|
||||
pub is_skipped: Option<bool>,
|
||||
pub is_flow_step: Option<bool>,
|
||||
pub suspended: Option<bool>,
|
||||
@@ -153,17 +151,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<NegatedListFilter<String>>,
|
||||
pub tag: Option<String>,
|
||||
pub scheduled_for_before_now: Option<bool>,
|
||||
pub all_workspaces: Option<bool>,
|
||||
pub has_null_parent: Option<bool>,
|
||||
pub label: Option<NegatedListFilter<String>>,
|
||||
pub label: Option<String>,
|
||||
pub is_not_schedule: Option<bool>,
|
||||
pub concurrency_key: Option<String>,
|
||||
pub worker: Option<NegatedListFilter<String>>,
|
||||
pub worker: Option<String>,
|
||||
pub allow_wildcards: Option<bool>,
|
||||
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub trigger_kind: Option<JobTriggerKind>,
|
||||
pub trigger_path: Option<String>,
|
||||
pub include_args: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -580,7 +578,8 @@ 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"));
|
||||
}
|
||||
@@ -645,15 +644,22 @@ 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());
|
||||
}
|
||||
|
||||
@@ -662,10 +668,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_list_completed_to_queue_query_conversion() {
|
||||
let lcq = ListCompletedQuery {
|
||||
script_path_start: Some(NegatedListFilter::positive(vec!["f/test".to_string()])),
|
||||
script_path_start: Some("f/test".to_string()),
|
||||
script_path_exact: None,
|
||||
script_hash: None,
|
||||
created_by: Some(NegatedListFilter::positive(vec!["admin".to_string()])),
|
||||
created_by: Some("admin".to_string()),
|
||||
started_before: None,
|
||||
started_after: None,
|
||||
created_before: Some(chrono::Utc::now()),
|
||||
@@ -681,17 +687,14 @@ mod tests {
|
||||
running: Some(true),
|
||||
parent_job: None,
|
||||
order_desc: Some(true),
|
||||
job_kinds: Some(NegatedListFilter::positive(vec![
|
||||
"script".to_string(),
|
||||
"flow".to_string(),
|
||||
])),
|
||||
job_kinds: Some("script,flow".to_string()),
|
||||
is_skipped: None,
|
||||
is_flow_step: None,
|
||||
suspended: None,
|
||||
schedule_path: None,
|
||||
args: None,
|
||||
result: None,
|
||||
tag: Some(NegatedListFilter::positive(vec!["custom".to_string()])),
|
||||
tag: Some("custom".to_string()),
|
||||
scheduled_for_before_now: None,
|
||||
all_workspaces: None,
|
||||
has_null_parent: None,
|
||||
@@ -706,24 +709,11 @@ mod tests {
|
||||
};
|
||||
|
||||
let lqq: ListQueueQuery = lcq.into();
|
||||
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.script_path_start, Some("f/test".to_string()));
|
||||
assert_eq!(lqq.created_by, Some("admin".to_string()));
|
||||
assert_eq!(lqq.running, Some(true));
|
||||
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())
|
||||
);
|
||||
assert_eq!(lqq.job_kinds, Some("script,flow".to_string()));
|
||||
assert_eq!(lqq.tag, Some("custom".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* 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},
|
||||
@@ -16,10 +18,8 @@ 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,15 +486,8 @@ 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)]
|
||||
@@ -550,18 +543,6 @@ 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,6 +30,7 @@ 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};
|
||||
@@ -54,7 +55,6 @@ 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, is_fileset)
|
||||
SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset
|
||||
"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
|
||||
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, is_fileset
|
||||
"SELECT schema, description, format_extension
|
||||
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, is_fileset
|
||||
"SELECT schema, description, format_extension
|
||||
FROM resource_type
|
||||
WHERE workspace_id = $1 AND name = $2",
|
||||
fork_workspace_id,
|
||||
@@ -5280,7 +5280,6 @@ async fn compare_two_resource_types(
|
||||
if source.schema != target.schema
|
||||
|| source.description != target.description
|
||||
|| source.format_extension != target.format_extension
|
||||
|| source.is_fileset != target.is_fileset
|
||||
{
|
||||
has_changes = true;
|
||||
}
|
||||
|
||||
@@ -28049,7 +28049,6 @@ components:
|
||||
name: trigger_kind
|
||||
description: trigger kind (schedule, http, websocket...)
|
||||
in: query
|
||||
x-go-name: JobTriggerKindParam
|
||||
schema: *ref_160
|
||||
OrderDesc:
|
||||
name: order_desc
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.644.0
|
||||
version: 1.642.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -4091,21 +4091,6 @@ 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:
|
||||
@@ -5105,21 +5090,6 @@ 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
|
||||
@@ -5244,19 +5214,10 @@ paths:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: map from resource type to file resource info
|
||||
description: map from resource type to file ext
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
format_extension:
|
||||
type: string
|
||||
nullable: true
|
||||
is_fileset:
|
||||
type: boolean
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/resources/type/delete/{path}:
|
||||
delete:
|
||||
@@ -11159,7 +11120,7 @@ paths:
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- $ref: "#/components/parameters/ArgsFilter"
|
||||
- name: path
|
||||
description: filter by path (script path)
|
||||
description: filter by path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -11173,21 +11134,6 @@ 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
|
||||
@@ -16939,16 +16885,6 @@ 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
|
||||
@@ -17322,11 +17258,10 @@ components:
|
||||
type: integer
|
||||
JobTriggerKind:
|
||||
name: trigger_kind
|
||||
description: "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')"
|
||||
description: trigger kind (schedule, http, websocket...)
|
||||
in: query
|
||||
x-go-name: JobTriggerKindParam
|
||||
schema:
|
||||
type: string
|
||||
$ref: "#/components/schemas/JobTriggerKind"
|
||||
OrderDesc:
|
||||
name: order_desc
|
||||
description: order by desc order (default true)
|
||||
@@ -17335,19 +17270,19 @@ components:
|
||||
type: boolean
|
||||
CreatedBy:
|
||||
name: created_by
|
||||
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')"
|
||||
description: mask to filter exact matching user creator
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
Label:
|
||||
name: label
|
||||
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')"
|
||||
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')
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
Worker:
|
||||
name: worker
|
||||
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')"
|
||||
description: worker this job was ran on
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17413,7 +17348,7 @@ components:
|
||||
type: string
|
||||
ScriptStartPath:
|
||||
name: script_path_start
|
||||
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')"
|
||||
description: mask to filter matching starting path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17425,13 +17360,13 @@ components:
|
||||
type: string
|
||||
TriggerPath:
|
||||
name: 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')"
|
||||
description: mask to filter by trigger path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
ScriptExactPath:
|
||||
name: script_path_exact
|
||||
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')"
|
||||
description: mask to filter exact matching path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17546,7 +17481,7 @@ components:
|
||||
type: string
|
||||
Tag:
|
||||
name: tag
|
||||
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')"
|
||||
description: filter on jobs with a given tag/worker group
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17590,7 +17525,9 @@ components:
|
||||
enum: [Create, Update, Delete, Execute]
|
||||
JobKinds:
|
||||
name: job_kinds
|
||||
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')"
|
||||
description:
|
||||
filter on job kind (values 'preview', 'script', 'dependencies', 'flow')
|
||||
separated by,
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -19895,8 +19832,6 @@ components:
|
||||
format: date-time
|
||||
format_extension:
|
||||
type: string
|
||||
is_fileset:
|
||||
type: boolean
|
||||
required:
|
||||
- name
|
||||
|
||||
@@ -19906,8 +19841,6 @@ components:
|
||||
schema: {}
|
||||
description:
|
||||
type: string
|
||||
is_fileset:
|
||||
type: boolean
|
||||
|
||||
Schedule:
|
||||
type: object
|
||||
|
||||
@@ -15,10 +15,7 @@ use sqlx::{
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
pub use windmill_common::db::DB;
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
utils::{generate_lock_id, GIT_VERSION},
|
||||
};
|
||||
use windmill_common::{error::Error, utils::{generate_lock_id, GIT_VERSION}};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use windmill_api_auth::{ApiAuthed, OptJobAuthed};
|
||||
@@ -258,7 +255,8 @@ pub async fn migrate(
|
||||
if let Err(err) = sqlx::query!(
|
||||
"DELETE FROM _sqlx_migrations WHERE
|
||||
version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR
|
||||
version=20250201145631 OR version=20250201145632 OR version=20251006143821"
|
||||
version=20250201145631 OR version=20250201145632 OR version=20251006143821 OR
|
||||
version=20260207000001 OR version=20260207000002 OR version=20260207000003 OR version=20260207000004"
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
@@ -266,31 +264,6 @@ pub async fn migrate(
|
||||
tracing::info!("Could not remove sqlx migrations: {err:#}");
|
||||
}
|
||||
|
||||
// For migrations that were replaced (same version, new content), only delete if
|
||||
// the stored checksum doesn't match the current file — i.e., it's a stale record
|
||||
// from the old broken version. Once the new migration is applied, the checksum
|
||||
// matches and the record is kept, avoiding expensive re-application on every start.
|
||||
let migrator = sqlx::migrate!("../migrations");
|
||||
let potentially_stale: &[i64] = &[
|
||||
20260207000001,
|
||||
20260207000002,
|
||||
20260207000003,
|
||||
20260207000004,
|
||||
];
|
||||
for m in migrator.migrations.iter() {
|
||||
if potentially_stale.contains(&m.version) {
|
||||
if let Err(err) =
|
||||
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = $1 AND checksum != $2")
|
||||
.bind(m.version)
|
||||
.bind(&*m.checksum)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::info!("Could not clean up stale migration {}: {err:#}", m.version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = killpill_rx.recv() => {
|
||||
tracing::info!("Killpill received, stopping migration");
|
||||
@@ -336,11 +309,12 @@ pub async fn wait_for_migrations(
|
||||
let mut attempts = 0;
|
||||
|
||||
loop {
|
||||
let is_applied: Result<Option<bool>, sqlx::Error> =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = $1)")
|
||||
.bind(latest_version)
|
||||
.fetch_one(db)
|
||||
.await;
|
||||
let is_applied: Result<Option<bool>, sqlx::Error> = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = $1)",
|
||||
)
|
||||
.bind(latest_version)
|
||||
.fetch_one(db)
|
||||
.await;
|
||||
|
||||
match is_applied {
|
||||
Ok(Some(true)) => {
|
||||
|
||||
@@ -1933,7 +1933,7 @@ async fn count_completed_jobs_detail(
|
||||
|
||||
if let Some(after_s_ago) = query.completed_after_s_ago {
|
||||
let after = Utc::now() - chrono::Duration::seconds(after_s_ago);
|
||||
sqlb.and_where_gt("completed_at", "?".bind(&after.to_rfc3339()));
|
||||
sqlb.and_where_gt("ended_at", "?".bind(&after.to_rfc3339()));
|
||||
}
|
||||
|
||||
if let Some(success) = query.success {
|
||||
|
||||
@@ -98,7 +98,6 @@ 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)]
|
||||
@@ -107,14 +106,12 @@ 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)]
|
||||
@@ -163,13 +160,9 @@ struct EditResource {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListResourceQuery {
|
||||
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>,
|
||||
resource_type: Option<String>,
|
||||
resource_type_exclude: Option<String>,
|
||||
path_start: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -289,18 +282,6 @@ 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)
|
||||
@@ -1212,38 +1193,29 @@ 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, FileResourceTypeInfo>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
) -> JsonResult<HashMap<String, String>> {
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
struct LocalFileResourceExtension {
|
||||
name: String,
|
||||
format_extension: Option<String>,
|
||||
is_fileset: bool,
|
||||
}
|
||||
|
||||
let r = sqlx::query_as!(LocalFileResourceExtension, "
|
||||
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)
|
||||
SELECT name, format_extension FROM resource_type WHERE format_extension IS NOT NULL AND (workspace_id = $1 OR workspace_id = 'admins')", w_id)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
let hashmap: HashMap<String, FileResourceTypeInfo> = r
|
||||
let hashmap: HashMap<String, String> = r
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.name,
|
||||
FileResourceTypeInfo {
|
||||
format_extension: entry.format_extension,
|
||||
is_fileset: entry.is_fileset,
|
||||
},
|
||||
)
|
||||
.filter_map(|entry| {
|
||||
if let Some(format_extension) = entry.format_extension {
|
||||
Some((entry.name, format_extension))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1343,25 +1315,16 @@ 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, is_fileset, edited_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
|
||||
(workspace_id, name, schema, description, created_by, format_extension, edited_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())",
|
||||
w_id,
|
||||
resource_type.name,
|
||||
resource_type.schema,
|
||||
resource_type.description,
|
||||
authed.username,
|
||||
resource_type.format_extension,
|
||||
is_fileset,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1522,9 +1485,6 @@ 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,11 +96,7 @@ async fn list_contextual_variables(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListVariableQuery {
|
||||
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>,
|
||||
path_start: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_variables(
|
||||
@@ -110,76 +106,33 @@ 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 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();
|
||||
|
||||
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?;
|
||||
|
||||
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?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
|
||||
@@ -540,48 +540,53 @@ impl FlowModule {
|
||||
) -> anyhow::Result<()> {
|
||||
for module in modules {
|
||||
cb(module)?;
|
||||
let module_value = module
|
||||
match module
|
||||
.get_value()
|
||||
.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)?;
|
||||
.map_err(|e| anyhow::anyhow!("Module '{}': {}", module.id, e))?
|
||||
{
|
||||
FlowModuleValue::ForloopFlow { modules, .. }
|
||||
| FlowModuleValue::WhileloopFlow { modules, .. } => {
|
||||
Self::traverse_modules(&modules, cb)?;
|
||||
}
|
||||
Self::traverse_modules(default, cb)?;
|
||||
}
|
||||
FlowModuleValue::BranchAll { branches, .. } => {
|
||||
for branch in branches {
|
||||
Self::traverse_modules(&branch.modules, cb)?;
|
||||
FlowModuleValue::BranchOne { branches, default, .. } => {
|
||||
for branch in branches {
|
||||
Self::traverse_modules(&branch.modules, cb)?;
|
||||
}
|
||||
Self::traverse_modules(&default, 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)?;
|
||||
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(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1066,10 +1071,7 @@ 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,
|
||||
is_completed_input_transform, update_flow_status_module_with_actions,
|
||||
update_flow_status_module_with_actions_success, FlowContext,
|
||||
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,6 +21,7 @@ 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")]
|
||||
@@ -34,15 +35,15 @@ type McpClient = McpClientStub;
|
||||
use windmill_common::{
|
||||
client::AuthedClient,
|
||||
db::DB,
|
||||
error::Error,
|
||||
error::{to_anyhow, Error},
|
||||
flow_conversations::MessageType,
|
||||
flow_status::AgentAction,
|
||||
flows::FlowModuleValue,
|
||||
worker::{to_raw_value, Connection},
|
||||
};
|
||||
use windmill_queue::{
|
||||
add_completed_job, add_completed_job_error, get_mini_pulled_job, push, MiniCompletedJob,
|
||||
MiniPulledJob, PushArgs, PushIsolationLevel,
|
||||
get_mini_pulled_job, push, JobCompleted, MiniCompletedJob, MiniPulledJob, PushArgs,
|
||||
PushIsolationLevel,
|
||||
};
|
||||
|
||||
/// Context for tool execution containing all required references and state
|
||||
@@ -53,9 +54,8 @@ pub struct ToolExecutionContext<'a> {
|
||||
|
||||
// Job context
|
||||
pub job: &'a MiniPulledJob,
|
||||
pub parent_job: Option<&'a Uuid>,
|
||||
pub parent_job: &'a Uuid,
|
||||
pub summary: &'a Option<&'a str>,
|
||||
pub flow_step_id_override: Option<&'a str>,
|
||||
|
||||
// Execution parameters
|
||||
pub client: &'a AuthedClient,
|
||||
@@ -66,6 +66,7 @@ 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
|
||||
@@ -282,9 +283,7 @@ async fn execute_windmill_tool(
|
||||
module_id: tool_module.id.clone(),
|
||||
});
|
||||
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions(ctx.db, parent_job, actions).await?;
|
||||
}
|
||||
update_flow_status_module_with_actions(ctx.db, ctx.parent_job, actions).await?;
|
||||
|
||||
let raw_tool_call_args = if tool_call.function.arguments.is_empty() {
|
||||
"{}".to_string()
|
||||
@@ -302,14 +301,11 @@ 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_value {
|
||||
FlowModuleValue::Script { input_transforms, .. }
|
||||
| FlowModuleValue::RawScript { input_transforms, .. }
|
||||
| FlowModuleValue::FlowScript { input_transforms, .. }
|
||||
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
|
||||
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,
|
||||
_ => {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Unsupported tool: {}",
|
||||
@@ -335,8 +331,17 @@ 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
|
||||
if !is_completed_input_transform(transform) {
|
||||
continue;
|
||||
match transform {
|
||||
InputTransform::Static { value } => {
|
||||
let val = value.get().trim();
|
||||
if val.is_empty() || val == "null" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
InputTransform::Ai => {
|
||||
continue;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
let result = evaluate_input_transform::<Box<RawValue>>(
|
||||
transform,
|
||||
@@ -351,7 +356,7 @@ async fn execute_windmill_tool(
|
||||
tool_call_args.insert(key.clone(), result);
|
||||
}
|
||||
|
||||
let job_payload = match tool_value {
|
||||
let job_payload = match tool_module.get_value()? {
|
||||
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
|
||||
script_to_payload(
|
||||
script_hash,
|
||||
@@ -375,6 +380,7 @@ 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,
|
||||
@@ -388,7 +394,8 @@ async fn execute_windmill_tool(
|
||||
}
|
||||
FlowModuleValue::FlowScript { id, language, concurrency_settings, tag, .. } => {
|
||||
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
|
||||
JobPayloadWithTag {
|
||||
|
||||
let payload = JobPayloadWithTag {
|
||||
payload: JobPayload::FlowScript {
|
||||
id,
|
||||
language,
|
||||
@@ -402,29 +409,8 @@ async fn execute_windmill_tool(
|
||||
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
};
|
||||
payload
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::internal_err(format!(
|
||||
@@ -466,8 +452,8 @@ async fn execute_windmill_tool(
|
||||
None,
|
||||
ctx.job.schedule_path(),
|
||||
Some(ctx.job.id),
|
||||
ctx.job.root_job.or(Some(ctx.job.id)),
|
||||
ctx.job.flow_innermost_root_job.or(Some(ctx.job.id)),
|
||||
None,
|
||||
None,
|
||||
Some(job_id),
|
||||
false,
|
||||
false,
|
||||
@@ -558,6 +544,7 @@ 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(
|
||||
@@ -640,9 +627,7 @@ async fn handle_tool_execution_error(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
|
||||
}
|
||||
update_flow_status_module_with_actions_success(ctx.db, ctx.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;
|
||||
@@ -664,50 +649,23 @@ 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(ref jc), ..
|
||||
}) = send_result
|
||||
result: SendResultPayload::JobCompleted(JobCompleted { result, .. }),
|
||||
..
|
||||
}) = send_result.as_ref()
|
||||
{
|
||||
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),
|
||||
)
|
||||
let result = result.clone();
|
||||
ctx.job_completed_tx
|
||||
.send(send_result.unwrap().result, true)
|
||||
.await
|
||||
.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}")))?;
|
||||
}
|
||||
.map_err(to_anyhow)?;
|
||||
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(),
|
||||
));
|
||||
@@ -738,9 +696,7 @@ async fn handle_tool_execution_success(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?;
|
||||
}
|
||||
update_flow_status_module_with_actions_success(ctx.db, ctx.parent_job, success).await?;
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled
|
||||
let content = if success {
|
||||
@@ -775,10 +731,8 @@ async fn add_tool_message_to_chat(
|
||||
.and_then(|fs| fs.memory_id)
|
||||
{
|
||||
let db_clone = ctx.db.clone();
|
||||
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 step_name =
|
||||
get_step_name_from_flow(ctx.summary.as_deref(), ctx.job.flow_step_id.as_deref());
|
||||
let content = content.to_string();
|
||||
|
||||
// Spawn task because we do not need to wait for the result
|
||||
|
||||
@@ -62,17 +62,6 @@ 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(
|
||||
@@ -88,7 +77,14 @@ pub fn filter_schema_by_input_transforms(
|
||||
let keys_to_remove: HashSet<String> = input_transforms
|
||||
.iter()
|
||||
.filter_map(|(key, transform)| {
|
||||
let is_completed = is_completed_input_transform(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,
|
||||
};
|
||||
if is_completed {
|
||||
Some(key.clone())
|
||||
} else {
|
||||
@@ -127,13 +123,10 @@ 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(
|
||||
@@ -142,7 +135,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: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
|
||||
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\" FROM v2_job WHERE id = $1",
|
||||
job_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
@@ -697,7 +690,6 @@ 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,6 +47,7 @@ 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! {
|
||||
@@ -78,57 +79,11 @@ 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,
|
||||
@@ -142,6 +97,7 @@ 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,
|
||||
@@ -161,57 +117,26 @@ pub async fn handle_ai_agent_job(
|
||||
return handle_credentials_check(&args.provider).await;
|
||||
}
|
||||
|
||||
// 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(flow_step_id) = &job.flow_step_id else {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent job has no flow step id".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let Some(immediate_parent_job) = &job.parent_job else {
|
||||
let Some(parent_job) = &job.parent_job else {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent job has no parent job".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
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_job = get_flow_job_runnable_and_raw_flow(db, &parent_job).await?;
|
||||
|
||||
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, &flow_job_id, flow_job.raw_flow).await?
|
||||
cache::job::fetch_preview_flow(db, &parent_job, flow_job.raw_flow).await?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::internal_err(
|
||||
@@ -222,18 +147,8 @@ pub async fn handle_ai_agent_job(
|
||||
|
||||
let value = flow_data.value();
|
||||
|
||||
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 module = value.modules.iter().find(|m| m.id == *flow_step_id);
|
||||
let summary = module.as_ref().and_then(|m| m.summary.clone());
|
||||
|
||||
let Some(module) = module else {
|
||||
return Err(Error::internal_err(
|
||||
@@ -241,8 +156,6 @@ 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(),
|
||||
@@ -372,16 +285,6 @@ 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: {}",
|
||||
@@ -439,24 +342,18 @@ 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,
|
||||
flow_status_job.as_ref(),
|
||||
Some(flow_step_id.as_str()),
|
||||
parent_job,
|
||||
&args,
|
||||
&tools,
|
||||
&mcp_clients,
|
||||
summary.as_deref(),
|
||||
client,
|
||||
&mut inner_occupancy_metrics,
|
||||
job_completed_tx,
|
||||
worker_dir,
|
||||
base_internal_url,
|
||||
worker_name,
|
||||
@@ -494,8 +391,7 @@ pub async fn run_agent(
|
||||
|
||||
// agent job and flow data
|
||||
job: &MiniPulledJob,
|
||||
parent_job: Option<&Uuid>,
|
||||
flow_step_id_override: Option<&str>,
|
||||
parent_job: &Uuid,
|
||||
args: &AIAgentArgs,
|
||||
tools: &[Tool],
|
||||
mcp_clients: &HashMap<String, Arc<McpClient>>,
|
||||
@@ -504,6 +400,7 @@ 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,
|
||||
@@ -536,10 +433,6 @@ 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;
|
||||
|
||||
@@ -586,7 +479,7 @@ pub async fn run_agent(
|
||||
}
|
||||
Some(Memory::Auto { context_length, .. }) => {
|
||||
// Auto mode: load from memory
|
||||
if let Some(step_id) = effective_flow_step_id {
|
||||
if let Some(step_id) = job.flow_step_id.as_deref() {
|
||||
if let Some(memory_id) = memory_id {
|
||||
// Read messages from memory
|
||||
match read_from_memory(db, &job.workspace_id, memory_id, step_id).await {
|
||||
@@ -641,8 +534,9 @@ 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 = effective_flow_step_id
|
||||
.map(str::to_string)
|
||||
let previous_id = job
|
||||
.flow_step_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
Some(get_transform_context(job, &previous_id, flow_status))
|
||||
@@ -755,7 +649,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(), effective_flow_step_id);
|
||||
let step_name = get_step_name_from_flow(summary.as_deref(), job.flow_step_id.as_deref());
|
||||
|
||||
let max_iterations = args
|
||||
.max_iterations
|
||||
@@ -987,11 +881,8 @@ pub async fn run_agent(
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
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?;
|
||||
}
|
||||
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()));
|
||||
|
||||
@@ -1049,13 +940,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,
|
||||
@@ -1180,7 +1071,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) = effective_flow_step_id {
|
||||
if let Some(step_id) = job.flow_step_id.as_deref() {
|
||||
// Extract OpenAIMessages from final_messages
|
||||
let all_messages: Vec<OpenAIMessage> =
|
||||
final_messages.iter().map(|m| m.message.clone()).collect();
|
||||
|
||||
@@ -3398,6 +3398,7 @@ 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,
|
||||
|
||||
@@ -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.644.0";
|
||||
export const VERSION = "v1.642.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -112,15 +112,39 @@ source <(wmill completions zsh)
|
||||
|
||||
### Testing with a local `windmill-yaml-validator`
|
||||
|
||||
To test local changes to the validator before publishing, use `npm link`:
|
||||
The CLI imports `windmill-yaml-validator` from npm (`npm:windmill-yaml-validator@1.1.0`).
|
||||
To test local changes to the validator before publishing, use the Deno compatibility
|
||||
script and import map override:
|
||||
|
||||
1. Make the validator sources Deno-compatible:
|
||||
|
||||
```bash
|
||||
# In windmill-yaml-validator/
|
||||
npm run build
|
||||
npm link
|
||||
cd ../windmill-yaml-validator
|
||||
./deno-compat.sh
|
||||
```
|
||||
|
||||
# In cli/
|
||||
npm link windmill-yaml-validator
|
||||
2. Add the following entries to `cli/deno.json` imports:
|
||||
|
||||
```json
|
||||
"npm:windmill-yaml-validator@1.1.0": "../windmill-yaml-validator/src/index.ts",
|
||||
"ajv": "npm:ajv@^8.17.1",
|
||||
"@stoplight/yaml": "npm:@stoplight/yaml@^4.3.0"
|
||||
```
|
||||
|
||||
3. Run the CLI directly with Deno:
|
||||
|
||||
```bash
|
||||
deno run -A src/main.ts lint
|
||||
```
|
||||
|
||||
4. When done, restore everything:
|
||||
|
||||
```bash
|
||||
# Restore validator sources
|
||||
cd ../windmill-yaml-validator
|
||||
./deno-compat.sh -r
|
||||
|
||||
# Remove the 3 import map lines from cli/deno.json
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
@@ -132,13 +156,13 @@ npm link windmill-yaml-validator
|
||||
**Run tests locally (full features):**
|
||||
|
||||
```bash
|
||||
bun test test/
|
||||
deno test --allow-all --no-check
|
||||
```
|
||||
|
||||
**Run tests in CI mode (minimal features, skips EE tests):**
|
||||
|
||||
```bash
|
||||
CI_MINIMAL_FEATURES=true bun test test/
|
||||
CI_MINIMAL_FEATURES=true deno test --allow-all --no-check
|
||||
```
|
||||
|
||||
| Variable | Description |
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"yaml": "^2.7.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
@@ -152,8 +151,6 @@
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/diff": ["@types/diff@5.2.3", "", {}, "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
@@ -186,8 +183,6 @@
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
183
cli/package-lock.json
generated
183
cli/package-lock.json
generated
@@ -6,11 +6,16 @@
|
||||
"": {
|
||||
"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",
|
||||
"@windmill-labs/shared-utils": "^1.0.12",
|
||||
"@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",
|
||||
"diff": "^5.2.0",
|
||||
"esbuild": "0.24.2",
|
||||
"get-port": "7.1.0",
|
||||
@@ -18,7 +23,6 @@
|
||||
"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": "*",
|
||||
@@ -40,11 +44,25 @@
|
||||
"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",
|
||||
@@ -605,6 +623,15 @@
|
||||
"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",
|
||||
@@ -644,6 +671,38 @@
|
||||
"@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",
|
||||
@@ -725,16 +784,6 @@
|
||||
"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",
|
||||
@@ -803,20 +852,6 @@
|
||||
"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",
|
||||
@@ -826,20 +861,6 @@
|
||||
"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",
|
||||
@@ -992,27 +1013,12 @@
|
||||
"@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",
|
||||
@@ -1041,6 +1047,18 @@
|
||||
"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",
|
||||
@@ -1245,6 +1263,12 @@
|
||||
"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",
|
||||
@@ -1254,17 +1278,6 @@
|
||||
"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",
|
||||
@@ -1274,6 +1287,13 @@
|
||||
"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",
|
||||
@@ -1301,26 +1321,6 @@
|
||||
"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,6 +1484,15 @@
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { stat, readdir, writeFile, mkdir } from "node:fs/promises";
|
||||
import { stat, writeFile, mkdir } from "node:fs/promises";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
@@ -6,19 +6,17 @@ import { Command } from "@cliffy/command";
|
||||
import { Table } from "@cliffy/table";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { sep as SEP } from "node:path";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace } from "../../core/context.ts";
|
||||
import { resolveWorkspace, validatePath } from "../../core/context.ts";
|
||||
import { GlobalOptions, isSuperset, parseFromFile } from "../../types.ts";
|
||||
import { Folder } from "../../../gen/types.gen.ts";
|
||||
|
||||
export interface FolderFile {
|
||||
summary: string | undefined;
|
||||
display_name: string | undefined;
|
||||
owners: Array<string> | undefined;
|
||||
extra_perms: { [record: string]: boolean } | undefined;
|
||||
display_name: string | undefined;
|
||||
}
|
||||
|
||||
async function list(opts: GlobalOptions & { json?: boolean }) {
|
||||
@@ -47,7 +45,7 @@ async function list(opts: GlobalOptions & { json?: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function newFolder(opts: GlobalOptions & { summary?: string }, name: string) {
|
||||
async function newFolder(opts: GlobalOptions, name: string) {
|
||||
const dirPath = `f${SEP}${name}`;
|
||||
const filePath = `${dirPath}${SEP}folder.meta.yaml`;
|
||||
try {
|
||||
@@ -56,9 +54,7 @@ async function newFolder(opts: GlobalOptions & { summary?: string }, name: strin
|
||||
} catch (e: any) {
|
||||
if (e.message?.startsWith("File already exists")) throw e;
|
||||
}
|
||||
const template: FolderFile = {
|
||||
summary: opts.summary ?? "",
|
||||
display_name: name,
|
||||
const template: Omit<FolderFile, "display_name"> = {
|
||||
owners: [],
|
||||
extra_perms: {},
|
||||
};
|
||||
@@ -147,72 +143,30 @@ export async function pushFolder(
|
||||
}
|
||||
}
|
||||
|
||||
async function push(opts: GlobalOptions, name: string) {
|
||||
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const metaPath = `f${SEP}${name}${SEP}folder.meta.yaml`;
|
||||
try {
|
||||
await stat(metaPath);
|
||||
} catch {
|
||||
throw new Error(`Could not find ${metaPath}. Does the folder exist locally?`);
|
||||
if (!validatePath(remotePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fstat = await stat(filePath);
|
||||
if (!fstat.isFile()) {
|
||||
throw new Error("file path must refer to a file.");
|
||||
}
|
||||
|
||||
console.log(colors.bold.yellow("Pushing folder..."));
|
||||
|
||||
await pushFolder(
|
||||
workspace.workspaceId,
|
||||
name,
|
||||
remotePath,
|
||||
undefined,
|
||||
parseFromFile(metaPath)
|
||||
parseFromFile(filePath)
|
||||
);
|
||||
console.log(colors.bold.underline.green("Folder pushed"));
|
||||
}
|
||||
|
||||
async function addMissing(opts: GlobalOptions & { yes?: boolean }) {
|
||||
const fDir = `f`;
|
||||
try {
|
||||
await stat(fDir);
|
||||
} catch {
|
||||
log.info("No 'f/' directory found. Nothing to do.");
|
||||
return;
|
||||
}
|
||||
const entries = await readdir(fDir, { withFileTypes: true });
|
||||
const missing: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const metaPath = `${fDir}${SEP}${entry.name}${SEP}folder.meta.yaml`;
|
||||
try {
|
||||
await stat(metaPath);
|
||||
} catch {
|
||||
missing.push(entry.name);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) {
|
||||
log.info("All folders already have a folder.meta.yaml. Nothing to do.");
|
||||
return;
|
||||
}
|
||||
log.info(`Missing folder.meta.yaml for:`);
|
||||
for (const name of missing) {
|
||||
log.info(` - ${name}`);
|
||||
}
|
||||
if (
|
||||
!opts.yes &&
|
||||
!(await Confirm.prompt({
|
||||
message: `Create ${missing.length} folder.meta.yaml file(s)?`,
|
||||
default: true,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const name of missing) {
|
||||
await newFolder(opts, name);
|
||||
}
|
||||
log.info(
|
||||
`\nCreated ${missing.length} folder.meta.yaml file(s). You can now run 'wmill sync push' to push them.`,
|
||||
);
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.description("folder related commands")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
@@ -226,19 +180,12 @@ const command = new Command()
|
||||
.action(get as any)
|
||||
.command("new", "create a new folder locally")
|
||||
.arguments("<name:string>")
|
||||
.option("--summary <summary:string>", "folder summary")
|
||||
.action(newFolder as any)
|
||||
.command(
|
||||
"push",
|
||||
"push a local folder to the remote by name. This overrides any remote versions."
|
||||
"push a local folder spec. This overrides any remote versions."
|
||||
)
|
||||
.arguments("<name:string>")
|
||||
.action(push as any)
|
||||
.command(
|
||||
"add-missing",
|
||||
"create default folder.meta.yaml for all subdirectories of f/ that are missing one"
|
||||
)
|
||||
.option("-y, --yes", "skip confirmation prompt")
|
||||
.action(addMissing as any);
|
||||
.arguments("<file_path:string> <remote_path:string>")
|
||||
.action(push as any);
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -16,7 +16,6 @@ interface HubResourceType {
|
||||
schema: string;
|
||||
app: string;
|
||||
description: string;
|
||||
is_fileset?: boolean;
|
||||
}
|
||||
|
||||
export async function pull(opts: GlobalOptions) {
|
||||
@@ -115,8 +114,7 @@ export async function pull(opts: GlobalOptions) {
|
||||
y.name === x.name &&
|
||||
typeof y.schema !== "string" &&
|
||||
deepEqual(y.schema, x.schema) &&
|
||||
y.description === x.description &&
|
||||
(y.is_fileset ?? false) === (x.is_fileset ?? false)
|
||||
y.description === x.description
|
||||
)
|
||||
) {
|
||||
log.info("skipping " + x.name + " (same as current)");
|
||||
|
||||
@@ -24,7 +24,6 @@ import { capitalize, toCamel } from "../../utils/utils.ts";
|
||||
export interface ResourceTypeFile {
|
||||
schema?: any;
|
||||
description?: string;
|
||||
is_fileset?: boolean;
|
||||
}
|
||||
|
||||
export async function pushResourceType(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { stat, writeFile, readdir, readFile } from "node:fs/promises";
|
||||
import { stat, writeFile } from "node:fs/promises";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import nodePath from "node:path";
|
||||
|
||||
import {
|
||||
GlobalOptions,
|
||||
@@ -28,24 +27,6 @@ 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,
|
||||
@@ -65,10 +46,7 @@ export async function pushResource(
|
||||
|
||||
// Helper function to resolve inline content
|
||||
const resolveInlineContent = async () => {
|
||||
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 ")) {
|
||||
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
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
deepEqual,
|
||||
fetchRemoteVersion,
|
||||
isFileResource,
|
||||
isFilesetResource,
|
||||
isRawAppFile,
|
||||
isWorkspaceDependencies,
|
||||
} from "../../utils/utils.ts";
|
||||
@@ -485,53 +484,11 @@ 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(
|
||||
@@ -903,17 +860,10 @@ function ZipFSElement(
|
||||
log.error(`Failed to parse resource.yaml at path: ${p}`);
|
||||
throw error;
|
||||
}
|
||||
const resourceType = parsed["resource_type"];
|
||||
const formatExtension =
|
||||
resourceTypeToFormatExtension[resourceType];
|
||||
const isFileset = resourceTypeToIsFileset[resourceType] ?? false;
|
||||
resourceTypeToFormatExtension[parsed["resource_type"]];
|
||||
|
||||
if (isFileset) {
|
||||
parsed["value"] =
|
||||
"!inline_fileset " +
|
||||
removeSuffix(p.replaceAll(SEP, "/"), ".resource.json") +
|
||||
".fileset";
|
||||
} else if (formatExtension) {
|
||||
if (formatExtension) {
|
||||
parsed["value"]["content"] =
|
||||
"!inline " +
|
||||
removeSuffix(p.replaceAll(SEP, "/"), ".resource.json") +
|
||||
@@ -968,37 +918,10 @@ function ZipFSElement(
|
||||
log.error(`Failed to parse resource file content at path: ${p}`);
|
||||
throw error;
|
||||
}
|
||||
const resourceType = parsed["resource_type"];
|
||||
const formatExtension =
|
||||
resourceTypeToFormatExtension[resourceType];
|
||||
const isFileset = resourceTypeToIsFileset[resourceType] ?? false;
|
||||
resourceTypeToFormatExtension[parsed["resource_type"]];
|
||||
|
||||
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) {
|
||||
if (formatExtension) {
|
||||
const fileContent: string = parsed["value"]["content"];
|
||||
if (typeof fileContent === "string") {
|
||||
r.push({
|
||||
@@ -1135,7 +1058,6 @@ export async function elementsToMap(
|
||||
const path = entry.path;
|
||||
if (
|
||||
!isFileResource(path) &&
|
||||
!isFilesetResource(path) &&
|
||||
!isRawAppFile(path) &&
|
||||
!isWorkspaceDependencies(path)
|
||||
) {
|
||||
@@ -1181,7 +1103,7 @@ export async function elementsToMap(
|
||||
}
|
||||
}
|
||||
|
||||
if (skips.skipResources && (isFileResource(path) || isFilesetResource(path))) continue;
|
||||
if (skips.skipResources && isFileResource(path)) continue;
|
||||
|
||||
const ext = json ? ".json" : ".yaml";
|
||||
if (!skips.includeSchedules && path.endsWith(".schedule" + ext)) continue;
|
||||
@@ -1793,14 +1715,10 @@ export async function pull(
|
||||
);
|
||||
|
||||
let resourceTypeToFormatExtension: Record<string, string> = {};
|
||||
let resourceTypeToIsFileset: Record<string, boolean> = {};
|
||||
try {
|
||||
const raw = (await wmill.fileResourceTypeToFileExtMap({
|
||||
resourceTypeToFormatExtension = (await wmill.fileResourceTypeToFileExtMap({
|
||||
workspace: workspace.workspaceId,
|
||||
})) as Record<string, string | FileResourceTypeInfo>;
|
||||
const parsed = parseFileResourceTypeMap(raw);
|
||||
resourceTypeToFormatExtension = parsed.formatExtMap;
|
||||
resourceTypeToIsFileset = parsed.filesetMap;
|
||||
})) as Record<string, string>;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -1827,7 +1745,6 @@ export async function pull(
|
||||
!opts.json,
|
||||
opts.defaultTs ?? "bun",
|
||||
resourceTypeToFormatExtension,
|
||||
resourceTypeToIsFileset,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -2324,14 +2241,10 @@ export async function push(
|
||||
),
|
||||
);
|
||||
let resourceTypeToFormatExtension: Record<string, string> = {};
|
||||
let resourceTypeToIsFileset: Record<string, boolean> = {};
|
||||
try {
|
||||
const raw = (await wmill.fileResourceTypeToFileExtMap({
|
||||
resourceTypeToFormatExtension = (await wmill.fileResourceTypeToFileExtMap({
|
||||
workspace: workspace.workspaceId,
|
||||
})) as Record<string, string | FileResourceTypeInfo>;
|
||||
const parsed = parseFileResourceTypeMap(raw);
|
||||
resourceTypeToFormatExtension = parsed.formatExtMap;
|
||||
resourceTypeToIsFileset = parsed.filesetMap;
|
||||
})) as Record<string, string>;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -2356,7 +2269,6 @@ export async function push(
|
||||
!opts.json,
|
||||
opts.defaultTs ?? "bun",
|
||||
resourceTypeToFormatExtension,
|
||||
resourceTypeToIsFileset,
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -2480,45 +2392,6 @@ export async function push(
|
||||
log.info(
|
||||
`remote (${workspace.name}) <- local: ${changes.length} changes to apply`,
|
||||
);
|
||||
// Check that every folder referenced in the changeset has a local folder.meta.yaml
|
||||
const missingFolders: string[] = [];
|
||||
if (changes.length > 0) {
|
||||
const folderNames = new Set<string>();
|
||||
for (const change of changes) {
|
||||
const parts = change.path.split(SEP);
|
||||
if (parts.length >= 3 && parts[0] === "f" && change.name !== "deleted") {
|
||||
folderNames.add(parts[1]);
|
||||
}
|
||||
}
|
||||
for (const folderName of folderNames) {
|
||||
try {
|
||||
await stat(path.join("f", folderName, "folder.meta.yaml"));
|
||||
} catch {
|
||||
missingFolders.push(folderName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFolders.length > 0) {
|
||||
const folderList = missingFolders.map((f) => ` - ${f}`).join("\n");
|
||||
const user = await wmill.whoami({ workspace: workspace.workspaceId });
|
||||
const userIsAdmin = user.is_admin;
|
||||
const msg =
|
||||
`${userIsAdmin ? "Warning: " : ""}Missing folder.meta.yaml for:\n${folderList}\n` +
|
||||
`Run 'wmill folder add-missing' to create them locally, then push again.`;
|
||||
if (!userIsAdmin) {
|
||||
if (opts.jsonOutput) {
|
||||
console.log(JSON.stringify({ success: false, error: "missing_folders", missing_folders: missingFolders, message: msg }, null, 2));
|
||||
} else {
|
||||
log.error(msg);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (!opts.jsonOutput) {
|
||||
log.warn(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON output for dry-run
|
||||
if (opts.dryRun && opts.jsonOutput) {
|
||||
const result = {
|
||||
@@ -2550,7 +2423,6 @@ export async function push(
|
||||
if (!opts.jsonOutput) {
|
||||
prettyChanges(changes, specificItems, opts.branch);
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
log.info(colors.gray(`Dry run complete.`));
|
||||
return;
|
||||
@@ -2715,39 +2587,6 @@ 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);
|
||||
|
||||
@@ -2780,8 +2619,7 @@ export async function push(
|
||||
change.path.endsWith(".script.json") ||
|
||||
change.path.endsWith(".script.yaml") ||
|
||||
change.path.endsWith(".lock") ||
|
||||
isFileResource(change.path) ||
|
||||
isFilesetResource(change.path)
|
||||
isFileResource(change.path)
|
||||
) {
|
||||
continue;
|
||||
} else if (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { minimatch } from "minimatch";
|
||||
import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts";
|
||||
import { isFileResource, isFilesetResource } from "../utils/utils.ts";
|
||||
import { isFileResource } 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) || isFilesetResource(path)) {
|
||||
if (isFileResource(path)) {
|
||||
return specificItems.resources !== undefined;
|
||||
}
|
||||
|
||||
@@ -219,14 +219,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
cli/src/main.ts
Executable file → Normal file
2
cli/src/main.ts
Executable file → Normal file
@@ -65,7 +65,7 @@ export {
|
||||
workspaceAdd,
|
||||
};
|
||||
|
||||
export const VERSION = "1.644.0";
|
||||
export const VERSION = "1.642.0";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
|
||||
@@ -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, isFilesetResource, isWorkspaceDependencies } from "./utils/utils.ts";
|
||||
import { deepEqual, isFileResource, 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) || isFilesetResource(p)) {
|
||||
if (isFileResource(p)) {
|
||||
return "resource";
|
||||
}
|
||||
throw new Error("Could not infer type of path " + JSON.stringify(parsed));
|
||||
|
||||
@@ -154,11 +154,6 @@ export function isFileResource(path: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches children inside a .fileset/ directory, not the directory itself. */
|
||||
export function isFilesetResource(path: string): boolean {
|
||||
return path.includes(".fileset/") || path.includes(".fileset\\");
|
||||
}
|
||||
|
||||
export function isRawAppFile(path: string): boolean {
|
||||
return isRawAppPath(path);
|
||||
}
|
||||
|
||||
@@ -232,9 +232,8 @@ export class CargoBackend {
|
||||
*/
|
||||
private getBasePostgresUrl(): string {
|
||||
const url = new URL(this.config.postgresUrl);
|
||||
// Remove any existing database path and query params (e.g. ?sslmode=disable)
|
||||
// Remove any existing database path
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
return url.toString().replace(/\/$/, ""); // Remove trailing slash
|
||||
}
|
||||
|
||||
@@ -630,13 +629,13 @@ export class CargoBackend {
|
||||
/**
|
||||
* Create CLI command with proper authentication
|
||||
*/
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { command: string, args: string[], cwd: string, env: Record<string, string> } {
|
||||
const workspace = opts?.workspace || this.config.workspace;
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): { command: string, args: string[], cwd: string, env: Record<string, string> } {
|
||||
const workspace = workspaceName || this.config.workspace;
|
||||
const cliDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const fullArgs = [
|
||||
"--base-url", this.baseUrl,
|
||||
"--workspace", workspace,
|
||||
"--token", opts?.token || this.token,
|
||||
"--token", this.token,
|
||||
"--config-dir", this.config.testConfigDir,
|
||||
...args,
|
||||
];
|
||||
@@ -661,12 +660,12 @@ export class CargoBackend {
|
||||
/**
|
||||
* Run CLI command and return result
|
||||
*/
|
||||
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
|
||||
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}> {
|
||||
const cmd = this.createCLICommand(args, workingDir, opts);
|
||||
const cmd = this.createCLICommand(args, workingDir, workspaceName);
|
||||
const proc = Bun.spawn([cmd.command, ...cmd.args], {
|
||||
cwd: cmd.cwd,
|
||||
env: cmd.env,
|
||||
|
||||
@@ -1020,12 +1020,12 @@ export async function main(
|
||||
/**
|
||||
* Create CLI command with proper authentication
|
||||
*/
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { cmd: string[], cwd: string } {
|
||||
const workspace = opts?.workspace || this.config.workspace;
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): { cmd: string[], cwd: string } {
|
||||
const workspace = workspaceName || this.config.workspace;
|
||||
const fullArgs = [
|
||||
'--base-url', this.config.baseUrl,
|
||||
'--workspace', workspace,
|
||||
'--token', opts?.token || this.config.token,
|
||||
'--token', this.config.token,
|
||||
'--config-dir', this.config.testConfigDir,
|
||||
...args
|
||||
];
|
||||
@@ -1049,12 +1049,12 @@ export async function main(
|
||||
/**
|
||||
* Run CLI command and return result
|
||||
*/
|
||||
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
|
||||
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}> {
|
||||
const { cmd, cwd } = this.createCLICommand(args, workingDir, opts);
|
||||
const { cmd, cwd } = this.createCLICommand(args, workingDir, workspaceName);
|
||||
const proc = Bun.spawn(cmd, {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
/**
|
||||
* Tests for missing folder.meta.yaml detection during sync push,
|
||||
* the `folder add-missing` command, and the simplified `folder push` command.
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { writeFile, mkdir, readFile, rm, mkdtemp } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getTestBackend, createNonAdminUser } from "./test_backend.ts";
|
||||
|
||||
type IsolatedWorkspaceTestContext = {
|
||||
backend: any;
|
||||
tempDir: string;
|
||||
workspaceId: string;
|
||||
runCLICommand: (
|
||||
args: string[],
|
||||
opts?: { token?: string }
|
||||
) => Promise<{ stdout: string; stderr: string; code: number }>;
|
||||
apiRequest: (path: string, options?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
async function createWorkspace(backend: any, workspaceId: string): Promise<void> {
|
||||
const response = await backend.apiRequest!("/api/workspaces/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: workspaceId,
|
||||
// Workspace name has a 50-char DB limit; keep it identical to the short ID.
|
||||
name: workspaceId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
if (!error.includes("already exists") && !error.includes("duplicate")) {
|
||||
throw new Error(`Failed to create workspace ${workspaceId}: ${error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await response.text();
|
||||
}
|
||||
|
||||
async function withIsolatedWorkspace(
|
||||
testFn: (ctx: IsolatedWorkspaceTestContext) => Promise<void>
|
||||
): Promise<void> {
|
||||
const backend = await getTestBackend();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "windmill_cli_test_"));
|
||||
const workspaceId = `fmeta_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
let workspaceCreated = false;
|
||||
|
||||
try {
|
||||
await createWorkspace(backend, workspaceId);
|
||||
workspaceCreated = true;
|
||||
|
||||
await testFn({
|
||||
backend,
|
||||
tempDir,
|
||||
workspaceId,
|
||||
runCLICommand: (args: string[], opts?: { token?: string }) =>
|
||||
backend.runCLICommand(args, tempDir, {
|
||||
workspace: workspaceId,
|
||||
token: opts?.token,
|
||||
}),
|
||||
apiRequest: (path: string, options?: RequestInit) =>
|
||||
backend.apiRequest!(`/api/w/${workspaceId}${path}`, options),
|
||||
});
|
||||
} finally {
|
||||
if (workspaceCreated) {
|
||||
try {
|
||||
const archiveResponse = await backend.apiRequest!(
|
||||
`/api/w/${workspaceId}/workspaces/archive`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
await archiveResponse.text();
|
||||
} catch {
|
||||
// Best-effort cleanup to avoid exceeding non-enterprise workspace limits.
|
||||
}
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function wmillYaml(): string {
|
||||
return `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// folder new — creates folder.meta.yaml with summary and display_name
|
||||
// =============================================================================
|
||||
|
||||
describe("folder new", () => {
|
||||
test("creates folder.meta.yaml with summary and display_name", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const folderName = `newfolder${Date.now()}`;
|
||||
const result = await runCLICommand(
|
||||
["folder", "new", folderName, "--summary", "My summary"],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
|
||||
const metaPath = join(tempDir, "f", folderName, "folder.meta.yaml");
|
||||
const content = await readFile(metaPath, "utf-8");
|
||||
expect(content).toContain("summary: My summary");
|
||||
expect(content).toContain(`display_name: ${folderName}`);
|
||||
expect(content).toContain("owners:");
|
||||
expect(content).toContain("extra_perms:");
|
||||
});
|
||||
});
|
||||
|
||||
test("creates folder.meta.yaml with empty summary when none provided", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const folderName = `nosummary${Date.now()}`;
|
||||
const result = await runCLICommand(
|
||||
["folder", "new", folderName],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
|
||||
const content = await readFile(
|
||||
join(tempDir, "f", folderName, "folder.meta.yaml"),
|
||||
"utf-8"
|
||||
);
|
||||
expect(content).toContain('summary: ""');
|
||||
expect(content).toContain(`display_name: ${folderName}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("fails if folder.meta.yaml already exists", async () => {
|
||||
await withIsolatedWorkspace(async ({ runCLICommand }) => {
|
||||
const folderName = `dupfolder${Date.now()}`;
|
||||
// Create first
|
||||
await runCLICommand(["folder", "new", folderName]);
|
||||
|
||||
// Try again — should fail
|
||||
const result = await runCLICommand(
|
||||
["folder", "new", folderName],
|
||||
);
|
||||
expect(result.code).not.toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// folder add-missing — scaffolds missing folder.meta.yaml files
|
||||
// =============================================================================
|
||||
|
||||
describe("folder add-missing", () => {
|
||||
test("creates folder.meta.yaml for directories missing one", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
// Create two folders: one with meta, one without
|
||||
const withMeta = `withmeta${Date.now()}`;
|
||||
const withoutMeta = `withoutmeta${Date.now()}`;
|
||||
|
||||
await mkdir(join(tempDir, "f", withMeta), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", withMeta, "folder.meta.yaml"),
|
||||
'summary: ""\ndisplay_name: existing\nowners: []\nextra_perms: {}\n',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
await mkdir(join(tempDir, "f", withoutMeta), { recursive: true });
|
||||
// No folder.meta.yaml for withoutMeta
|
||||
|
||||
const result = await runCLICommand(
|
||||
["folder", "add-missing", "-y"],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
|
||||
// withoutMeta should now have a folder.meta.yaml
|
||||
const createdMeta = await readFile(
|
||||
join(tempDir, "f", withoutMeta, "folder.meta.yaml"),
|
||||
"utf-8"
|
||||
);
|
||||
expect(createdMeta).toContain(`display_name: ${withoutMeta}`);
|
||||
expect(createdMeta).toContain("owners:");
|
||||
|
||||
// withMeta should be unchanged
|
||||
const existingMeta = await readFile(
|
||||
join(tempDir, "f", withMeta, "folder.meta.yaml"),
|
||||
"utf-8"
|
||||
);
|
||||
expect(existingMeta).toContain("display_name: existing");
|
||||
});
|
||||
});
|
||||
|
||||
test("reports nothing to do when all folders have meta", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const folderName = `alldone${Date.now()}`;
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "folder.meta.yaml"),
|
||||
'summary: ""\ndisplay_name: done\nowners: []\nextra_perms: {}\n',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["folder", "add-missing", "-y"],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout + result.stderr).toContain("Nothing to do");
|
||||
});
|
||||
});
|
||||
|
||||
test("reports nothing to do when no f/ directory exists", async () => {
|
||||
await withIsolatedWorkspace(async ({ runCLICommand }) => {
|
||||
const result = await runCLICommand(
|
||||
["folder", "add-missing", "-y"],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout + result.stderr).toContain("Nothing to do");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// folder push — simplified single-arg signature
|
||||
// =============================================================================
|
||||
|
||||
describe("folder push", () => {
|
||||
test("pushes a folder by name", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand, apiRequest }) => {
|
||||
const folderName = `pushbyname${Date.now()}`;
|
||||
|
||||
// Create local folder meta
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "folder.meta.yaml"),
|
||||
`summary: "pushed"\ndisplay_name: "${folderName}"\nowners:\n - "admin@windmill.dev"\nextra_perms: {}\n`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["folder", "push", folderName],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout + result.stderr).toContain("Folder pushed");
|
||||
|
||||
// Verify via API
|
||||
const apiResp = await apiRequest(`/folders/get/${folderName}`);
|
||||
expect(apiResp.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
test("fails when folder does not exist locally", async () => {
|
||||
await withIsolatedWorkspace(async ({ runCLICommand }) => {
|
||||
const result = await runCLICommand(
|
||||
["folder", "push", "nonexistent"],
|
||||
);
|
||||
|
||||
expect(result.code).not.toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// sync push — missing folder.meta.yaml detection
|
||||
// =============================================================================
|
||||
|
||||
describe("sync push missing folder detection", () => {
|
||||
test("admin user gets warning but push succeeds", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const uniqueId = Date.now();
|
||||
const folderName = `nometaadmin${uniqueId}`;
|
||||
|
||||
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
|
||||
|
||||
// Create a script inside a folder WITHOUT folder.meta.yaml
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "test_script.ts"),
|
||||
'export async function main() { return "hello"; }',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
|
||||
);
|
||||
|
||||
// Admin should get a warning but push succeeds (exit 0)
|
||||
expect(result.code).toEqual(0);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("Missing folder.meta.yaml");
|
||||
expect(output).toContain(folderName);
|
||||
expect(output).toContain("wmill folder add-missing");
|
||||
});
|
||||
});
|
||||
|
||||
test("no warning when folder.meta.yaml exists", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
|
||||
const uniqueId = Date.now();
|
||||
const folderName = `withmeta${uniqueId}`;
|
||||
|
||||
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
|
||||
|
||||
// Create folder WITH folder.meta.yaml
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "folder.meta.yaml"),
|
||||
`summary: ""\ndisplay_name: "${folderName}"\nowners: []\nextra_perms: {}\n`,
|
||||
"utf-8"
|
||||
);
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "test_script.ts"),
|
||||
'export async function main() { return "hello"; }',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).not.toContain("Missing folder.meta.yaml");
|
||||
});
|
||||
});
|
||||
|
||||
test.skipIf(!process.env["EE_LICENSE_KEY"])("non-admin user gets error and exit code 1", async () => {
|
||||
await withIsolatedWorkspace(async ({ backend, tempDir, workspaceId, runCLICommand, apiRequest }) => {
|
||||
const nonAdminToken = await createNonAdminUser(backend, workspaceId);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const folderName = `nometanonadmin${uniqueId}`;
|
||||
|
||||
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
|
||||
|
||||
// Create a script inside a folder WITHOUT folder.meta.yaml
|
||||
// First create the folder on remote so the non-admin has somewhere to push
|
||||
await apiRequest(
|
||||
"/folders/create",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: folderName,
|
||||
extra_perms: { "g/all": true },
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
await mkdir(join(tempDir, "f", folderName), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "test_script.ts"),
|
||||
'export async function main() { return "hello"; }',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const result = await runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
|
||||
{ token: nonAdminToken }
|
||||
);
|
||||
|
||||
// Non-admin should get exit code 1
|
||||
expect(result.code).toEqual(1);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("Missing folder.meta.yaml");
|
||||
expect(output).toContain("wmill folder add-missing");
|
||||
});
|
||||
});
|
||||
|
||||
test("no warning for deleted changes without folder.meta.yaml", async () => {
|
||||
await withIsolatedWorkspace(async ({ tempDir, runCLICommand, apiRequest }) => {
|
||||
const uniqueId = Date.now();
|
||||
const folderName = `delfolder${uniqueId}`;
|
||||
|
||||
// Create folder and script on remote via API
|
||||
await apiRequest(
|
||||
"/folders/create",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: folderName }),
|
||||
}
|
||||
);
|
||||
|
||||
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
|
||||
|
||||
// Pull to get remote state, then delete the folder locally
|
||||
await runCLICommand(["sync", "pull", "--yes"]);
|
||||
|
||||
// Remove the folder locally to trigger a "deleted" change
|
||||
await rm(join(tempDir, "f", folderName), { recursive: true, force: true });
|
||||
|
||||
const result = await runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
|
||||
);
|
||||
|
||||
// Should not warn about missing meta for deleted items
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).not.toContain("Missing folder.meta.yaml");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,8 @@ export interface TestBackend {
|
||||
stop(): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any;
|
||||
runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any;
|
||||
runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
@@ -97,12 +97,12 @@ class CargoBackendAdapter implements TestBackend {
|
||||
await this.backend.reset();
|
||||
}
|
||||
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any {
|
||||
return this.backend.createCLICommand(args, workingDir, opts);
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any {
|
||||
return this.backend.createCLICommand(args, workingDir, workspaceName);
|
||||
}
|
||||
|
||||
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }) {
|
||||
return this.backend.runCLICommand(args, workingDir, opts);
|
||||
async runCLICommand(args: string[], workingDir: string, workspaceName?: string) {
|
||||
return this.backend.runCLICommand(args, workingDir, workspaceName);
|
||||
}
|
||||
|
||||
async apiRequest(path: string, options?: RequestInit): Promise<Response> {
|
||||
@@ -369,12 +369,12 @@ class ContainerizedBackendAdapter implements TestBackend {
|
||||
await this.backend.reset();
|
||||
}
|
||||
|
||||
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any {
|
||||
return this.backend.createCLICommand(args, workingDir, opts);
|
||||
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any {
|
||||
return this.backend.createCLICommand(args, workingDir, workspaceName);
|
||||
}
|
||||
|
||||
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }) {
|
||||
return this.backend.runCLICommand(args, workingDir, opts);
|
||||
async runCLICommand(args: string[], workingDir: string, workspaceName?: string) {
|
||||
return this.backend.runCLICommand(args, workingDir, workspaceName);
|
||||
}
|
||||
|
||||
async seedTestData(): Promise<void> {
|
||||
@@ -507,66 +507,6 @@ function registerCleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a non-admin user, add them to the workspace, and return their token.
|
||||
*/
|
||||
export async function createNonAdminUser(
|
||||
backend: TestBackend,
|
||||
workspaceId: string = backend.workspace
|
||||
): Promise<string> {
|
||||
if (!backend.apiRequest) {
|
||||
throw new Error("Backend does not support apiRequest");
|
||||
}
|
||||
|
||||
const email = `nonadmin_${Date.now()}@test.dev`;
|
||||
const password = "testpass123";
|
||||
|
||||
// Create user globally (as admin)
|
||||
const createResp = await backend.apiRequest("/api/users/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
super_admin: false,
|
||||
name: "Non-Admin Test User",
|
||||
}),
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
throw new Error(`Failed to create user: ${await createResp.text()}`);
|
||||
}
|
||||
await createResp.text();
|
||||
|
||||
// Add user to workspace as non-admin
|
||||
const addResp = await backend.apiRequest(
|
||||
`/api/w/${workspaceId}/workspaces/add_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
is_admin: false,
|
||||
operator: false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!addResp.ok) {
|
||||
throw new Error(`Failed to add user to workspace: ${await addResp.text()}`);
|
||||
}
|
||||
await addResp.text();
|
||||
|
||||
// Login as the non-admin user to get a token
|
||||
const loginResp = await fetch(`${backend.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!loginResp.ok) {
|
||||
throw new Error(`Failed to login as non-admin: ${await loginResp.text()}`);
|
||||
}
|
||||
return await loginResp.text();
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { CargoBackendConfig } from "./cargo_backend.ts";
|
||||
export type { ContainerConfig } from "./containerized_backend.ts";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { deepEqual, isFileResource, isFilesetResource, toCamel, capitalize } from "../src/utils/utils.ts";
|
||||
import { deepEqual, isFileResource, toCamel, capitalize } from "../src/utils/utils.ts";
|
||||
import {
|
||||
getTypeStrFromPath,
|
||||
removeType,
|
||||
@@ -156,32 +156,6 @@ 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
|
||||
// =============================================================================
|
||||
@@ -267,11 +241,6 @@ 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();
|
||||
});
|
||||
|
||||
10
dev-dashboard/.gitignore
vendored
Normal file
10
dev-dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
bun.lock
|
||||
backend/node_modules/
|
||||
backend/bun.lock
|
||||
frontend/node_modules/
|
||||
frontend/bun.lock
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
public/
|
||||
.env
|
||||
247
dev-dashboard/README.md
Normal file
247
dev-dashboard/README.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Dev Dashboard
|
||||
|
||||
Web-based dashboard for managing Windmill development worktrees. Lets you create, monitor, and interact with multiple isolated development environments, each running its own AI coding agent (Claude or Codex), backend, and frontend.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
cargo install workmux # worktree orchestrator
|
||||
sudo apt install tmux socat # (or brew install tmux socat)
|
||||
curl -fsSL https://bun.sh/install | bash # bun >1.3.5 required
|
||||
|
||||
# 2. Create the workmux global config
|
||||
mkdir -p ~/.config/workmux
|
||||
cat > ~/.config/workmux/config.yaml << 'EOF'
|
||||
nerdfont: false
|
||||
|
||||
sandbox:
|
||||
image: windmill-sandbox
|
||||
|
||||
# Forward R2/AWS credentials into sandbox containers (for screenshot uploads).
|
||||
# The actual values come from dev-dashboard/.env, sourced by dev.sh/run.sh.
|
||||
env_passthrough:
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
- R2_ENDPOINT
|
||||
- R2_BUCKET
|
||||
- R2_PUBLIC_URL
|
||||
|
||||
extra_mounts:
|
||||
# Codex agent credentials
|
||||
- host_path: ~/.codex
|
||||
guest_path: /tmp/.codex
|
||||
writable: true
|
||||
# EE repo access (optional — only needed for enterprise features)
|
||||
- host_path: ~/windmill-ee-private
|
||||
writable: true
|
||||
- host_path: ~/windmill-ee-private__worktrees
|
||||
writable: true
|
||||
EOF
|
||||
|
||||
# 3. (Optional) Build sandbox image — only needed for agent-yolo profile
|
||||
docker build -f Dockerfile.sandbox -t windmill-sandbox .
|
||||
|
||||
# 4. Install frontend deps
|
||||
cd dev-dashboard/frontend && bun install && cd ..
|
||||
|
||||
# 5. Start the dashboard
|
||||
./dev.sh # dev mode (hot reload), UI on :5112
|
||||
# or
|
||||
./run.sh # production mode (build + serve), UI on :4173
|
||||
|
||||
# 6. Open http://localhost:5112
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (localhost:5112)
|
||||
│
|
||||
├── REST API (/api/*) ──┐
|
||||
└── WebSocket (/ws/*) ──┤
|
||||
│
|
||||
Vite dev proxy
|
||||
│
|
||||
Backend (localhost:5111)
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
workmux CLI tmux sessions socat
|
||||
(worktree (terminal (port forwarding
|
||||
lifecycle) access) for sandboxes)
|
||||
```
|
||||
|
||||
**Backend** — Bun/TypeScript HTTP + WebSocket server (`backend/src/server.ts`). Exposes two interfaces:
|
||||
|
||||
- **REST API** (`/api/*`) — CRUD for worktrees. Wraps the `workmux` CLI to create/remove/merge worktrees and runs `socat` port forwarding for Docker sandbox containers. The `GET /api/worktrees` endpoint enriches each worktree with its directory, assigned ports (from `.env.local`), and whether the backend/frontend services are actually responding.
|
||||
- **WebSocket** (`/ws/*`) — Live terminal connection. This is what makes the in-browser terminal work. See [Terminal streaming](#terminal-streaming) below.
|
||||
|
||||
**Frontend** — Svelte 5 SPA with Tailwind CSS and xterm.js (`frontend/src/`). Provides a two-panel UI: worktree list sidebar + embedded terminal. Polls the REST API every 5 seconds for status updates. The terminal is rendered by [xterm.js](https://xtermjs.org/), which handles all terminal emulation (escape sequences, colors, cursor, scrollback) in a `<canvas>`/DOM element.
|
||||
|
||||
### Terminal streaming
|
||||
|
||||
The WebSocket provides a bidirectional bridge between xterm.js in the browser and a tmux session on the server. The data flow:
|
||||
|
||||
```
|
||||
Browser (xterm.js) ←— WebSocket —→ Backend ←— stdin/stdout pipes —→ script (PTY) ←— tmux attach —→ tmux grouped session
|
||||
```
|
||||
|
||||
When a worktree is selected, the frontend opens a WebSocket to `/ws/<worktree>` and sends an initial `resize` message with the terminal dimensions. The backend then:
|
||||
|
||||
1. Spawns `script -q -c "... tmux attach-session ..." /dev/null` — `script` allocates a real PTY (pseudo-terminal), which is necessary for tmux to produce proper terminal escape sequences, colors, and cursor movement.
|
||||
2. The tmux command creates a **grouped session** (`tmux new-session -t <main-session>`), which is a separate "view" into the same tmux windows. This allows the dashboard and a real terminal to view the same worktree simultaneously without fighting over window/pane focus.
|
||||
3. An async reader loop reads the PTY's stdout and forwards the data over the WebSocket as `{ type: "output" }` messages, which xterm.js renders.
|
||||
4. Keystrokes arrive as `{ type: "input" }` messages and are written to the PTY's stdin pipe.
|
||||
5. Resize events trigger `tmux resize-window` to keep dimensions in sync.
|
||||
|
||||
Output is also buffered in a scrollback array (up to 5000 chunks) so that reconnecting clients receive recent history immediately.
|
||||
|
||||
### Worktree Profiles
|
||||
|
||||
When creating a worktree, you pick a profile that determines what runs inside it:
|
||||
|
||||
| Profile | What it does |
|
||||
|---------|-------------|
|
||||
| `full` | Agent + Cargo backend + Vite frontend (uses pane layout from `.workmux.yaml`) |
|
||||
| `agent-yolo` | Agent runs inside a Docker sandbox container with `--dangerously-skip-permissions`. Socat forwards the container's ports to the host so they're reachable from your browser. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required tools
|
||||
|
||||
| Tool | Min version | Purpose |
|
||||
|------|-------------|---------|
|
||||
| [**bun**](https://bun.sh) | >1.3.5 | Runtime for both backend and frontend dev server |
|
||||
| [**workmux**](https://github.com/raine/workmux) | latest | Worktree + tmux orchestration (`cargo install workmux` or see its repo) |
|
||||
| **tmux** | 3.x | Terminal multiplexer — workmux manages sessions/windows through it |
|
||||
| **socat** | 1.7+ | TCP port forwarding for sandbox containers (only needed for `agent-yolo` profile) |
|
||||
| **git** | 2.x | Worktree management |
|
||||
| **docker** | 28+ | Only needed for `agent-yolo` sandbox profile |
|
||||
|
||||
### Workmux global config
|
||||
|
||||
Workmux reads a global config from `~/.config/workmux/config.yaml`. Create it if it doesn't exist:
|
||||
|
||||
```yaml
|
||||
nerdfont: false
|
||||
|
||||
sandbox:
|
||||
image: windmill-sandbox
|
||||
env_passthrough:
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
- R2_ENDPOINT
|
||||
- R2_BUCKET
|
||||
- R2_PUBLIC_URL
|
||||
extra_mounts:
|
||||
- host_path: ~/.codex
|
||||
guest_path: /tmp/.codex
|
||||
writable: true
|
||||
- host_path: ~/windmill-ee-private
|
||||
writable: true
|
||||
- host_path: ~/windmill-ee-private__worktrees
|
||||
writable: true
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- **`nerdfont`** — Set to `true` if your terminal uses a Nerd Font (adds icons to `workmux list` output). Default `false`.
|
||||
- **`sandbox.image`** — Docker image used for `agent-yolo` sandboxed worktrees. Must be pre-built with `workmux sandbox build` or pulled with `workmux sandbox pull`.
|
||||
- **`sandbox.env_passthrough`** — Host env vars to forward into sandbox containers (global config only). Used here for R2 screenshot upload credentials.
|
||||
- **`sandbox.extra_mounts`** — Additional bind mounts into sandbox containers. Mounts Codex credentials and the EE repo for enterprise features.
|
||||
|
||||
To build the sandbox image (from the Windmill repo root):
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.sandbox -t windmill-sandbox .
|
||||
```
|
||||
|
||||
### Workmux project config
|
||||
|
||||
The repo-level `.workmux.yaml` at the Windmill root configures how worktrees are created. Key settings:
|
||||
|
||||
- **`post_create`** — Runs `./scripts/worktree-env` after creating a worktree, which generates a `.env.local` file with unique `BACKEND_PORT` and `FRONTEND_PORT` assignments so multiple worktrees don't collide.
|
||||
- **`panes`** — Defines the tmux pane layout for `full` profile: agent pane (focused), backend pane (`cargo watch`), and frontend pane (`npm run dev`).
|
||||
- **`files.copy`** — Copies `backend/.env` and `scripts/` into each new worktree.
|
||||
|
||||
## Running
|
||||
|
||||
From the `dev-dashboard/` directory:
|
||||
|
||||
```bash
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
This starts both backend and frontend, with logs prefixed `[BE]` / `[FE]`. `Ctrl+C` stops both.
|
||||
|
||||
You can also start them separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1: backend (auto-reloads on save)
|
||||
cd backend && bun run dev
|
||||
|
||||
# Terminal 2: frontend (Vite dev server)
|
||||
cd frontend && bun run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5112 in your browser.
|
||||
|
||||
### Cursor IDE integration
|
||||
|
||||
The top bar has a **Cursor** button that opens the selected worktree's directory in Cursor IDE via the `cursor://` protocol. Click the gear icon next to it to configure SSH remote host.
|
||||
|
||||
By default, clicking the button reuses an existing Cursor window. To always open in a **new window**, add this to your Cursor `settings.json` (`Cmd+Shift+P` → "Preferences: Open Settings (JSON)"):
|
||||
|
||||
```json
|
||||
"window.openFoldersInNewWindow": "on"
|
||||
```
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Cmd+Up/Down` | Navigate between worktrees |
|
||||
| `Cmd+K` | Create new worktree |
|
||||
| `Cmd+D` | Remove selected worktree |
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DASHBOARD_PORT` | `5111` | Backend API port |
|
||||
|
||||
The frontend dev server is hardcoded to port `5112` and proxies `/api/*` and `/ws/*` to the backend.
|
||||
|
||||
### Screenshot uploads (optional)
|
||||
|
||||
Sandbox agents can take screenshots of the frontend UI with Playwright and upload them to a Cloudflare R2 bucket for use in PR descriptions. To enable this, create a `dev-dashboard/.env` file (already gitignored):
|
||||
|
||||
```bash
|
||||
# Cloudflare R2 credentials — get from:
|
||||
# Dashboard → R2 → Manage R2 API Tokens → Create API Token (Object Read & Write, scoped to your bucket)
|
||||
AWS_ACCESS_KEY_ID=<your-r2-access-key>
|
||||
AWS_SECRET_ACCESS_KEY=<your-r2-secret-key>
|
||||
|
||||
# Account ID is on the R2 overview page (right sidebar)
|
||||
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||
R2_BUCKET=windmill-screenshots
|
||||
|
||||
# Enable public access on the bucket (Settings → Public access → r2.dev subdomain)
|
||||
R2_PUBLIC_URL=https://pub-<hash>.r2.dev
|
||||
```
|
||||
|
||||
When these are set, `dev.sh`/`run.sh` source the file and the env vars are inlined onto the `workmux sandbox agent` command. The workmux global config's `env_passthrough` (see [above](#workmux-global-config)) forwards them into the container. The agent's system prompt automatically includes screenshot instructions when R2 is configured.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/worktrees` | List all worktrees with status, ports, and service health |
|
||||
| `POST` | `/api/worktrees` | Create a worktree (`{ branch, profile?, agent?, prompt? }`) |
|
||||
| `DELETE` | `/api/worktrees/:name` | Remove a worktree |
|
||||
| `POST` | `/api/worktrees/:name/open` | Open/focus a worktree's tmux window |
|
||||
| `POST` | `/api/worktrees/:name/close` | Close a worktree's tmux window (keeps the worktree) |
|
||||
| `POST` | `/api/worktrees/:name/send` | Send a prompt to the worktree's agent (`{ prompt }`) |
|
||||
| `GET` | `/api/worktrees/:name/status` | Get agent status for a worktree |
|
||||
| `WS` | `/ws/:worktree` | Terminal WebSocket (xterm.js ↔ tmux) |
|
||||
13
dev-dashboard/backend/package.json
Normal file
13
dev-dashboard/backend/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "windmill-dev-dashboard-backend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"start": "bun src/server.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
15
dev-dashboard/backend/src/env.ts
Normal file
15
dev-dashboard/backend/src/env.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Read key=value pairs from a worktree's .env.local file. */
|
||||
export function readEnvLocal(wtDir: string): Record<string, string> {
|
||||
try {
|
||||
const content = Bun.spawnSync(["cat", `${wtDir}/.env.local`], { stdout: "pipe" });
|
||||
const text = new TextDecoder().decode(content.stdout).trim();
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const match = line.match(/^(\w+)=(.*)$/);
|
||||
if (match) env[match[1]] = match[2];
|
||||
}
|
||||
return env;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
290
dev-dashboard/backend/src/server.ts
Normal file
290
dev-dashboard/backend/src/server.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
listWorktrees,
|
||||
getStatus,
|
||||
addWorktree,
|
||||
removeWorktree,
|
||||
openWorktree,
|
||||
mergeWorktree,
|
||||
readEnvLocal,
|
||||
type Profile,
|
||||
type Agent,
|
||||
} from "./workmux";
|
||||
import { reconcileForwarding, stopAll } from "./socat";
|
||||
import {
|
||||
attach,
|
||||
detach,
|
||||
write,
|
||||
resize,
|
||||
selectPane,
|
||||
getScrollback,
|
||||
setCallbacks,
|
||||
clearCallbacks,
|
||||
cleanupStaleSessions,
|
||||
} from "./terminal";
|
||||
|
||||
const PORT = parseInt(process.env.DASHBOARD_PORT || "5111");
|
||||
|
||||
function ts(): string {
|
||||
return new Date().toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
/** Map branch name → worktree directory using git worktree list. */
|
||||
function getWorktreePaths(): Map<string, string> {
|
||||
const result = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe" });
|
||||
const output = new TextDecoder().decode(result.stdout);
|
||||
const paths = new Map<string, string>();
|
||||
let currentPath = "";
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
currentPath = line.slice("worktree ".length);
|
||||
} else if (line.startsWith("branch ")) {
|
||||
// branch refs/heads/foo → "foo"
|
||||
const branch = line.slice("branch ".length).replace("refs/heads/", "");
|
||||
// Also map by directory basename (workmux uses basename as branch key)
|
||||
const basename = currentPath.split("/").pop() ?? "";
|
||||
paths.set(branch, currentPath);
|
||||
if (basename !== branch) paths.set(basename, currentPath);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Check if a port has a service responding (not just a TCP handshake). */
|
||||
function isPortListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => { resolve(false); }, 1000);
|
||||
fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) })
|
||||
.then((res) => { clearTimeout(timeout); resolve(true); })
|
||||
.catch(() => { clearTimeout(timeout); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(message: string, status = 500): Response {
|
||||
return jsonResponse({ error: message }, status);
|
||||
}
|
||||
|
||||
interface WsData {
|
||||
worktree: string;
|
||||
attached: boolean;
|
||||
}
|
||||
|
||||
function makeCallbacks(ws: { send: (data: string) => void; readyState: number }) {
|
||||
return {
|
||||
onData: (data: string) => {
|
||||
if (ws.readyState <= 1) {
|
||||
ws.send(JSON.stringify({ type: "output", data }));
|
||||
}
|
||||
},
|
||||
onExit: (exitCode: number) => {
|
||||
if (ws.readyState <= 1) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Bun.serve<WsData>({
|
||||
port: PORT,
|
||||
idleTimeout: 255, // seconds; worktree removal can take >10s
|
||||
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const wsMatch = url.pathname.match(/^\/ws\/(.+)$/);
|
||||
if (wsMatch) {
|
||||
const worktree = decodeURIComponent(wsMatch[1]);
|
||||
const upgraded = server.upgrade(req, { data: { worktree, attached: false } });
|
||||
if (upgraded) return undefined as unknown as Response;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return handleApi(req, url);
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws) {
|
||||
console.log(`[ws:${ts()}] open worktree=${ws.data.worktree}`);
|
||||
},
|
||||
|
||||
async message(ws, message) {
|
||||
try {
|
||||
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
||||
const { worktree } = ws.data;
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
write(worktree, msg.data);
|
||||
break;
|
||||
case "selectPane":
|
||||
if (ws.data.attached && typeof msg.pane === "number") {
|
||||
console.log(`[ws:${ts()}] selectPane pane=${msg.pane} worktree=${worktree}`);
|
||||
selectPane(worktree, msg.pane);
|
||||
}
|
||||
break;
|
||||
case "resize":
|
||||
if (!ws.data.attached) {
|
||||
// First resize = client reporting actual dimensions. Spawn now.
|
||||
ws.data.attached = true;
|
||||
console.log(`[ws:${ts()}] first resize (attaching) worktree=${worktree} cols=${msg.cols} rows=${msg.rows}`);
|
||||
try {
|
||||
const initialPane = typeof msg.initialPane === "number" ? msg.initialPane : undefined;
|
||||
if (initialPane !== undefined) {
|
||||
console.log(`[ws:${ts()}] initialPane=${initialPane} worktree=${worktree}`);
|
||||
}
|
||||
await attach(worktree, msg.cols, msg.rows, initialPane);
|
||||
const { onData, onExit } = makeCallbacks(ws);
|
||||
setCallbacks(worktree, onData, onExit);
|
||||
const scrollback = getScrollback(worktree);
|
||||
console.log(`[ws:${ts()}] attached worktree=${worktree} scrollback=${scrollback.length} bytes`);
|
||||
if (scrollback) {
|
||||
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.log(`[ws:${ts()}] attach failed worktree=${worktree}: ${errMsg}`);
|
||||
ws.send(JSON.stringify({ type: "error", message: errMsg }));
|
||||
ws.close();
|
||||
}
|
||||
} else {
|
||||
resize(worktree, msg.cols, msg.rows);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
},
|
||||
|
||||
async close(ws) {
|
||||
console.log(`[ws:${ts()}] close worktree=${ws.data.worktree} attached=${ws.data.attached}`);
|
||||
clearCallbacks(ws.data.worktree);
|
||||
await detach(ws.data.worktree);
|
||||
console.log(`[ws:${ts()}] close complete worktree=${ws.data.worktree}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function handleApi(req: Request, url: URL): Promise<Response> {
|
||||
const method = req.method;
|
||||
const parts = url.pathname.slice(5).split("/").filter(Boolean);
|
||||
|
||||
try {
|
||||
// GET /api/worktrees
|
||||
if (parts[0] === "worktrees" && parts.length === 1 && method === "GET") {
|
||||
const [worktrees, status] = await Promise.all([listWorktrees(), getStatus()]);
|
||||
const wtPaths = getWorktreePaths();
|
||||
const merged = await Promise.all(worktrees.map(async (wt) => {
|
||||
const st = status.find(s =>
|
||||
s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch)
|
||||
);
|
||||
const wtDir = wtPaths.get(wt.branch);
|
||||
const env = wtDir ? readEnvLocal(wtDir) : {};
|
||||
const backendPort = env.BACKEND_PORT ? parseInt(env.BACKEND_PORT) : null;
|
||||
const frontendPort = env.FRONTEND_PORT ? parseInt(env.FRONTEND_PORT) : null;
|
||||
const [backendRunning, frontendRunning] = await Promise.all([
|
||||
backendPort ? isPortListening(backendPort) : false,
|
||||
frontendPort ? isPortListening(frontendPort) : false,
|
||||
]);
|
||||
return {
|
||||
...wt,
|
||||
dir: wtDir ?? null,
|
||||
status: st?.status ?? "",
|
||||
elapsed: st?.elapsed ?? "",
|
||||
title: st?.title ?? "",
|
||||
profile: env.PROFILE || null,
|
||||
agentName: env.AGENT || null,
|
||||
backendPort,
|
||||
frontendPort,
|
||||
backendRunning,
|
||||
frontendRunning,
|
||||
};
|
||||
}));
|
||||
return jsonResponse(merged);
|
||||
}
|
||||
|
||||
// POST /api/worktrees
|
||||
if (parts[0] === "worktrees" && parts.length === 1 && method === "POST") {
|
||||
const body = await req.json() as { branch?: string; prompt?: string; profile?: string; agent?: string };
|
||||
if (!body.branch) {
|
||||
return errorResponse("branch is required", 400);
|
||||
}
|
||||
const validProfiles = ["full", "agent-yolo"] as const;
|
||||
const validAgents = ["claude", "codex"] as const;
|
||||
const profile = validProfiles.includes(body.profile as any) ? body.profile as Profile : "full";
|
||||
const agent = validAgents.includes(body.agent as any) ? body.agent as Agent : "claude";
|
||||
console.log(`[worktree:add] branch=${body.branch} agent=${agent} profile=${profile}${body.prompt ? ` prompt="${body.prompt.slice(0, 80)}"` : ""}`);
|
||||
const result = await addWorktree(body.branch, { prompt: body.prompt, profile, agent });
|
||||
console.log(`[worktree:add] done branch=${body.branch}: ${result}`);
|
||||
return jsonResponse({ message: result }, 201);
|
||||
}
|
||||
|
||||
// DELETE /api/worktrees/:name
|
||||
if (parts[0] === "worktrees" && parts.length === 2 && method === "DELETE") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
console.log(`[worktree:rm] name=${name}`);
|
||||
const result = await removeWorktree(name);
|
||||
console.log(`[worktree:rm] done name=${name}: ${result}`);
|
||||
return jsonResponse({ message: result });
|
||||
}
|
||||
|
||||
// POST /api/worktrees/:name/open
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "open" && method === "POST") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
console.log(`[worktree:open] name=${name}`);
|
||||
return jsonResponse({ message: await openWorktree(name) });
|
||||
}
|
||||
|
||||
// POST /api/worktrees/:name/merge
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "merge" && method === "POST") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
console.log(`[worktree:merge] name=${name}`);
|
||||
const result = await mergeWorktree(name);
|
||||
console.log(`[worktree:merge] done name=${name}: ${result}`);
|
||||
return jsonResponse({ message: result });
|
||||
}
|
||||
|
||||
// GET /api/worktrees/:name/status
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "status" && method === "GET") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
const status = await getStatus();
|
||||
const match = status.find(s => s.worktree.includes(name));
|
||||
return jsonResponse(match ?? { status: "unknown" });
|
||||
}
|
||||
|
||||
return errorResponse("Not Found", 404);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[api:error] ${method} ${url.pathname}: ${message}`);
|
||||
return errorResponse(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure tmux server is running (needs at least one session to persist)
|
||||
const tmuxCheck = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
|
||||
if (tmuxCheck.exitCode !== 0) {
|
||||
Bun.spawnSync(["tmux", "new-session", "-d", "-s", "0"]);
|
||||
console.log("Started tmux session");
|
||||
}
|
||||
|
||||
cleanupStaleSessions();
|
||||
|
||||
// Re-establish socat forwarding for any sandbox containers still running
|
||||
const wtPathsForReconcile = getWorktreePaths();
|
||||
reconcileForwarding((branch) => wtPathsForReconcile.get(branch));
|
||||
|
||||
// Clean shutdown: kill socat processes
|
||||
process.on("SIGINT", () => { stopAll(); process.exit(0); });
|
||||
process.on("SIGTERM", () => { stopAll(); process.exit(0); });
|
||||
|
||||
console.log(`Dev Dashboard API running at http://localhost:${PORT}`);
|
||||
130
dev-dashboard/backend/src/socat.ts
Normal file
130
dev-dashboard/backend/src/socat.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Manages socat port forwarding for sandbox containers.
|
||||
*
|
||||
* When a worktree runs inside a Docker sandbox, its ports are only reachable
|
||||
* via the container's bridge IP. socat forwards host ports to the container
|
||||
* so the browser (over SSH) can reach them.
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { readEnvLocal } from "./env";
|
||||
|
||||
interface ForwardingEntry {
|
||||
branch: string;
|
||||
containerIp: string;
|
||||
ports: { host: number; proc: ReturnType<typeof Bun.spawn> }[];
|
||||
}
|
||||
|
||||
const registry = new Map<string, ForwardingEntry>();
|
||||
|
||||
/** Get the bridge IP of a running sandbox container for a worktree branch. */
|
||||
async function getContainerIp(branch: string): Promise<string | null> {
|
||||
try {
|
||||
// Container names follow the pattern wm-{branch}-*
|
||||
const ps = await $`docker ps --filter name=wm-${branch}- --format {{.ID}}`.text();
|
||||
const containerId = ps.trim().split("\n")[0];
|
||||
if (!containerId) return null;
|
||||
const ip = (await $`docker inspect ${containerId} --format {{.NetworkSettings.IPAddress}}`.text()).trim();
|
||||
return ip || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start socat forwarding for a sandbox worktree. Returns true if forwarding was started. */
|
||||
export async function startForwarding(branch: string, wtDir: string): Promise<boolean> {
|
||||
// Don't double-start
|
||||
if (registry.has(branch)) return true;
|
||||
|
||||
const containerIp = await getContainerIp(branch);
|
||||
if (!containerIp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const env = readEnvLocal(wtDir);
|
||||
const backendPort = env.BACKEND_PORT ? parseInt(env.BACKEND_PORT) : null;
|
||||
const frontendPort = env.FRONTEND_PORT ? parseInt(env.FRONTEND_PORT) : null;
|
||||
|
||||
const entry: ForwardingEntry = { branch, containerIp, ports: [] };
|
||||
|
||||
for (const port of [backendPort, frontendPort]) {
|
||||
if (!port) continue;
|
||||
const proc = Bun.spawn([
|
||||
"socat",
|
||||
`TCP-LISTEN:${port},fork,reuseaddr`,
|
||||
`TCP:${containerIp}:${port}`,
|
||||
], { stdout: "ignore", stderr: "pipe" });
|
||||
// Consume the exit promise so Bun reaps the child (prevents zombies)
|
||||
proc.exited.then(() => {});
|
||||
entry.ports.push({ host: port, proc });
|
||||
console.log(`[socat] forwarding :${port} → ${containerIp}:${port} (branch=${branch}, pid=${proc.pid})`);
|
||||
}
|
||||
|
||||
if (entry.ports.length > 0) {
|
||||
registry.set(branch, entry);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stop socat forwarding for a worktree. */
|
||||
export function stopForwarding(branch: string): void {
|
||||
const entry = registry.get(branch);
|
||||
if (!entry) return;
|
||||
|
||||
for (const { host, proc } of entry.ports) {
|
||||
try {
|
||||
proc.kill();
|
||||
console.log(`[socat] stopped :${host} (branch=${branch}, pid=${proc.pid})`);
|
||||
} catch {
|
||||
// Already exited
|
||||
}
|
||||
}
|
||||
registry.delete(branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile socat forwarding on startup.
|
||||
* Kills any orphaned socat processes from a previous run, then starts
|
||||
* forwarding for any running sandbox containers.
|
||||
*/
|
||||
export async function reconcileForwarding(getWorktreeDir: (branch: string) => string | undefined): Promise<void> {
|
||||
try {
|
||||
// Kill orphaned socat processes from previous dashboard runs
|
||||
try {
|
||||
await $`pkill -f ${"socat TCP-LISTEN.*TCP:172\\."}`.quiet();
|
||||
console.log("[socat] reconcile: killed orphaned socat processes");
|
||||
} catch {
|
||||
// No orphans found (pkill exits non-zero when no match)
|
||||
}
|
||||
|
||||
const ps = await $`docker ps --filter name=wm- --format {{.Names}}`.text();
|
||||
const names = ps.trim().split("\n").filter(Boolean);
|
||||
|
||||
for (const name of names) {
|
||||
// Container name format: wm-{branch}-{pid}
|
||||
const match = name.match(/^wm-(.+)-\d+$/);
|
||||
if (!match) continue;
|
||||
const branch = match[1];
|
||||
|
||||
if (registry.has(branch)) continue;
|
||||
|
||||
const wtDir = getWorktreeDir(branch);
|
||||
if (!wtDir) {
|
||||
console.log(`[socat] reconcile: no worktree dir found for ${branch}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[socat] reconcile: starting forwarding for ${branch}`);
|
||||
await startForwarding(branch, wtDir);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[socat] reconcile failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop all forwarding (for clean shutdown). */
|
||||
export function stopAll(): void {
|
||||
for (const branch of [...registry.keys()]) {
|
||||
stopForwarding(branch);
|
||||
}
|
||||
}
|
||||
212
dev-dashboard/backend/src/terminal.ts
Normal file
212
dev-dashboard/backend/src/terminal.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { FileSink } from "bun";
|
||||
import { getTmuxSession } from "./workmux";
|
||||
|
||||
interface TerminalSession {
|
||||
proc: ReturnType<typeof Bun.spawn>;
|
||||
groupedSessionName: string;
|
||||
scrollback: string[];
|
||||
onData: ((data: string) => void) | null;
|
||||
onExit: ((exitCode: number) => void) | null;
|
||||
}
|
||||
|
||||
const SESSION_PREFIX = "wm-dash-";
|
||||
const MAX_SCROLLBACK = 5000;
|
||||
const sessions = new Map<string, TerminalSession>();
|
||||
let sessionCounter = 0;
|
||||
|
||||
function ts(): string {
|
||||
return new Date().toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
function groupedName(): string {
|
||||
return `${SESSION_PREFIX}${++sessionCounter}`;
|
||||
}
|
||||
|
||||
/** Kill any orphaned wm-dash-* tmux sessions left from previous server runs. */
|
||||
export function cleanupStaleSessions(): void {
|
||||
try {
|
||||
const result = Bun.spawnSync(
|
||||
["tmux", "list-sessions", "-F", "#{session_name}"],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
if (result.exitCode !== 0) return;
|
||||
const lines = new TextDecoder().decode(result.stdout).trim().split("\n");
|
||||
for (const name of lines) {
|
||||
if (name.startsWith(SESSION_PREFIX)) {
|
||||
Bun.spawnSync(["tmux", "kill-session", "-t", name]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No tmux server running
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill a tmux session by name, ignoring errors. */
|
||||
function killTmuxSession(name: string): void {
|
||||
try {
|
||||
Bun.spawnSync(["tmux", "kill-session", "-t", name]);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function attach(
|
||||
worktreeName: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialPane?: number
|
||||
): Promise<string> {
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) cols=${cols} rows=${rows} existing=${sessions.has(worktreeName)}`);
|
||||
if (sessions.has(worktreeName)) {
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) detaching existing session first`);
|
||||
await detach(worktreeName);
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) detach complete`);
|
||||
}
|
||||
|
||||
const tmuxSession = await getTmuxSession();
|
||||
const gName = groupedName();
|
||||
const windowTarget = `wm-${worktreeName}`;
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) tmuxSession=${tmuxSession} gName=${gName} window=${windowTarget}`);
|
||||
|
||||
// Kill stale session with same name if it exists (leftover from previous server run)
|
||||
killTmuxSession(gName);
|
||||
|
||||
const paneTarget = `${gName}:${windowTarget}.${initialPane ?? 0}`;
|
||||
const cmd = [
|
||||
`tmux new-session -d -s "${gName}" -t "${tmuxSession}"`,
|
||||
`tmux set-option -t "${gName}" mouse on`,
|
||||
`tmux set-option -t "${gName}" set-clipboard on`,
|
||||
`tmux select-window -t "${gName}:${windowTarget}"`,
|
||||
// Unzoom if a previous session left a pane zoomed (zoom state is shared across grouped sessions)
|
||||
`if [ "$(tmux display-message -t '${gName}:${windowTarget}' -p '#{window_zoomed_flag}')" = "1" ]; then tmux resize-pane -Z -t '${gName}:${windowTarget}'; fi`,
|
||||
`tmux select-pane -t "${paneTarget}"`,
|
||||
// On mobile, zoom the selected pane to fill the window
|
||||
...(initialPane !== undefined ? [`tmux resize-pane -Z -t "${paneTarget}"`] : []),
|
||||
`stty rows ${rows} cols ${cols}`,
|
||||
`exec tmux attach-session -t "${gName}"`,
|
||||
].join(" && ");
|
||||
|
||||
const session: TerminalSession = {
|
||||
proc: null as any,
|
||||
groupedSessionName: gName,
|
||||
scrollback: [],
|
||||
onData: null,
|
||||
onExit: null,
|
||||
};
|
||||
|
||||
sessions.set(worktreeName, session);
|
||||
|
||||
const proc = Bun.spawn(["script", "-q", "-c", cmd, "/dev/null"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
|
||||
session.proc = proc;
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) spawned pid=${proc.pid}`);
|
||||
|
||||
// Read stdout → push to scrollback + callback
|
||||
(async () => {
|
||||
const reader = proc.stdout.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const str = new TextDecoder().decode(value);
|
||||
session.scrollback.push(str);
|
||||
if (session.scrollback.length > MAX_SCROLLBACK) {
|
||||
session.scrollback.shift();
|
||||
}
|
||||
session.onData?.(str);
|
||||
}
|
||||
} catch {
|
||||
// Stream closed
|
||||
}
|
||||
})();
|
||||
|
||||
proc.exited.then((exitCode) => {
|
||||
console.log(`[term:${ts()}] proc exited(${worktreeName}) pid=${proc.pid} code=${exitCode}`);
|
||||
// Only clean up if this session is still the active one (not replaced by a new attach)
|
||||
if (sessions.get(worktreeName) === session) {
|
||||
session.onExit?.(exitCode);
|
||||
sessions.delete(worktreeName);
|
||||
} else {
|
||||
console.log(`[term:${ts()}] proc exited(${worktreeName}) stale session, skipping cleanup`);
|
||||
}
|
||||
killTmuxSession(gName);
|
||||
});
|
||||
|
||||
return worktreeName;
|
||||
}
|
||||
|
||||
export async function detach(worktreeName: string): Promise<void> {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) {
|
||||
console.log(`[term:${ts()}] detach(${worktreeName}) no session found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[term:${ts()}] detach(${worktreeName}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`);
|
||||
session.proc.kill();
|
||||
sessions.delete(worktreeName);
|
||||
|
||||
killTmuxSession(session.groupedSessionName);
|
||||
console.log(`[term:${ts()}] detach(${worktreeName}) done`);
|
||||
}
|
||||
|
||||
export function write(worktreeName: string, data: string): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) {
|
||||
console.log(`[term:${ts()}] write(${worktreeName}) NO SESSION - input dropped (${data.length} bytes)`);
|
||||
return;
|
||||
}
|
||||
if (!session.proc.stdin) {
|
||||
console.log(`[term:${ts()}] write(${worktreeName}) NO STDIN - input dropped (${data.length} bytes)`);
|
||||
return;
|
||||
}
|
||||
(session.proc.stdin as FileSink).write(new TextEncoder().encode(data));
|
||||
}
|
||||
|
||||
export function resize(worktreeName: string, cols: number, rows: number): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) return;
|
||||
// Resize via tmux directly (we don't have access to script's internal PTY)
|
||||
Bun.spawnSync(["tmux", "resize-window", "-t", session.groupedSessionName, "-x", String(cols), "-y", String(rows)]);
|
||||
}
|
||||
|
||||
export function getScrollback(worktreeName: string): string {
|
||||
return sessions.get(worktreeName)?.scrollback.join("") ?? "";
|
||||
}
|
||||
|
||||
export function setCallbacks(
|
||||
worktreeName: string,
|
||||
onData: (data: string) => void,
|
||||
onExit: (exitCode: number) => void
|
||||
): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (session) {
|
||||
session.onData = onData;
|
||||
session.onExit = onExit;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectPane(worktreeName: string, paneIndex: number): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) {
|
||||
console.log(`[term:${ts()}] selectPane(${worktreeName}) no session found`);
|
||||
return;
|
||||
}
|
||||
const windowTarget = `wm-${worktreeName}`;
|
||||
const target = `${session.groupedSessionName}:${windowTarget}.${paneIndex}`;
|
||||
console.log(`[term:${ts()}] selectPane(${worktreeName}) pane=${paneIndex} target=${target}`);
|
||||
const r1 = Bun.spawnSync(["tmux", "select-pane", "-t", target]);
|
||||
const r2 = Bun.spawnSync(["tmux", "resize-pane", "-Z", "-t", target]);
|
||||
console.log(`[term:${ts()}] selectPane(${worktreeName}) select=${r1.exitCode} zoom=${r2.exitCode}`);
|
||||
}
|
||||
|
||||
export function clearCallbacks(worktreeName: string): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (session) {
|
||||
session.onData = null;
|
||||
session.onExit = null;
|
||||
}
|
||||
}
|
||||
272
dev-dashboard/backend/src/workmux.ts
Normal file
272
dev-dashboard/backend/src/workmux.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { $ } from "bun";
|
||||
import { startForwarding, stopForwarding } from "./socat";
|
||||
import { readEnvLocal } from "./env";
|
||||
|
||||
export interface Worktree {
|
||||
branch: string;
|
||||
agent: string;
|
||||
mux: string;
|
||||
unmerged: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface WorktreeStatus {
|
||||
worktree: string;
|
||||
status: string;
|
||||
elapsed: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function parseTable<T>(output: string, mapper: (cols: string[]) => T): T[] {
|
||||
const lines = output.trim().split("\n").filter(Boolean);
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headerLine = lines[0];
|
||||
|
||||
// Find column positions based on header spacing
|
||||
const colStarts: number[] = [];
|
||||
let inSpace = true;
|
||||
for (let i = 0; i < headerLine.length; i++) {
|
||||
if (headerLine[i] !== " " && inSpace) {
|
||||
colStarts.push(i);
|
||||
inSpace = false;
|
||||
} else if (headerLine[i] === " " && !inSpace) {
|
||||
inSpace = true;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.slice(1).map(line => {
|
||||
const cols = colStarts.map((start, idx) => {
|
||||
const end = idx + 1 < colStarts.length ? colStarts[idx + 1] : line.length;
|
||||
return line.slice(start, end).trim();
|
||||
});
|
||||
return mapper(cols);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWorktrees(): Promise<Worktree[]> {
|
||||
const result = await $`workmux list`.text();
|
||||
return parseTable(result, (cols) => ({
|
||||
branch: cols[0] ?? "",
|
||||
agent: cols[1] ?? "",
|
||||
mux: cols[2] ?? "",
|
||||
unmerged: cols[3] ?? "",
|
||||
path: cols[4] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<WorktreeStatus[]> {
|
||||
const result = await $`workmux status`.text();
|
||||
return parseTable(result, (cols) => ({
|
||||
worktree: cols[0] ?? "",
|
||||
status: cols[1] ?? "",
|
||||
elapsed: cols[2] ?? "",
|
||||
title: cols[3] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
async function runChecked(args: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const msg = `${args.join(" ")} failed (exit ${exitCode}): ${stderr || stdout}`;
|
||||
console.error(`[workmux:exec] ${msg}`);
|
||||
throw new Error(msg);
|
||||
}
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
export type Profile = "full" | "agent-yolo";
|
||||
export type Agent = "claude" | "codex";
|
||||
|
||||
export { readEnvLocal } from "./env";
|
||||
|
||||
function buildSandboxSystemPrompt(env: Record<string, string>): string {
|
||||
const backendPort = env.BACKEND_PORT || "8000";
|
||||
const frontendPort = env.FRONTEND_PORT || "3000";
|
||||
const hasR2 = !!(process.env.R2_ENDPOINT && process.env.R2_BUCKET && process.env.R2_PUBLIC_URL);
|
||||
console.log(`[workmux:buildSandboxSystemPrompt] hasR2=${hasR2}`);
|
||||
const lines: string[] = [
|
||||
"You are running inside a sandboxed container with full permissions.",
|
||||
`This worktree is configured with the following ports:`,
|
||||
`- Backend: port ${backendPort}. Start with: cd backend && PORT=${backendPort} DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill cargo watch -x run`,
|
||||
`- Frontend: port ${frontendPort}. Start with: cd frontend && REMOTE=http://localhost:${backendPort} npm run dev -- --port ${frontendPort} --host 0.0.0.0`,
|
||||
];
|
||||
if (hasR2) {
|
||||
lines.push(
|
||||
`--- 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:${frontendPort}/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)`,
|
||||
);
|
||||
}
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
/** Env vars to forward into the sandbox container (via workmux env_passthrough). */
|
||||
const SANDBOX_ENV_PASSTHROUGH = [
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"R2_ENDPOINT",
|
||||
"R2_BUCKET",
|
||||
"R2_PUBLIC_URL",
|
||||
];
|
||||
|
||||
/** Build an inline env prefix (e.g. "KEY=val KEY2=val2 ") from process.env. */
|
||||
function buildEnvPrefix(): string {
|
||||
const parts: string[] = [];
|
||||
for (const key of SANDBOX_ENV_PASSTHROUGH) {
|
||||
const val = process.env[key];
|
||||
if (val) {
|
||||
// Shell-escape the value (single quotes, escaping inner single quotes)
|
||||
const escaped = val.replace(/'/g, "'\\''");
|
||||
parts.push(`${key}='${escaped}'`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" ") + " " : "";
|
||||
}
|
||||
|
||||
function buildSandboxAgentCmd(env: Record<string, string>, agent: Agent): string {
|
||||
const prompt = buildSandboxSystemPrompt(env);
|
||||
const innerEscaped = prompt.replace(/["\\$`]/g, "\\$&");
|
||||
const envPrefix = buildEnvPrefix();
|
||||
|
||||
if (agent === "codex") {
|
||||
return `${envPrefix}workmux sandbox agent -- codex --yolo -c '"developer_instructions=${innerEscaped}"'`;
|
||||
}
|
||||
return `${envPrefix}workmux sandbox agent -- claude --dangerously-skip-permissions --append-system-prompt '"${innerEscaped}"'`;
|
||||
}
|
||||
|
||||
function ensureTmux(): void {
|
||||
const check = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
|
||||
if (check.exitCode !== 0) {
|
||||
Bun.spawnSync(["tmux", "new-session", "-d", "-s", "0"]);
|
||||
console.log("[workmux] restarted tmux session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addWorktree(
|
||||
branch: string,
|
||||
opts?: { prompt?: string; profile?: Profile; agent?: Agent }
|
||||
): Promise<string> {
|
||||
ensureTmux();
|
||||
const profile = opts?.profile ?? "full";
|
||||
const agent = opts?.agent ?? "claude";
|
||||
const args: string[] = ["workmux", "add", "-b"]; // -b = background (don't switch tmux)
|
||||
|
||||
// Skip default pane commands for non-full profiles
|
||||
if (profile !== "full") {
|
||||
args.push("-C"); // --no-pane-cmds
|
||||
}
|
||||
|
||||
// Enable sandbox for yolo profile (safe to skip permissions inside container)
|
||||
if (profile === "agent-yolo") {
|
||||
args.push("-S"); // --sandbox
|
||||
}
|
||||
|
||||
if (opts?.prompt) args.push("-p", opts.prompt);
|
||||
args.push(branch);
|
||||
|
||||
console.log(`[workmux:add] running: ${args.join(" ")}`);
|
||||
const result = await runChecked(args);
|
||||
console.log(`[workmux:add] result: ${result}`);
|
||||
|
||||
const windowTarget = `wm-${branch}`;
|
||||
|
||||
// Read worktree dir and log assigned ports
|
||||
const wtDirResult = Bun.spawnSync(
|
||||
["tmux", "display-message", "-t", `${windowTarget}.0`, "-p", "#{pane_current_path}"],
|
||||
{ stdout: "pipe" }
|
||||
);
|
||||
const wtDir = new TextDecoder().decode(wtDirResult.stdout).trim();
|
||||
const env = readEnvLocal(wtDir);
|
||||
console.log(`[workmux:add] branch=${branch} dir=${wtDir} ports: backend=${env.BACKEND_PORT || "8000"} frontend=${env.FRONTEND_PORT || "3000"}`);
|
||||
|
||||
// Append profile to .env.local (worktree-env creates it, we just add to it)
|
||||
if (wtDir) {
|
||||
const envPath = `${wtDir}/.env.local`;
|
||||
const existing = await Bun.file(envPath).text().catch(() => "");
|
||||
if (!existing.includes("PROFILE=")) {
|
||||
await Bun.write(envPath, existing.trimEnd() + `\nPROFILE=${profile}\nAGENT=${agent}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// For non-full profiles, kill extra panes and send commands
|
||||
if (profile !== "full") {
|
||||
// Kill extra panes (highest index first to avoid shifting)
|
||||
const paneCountResult = Bun.spawnSync(
|
||||
["tmux", "list-panes", "-t", windowTarget, "-F", "#{pane_index}"],
|
||||
{ stdout: "pipe" }
|
||||
);
|
||||
const paneIds = new TextDecoder().decode(paneCountResult.stdout).trim().split("\n");
|
||||
// Kill all panes except pane 0
|
||||
for (let i = paneIds.length - 1; i >= 1; i--) {
|
||||
Bun.spawnSync(["tmux", "kill-pane", "-t", `${windowTarget}.${paneIds[i]}`]);
|
||||
}
|
||||
// Build and send agent command for sandbox (env vars are inlined as a prefix)
|
||||
const agentCmd = buildSandboxAgentCmd(env, agent);
|
||||
console.log(`[workmux] sending command to ${windowTarget}.0:\n${agentCmd}`);
|
||||
Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, agentCmd, "Enter"]);
|
||||
// Open a shell pane on the right (1/3 width) in the worktree dir
|
||||
Bun.spawnSync(["tmux", "split-window", "-h", "-t", `${windowTarget}.0`, "-l", "25%", "-c", wtDir]);
|
||||
// Keep focus on the agent pane (left)
|
||||
Bun.spawnSync(["tmux", "select-pane", "-t", `${windowTarget}.0`]);
|
||||
|
||||
// Start socat port forwarding for sandbox containers (non-blocking).
|
||||
// The container takes a few seconds to start after the tmux command is sent,
|
||||
// so we poll in the background rather than blocking the API response.
|
||||
if (profile === "agent-yolo" && wtDir) {
|
||||
(async () => {
|
||||
console.log(`[socat] waiting for container to start for ${branch}...`);
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
if (await startForwarding(branch, wtDir)) return;
|
||||
console.log(`[socat] container not ready for ${branch}, retrying (${i}/15)...`);
|
||||
}
|
||||
console.error(`[socat] gave up waiting for container for ${branch} after 30s`);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function removeWorktree(name: string): Promise<string> {
|
||||
console.log(`[workmux:rm] running: workmux rm --force ${name}`);
|
||||
stopForwarding(name);
|
||||
const result = await runChecked(["workmux", "rm", "--force", name]);
|
||||
console.log(`[workmux:rm] result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function openWorktree(name: string): Promise<string> {
|
||||
return runChecked(["workmux", "open", name]);
|
||||
}
|
||||
|
||||
export async function mergeWorktree(name: string): Promise<string> {
|
||||
console.log(`[workmux:merge] running: workmux merge ${name}`);
|
||||
stopForwarding(name);
|
||||
const result = await runChecked(["workmux", "merge", name]);
|
||||
console.log(`[workmux:merge] result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getTmuxSession(): Promise<string> {
|
||||
try {
|
||||
const result = await $`tmux list-windows -a -F "#{session_name}:#{window_name}"`.text();
|
||||
for (const line of result.trim().split("\n")) {
|
||||
const [session, window] = line.split(":");
|
||||
if (window?.startsWith("wm-")) {
|
||||
return session!;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No tmux server running
|
||||
}
|
||||
return "0";
|
||||
}
|
||||
14
dev-dashboard/backend/tsconfig.json
Normal file
14
dev-dashboard/backend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
27
dev-dashboard/dev.sh
Executable file
27
dev-dashboard/dev.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Load env vars (R2 credentials, etc.) if present
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
kill $BE_PID $FE_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Backend (bun --watch)
|
||||
cd backend
|
||||
bun run dev 2>&1 | sed 's/^/[BE] /' &
|
||||
BE_PID=$!
|
||||
cd ..
|
||||
|
||||
# Frontend (vite dev)
|
||||
cd frontend
|
||||
bun run dev 2>&1 | sed 's/^/[FE] /' &
|
||||
FE_PID=$!
|
||||
cd ..
|
||||
|
||||
wait
|
||||
15
dev-dashboard/frontend/index.html
Normal file
15
dev-dashboard/frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>Windmill Dev Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
dev-dashboard/frontend/package.json
Normal file
23
dev-dashboard/frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "windmill-dev-dashboard-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
319
dev-dashboard/frontend/src/App.svelte
Normal file
319
dev-dashboard/frontend/src/App.svelte
Normal file
@@ -0,0 +1,319 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import WorktreeList from "./lib/WorktreeList.svelte";
|
||||
import TopBar from "./lib/TopBar.svelte";
|
||||
import Terminal from "./lib/Terminal.svelte";
|
||||
import ConfirmDialog from "./lib/ConfirmDialog.svelte";
|
||||
import CreateWorktreeDialog from "./lib/CreateWorktreeDialog.svelte";
|
||||
import SettingsDialog from "./lib/SettingsDialog.svelte";
|
||||
import PaneBar from "./lib/PaneBar.svelte";
|
||||
import type { WorktreeInfo } from "./lib/types";
|
||||
import type { Profile, Agent } from "./lib/api";
|
||||
import * as api from "./lib/api";
|
||||
|
||||
let worktrees = $state<WorktreeInfo[]>([]);
|
||||
let selectedBranch = $state<string | null>(null);
|
||||
let removeBranch = $state<string | null>(null);
|
||||
let mergeBranch = $state<string | null>(null);
|
||||
let merging = $state(false);
|
||||
let mergeError = $state("");
|
||||
let removingBranches = $state<Set<string>>(new Set());
|
||||
const SSH_STORAGE_KEY = "wt-ssh-host";
|
||||
let showCreateDialog = $state(false);
|
||||
let showSettingsDialog = $state(false);
|
||||
let creating = $state(false);
|
||||
let sshHost = $state(localStorage.getItem(SSH_STORAGE_KEY) ?? "");
|
||||
|
||||
// Mobile state
|
||||
let isMobile = $state(false);
|
||||
let sidebarOpen = $state(false);
|
||||
let activePane = $state(0);
|
||||
let terminalRef: { sendSelectPane: (pane: number) => void } | undefined = $state();
|
||||
|
||||
let visibleWorktrees = $derived(
|
||||
worktrees.filter((w) => w.path === "(here)" || w.branch === "main" || w.mux === "✓")
|
||||
);
|
||||
let selectedWorktree = $derived(visibleWorktrees.find((w) => w.branch === selectedBranch));
|
||||
let isMain = $derived(selectedWorktree?.path === "(here)" || selectedBranch === "main");
|
||||
let canConnect = $derived(!!selectedBranch && !isMain);
|
||||
|
||||
let paneBarProfile = $derived(
|
||||
selectedWorktree?.profile === "full" || selectedWorktree?.profile === "agent-yolo"
|
||||
? selectedWorktree.profile as "full" | "agent-yolo"
|
||||
: null
|
||||
);
|
||||
let showPaneBar = $derived(isMobile && canConnect && paneBarProfile !== null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
worktrees = await api.fetchWorktrees();
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function randomName(len: number): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Sanitize user input into a valid git branch name */
|
||||
function sanitizeBranchName(raw: string): string {
|
||||
return raw
|
||||
.replace(/\s+/g, "-") // spaces → dashes
|
||||
.replace(/[~^:?*\[\]\\]+/g, "") // remove git-invalid chars
|
||||
.replace(/\.{2,}/g, ".") // collapse ".." → "."
|
||||
.replace(/\/{2,}/g, "/") // collapse consecutive slashes
|
||||
.replace(/-{2,}/g, "-") // collapse consecutive dashes
|
||||
.replace(/^[.\-/]+|[.\-/]+$/g, "") // no leading/trailing . - /
|
||||
.replace(/\.lock$/i, ""); // no trailing .lock
|
||||
}
|
||||
|
||||
async function handleCreate(name: string, profile: Profile, agent: Agent) {
|
||||
const branch = (name && sanitizeBranchName(name)) || randomName(8);
|
||||
creating = true;
|
||||
try {
|
||||
await api.createWorktree(branch, profile, agent);
|
||||
await api.openWorktree(branch);
|
||||
showCreateDialog = false;
|
||||
await refresh();
|
||||
selectedBranch = branch;
|
||||
} catch (err) {
|
||||
alert(`Failed to create: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNeighborOf(branch: string) {
|
||||
if (selectedBranch !== branch) return;
|
||||
const idx = visibleWorktrees.findIndex((w) => w.branch === branch);
|
||||
const neighbor = visibleWorktrees[idx - 1] ?? visibleWorktrees[idx + 1];
|
||||
const isNeighborMain = neighbor && (neighbor.path === "(here)" || neighbor.branch === "main");
|
||||
selectedBranch = neighbor && !isNeighborMain ? neighbor.branch : null;
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
const branch = removeBranch;
|
||||
if (!branch) return;
|
||||
removeBranch = null;
|
||||
selectNeighborOf(branch);
|
||||
|
||||
removingBranches = new Set([...removingBranches, branch]);
|
||||
try {
|
||||
await api.removeWorktree(branch);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
alert(`Failed to remove: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
removingBranches = new Set([...removingBranches].filter((b) => b !== branch));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMerge() {
|
||||
const branch = mergeBranch;
|
||||
if (!branch) return;
|
||||
|
||||
merging = true;
|
||||
mergeError = "";
|
||||
try {
|
||||
await api.mergeWorktree(branch);
|
||||
mergeBranch = null;
|
||||
selectNeighborOf(branch);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
mergeError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
merging = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNeighborWorktree(direction: -1 | 1) {
|
||||
const selectable = visibleWorktrees.filter(
|
||||
(w) => w.path !== "(here)" && w.branch !== "main" && !removingBranches.has(w.branch)
|
||||
);
|
||||
if (selectable.length === 0) return;
|
||||
if (!selectedBranch) {
|
||||
selectedBranch = selectable[direction === 1 ? 0 : selectable.length - 1].branch;
|
||||
return;
|
||||
}
|
||||
const idx = selectable.findIndex((w) => w.branch === selectedBranch);
|
||||
const next = idx + direction;
|
||||
if (next >= 0 && next < selectable.length) {
|
||||
selectedBranch = selectable[next].branch;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ignore shortcuts when a dialog is open (let dialog handle its own keys)
|
||||
if (showCreateDialog || removeBranch || mergeBranch) return;
|
||||
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (!mod) return;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
selectNeighborWorktree(-1);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
selectNeighborWorktree(1);
|
||||
} else if (e.key === "k" || e.key === "K") {
|
||||
e.preventDefault();
|
||||
if (!creating) showCreateDialog = true;
|
||||
} else if (e.key === "m" || e.key === "M") {
|
||||
e.preventDefault();
|
||||
if (selectedBranch && !isMain) mergeBranch = selectedBranch;
|
||||
} else if (e.key === "d" || e.key === "D") {
|
||||
e.preventDefault();
|
||||
if (selectedBranch && !isMain) removeBranch = selectedBranch;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaneSelect(pane: number) {
|
||||
activePane = pane;
|
||||
terminalRef?.sendSelectPane(pane);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
const interval = setInterval(refresh, 5000);
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
|
||||
const mq = window.matchMedia("(max-width: 768px)");
|
||||
isMobile = mq.matches;
|
||||
function onMqChange(e: MediaQueryListEvent) { isMobile = e.matches; }
|
||||
mq.addEventListener("change", onMqChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
mq.removeEventListener("change", onMqChange);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen bg-surface text-primary">
|
||||
<!-- Sidebar: fixed overlay on mobile, static on desktop -->
|
||||
{#if !isMobile || sidebarOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
{#if isMobile}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
onkeydown={(e) => { if (e.key === "Escape") sidebarOpen = false; }}
|
||||
></div>
|
||||
{/if}
|
||||
<aside class="{isMobile ? 'fixed inset-0 z-50 w-full' : 'w-[220px] min-w-[220px]'} bg-sidebar border-r border-edge flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 border-b border-edge">
|
||||
<h1 class="text-base font-semibold">Windmill</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="h-8 px-2 gap-1.5 rounded-md border border-edge bg-surface text-accent text-xs flex items-center justify-center cursor-pointer hover:bg-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={() => (showCreateDialog = true)}
|
||||
disabled={creating}
|
||||
title="New Worktree (Cmd+K)"
|
||||
><span class="text-lg leading-none">+</span> New</button>
|
||||
{#if isMobile}
|
||||
<button
|
||||
class="h-8 w-8 rounded-md border border-edge bg-surface text-muted text-sm flex items-center justify-center cursor-pointer hover:bg-hover"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
title="Close sidebar"
|
||||
>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<WorktreeList
|
||||
worktrees={visibleWorktrees}
|
||||
selected={selectedBranch}
|
||||
removing={removingBranches}
|
||||
onselect={(b) => { selectedBranch = b; if (isMobile) sidebarOpen = false; }}
|
||||
onremove={(b) => (removeBranch = b)}
|
||||
/>
|
||||
{#if !isMobile}
|
||||
<div class="shrink-0 border-t border-edge px-4 py-3 text-[11px] text-muted flex flex-col gap-1">
|
||||
<div class="flex justify-between"><span>Navigate</span><kbd class="opacity-60">Cmd+Up/Down</kbd></div>
|
||||
<div class="flex justify-between"><span>New worktree</span><kbd class="opacity-60">Cmd+K</kbd></div>
|
||||
<div class="flex justify-between"><span>Merge</span><kbd class="opacity-60">Cmd+M</kbd></div>
|
||||
<div class="flex justify-between"><span>Remove</span><kbd class="opacity-60">Cmd+D</kbd></div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<main class="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<TopBar
|
||||
name={selectedBranch}
|
||||
worktree={selectedWorktree}
|
||||
{sshHost}
|
||||
{isMobile}
|
||||
ontogglesidebar={() => (sidebarOpen = !sidebarOpen)}
|
||||
onmerge={() => { if (selectedBranch) mergeBranch = selectedBranch; }}
|
||||
onremove={() => { if (selectedBranch) removeBranch = selectedBranch; }}
|
||||
onsettings={() => (showSettingsDialog = true)}
|
||||
/>
|
||||
|
||||
{#if canConnect}
|
||||
{#key selectedBranch}
|
||||
<Terminal
|
||||
worktree={selectedBranch!}
|
||||
{isMobile}
|
||||
initialPane={isMobile ? activePane : undefined}
|
||||
bind:this={terminalRef}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center text-muted text-sm">
|
||||
<p>
|
||||
{#if isMain}
|
||||
Main worktree — use workmux to manage
|
||||
{:else}
|
||||
Select a worktree from the sidebar to connect
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPaneBar}
|
||||
<PaneBar {activePane} profile={paneBarProfile!} onselect={handlePaneSelect} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{#if showCreateDialog}
|
||||
<CreateWorktreeDialog
|
||||
loading={creating}
|
||||
oncreate={handleCreate}
|
||||
oncancel={() => (showCreateDialog = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if removeBranch}
|
||||
<ConfirmDialog
|
||||
message={`Remove worktree "${removeBranch}"? This action cannot be undone.`}
|
||||
onconfirm={handleRemove}
|
||||
oncancel={() => (removeBranch = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if mergeBranch}
|
||||
<ConfirmDialog
|
||||
message={`Merge worktree "${mergeBranch}" into main? The worktree will be removed after merging.`}
|
||||
confirmLabel="Merge"
|
||||
variant="accent"
|
||||
loading={merging}
|
||||
error={mergeError}
|
||||
onconfirm={handleMerge}
|
||||
oncancel={() => { mergeBranch = null; mergeError = ""; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showSettingsDialog}
|
||||
<SettingsDialog
|
||||
onsave={(host) => { sshHost = host; showSettingsDialog = false; }}
|
||||
onclose={() => (showSettingsDialog = false)}
|
||||
/>
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user