Compare commits

...
29 Commits
Author SHA1 Message Date
Ruben Fiszelandrubenfiszel b59d60378c chore(main): release 1.643.0 (#8053)
* chore(main): release 1.643.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-02-24 08:33:56 +00:00
Ruben Fiszel 8869fde737 ci improvement 2026-02-24 08:33:42 +00:00
Ruben FiszelandClaude Opus 4.5 90a6db72a2 disable Reset Code and Apply Changes buttons when no YAML changes (#8067)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-24 08:12:19 +00:00
Ruben FiszelandClaude Opus 4.5 3aba0ed250 fix: use correct column name completed_at instead of ended_at in count_completed_jobs_detail (#8066)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-24 08:03:52 +00:00
centdix 207dcdb4f7 internal: workmux config (#8065)
* config

* nit

* add wmdev config

* remove playwright mcp

* add asciicinema
2026-02-24 07:09:49 +00:00
Ruben Fiszel b97216cf37 adapt hub pull to is_fileset from hub 2026-02-24 06:58:20 +00:00
Ruben Fiszel b3ac0249de Merge main into fileset-resource-type 2026-02-24 06:43:32 +00:00
Ruben FiszelandClaude Opus 4.6 9ac07897cf fix: fileset editor takes full height with matching header
Set fileset editor container to h-[60vh] so both navbar and editor
fill available space. Match editor filename bar height to navbar header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 06:42:07 +00:00
Ruben FiszelandClaude Opus 4.6 c15b9abe5e feat: add fileset resource type support (#8063)
* feat: add fileset resource type support

Add a new "fileset" resource type that represents a collection of files
stored as a relpath→content map. This enables resource types to manage
multiple files (e.g., config directories, template sets) instead of just
a single file.

Backend:
- Add is_fileset column to resource_type table
- Update CRUD operations and workspace duplication to handle is_fileset
- Add integration tests for fileset resource types

Frontend:
- Add FilesetEditor component with file explorer + Monaco editor
- Extract shared FileExplorer component from RawAppSidebar (dedup)
- Add fileset toggle to EditableSchemaWrapper
- Show fileset editor in ResourceEditor and ApiConnectForm
- Show folder icon for fileset resource types in IconedResourceType

CLI:
- Support fileset resources in sync pull (expand to .fileset/ directory)
- Support fileset resources in sync push (reconstruct from directory)
- Handle !inline_fileset YAML tag in resource resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* sqlx

* fix: resolve svelte warnings and type error in fileset components

- Fix state_referenced_locally warnings in FilesetEditor by computing
  initial values before creating $state
- Fix Promise<boolean> type error in +page.svelte by making
  resourceNameIsFileset/resourceNameToFileExt synchronous lookups
  with eager map loading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings for fileset feature

- Use sqlb.set() instead of set_str() for boolean is_fileset field
  to avoid quoting (SET is_fileset = TRUE not 'TRUE')
- Add JSDoc comment to isFilesetResource explaining it matches
  children inside .fileset/ directories, not the directory itself
- Update OpenAPI spec for file_resource_type_to_file_ext_map endpoint
  to document the new response schema with format_extension and
  is_fileset fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address second round of review findings

- Remove bidirectional $effect sync in RawAppSidebar; bind FileExplorer
  directly to files prop with {} default
- Avoid creating new files object on every keystroke in FilesetEditor;
  merge editContent → args in a single effect without intermediate spread
- Simplify no-op `?? undefined` in addResourceType
- Add backend validation: reject create_resource_type when both
  is_fileset and format_extension are set
- Fix fileset alert title showing undefined format extension

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: exclude app_theme resources from workspace tab

Theme resources (app_theme) were showing on the workspace tab alongside
regular resources. Now they are excluded from the workspace tab
(like cache and state) and the theme tab loads only app_theme resources.

Also includes review fixes:
- Remove bidirectional $effect sync in RawAppSidebar
- Avoid spreading new files object on every keystroke in FilesetEditor
- Simplify ?? undefined no-op
- Add backend validation for is_fileset + format_extension conflict
- Fix fileset alert title

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: restore full-width file tree items in raw app sidebar

FileExplorer's tree container was missing w-full, causing items to not
stretch inside PanelSection's items-start flex container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent iframe from overriding file selection after file creation

When files change in the sidebar, setFilesInIframe sends the new files
to the iframe which responds with setActiveDocument defaulting to
App.tsx, overriding the user's selection. Now we ignore setActiveDocument
messages for 500ms after sending setFiles to the iframe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert "fix: prevent iframe from overriding file selection after file creation"

This reverts commit 7f3ddd7edd.

* fix: suppress iframe setActiveDocument during file population

Use setFilesAndSelectInIframe in populateFiles to keep the current
document selected when re-sending files. Suppress setActiveDocument
for 500ms after population to prevent the iframe from defaulting
back to App.tsx on focus changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 06:37:55 +00:00
Ruben FiszelandClaude Opus 4.6 1abfeea81a fix: suppress iframe setActiveDocument during file population
Use setFilesAndSelectInIframe in populateFiles to keep the current
document selected when re-sending files. Suppress setActiveDocument
for 500ms after population to prevent the iframe from defaulting
back to App.tsx on focus changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 06:26:28 +00:00
Ruben Fiszel 97c163bb33 Revert "fix: prevent iframe from overriding file selection after file creation"
This reverts commit 7f3ddd7edd.
2026-02-23 23:07:51 +00:00
Ruben FiszelandClaude Opus 4.6 7f3ddd7edd fix: prevent iframe from overriding file selection after file creation
When files change in the sidebar, setFilesInIframe sends the new files
to the iframe which responds with setActiveDocument defaulting to
App.tsx, overriding the user's selection. Now we ignore setActiveDocument
messages for 500ms after sending setFiles to the iframe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:54:03 +00:00
Ruben FiszelandClaude Opus 4.6 5bac8b093d fix: restore full-width file tree items in raw app sidebar
FileExplorer's tree container was missing w-full, causing items to not
stretch inside PanelSection's items-start flex container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:50:35 +00:00
Ruben FiszelandClaude Opus 4.6 9c513b2c62 fix: exclude app_theme resources from workspace tab
Theme resources (app_theme) were showing on the workspace tab alongside
regular resources. Now they are excluded from the workspace tab
(like cache and state) and the theme tab loads only app_theme resources.

Also includes review fixes:
- Remove bidirectional $effect sync in RawAppSidebar
- Avoid spreading new files object on every keystroke in FilesetEditor
- Simplify ?? undefined no-op
- Add backend validation for is_fileset + format_extension conflict
- Fix fileset alert title

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:49:24 +00:00
Ruben FiszelandClaude Opus 4.6 753c05a030 fix: address second round of review findings
- Remove bidirectional $effect sync in RawAppSidebar; bind FileExplorer
  directly to files prop with {} default
- Avoid creating new files object on every keystroke in FilesetEditor;
  merge editContent → args in a single effect without intermediate spread
- Simplify no-op `?? undefined` in addResourceType
- Add backend validation: reject create_resource_type when both
  is_fileset and format_extension are set
- Fix fileset alert title showing undefined format extension

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:32:25 +00:00
Ruben FiszelandClaude Opus 4.6 1b4489acac fix: address code review findings for fileset feature
- Use sqlb.set() instead of set_str() for boolean is_fileset field
  to avoid quoting (SET is_fileset = TRUE not 'TRUE')
- Add JSDoc comment to isFilesetResource explaining it matches
  children inside .fileset/ directories, not the directory itself
- Update OpenAPI spec for file_resource_type_to_file_ext_map endpoint
  to document the new response schema with format_extension and
  is_fileset fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:52:19 +00:00
Diego Imbert 302fea683c Load more button (#8064) 2026-02-23 21:50:05 +00:00
Ruben FiszelandClaude Opus 4.6 4c06d74bd0 fix: resolve svelte warnings and type error in fileset components
- Fix state_referenced_locally warnings in FilesetEditor by computing
  initial values before creating $state
- Fix Promise<boolean> type error in +page.svelte by making
  resourceNameIsFileset/resourceNameToFileExt synchronous lookups
  with eager map loading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:25:06 +00:00
Ruben Fiszel 680cac7084 Merge branch 'main' into fileset-resource-type 2026-02-23 19:18:32 +00:00
Ruben Fiszel cee3198c9b sqlx 2026-02-23 19:16:36 +00:00
Diego Imbertandclaude[bot] 9b28c85469 feat: Unified filters and new runs page (#8027)
* RunsPage redesign v0

* nit

* Remove manualdatepicker

* remove shadow

* ui nits

* nit scrollbar bg

* prettier cards

* nit

* Remove code

* command/meta multi select

* Shift select

* RightClickPopover

* nit

* Ctrl A

* nit card

* DropdownMenu

* nit

* count hint

* fix stuck keys

* opacity UX

* error toasts pickhubscript

* Improve UX

* fix undefined error

* keyboard nav

* nit batch rerun fixes

* nit fix scroll / height

* Batch reruns actions + nits

* nit

* Cancel selected jobs

* Cancel / re-run all filtered jobs

* Go to job / flow / script action

* nit

* add batch actions back

* nit

* nit

* bar on splitpane hover

* nit

* New Timeframe system

* reset btn

* nit fixes

* dead code

* nits

* typecheck

* naming clarity

* Update frontend/src/lib/components/RightClickPopover.svelte

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* unnecessary json stringify

* dedup 'the'

* Code deletion to prepare for changes

* filter types

* ui

* fix bug with maxTs

* stuck with melt

* GenericDropdown

* filters onclick

* iterate

* iter

* add all filters

* Descriptions

* focus position

* stash

* TaggedTextInput works much much better

* placeholder

* currentTag suggestion

* improve

* nit

* Keyboard nav

* buildRunsFilterSearchbarSchema

* nit naming

* assignObjInPlace

* Escaping + pretty dates

* nit empty

* fix cursor

* nit space

* Filter filtering

* escape pasted value

* nit

* escape spaces

* nit undefined

* add space at end if right arrow

* escape all spaces

* arrow skips escape chars

* escape \ too

* delete whole escaped characters

* double space to escape tag

* code refactor

* Ensure cursor visible

* fix keyboard nav

* safety

* filterSchemaRecToZodSchema

* URL Sync

* fix readonly

* fix typing

* start replacing old filter logic

* use new filter impl

* nit

* nit reactivity

* nit fix

* no more localStorage

* Add back status and kind toggles

* Nit fix

* style nit

* focus at end on click

* clearn btn + fixes

* fix broken date uri

* nit

* useSyncedTimeframe

* negative filter button

* negative filters helpers rust

* Negated filters backed

* nit

* highlight

* New useSearchParams

* Accept comma separated list

* nit allowNegative

* openapi update

* Fix trigger kind list/negation not working

* nit oipenpai

* Presets

* DebouncedTempValue

* remove presets from list when already applied

* UI nit improvements

* allowMultiple

* hint

* validateFilterInstance fn

* nit fix

* error highlights

* nit ux selecting negative list

* nit

* on clear btn

* SimpleEditor for JSON

* nit

* flop

* Pass presets as param

* nit delete

* preventCursorMoveOnNextSync

* responsive layout

* Escape \n

* Inline calendar input

* mm/dd or dd/mm depending on US or not

* onClickBehavior

* infiniteRange

* other nits

* Wiring with runs filter

* formatDateRange better

* inits on right page

* style

* min hour support

* Time input

* use our components

* Improve SKILL.md

* dd mm yyyy numeric input

* TimeframeSelect with new date picker

* fixes

* ensure date is in view when value changes externally

* fixes

* nit select all on focus

* select year + nits

* nit layout shift

* nit negative when starting with !

* nit

* SelectDropdown uses GenericDropdown now

* Fix blank select dropdown rendering bug

* icons

* Reset btn + shorter date range formatting

* overflow fix

* unnecessary absolute

* fix clear btn overlap

* Update routes for new filters (assets, schedule, resource, variables)

* update openapi

* Impl for other pages

* ui nits

* nit fixes

* Fix columns filter

* super nits

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-23 17:53:09 +00:00
Ruben FiszelandClaude Opus 4.6 32c4b474f9 feat: add fileset resource type support
Add a new "fileset" resource type that represents a collection of files
stored as a relpath→content map. This enables resource types to manage
multiple files (e.g., config directories, template sets) instead of just
a single file.

Backend:
- Add is_fileset column to resource_type table
- Update CRUD operations and workspace duplication to handle is_fileset
- Add integration tests for fileset resource types

Frontend:
- Add FilesetEditor component with file explorer + Monaco editor
- Extract shared FileExplorer component from RawAppSidebar (dedup)
- Add fileset toggle to EditableSchemaWrapper
- Show fileset editor in ResourceEditor and ApiConnectForm
- Show folder icon for fileset resource types in IconedResourceType

CLI:
- Support fileset resources in sync pull (expand to .fileset/ directory)
- Support fileset resources in sync push (reconstruct from directory)
- Handle !inline_fileset YAML tag in resource resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:50:37 +00:00
GuilhemandClaude Opus 4.6 6ba0da3ee5 truncate long summary and path in SummaryPathDisplay (#8062)
* fix: truncate long summary and path in SummaryPathDisplay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: allow summary to shrink in flow editor top bar

Add min-w-0 to the SummaryPathDisplay wrapper in FlowBuilder so flex
shrinking works. Remove max-w cap on the wrapper so the summary can use
all available space. Remove w-full and max-w-md from the empty middle
schedule div that was stealing space even when empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 17:42:50 +00:00
centdixandClaude Opus 4.6 de6fd160d5 feat(aiagent): handle ai agent as tool (#8031)
* worker: support AIAgent tools in AI executor

* worker: complete nested AIAgent tool execution path

* worker: inline AIAgent tool schema usage

* fix agent action

* frontend: add AI Agent as tool type in flow builder

Add the ability to insert a nested AI Agent as a tool within another
AI Agent step. Includes type definitions, factory function, graph icon,
insert/event wiring, and a dedicated editor component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove AiAgentToolEditor, reuse FlowModuleComponent for AI agent tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: populate all input transforms for nested AI agent tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: avoid missing v2_job_status error for nested AI agent tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* sqlx

* nit

* refactor: cleanup nested AI agent tool implementation

- Add max nesting depth guard (5) on parent chain traversal
- Reject 3+ level nesting explicitly with clear error message
- Remove unnecessary flow_step_id tuple scaffolding in tool dispatch
- Consolidate get_value() calls using borrow in first match
- Replace unsafe `as unknown as FlowModule` casts with agentToolToFlowModule()
- Simplify toolKind ternary chain with .includes() lookup
- Fix leftover over-indentation from tuple removal
- Remove duplicate doc comment on is_completed_input_transform

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: pass flow_step_id and flow_job_id overrides to run_agent for nested AI agents

For nested AI agent tools, job.flow_step_id is None and job.parent_job
points to the parent agent instead of the flow. This caused memory
read/write and flow context resolution to silently fail.

handle_ai_agent_job already computes the correct flow_step_id (via
runnable_path fallback) and flow_job_id (via parent chain traversal).
This change threads those values through run_agent and
ToolExecutionContext so all downstream consumers use the correct IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* cleaning

* cleaning

* move const

* fix

* refactor: replace defaultToAi boolean with allowedAiTransforms whitelist

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: propagate root_job at push time, remove flow_job_id_override

Instead of threading flow_job_id_override through run_agent and
get_flow_context, propagate root_job and flow_innermost_root_job
when pushing tool jobs so nested AI agents can find the flow
job naturally via the existing job fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: simplify nested AI agent parent chain walk-up

Replace the generic depth-limited loop with a single-level check since
only flow → agent → nested agent tool is supported. Remove
MAX_AGENT_NESTING_DEPTH constant and flatten the module lookup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: reject 3+ level nested AI agent tools before job creation

Check at the parent agent level whether a nested AIAgent tool contains
AIAgent sub-tools. If so, return a fatal error immediately, preventing
the sub-job from being created and avoiding retry loops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve deadlock in nested AI agent tool execution

Replace channel forwarding with inline DB writes for tool job
completions. Nested agents used bounded(1) channels where a sub-tool's
forwarded result would fill the parent channel, leaving no room for the
agent's own completion — causing a deadlock. Writing directly via
add_completed_job/add_completed_job_error bypasses the channel entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:59:39 +00:00
hugocasa 705e186f3d fix: update git sync init script to hub version 28158 (#8061) 2026-02-23 15:47:02 +00:00
GuilhemandClaude Opus 4.6 0935bf9fc4 feat: add light mode for navigation sidebar (#8057)
* feat: add light mode support for navigation sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: use custom selected background for sidebar items

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: darken sidebar section separators in light mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: lighten sidebar bg to #F3F3F7 and use border-light for separators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use text-secondary for sidebar labels and text-hint for icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: extract dark sidebar background color to constant

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:25:46 +00:00
HugoCasa 26270d8cd1 port forward nit workmux cursor 2026-02-23 16:23:37 +01:00
hugocasaandClaude Opus 4.6 9a7a0135f7 Cursor SSH remote integration for workmux worktrees (#8060)
Add wm-cursor (wmc) script that bridges workmux with Cursor SSH remote,
giving each worktree its own Cursor window with an independently-focused
grouped tmux session.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 14:46:32 +00:00
GuilhemandClaude Opus 4.6 0604600b8b autofocus summary input when opening SummaryPathDisplay popover (#8052)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:48:34 +00:00
163 changed files with 8153 additions and 4158 deletions
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
# Resolve _ee.rs symlinks to actual files so Claude can read them
# This script runs before each user prompt is processed
set -e
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
# Find all _ee.rs symlinks and store their targets
find "$PROJECT_DIR" -name "*_ee.rs" -type l 2>/dev/null | while read -r symlink; do
target=$(readlink -f "$symlink" 2>/dev/null) || continue
# Only process if target file exists
if [[ -f "$target" ]]; then
# Store symlink path and target in manifest
echo "$symlink|$target" >> "$MANIFEST_FILE.tmp"
# Replace symlink with actual file content
rm "$symlink"
cp "$target" "$symlink"
fi
done
# Atomically replace manifest
if [[ -f "$MANIFEST_FILE.tmp" ]]; then
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
fi
exit 0
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Restore _ee.rs symlinks after Claude finishes processing
# This script runs when Claude stops
# IMPORTANT: Copies any modifications back to the target before restoring symlinks
set -e
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
# Check if manifest exists
if [[ ! -f "$MANIFEST_FILE" ]]; then
exit 0
fi
# Read manifest and restore symlinks
while IFS='|' read -r symlink target; do
if [[ -n "$symlink" && -n "$target" ]]; then
# If the file exists (not a symlink) and target exists, copy changes back
if [[ -f "$symlink" && ! -L "$symlink" && -e "$target" ]]; then
# Copy the potentially modified file back to the target
cp "$symlink" "$target"
fi
# Remove the regular file (which was a copy)
rm -f "$symlink" 2>/dev/null || true
# Recreate the symlink
ln -s "$target" "$symlink" 2>/dev/null || true
fi
done < "$MANIFEST_FILE"
# Clean up manifest
rm -f "$MANIFEST_FILE"
exit 0
+4 -34
View File
@@ -1,5 +1,8 @@
{
"permissions": {
"additionalDirectories": [
"../windmill-ee-private"
],
"allow": [
"Bash(ls:*)",
"Bash(grep:*)",
@@ -63,39 +66,6 @@
},
"enableAllProjectMcpServers": true,
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/resolve-symlinks.sh",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
"timeout": 30
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
@@ -130,4 +100,4 @@
"typescript-lsp@claude-plugins-official": true,
"code-review@claude-plugins-official": true
}
}
}
+90 -1
View File
@@ -226,4 +226,93 @@ When generating Svelte 5 code, prioritize frontend performance by applying the f
Hello
</div>
```
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
## Windmill UI Component Rules (MUST follow)
Always use Windmill's own design-system components instead of raw HTML elements. Using raw HTML elements produces inconsistent styling and breaks the design language.
### Icons — use `lucide-svelte`
**Never** write inline SVGs. Import icons from `lucide-svelte`.
```svelte
<script>
import { ChevronLeft, ChevronRight, X } from 'lucide-svelte'
</script>
<ChevronLeft size={16} />
```
### Buttons — use `<Button>`
**Never** use `<button>`. Import and use `Button` from `$lib/components/common`.
```svelte
<script>
import { Button } from '$lib/components/common'
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
</script>
<!-- Regular button -->
<Button variant="default" onclick={handleClick}>Label</Button>
<!-- Icon-only button (no label) -->
<Button startIcon={{ icon: ChevronLeft }} iconOnly onclick={prevMonth} />
<Button startIcon={{ icon: ChevronRight }} iconOnly onclick={nextMonth} />
```
Key `Button` props:
- `variant?: 'accent' | 'accent-secondary' | 'default' | 'subtle'`
- `unifiedSize?: 'sm' | 'md' | 'lg'`
- `startIcon?: { icon: SvelteComponent }` — renders an icon before the label
- `iconOnly?: boolean` — renders icon with no surrounding label text
- `disabled?: boolean`
### Text inputs — use `<TextInput>`
**Never** use `<input>`. Import and use `TextInput` from `$lib/components/common`.
```svelte
<script>
import { TextInput } from '$lib/components/common'
let val = $state('')
</script>
<TextInput bind:value={val} placeholder="Enter value" />
```
Key `TextInput` props:
- `value?: string | number` (bindable)
- `placeholder?: string`
- `disabled?: boolean`
- `error?: string | boolean`
- `size?: 'sm' | 'md' | 'lg'`
- `inputProps?` — forwarded to the underlying `<input>`
### Selects — use `<Select>`
**Never** use `<select>`. Import and use `Select` from `$lib/components/select/Select.svelte`.
```svelte
<script>
import Select from '$lib/components/select/Select.svelte'
const monthItems = [
{ label: 'January', value: 1 },
{ label: 'February', value: 2 },
// ...
]
let selectedMonth = $state(1)
</script>
<Select items={monthItems} bind:value={selectedMonth} />
```
Key `Select` props:
- `items?: Array<{ label?: string; value: any; subtitle?: string; disabled?: boolean }>`
- `value` (bindable) — the currently selected `.value`
- `placeholder?: string`
- `clearable?: boolean`
- `disabled?: boolean`
- `size?: 'sm' | 'md' | 'lg'`
+3 -18
View File
@@ -19,7 +19,7 @@ defaults:
jobs:
cargo_test:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubicloud-standard-16
services:
postgres:
image: postgres
@@ -86,22 +86,8 @@ jobs:
working-directory: /
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
cache-workspaces: backend
toolchain: 1.93.0
- name: Cache cargo target directory
uses: useblacksmith/stickydisk@v1
with:
key: cargo-target
path: ./backend/target
- name: Cache cargo registry
uses: useblacksmith/cache@v1
with:
path: |
~/.cargo/registry
~/.cargo/git
key: cargo-registry-${{ hashFiles('backend/Cargo.lock') }}
restore-keys: |
cargo-registry-
- name: Read EE repo commit hash
run: |
echo "ee_repo_ref=$(cat ./ee-repo-ref.txt)" >> "$GITHUB_ENV"
@@ -229,7 +215,7 @@ jobs:
fi
echo "Verified: Package requires authentication for @windmill-test/private-pkg"
- name: Cache DuckDB FFI module build
uses: useblacksmith/cache@v1
uses: actions/cache@v3
with:
path: ./backend/windmill-duckdb-ffi-internal/target
key: ${{ runner.os }}-duckdb-ffi-${{ hashFiles('./backend/windmill-duckdb-ffi-internal/src/**/*.rs', './backend/windmill-duckdb-ffi-internal/Cargo.toml', './backend/windmill-duckdb-ffi-internal/Cargo.lock') }}
@@ -245,7 +231,6 @@ jobs:
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
CARGO_INCREMENTAL: 1
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
+3
View File
@@ -17,6 +17,9 @@ rust-client/Cargo.toml
# Worktree-generated port isolation
.env.local
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
.claude/settings.local.json
# Symlinked cache directories (for git worktrees)
backend/target
frontend/node_modules
-4
View File
@@ -3,10 +3,6 @@
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
+66
View File
@@ -0,0 +1,66 @@
services:
- name: BE
portEnv: BACKEND_PORT
- name: FE
portEnv: FRONTEND_PORT
profiles:
default:
name: default
sandbox:
name: sandbox
systemPrompt: >
You are running inside a sandboxed container with full permissions.
This worktree is configured with the following ports:
- Backend: port ${BACKEND_PORT}.
Start with: cd backend && PORT=${BACKEND_PORT}
DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill
cargo watch -x run
- Frontend: port ${FRONTEND_PORT}.
Start with: cd frontend && REMOTE=http://localhost:${BACKEND_PORT}
npm run dev -- --port ${FRONTEND_PORT} --host 0.0.0.0
--- Screenshots ---
You can take screenshots of the frontend UI and upload them to R2
for use in PR descriptions.
1) Take a screenshot:
bunx playwright screenshot --browser chromium
http://localhost:${FRONTEND_PORT}/path/to/page /tmp/screenshot.png
2) Upload to R2:
aws s3 cp /tmp/screenshot.png
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/screenshot.png"
--endpoint-url "$(printenv R2_ENDPOINT)"
3) The public URL will be:
$(printenv R2_PUBLIC_URL)/<branch>/screenshot.png
4) Include screenshots in PR descriptions as markdown images:
![description]($(printenv R2_PUBLIC_URL)/<branch>/screenshot.png)
--- Terminal Recordings (asciinema) ---
You can record terminal sessions and upload them for sharing.
asciinema is pre-installed at /usr/local/bin/asciinema.
1) Write a shell script with the commands to demo. Add sleep
delays for readable pacing:
- 0.5s after printing a "$ command" line (lets viewer read it)
- 1.5-2s after command output (lets viewer absorb the result)
- Set GIT_PAGER=cat and PAGER=cat to prevent pager hangs
2) Record headlessly:
asciinema rec --headless --overwrite \
-c "bash /tmp/demo.sh" \
--window-size 120x50 \
--title "Description of demo" \
/tmp/demo.cast
3) Upload to asciinema.org:
XDG_DATA_HOME=/tmp/.local/share \
asciinema upload --server-url https://asciinema.org /tmp/demo.cast
envPassthrough:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- R2_ENDPOINT
- R2_BUCKET
- R2_PUBLIC_URL
+14 -2
View File
@@ -46,11 +46,20 @@ pre_remove:
- ./scripts/worktree-cleanup
panes:
- command: <agent>
- command: >-
claude --append-system-prompt
"You are running inside a tmux session with other panes running services.\n
Pane layout (current window):\n
- Pane 0: this pane (claude agent)\n
- Pane 1: backend (cargo watch -x run)\n
- Pane 2: frontend (npm run dev)\n\n
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).\n
When restarting backend or frontend, make sure to use the ports listed in .env.local.\n
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check."
focus: true
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/backend" && PORT=${BACKEND_PORT:-8000} cargo watch -x run'
split: horizontal
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm install && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000}'
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm install && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0'
split: vertical
files:
@@ -61,3 +70,6 @@ files:
sandbox:
enabled: false
toolchain: off
# image, host_commands, and extra_mounts configured in global
# ~/.config/workmux/config.yaml — see README_WORKMUX_DEV.md for required
# extra_mounts (windmill-ee-private access in sandbox)
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## [1.643.0](https://github.com/windmill-labs/windmill/compare/v1.642.0...v1.643.0) (2026-02-24)
### Features
* add fileset resource type support ([32c4b47](https://github.com/windmill-labs/windmill/commit/32c4b474f92f3dbbd2077fab70bdf9e407581626))
* add fileset resource type support ([#8063](https://github.com/windmill-labs/windmill/issues/8063)) ([c15b9ab](https://github.com/windmill-labs/windmill/commit/c15b9abe5eb2a1566a7ce4b18784c961d178a669))
* add light mode for navigation sidebar ([#8057](https://github.com/windmill-labs/windmill/issues/8057)) ([0935bf9](https://github.com/windmill-labs/windmill/commit/0935bf9fc460c03c6d8469b93036e43714517ef2))
* **aiagent:** handle ai agent as tool ([#8031](https://github.com/windmill-labs/windmill/issues/8031)) ([de6fd16](https://github.com/windmill-labs/windmill/commit/de6fd160d56c1037adbbe785f195483c25982e1c))
* Unified filters and new runs page ([#8027](https://github.com/windmill-labs/windmill/issues/8027)) ([9b28c85](https://github.com/windmill-labs/windmill/commit/9b28c85469d6b2a8590810b313b030d9f00ee9e3))
### Bug Fixes
* address code review findings for fileset feature ([1b4489a](https://github.com/windmill-labs/windmill/commit/1b4489acac3b050f0a783548bacfc9bdf33ee593))
* address second round of review findings ([753c05a](https://github.com/windmill-labs/windmill/commit/753c05a03089b95b4ade68d3bf61c8818de422ce))
* **backend:** decimal between 0 and -1 in mssql ([#8051](https://github.com/windmill-labs/windmill/issues/8051)) ([9686608](https://github.com/windmill-labs/windmill/commit/9686608355615a50c8395f6e2fd51dcc25498226))
* **backend:** use filename instead of content_type to detect file fields in multipart form data ([#8054](https://github.com/windmill-labs/windmill/issues/8054)) ([0aa885d](https://github.com/windmill-labs/windmill/commit/0aa885db67d77202205fc1609e841b8ffd9a8121))
* exclude app_theme resources from workspace tab ([9c513b2](https://github.com/windmill-labs/windmill/commit/9c513b2c62acc369179fb9e404e1f4007cd854c6))
* fileset editor takes full height with matching header ([9ac0789](https://github.com/windmill-labs/windmill/commit/9ac07897cf99f3af27801e435c7376a46ef760c9))
* prevent iframe from overriding file selection after file creation ([7f3ddd7](https://github.com/windmill-labs/windmill/commit/7f3ddd7edd3ea993642aadd55cdba0ac2ea1eb9f))
* resolve svelte warnings and type error in fileset components ([4c06d74](https://github.com/windmill-labs/windmill/commit/4c06d74bd01ca2dda848be421d70dd5268520992))
* restore full-width file tree items in raw app sidebar ([5bac8b0](https://github.com/windmill-labs/windmill/commit/5bac8b093dbe913a563b02573959c64dd405ff61))
* suppress iframe setActiveDocument during file population ([1abfeea](https://github.com/windmill-labs/windmill/commit/1abfeea81a645c59934d62257ad869ed7b475634))
* update git sync init script to hub version 28158 ([#8061](https://github.com/windmill-labs/windmill/issues/8061)) ([705e186](https://github.com/windmill-labs/windmill/commit/705e186f3d4c7d8f8a88fc84b379ed9fe800a6b2))
* use correct column name completed_at instead of ended_at in count_completed_jobs_detail ([#8066](https://github.com/windmill-labs/windmill/issues/8066)) ([3aba0ed](https://github.com/windmill-labs/windmill/commit/3aba0ed2508debdc78a6631e49b074a97635f21d))
## [1.642.0](https://github.com/windmill-labs/windmill/compare/v1.641.0...v1.642.0) (2026-02-22)
+238
View File
@@ -0,0 +1,238 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
iptables \
gosu \
sudo \
unzip \
# Rust native build deps (for cargo check)
pkg-config \
cmake \
clang \
mold \
libtool \
libssl-dev \
libxml2-dev \
libxmlsec1-dev \
libxslt1-dev \
libffi-dev \
zlib1g-dev \
libcurl4-openssl-dev \
libclang-dev \
libkrb5-dev \
libsasl2-dev \
# PostgreSQL (for local DB during development)
postgresql \
postgresql-client \
# Node.js 22 (for npm run check / frontend dev)
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
# Container runs as arbitrary UIDs (--user uid:gid). These three lines make
# sudo work for any UID:
# 1) NOPASSWD rule so sudo never prompts for a password
# 2) Writable passwd/group so the entrypoint can register the dynamic UID
# 3) Writable shadow so unix_chkpwd can validate the account (without this,
# sudo fails with "account validation failure, is your account locked?")
&& echo "ALL ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/sandbox \
&& chmod 0440 /etc/sudoers.d/sandbox \
&& chmod 666 /etc/passwd /etc/group /etc/shadow
# ── GitHub CLI (for PR creation) ──────────────────────────────────────────────
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*
# ── Rust toolchain ────────────────────────────────────────────────────────────
# Install under /usr/local/lib/ so bins are world-readable with default umask.
# CARGO_HOME is overridden to /tmp/.cargo at the end for mutable runtime state.
ENV RUSTUP_HOME=/usr/local/lib/rustup CARGO_HOME=/usr/local/lib/cargo
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal && \
ln -s /usr/local/lib/cargo/bin/* /usr/local/bin/
RUN cargo install sqlx-cli --no-default-features --features native-tls,postgres && \
cargo install cargo-watch && \
cargo install --locked --git https://github.com/asciinema/asciinema && \
ln -sf /usr/local/lib/cargo/bin/sqlx /usr/local/bin/sqlx && \
ln -sf /usr/local/lib/cargo/bin/cargo-watch /usr/local/bin/cargo-watch && \
ln -sf /usr/local/lib/cargo/bin/asciinema /usr/local/bin/asciinema
# ── Register dynamic runtime users ───────────────────────────────────────────
RUN cat <<'SCRIPT' > /usr/local/bin/register-dynamic-user.sh
#!/bin/sh
set -eu
uid="${1:-}"
gid="${2:-}"
if [ -z "$uid" ] || [ -z "$gid" ]; then
echo "register-dynamic-user: usage: register-dynamic-user <uid> <gid>" >&2
exit 1
fi
if ! getent group "$gid" >/dev/null 2>&1; then
echo "sandbox:x:${gid}:" >> /etc/group
fi
if ! getent passwd "$uid" >/dev/null 2>&1; then
echo "sandbox:x:${uid}:${gid}:sandbox:/tmp:/bin/sh" >> /etc/passwd
fi
# Add a shadow entry ("*" = no password) so unix_chkpwd doesn't reject sudo.
if ! grep -q "^sandbox:" /etc/shadow 2>/dev/null; then
echo "sandbox:*:19000:0:99999:7:::" >> /etc/shadow
fi
SCRIPT
RUN chmod +x /usr/local/bin/register-dynamic-user.sh
# ── Network init script (iptables firewall + privilege drop) ──────────────────
RUN cat <<'SCRIPT' > /usr/local/bin/network-init.sh
#!/bin/bash
set -euo pipefail
if [ -n "${WM_PROXY_HOST:-}" ] && [ -n "${WM_PROXY_PORT:-}" ]; then
# Resolve hostnames to ALL IPs (multi-A records, round-robin DNS)
PROXY_IPS=$(getent ahostsv4 "$WM_PROXY_HOST" | awk '{print $1}' | sort -u)
RPC_HOST="${WM_RPC_HOST:-$WM_PROXY_HOST}"
RPC_IPS=$(getent ahostsv4 "$RPC_HOST" | awk '{print $1}' | sort -u)
if [ -z "$PROXY_IPS" ] || [ -z "$RPC_IPS" ]; then
echo "network-init: failed to resolve proxy/RPC host" >&2
exit 1
fi
# IPv4: default deny outbound
iptables -P OUTPUT DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow DNS (UDP/TCP 53) to configured nameservers.
if [ -f /etc/resolv.conf ]; then
grep '^nameserver' /etc/resolv.conf | awk '{print $2}' | while read -r ns; do
iptables -A OUTPUT -d "$ns" -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -d "$ns" -p tcp --dport 53 -j ACCEPT
done
fi
# Allow ALL resolved proxy IPs (handles multi-A DNS)
for ip in $PROXY_IPS; do
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_PROXY_PORT" -j ACCEPT
done
# Allow ALL resolved RPC IPs
if [ -n "${WM_RPC_PORT:-}" ]; then
for ip in $RPC_IPS; do
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_RPC_PORT" -j ACCEPT
done
fi
# Reject (not drop) everything else to fail fast instead of hanging
iptables -A OUTPUT -j REJECT
# IPv6: block entirely to prevent leaks (fail closed)
if ip6tables -L -n >/dev/null 2>&1; then
ip6tables -P OUTPUT DROP
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -A OUTPUT -j REJECT
else
if ! sysctl -w net.ipv6.conf.all.disable_ipv6=1 2>/dev/null; then
echo "network-init: failed to block IPv6 (neither ip6tables nor sysctl available)" >&2
exit 1
fi
fi
fi
# Add sandbox user/group so sudo works after dropping privileges.
if [ -z "${WM_TARGET_UID:-}" ] || [ -z "${WM_TARGET_GID:-}" ]; then
echo "network-init: WM_TARGET_UID and WM_TARGET_GID are required" >&2
exit 1
fi
/usr/local/bin/register-dynamic-user.sh "${WM_TARGET_UID}" "${WM_TARGET_GID}"
# Fix PTY ownership so the unprivileged user can read/write the terminal.
if [ -t 0 ]; then
chown "${WM_TARGET_UID}:${WM_TARGET_GID}" "$(tty)"
fi
# Drop privileges and exec the user command.
exec gosu "${WM_TARGET_UID}:${WM_TARGET_GID}" env HOME=/tmp "$@"
SCRIPT
RUN chmod +x /usr/local/bin/network-init.sh
# ── workmux (sandbox RPC) ────────────────────────────────────────────────────
RUN curl -fsSL https://raw.githubusercontent.com/raine/workmux/main/scripts/install.sh | bash
# ── Claude Code ───────────────────────────────────────────────────────────────
RUN curl -fsSL https://claude.ai/install.sh | bash && \
target="$(readlink -f /root/.local/bin/claude)" && \
mv /root/.local/share/claude /usr/local/lib/claude && \
ln -s "/usr/local/lib/claude/versions/$(basename "$target")" /usr/local/bin/claude && \
mkdir -p /tmp/.local/bin && \
ln -s /usr/local/bin/claude /tmp/.local/bin/claude && \
chmod -R a+rwX /tmp/.local
# ── Codex ─────────────────────────────────────────────────────────────────────
RUN npm i -g @openai/codex
# ── Bun ───────────────────────────────────────────────────────────────────────
ENV BUN_INSTALL=/usr/local/lib/bun
RUN curl -fsSL https://bun.sh/install | bash && \
ln -s /usr/local/lib/bun/bin/bun /usr/local/bin/bun && \
ln -s /usr/local/lib/bun/bin/bunx /usr/local/bin/bunx
# ── Playwright + Chromium (for screenshots) ──────────────────────────────────
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/lib/playwright-browsers
RUN bun add -g @playwright/test \
&& bunx playwright install chromium --with-deps \
&& chmod -R a+rwX /usr/local/lib/playwright-browsers \
&& chmod -R a+rwX /usr/local/lib/bun/install \
&& rm -rf /var/lib/apt/lists/* /tmp/bunx-*
# ── AWS CLI (for S3-compatible uploads to R2) ─────────────────────────────────
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp \
&& /tmp/aws/install \
&& rm -rf /tmp/aws /tmp/awscliv2.zip
ENV AWS_DEFAULT_REGION=auto
# ── Runtime env for arbitrary UID ─────────────────────────────────────────────
# Mutable state goes to /tmp (writable by any UID). Toolchains stay read-only.
ENV CARGO_HOME=/tmp/.cargo BUN_TMPDIR=/tmp
# ── Entrypoint ────────────────────────────────────────────────────────────────
RUN cat <<'ENTRY' > /usr/local/bin/entrypoint.sh
#!/bin/sh
/usr/local/bin/register-dynamic-user.sh "$(id -u)" "$(id -g)"
# Start PostgreSQL (unix socket in /tmp, owned by postgres user)
mkdir -p /tmp/pgdata && sudo chown postgres:postgres /tmp/pgdata
if [ ! -f /tmp/pgdata/PG_VERSION ]; then
sudo -u postgres /usr/lib/postgresql/15/bin/initdb -D /tmp/pgdata --auth=trust
fi
sudo -u postgres /usr/lib/postgresql/15/bin/pg_ctl -D /tmp/pgdata -l /tmp/pg.log start -o "-k /tmp"
sudo -u postgres psql -h /tmp -c "CREATE ROLE sandbox SUPERUSER LOGIN" 2>/dev/null || true
sudo -u postgres createdb -h /tmp windmill 2>/dev/null || true
# Run database migrations so sqlx compile-time checks work
if [ -d "$PWD/backend/migrations" ]; then
DATABASE_URL="postgres://sandbox@localhost/windmill?host=/tmp" \
sqlx migrate run --source "$PWD/backend/migrations" 2>/dev/null || true
fi
# Install frontend dependencies and generate backend client
if [ -d "$PWD/frontend" ]; then
(cd "$PWD/frontend" && npm install && npm run generate-backend-client) 2>/dev/null || true
fi
exec "$@"
ENTRY
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+75
View File
@@ -172,6 +172,81 @@ The setup is defined in `.workmux.yaml` at the repo root. Key sections:
- **`files.copy`**: Copies `backend/.env` and `scripts/` into each worktree
- **`files.symlink`**: Symlinks `node_modules` and `.svelte-kit` to avoid reinstalling per worktree
## Enterprise (EE) Code Access
The enterprise source code lives in the `windmill-ee-private` repository (sibling to this repo). When you create a worktree, `scripts/worktree-env` automatically creates a matching EE worktree on the same branch and configures Claude Code's `additionalDirectories` to grant access.
### Sandbox setup
When using sandbox mode, the container needs explicit mounts to access the EE repo. Add the following to your global workmux config (`~/.config/workmux/config.yaml`):
```yaml
sandbox:
extra_mounts:
- host_path: ~/windmill-ee-private
writable: true
- host_path: ~/windmill-ee-private__worktrees
writable: true
```
This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container.
## Cursor SSH Integration (`wmc`)
`wm-cursor` (aliased as `wmc`) gives each worktree its own Cursor SSH remote window with an independently-focused tmux session. All windows are visible in the status bar across all Cursor terminals, but each one is focused on its own worktree.
This uses **grouped tmux sessions** — multiple sessions that share the same window list but track focus independently:
```
tmux session: main <-- your main Cursor terminal
tmux session: cursor-feat-a <-- Cursor window for feat-a (focused on wm-feat-a)
tmux session: cursor-feat-b <-- Cursor window for feat-b (focused on wm-feat-b)
\__ all three share the same windows in the status bar
```
### Setup
Run once from inside tmux on the remote:
```bash
./scripts/wm-cursor setup /home/hugo/projects/windmill
```
This:
1. **Merges `.vscode/settings.json`** — adds the `wm-tmux` terminal profile (auto-attaches to the `main` tmux session), disables auto port forwarding, configures forwarding for ports 8000/3000/5432, and stops rust-analyzer from auto-starting. Existing settings are preserved.
2. **Creates `.vscode/tasks.json`** — auto-starts the dev database (`start-dev-db.sh`) when the folder opens.
3. **Adds `wmc` alias to `~/.zshrc`** — so you can use `wmc` from any tmux window.
After setup, reopen Cursor's terminal to pick up the new profile.
### Usage
All commands run from inside a tmux session (i.e., from Cursor's integrated terminal after setup).
**Create a new worktree + open Cursor:**
```bash
wmc add -A -p "implement feature X"
```
This runs `workmux add`, creates a grouped tmux session, writes `.vscode/settings.json` in the worktree (with port forwarding matching the worktree's assigned ports), and opens a new Cursor window.
**Open Cursor for an existing worktree:**
```bash
wmc open my-feature
```
**Close a worktree's Cursor window and tmux window (keeps the worktree):**
```bash
wmc close my-feature
```
This kills the grouped tmux session and calls `workmux close` to close the tmux window. The worktree and branch are preserved. Grouped sessions are also automatically cleaned up when you `workmux rm` a worktree (via `scripts/worktree-cleanup`).
## Login
Default credentials: `admin@windmill.dev` / `changeme`
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "format_extension",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
}
],
"parameters": {
@@ -52,7 +57,8 @@
true,
true,
true,
true
true,
false
]
},
"hash": "03d63d2e64b012f624d2731b5bcb8849c74a9474777be61edf0ed43ddda07ef3"
@@ -43,8 +43,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "1c2157ce14e90f0751d7f0a9f2dbb3c5a5789a32423e75260098a5300a4af986"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT schema, description, format_extension\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2",
"query": "SELECT schema, description, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"ordinal": 2,
"name": "format_extension",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "is_fileset",
"type_info": "Bool"
}
],
"parameters": {
@@ -28,8 +33,9 @@
"nullable": [
true,
true,
true
true,
false
]
},
"hash": "7bc9fc05dbd162866bef1fdd3e7faeb50429881ed1bc962903f06e4b3d5f8d44"
"hash": "2768622b76ad92c05f4f44d997aff285707e1a43ce85e5bb8e87849d78a0637f"
}
@@ -42,8 +42,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -38,8 +38,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, now())",
"query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
"describe": {
"columns": [],
"parameters": {
@@ -10,10 +10,11 @@
"Jsonb",
"Text",
"Varchar",
"Varchar"
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "ffedbb3a2676a6d7b71f81f89109a02a8dba90d40144e942527f8a3fc36dfbc1"
"hash": "5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00"
}
@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH prev_sd AS (\n DELETE FROM debounce_stale_data WHERE job_id = $1 RETURNING to_relock\n ) INSERT INTO debounce_stale_data (job_id, to_relock)\n VALUES ($2, array_cat((SELECT to_relock FROM prev_sd), $3))\n ON CONFLICT (job_id) DO UPDATE SET to_relock = EXCLUDED.to_relock\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"TextArray"
]
},
"nullable": []
},
"hash": "61b37cb4db6e60c2d35f7d23db5afbe04e040a8dcd1d93afaaaa320665c8779a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\" FROM v2_job WHERE id = $1",
"query": "SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
@@ -42,12 +42,21 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 3,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "flow_step_id",
"type_info": "Varchar"
}
],
"parameters": {
@@ -58,8 +67,10 @@
"nullable": [
true,
true,
false
false,
true,
true
]
},
"hash": "805d633de90fee335f1726284eda0dbc200d45960fb8dea867492c8c7dd096d5"
"hash": "7aaa5b0bd873c2029e2201d287ea0aaae04678ac105374bbe387e534a6cb6333"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension\n FROM resource_type\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "7abd579d3ec97853ac36cc8dad29013eb133a28cd848bf8fdf9571b2ee402a3e"
}
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "format_extension",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
}
],
"parameters": {
@@ -51,7 +56,8 @@
true,
true,
true,
true
true,
false
]
},
"hash": "7b1239ad6460e8f5fb41bfe12f662a779528784ec8cf3f6dcce5545ab90bf234"
@@ -44,8 +44,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT name, format_extension, is_fileset FROM resource_type WHERE (format_extension IS NOT NULL OR is_fileset = true) AND (workspace_id = $1 OR workspace_id = 'admins')",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "format_extension",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_fileset",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "842775bcf91d747abb11ffe9c98fa1208595e012590606ef6667ea3a78105883"
}
@@ -102,8 +102,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -32,8 +32,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -72,8 +72,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -102,8 +102,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -72,8 +72,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "format_extension",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
}
],
"parameters": {
@@ -51,7 +56,8 @@
true,
true,
true,
true
true,
false
]
},
"hash": "b8d392ccfcccafe0c19511b3567bc11779b1052b0948c410468a8aeba1d26d33"
@@ -41,8 +41,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT name, format_extension FROM resource_type WHERE format_extension IS NOT NULL AND (workspace_id = $1 OR workspace_id = 'admins')",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "format_extension",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "cf1cef7e0fe2e7e3db96b0ec005360361b9eec023a6fc2a4a7a917f59d86af4d"
}
@@ -41,8 +41,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -31,8 +31,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -37,8 +37,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "format_extension",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
}
],
"parameters": {
@@ -49,7 +54,8 @@
true,
true,
true,
true
true,
false
]
},
"hash": "eb1f7f01461f5a7540c273b37e5d578c31cf151ab3ef813f7aada76533761e12"
@@ -32,8 +32,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
+88 -88
View File
@@ -2259,9 +2259,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.43"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -5588,7 +5588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.1.3",
"rustix 1.1.4",
"windows-sys 0.59.0",
]
@@ -5804,7 +5804,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
dependencies = [
"rustix 1.1.3",
"rustix 1.1.4",
"windows-sys 0.59.0",
]
@@ -8092,9 +8092,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.23"
version = "1.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839"
dependencies = [
"cc",
"libc",
@@ -8116,9 +8116,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
@@ -9729,9 +9729,9 @@ dependencies = [
[[package]]
name = "owo-colors"
version = "4.2.3"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "p224"
@@ -11598,14 +11598,14 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.3"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.9.4",
"errno",
"libc",
"linux-raw-sys 0.11.0",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
]
@@ -13838,14 +13838,14 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.25.0"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.3",
"rustix 1.1.4",
"windows-sys 0.61.2",
]
@@ -13864,7 +13864,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
dependencies = [
"rustix 1.1.3",
"rustix 1.1.4",
"windows-sys 0.60.2",
]
@@ -15725,7 +15725,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15789,7 +15789,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15802,7 +15802,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"argon2",
@@ -15940,7 +15940,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15963,7 +15963,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15976,7 +15976,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16002,7 +16002,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16012,7 +16012,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16029,7 +16029,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -16052,7 +16052,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16075,7 +16075,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16091,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16111,7 +16111,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16131,7 +16131,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16145,7 +16145,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16171,7 +16171,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16196,7 +16196,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"flate2",
@@ -16213,7 +16213,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16234,7 +16234,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16254,7 +16254,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16284,7 +16284,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16311,7 +16311,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"lazy_static",
"serde",
@@ -16323,7 +16323,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16346,7 +16346,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16360,7 +16360,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16390,7 +16390,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16404,7 +16404,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16423,7 +16423,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16522,7 +16522,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16541,7 +16541,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"regex",
"serde",
@@ -16556,7 +16556,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16580,7 +16580,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"futures",
@@ -16597,7 +16597,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16613,7 +16613,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16634,7 +16634,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16665,7 +16665,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16689,7 +16689,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-stream",
@@ -16723,7 +16723,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"futures",
@@ -16741,7 +16741,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16750,7 +16750,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16762,7 +16762,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16774,7 +16774,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16786,7 +16786,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16798,7 +16798,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16810,7 +16810,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16821,7 +16821,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16832,7 +16832,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16845,7 +16845,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16869,7 +16869,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16883,7 +16883,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16900,7 +16900,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16915,7 +16915,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16934,7 +16934,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"serde",
@@ -16945,7 +16945,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16982,7 +16982,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"const_format",
@@ -17020,7 +17020,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -17030,7 +17030,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17059,7 +17059,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -17082,7 +17082,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17115,7 +17115,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17135,7 +17135,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17169,7 +17169,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17204,7 +17204,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17227,7 +17227,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17251,7 +17251,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17275,7 +17275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17310,7 +17310,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17338,7 +17338,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17361,7 +17361,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17379,7 +17379,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.642.0"
version = "1.643.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -18252,7 +18252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.3",
"rustix 1.1.4",
]
[[package]]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.642.0"
version = "1.643.0"
authors.workspace = true
edition.workspace = true
@@ -76,7 +76,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.642.0"
version = "1.643.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -0,0 +1 @@
ALTER TABLE resource_type DROP COLUMN is_fileset;
@@ -0,0 +1 @@
ALTER TABLE resource_type ADD COLUMN is_fileset BOOLEAN NOT NULL DEFAULT FALSE;
+1 -1
View File
@@ -137,7 +137,7 @@ raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(
FK: (workspace_id) -> workspace(id)
resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char)
FK: (workspace_id) -> workspace(id)
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char)
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool)
FK: (workspace_id) -> workspace(id)
resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), approver(char), resume_id(int), approved(bool)
FK: (flow) -> v2_job_queue(id)
+34 -4
View File
@@ -27,9 +27,13 @@ struct ListAssetsQuery {
per_page: i64,
cursor_created_at: Option<chrono::DateTime<chrono::Utc>>,
cursor_id: Option<i64>,
asset_path: Option<String>,
usage_path: Option<String>,
asset_kinds: Option<String>,
pub asset_path: Option<String>,
pub usage_path: Option<String>,
pub asset_kinds: Option<String>,
// Exact path match filter
pub path: Option<String>,
// Filter by matching a subset of the columns using base64 encoded json subset
pub columns: Option<String>,
}
fn default_per_page() -> i64 {
@@ -75,12 +79,24 @@ async fn list_assets(
let mut param_count = 2; // $1 = workspace_id, $2 = limit
// Asset path filter
// Asset path filter (ILIKE pattern match)
if query.asset_path.is_some() {
param_count += 1;
asset_summary_filters.push(format!("asset.path ILIKE ${}", param_count));
}
// Exact path filter
if query.path.is_some() {
param_count += 1;
asset_summary_filters.push(format!("asset.path = ${}", param_count));
}
// Columns filter (check if JSONB has all specified keys)
if query.columns.is_some() {
param_count += 1;
asset_summary_filters.push(format!("asset.columns ?& ${}", param_count));
}
// Usage path filter - for jobs, also check runnable_path
let needs_job_join_in_cte = query.usage_path.is_some();
if query.usage_path.is_some() {
@@ -211,6 +227,20 @@ async fn list_assets(
query_builder = query_builder.bind(format!("%{}%", asset_path));
}
if let Some(ref path) = query.path {
query_builder = query_builder.bind(path);
}
if let Some(ref columns) = query.columns {
// Columns is a comma-separated string, split into array for ?& operator
let columns_array: Vec<String> = columns
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
query_builder = query_builder.bind(columns_array);
}
if let Some(ref usage_path) = query.usage_path {
query_builder = query_builder.bind(format!("%{}%", usage_path));
}
@@ -54,6 +54,21 @@ INSERT INTO resource (workspace_id, path, value, description, resource_type, ext
VALUES ('test-workspace', 'u/test-user/scalar_var_resource', '"$var:u/test-user/db_password"',
'Scalar var ref', 'string', '{}', 'test-user');
-- === fileset resource type test data ===
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, is_fileset)
VALUES ('test-workspace', 'test_fileset', '{}',
'Test fileset type', 'test-user', true);
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension)
VALUES ('test-workspace', 'test_file', '{"type": "object", "properties": {"content": {"type": "string"}}}',
'Test file type', 'test-user', 'txt');
INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/fileset_resource',
'{"config.yaml": "key: value", "data/input.json": "{\"items\": []}"}',
'A fileset resource', 'test_fileset', '{}', 'test-user');
-- === mcp_tools test data ===
INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
@@ -69,8 +69,12 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.status(), 404);
// --- get_value_interpolated ---
let resp =
authed_get(port, "get_value_interpolated", "u/test-user/simple_resource").await;
let resp = authed_get(
port,
"get_value_interpolated",
"u/test-user/simple_resource",
)
.await;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.json::<serde_json::Value>().await?,
@@ -78,8 +82,12 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// $var: interpolation
let resp =
authed_get(port, "get_value_interpolated", "u/test-user/resource_with_var").await;
let resp = authed_get(
port,
"get_value_interpolated",
"u/test-user/resource_with_var",
)
.await;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.json::<serde_json::Value>().await?,
@@ -87,8 +95,12 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// $res: interpolation
let resp =
authed_get(port, "get_value_interpolated", "u/test-user/resource_with_res").await;
let resp = authed_get(
port,
"get_value_interpolated",
"u/test-user/resource_with_res",
)
.await;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.json::<serde_json::Value>().await?,
@@ -96,8 +108,7 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// mixed $var: and $res: refs
let resp =
authed_get(port, "get_value_interpolated", "u/test-user/resource_mixed").await;
let resp = authed_get(port, "get_value_interpolated", "u/test-user/resource_mixed").await;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.json::<serde_json::Value>().await?,
@@ -105,8 +116,12 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// chained $res: -> $var:
let resp =
authed_get(port, "get_value_interpolated", "u/test-user/chained_resource").await;
let resp = authed_get(
port,
"get_value_interpolated",
"u/test-user/chained_resource",
)
.await;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.json::<serde_json::Value>().await?,
@@ -114,8 +129,7 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// null value
let resp =
authed_get(port, "get_value_interpolated", "u/test-user/null_resource").await;
let resp = authed_get(port, "get_value_interpolated", "u/test-user/null_resource").await;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.json::<serde_json::Value>().await?,
@@ -123,8 +137,7 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// not found
let resp =
authed_get(port, "get_value_interpolated", "u/test-user/nonexistent").await;
let resp = authed_get(port, "get_value_interpolated", "u/test-user/nonexistent").await;
assert_eq!(resp.status(), 404);
// array passthrough
@@ -162,7 +175,9 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
"expected at least 10 resources from fixture, got {}",
list.len()
);
assert!(list.iter().any(|r| r["path"] == "u/test-user/simple_resource"));
assert!(list
.iter()
.any(|r| r["path"] == "u/test-user/simple_resource"));
// list with resource_type filter
let resp = authed(client().get(format!("{base}/list?resource_type=mcp_server")))
@@ -259,9 +274,11 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(body["description"], "Updated description");
// --- update_value ---
let resp = authed(
client().post(resource_url(port, "update_value", "u/test-user/new_resource")),
)
let resp = authed(client().post(resource_url(
port,
"update_value",
"u/test-user/new_resource",
)))
.json(&json!({"value": {"url": "https://final.com"}}))
.send()
.await
@@ -275,35 +292,44 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// --- delete ---
let resp = authed(
client().delete(resource_url(port, "delete", "u/test-user/new_resource")),
)
.send()
.await
.unwrap();
let resp = authed(client().delete(resource_url(port, "delete", "u/test-user/new_resource")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = authed_get(port, "exists", "u/test-user/new_resource").await;
assert_eq!(resp.json::<bool>().await?, false);
// delete nonexistent -> 404
let resp = authed(
client().delete(resource_url(port, "delete", "u/test-user/new_resource")),
)
.send()
.await
.unwrap();
let resp = authed(client().delete(resource_url(port, "delete", "u/test-user/new_resource")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
// --- file_resource_type_to_file_ext_map ---
let resp = authed(client().get(format!(
"{base}/file_resource_type_to_file_ext_map"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{base}/file_resource_type_to_file_ext_map")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<serde_json::Value>().await?;
let ext_map = resp.json::<serde_json::Value>().await?;
// Verify the map includes fileset type info with is_fileset flag (no format_extension)
let fileset_info = &ext_map["test_fileset"];
assert_eq!(fileset_info["format_extension"], serde_json::Value::Null);
assert_eq!(fileset_info["is_fileset"], true);
// Verify non-fileset file type
let file_info = &ext_map["test_file"];
assert_eq!(file_info["format_extension"], "txt");
assert_eq!(file_info["is_fileset"], false);
// --- fileset resource value ---
let resp = authed_get(port, "get_value", "u/test-user/fileset_resource").await;
assert_eq!(resp.status(), 200);
let fileset_val = resp.json::<serde_json::Value>().await?;
assert_eq!(fileset_val["config.yaml"], "key: value");
assert_eq!(fileset_val["data/input.json"], "{\"items\": []}");
// --- resource types ---
@@ -384,17 +410,68 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(body["description"], "Updated type desc");
// type/delete
let resp = authed(
client().delete(resource_url(port, "type/delete", "new_test_type")),
)
.send()
.await
.unwrap();
let resp = authed(client().delete(resource_url(port, "type/delete", "new_test_type")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = authed_get(port, "type/exists", "new_test_type").await;
assert_eq!(resp.json::<bool>().await?, false);
// --- fileset resource type CRUD ---
// type/get for fileset type - verify is_fileset is returned
let resp = authed_get(port, "type/get", "test_fileset").await;
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["name"], "test_fileset");
assert_eq!(body["is_fileset"], true);
assert_eq!(body["format_extension"], serde_json::Value::Null);
// type/get for non-fileset type - verify is_fileset is false
let resp = authed_get(port, "type/get", "test_db").await;
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["is_fileset"], false);
// type/create fileset type (no format_extension needed)
let resp = authed(client().post(format!("{base}/type/create")))
.json(&json!({
"name": "new_fileset_type",
"description": "A fileset type",
"schema": {},
"is_fileset": true
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let resp = authed_get(port, "type/get", "new_fileset_type").await;
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["is_fileset"], true);
assert_eq!(body["format_extension"], serde_json::Value::Null);
// type/update - set is_fileset on existing type
let resp = authed(client().post(resource_url(port, "type/update", "new_fileset_type")))
.json(&json!({"is_fileset": false}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = authed_get(port, "type/get", "new_fileset_type").await;
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["is_fileset"], false);
// cleanup
let resp = authed(client().delete(resource_url(port, "type/delete", "new_fileset_type")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
Ok(())
}
+2
View File
@@ -10,9 +10,11 @@ pub mod concurrency_groups;
pub mod execution;
pub mod job_metrics;
pub mod jobs_export;
pub mod negated_filter;
pub mod query;
pub mod types;
pub use execution::*;
pub use negated_filter::{NegatedFilter, NegatedListFilter};
pub use query::*;
pub use types::*;
@@ -0,0 +1,126 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Filter wrappers that support an optional `!` negation prefix.
//!
//! - [`NegatedFilter<T>`] — a single value, e.g. `"schedule"` or `"!schedule"`.
//! - [`NegatedListFilter<T>`] — comma-separated values, e.g. `"!schedule,!email"` or `"http,webhook"`.
//! Every item in the list shares the same negated/non-negated sense; mixing is not supported
use serde::{
de::{self, DeserializeOwned},
Deserializer,
};
use std::{fmt, marker::PhantomData};
// ── NegatedFilter<T> ──────────────────────────────────────────────────────────
/// A single filter value optionally prefixed with `!` to indicate negation.
///
/// Deserializes `"schedule"` → `NegatedFilter { value: Schedule, negated: false }`
/// Deserializes `"!schedule"` → `NegatedFilter { value: Schedule, negated: true }`
#[derive(Debug, Clone)]
pub struct NegatedFilter<T> {
pub value: T,
pub negated: bool,
}
impl<T> NegatedFilter<T> {
pub fn positive(value: T) -> Self {
Self { value, negated: false }
}
pub fn negated(value: T) -> Self {
Self { value, negated: true }
}
}
struct NegatedFilterVisitor<T>(PhantomData<T>);
impl<'de, T: DeserializeOwned> de::Visitor<'de> for NegatedFilterVisitor<T> {
type Value = NegatedFilter<T>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a string optionally prefixed with '!'")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
let (negated, raw) = match s.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, s),
};
let value = serde_json::from_value(serde_json::Value::String(raw.to_owned()))
.map_err(|e| E::custom(format!("invalid filter value {:?}: {}", raw, e)))?;
Ok(NegatedFilter { value, negated })
}
}
impl<'de, T: DeserializeOwned> de::Deserialize<'de> for NegatedFilter<T> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(NegatedFilterVisitor(PhantomData))
}
}
// ── NegatedListFilter<T> ──────────────────────────────────────────────────────
/// A comma-separated list of filter values, all sharing the same negation sense.
///
/// Deserializes `"schedule,email"` → `NegatedListFilter { values: [Schedule, Email], negated: false }`
/// Deserializes `"!schedule,!email"` → `NegatedListFilter { values: [Schedule, Email], negated: true }`
///
/// The `!` is read from the **first** item only; subsequent items may or may not carry
/// `!` and it is stripped regardless, keeping the API forgiving.
#[derive(Debug, Clone)]
pub struct NegatedListFilter<T> {
pub values: Vec<T>,
pub negated: bool,
}
impl<T> NegatedListFilter<T> {
pub fn positive(values: Vec<T>) -> Self {
Self { values, negated: false }
}
}
struct NegatedListFilterVisitor<T>(PhantomData<T>);
impl<'de, T: DeserializeOwned> de::Visitor<'de> for NegatedListFilterVisitor<T> {
type Value = NegatedListFilter<T>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a comma-separated string optionally prefixed with '!'")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
let mut negated = false;
let values = s
.split(',')
.enumerate()
.map(|(i, item)| {
let raw = match item.strip_prefix('!') {
Some(rest) => {
if i == 0 {
negated = true;
}
rest
}
None => item,
};
serde_json::from_value::<T>(serde_json::Value::String(raw.to_owned()))
.map_err(|e| E::custom(format!("invalid filter value {:?}: {}", raw, e)))
})
.collect::<Result<Vec<T>, E>>()?;
Ok(NegatedListFilter { values, negated })
}
}
impl<'de, T: DeserializeOwned> de::Deserialize<'de> for NegatedListFilter<T> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(NegatedListFilterVisitor(PhantomData))
}
}
+246 -83
View File
@@ -14,6 +14,17 @@ use windmill_common::utils::{paginate_without_limits, Pagination};
use crate::types::{ListCompletedQuery, ListQueueQuery};
/// Build a `NOT IN (...)` clause that also includes `OR col IS NULL`, so that
/// rows where the nullable column is NULL are not silently excluded.
fn not_in_nullable(col: &str, quoted: &[String]) -> String {
format!(
"({} IS NULL OR {} NOT IN ({}))",
col,
col,
quoted.join(", ")
)
}
pub fn filter_list_queue_query(
mut sqlb: SqlBuilder,
lq: &ListQueueQuery,
@@ -33,18 +44,62 @@ pub fn filter_list_queue_query(
}
if let Some(w) = &lq.worker {
let quoted: Vec<_> = w.values.iter().map(|v| quote(v)).collect();
if lq.allow_wildcards.unwrap_or(false) {
sqlb.and_where_like_left("v2_job_queue.worker", w.replace("*", "%"));
let clauses: Vec<_> = w
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
if w.negated {
format!("v2_job_queue.worker NOT LIKE '{p}'")
} else {
format!("v2_job_queue.worker LIKE '{p}'")
}
})
.collect();
let sep = if w.negated { " AND " } else { " OR " };
let inner = clauses.join(sep);
if w.negated {
sqlb.and_where(format!("(v2_job_queue.worker IS NULL OR ({inner}))"));
} else {
sqlb.and_where(format!("({inner})"));
}
} else if w.negated {
sqlb.and_where(not_in_nullable("v2_job_queue.worker", &quoted));
} else {
sqlb.and_where_eq("v2_job_queue.worker", "?".bind(w));
sqlb.and_where_in("v2_job_queue.worker", &quoted);
}
}
if let Some(ps) = &lq.script_path_start {
sqlb.and_where_like_left("runnable_path", ps);
let clauses: Vec<_> = ps
.values
.iter()
.map(|v| {
let e = v.replace("'", "''");
if ps.negated {
format!("runnable_path NOT LIKE '{e}%'")
} else {
format!("runnable_path LIKE '{e}%'")
}
})
.collect();
let sep = if ps.negated { " AND " } else { " OR " };
let inner = clauses.join(sep);
if ps.negated {
sqlb.and_where(format!("(runnable_path IS NULL OR ({inner}))"));
} else {
sqlb.and_where(format!("({inner})"));
}
}
if let Some(p) = &lq.script_path_exact {
sqlb.and_where_eq("runnable_path", "?".bind(p));
let quoted: Vec<_> = p.values.iter().map(|v| quote(v)).collect();
if p.negated {
sqlb.and_where(not_in_nullable("runnable_path", &quoted));
} else {
sqlb.and_where_in("runnable_path", &quoted);
}
}
if let Some(p) = &lq.schedule_path {
sqlb.and_where_eq("trigger", "?".bind(p));
@@ -54,13 +109,34 @@ pub fn filter_list_queue_query(
sqlb.and_where_eq("runnable_id", "?".bind(h));
}
if let Some(cb) = &lq.created_by {
sqlb.and_where_eq("created_by", "?".bind(cb));
let quoted: Vec<_> = cb.values.iter().map(|v| quote(v)).collect();
if cb.negated {
sqlb.and_where_not_in("created_by", &quoted);
} else {
sqlb.and_where_in("created_by", &quoted);
}
}
if let Some(t) = &lq.tag {
let quoted: Vec<_> = t.values.iter().map(|v| quote(v)).collect();
if lq.allow_wildcards.unwrap_or(false) {
sqlb.and_where_like_left("v2_job.tag", t.replace("*", "%"));
let clauses: Vec<_> = t
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
if t.negated {
format!("v2_job.tag NOT LIKE '{p}'")
} else {
format!("v2_job.tag LIKE '{p}'")
}
})
.collect();
let sep = if t.negated { " AND " } else { " OR " };
sqlb.and_where(format!("({})", clauses.join(sep)));
} else if t.negated {
sqlb.and_where_not_in("v2_job.tag", &quoted);
} else {
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
sqlb.and_where_in("v2_job.tag", &quoted);
}
}
@@ -115,10 +191,12 @@ pub fn filter_list_queue_query(
}
if let Some(jk) = &lq.job_kinds {
sqlb.and_where_in(
"kind",
&jk.split(',').into_iter().map(quote).collect::<Vec<_>>(),
);
let quoted: Vec<_> = jk.values.iter().map(|v| quote(v)).collect();
if jk.negated {
sqlb.and_where_not_in("kind", &quoted);
} else {
sqlb.and_where_in("kind", &quoted);
}
}
if let Some(args) = &lq.args {
@@ -134,11 +212,21 @@ pub fn filter_list_queue_query(
}
if let Some(tk) = &lq.trigger_kind {
sqlb.and_where_eq("trigger_kind", "?".bind(&format!("{}", tk)));
let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect();
if tk.negated {
sqlb.and_where(not_in_nullable("trigger_kind", &quoted));
} else {
sqlb.and_where_in("trigger_kind", &quoted);
}
}
if let Some(tp) = &lq.trigger_path {
sqlb.and_where_eq("trigger", "?".bind(tp));
let quoted: Vec<_> = tp.values.iter().map(|v| quote(v)).collect();
if tp.negated {
sqlb.and_where(not_in_nullable("trigger", &quoted));
} else {
sqlb.and_where_in("trigger", &quoted);
}
}
sqlb
@@ -187,25 +275,71 @@ pub fn filter_list_completed_query(
if let Some(label) = &lq.label {
if lq.allow_wildcards.unwrap_or(false) {
let wh = format!(
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') label WHERE jsonb_typeof(result->'wm_labels') = 'array' AND label LIKE '{}')",
&label.replace("*", "%").replace("'", "''")
);
sqlb.and_where("result ? 'wm_labels'");
sqlb.and_where(&wh);
let clauses: Vec<_> = label
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
if label.negated {
format!(
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
)
} else {
format!(
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
)
}
})
.collect();
let sep = if label.negated { " AND " } else { " OR " };
if !label.negated {
sqlb.and_where("result ? 'wm_labels'");
}
sqlb.and_where(format!("({})", clauses.join(sep)));
} else if label.negated {
let clauses: Vec<_> = label
.values
.iter()
.map(|v| format!("NOT (result->'wm_labels' ? '{}')", v.replace("'", "''")))
.collect();
sqlb.and_where(format!("({})", clauses.join(" AND ")));
} else {
let mut wh = format!("result->'wm_labels' ? ");
wh.push_str(&format!("'{}'", &label.replace("'", "''")));
let clauses: Vec<_> = label
.values
.iter()
.map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''")))
.collect();
sqlb.and_where("result ? 'wm_labels'");
sqlb.and_where(&wh);
sqlb.and_where(format!("({})", clauses.join(" OR ")));
}
}
if let Some(worker) = &lq.worker {
let quoted: Vec<_> = worker.values.iter().map(|v| quote(v)).collect();
if lq.allow_wildcards.unwrap_or(false) {
sqlb.and_where_like_left("v2_job_completed.worker", worker.replace("*", "%"));
let clauses: Vec<_> = worker
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
if worker.negated {
format!("v2_job_completed.worker NOT LIKE '{p}'")
} else {
format!("v2_job_completed.worker LIKE '{p}'")
}
})
.collect();
let sep = if worker.negated { " AND " } else { " OR " };
let inner = clauses.join(sep);
if worker.negated {
sqlb.and_where(format!("(v2_job_completed.worker IS NULL OR ({inner}))"));
} else {
sqlb.and_where(format!("({inner})"));
}
} else if worker.negated {
sqlb.and_where(not_in_nullable("v2_job_completed.worker", &quoted));
} else {
sqlb.and_where_eq("v2_job_completed.worker", "?".bind(worker));
sqlb.and_where_in("v2_job_completed.worker", &quoted);
}
}
@@ -220,24 +354,68 @@ pub fn filter_list_completed_query(
}
if let Some(ps) = &lq.script_path_start {
sqlb.and_where_like_left("runnable_path", ps);
let clauses: Vec<_> = ps
.values
.iter()
.map(|v| {
let e = v.replace("'", "''");
if ps.negated {
format!("runnable_path NOT LIKE '{e}%'")
} else {
format!("runnable_path LIKE '{e}%'")
}
})
.collect();
let sep = if ps.negated { " AND " } else { " OR " };
let inner = clauses.join(sep);
if ps.negated {
sqlb.and_where(format!("(runnable_path IS NULL OR ({inner}))"));
} else {
sqlb.and_where(format!("({inner})"));
}
}
if let Some(p) = &lq.script_path_exact {
sqlb.and_where_eq("runnable_path", "?".bind(p));
let quoted: Vec<_> = p.values.iter().map(|v| quote(v)).collect();
if p.negated {
sqlb.and_where(not_in_nullable("runnable_path", &quoted));
} else {
sqlb.and_where_in("runnable_path", &quoted);
}
}
if let Some(h) = &lq.script_hash {
sqlb.and_where_eq("runnable_id", "?".bind(h));
}
if let Some(t) = &lq.tag {
let quoted: Vec<_> = t.values.iter().map(|v| quote(v)).collect();
if lq.allow_wildcards.unwrap_or(false) {
sqlb.and_where_like_left("v2_job.tag", t.replace("*", "%"));
let clauses: Vec<_> = t
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
if t.negated {
format!("v2_job.tag NOT LIKE '{p}'")
} else {
format!("v2_job.tag LIKE '{p}'")
}
})
.collect();
let sep = if t.negated { " AND " } else { " OR " };
sqlb.and_where(format!("({})", clauses.join(sep)));
} else if t.negated {
sqlb.and_where_not_in("v2_job.tag", &quoted);
} else {
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
sqlb.and_where_in("v2_job.tag", &quoted);
}
}
if let Some(cb) = &lq.created_by {
sqlb.and_where_eq("created_by", "?".bind(cb));
let quoted: Vec<_> = cb.values.iter().map(|v| quote(v)).collect();
if cb.negated {
sqlb.and_where_not_in("created_by", &quoted);
} else {
sqlb.and_where_in("created_by", &quoted);
}
}
if let Some(r) = &lq.success {
if *r {
@@ -308,10 +486,12 @@ pub fn filter_list_completed_query(
}
}
if let Some(jk) = &lq.job_kinds {
sqlb.and_where_in(
"kind",
&jk.split(',').into_iter().map(quote).collect::<Vec<_>>(),
);
let quoted: Vec<_> = jk.values.iter().map(|v| quote(v)).collect();
if jk.negated {
sqlb.and_where_not_in("kind", &quoted);
} else {
sqlb.and_where_in("kind", &quoted);
}
}
if let Some(args) = &lq.args {
@@ -327,11 +507,21 @@ pub fn filter_list_completed_query(
}
if let Some(tk) = &lq.trigger_kind {
sqlb.and_where_eq("trigger_kind", "?".bind(&format!("{}", tk)));
let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect();
if tk.negated {
sqlb.and_where(not_in_nullable("trigger_kind", &quoted));
} else {
sqlb.and_where_in("trigger_kind", &quoted);
}
}
if let Some(tp) = &lq.trigger_path {
sqlb.and_where_eq("trigger", "?".bind(tp));
let quoted: Vec<_> = tp.values.iter().map(|v| quote(v)).collect();
if tp.negated {
sqlb.and_where(not_in_nullable("trigger", &quoted));
} else {
sqlb.and_where_in("trigger", &quoted);
}
}
sqlb
@@ -375,6 +565,7 @@ pub fn list_completed_jobs_query(
#[cfg(test)]
mod tests {
use super::*;
use crate::negated_filter::NegatedListFilter;
fn empty_queue_query() -> ListQueueQuery {
ListQueueQuery {
@@ -478,7 +669,7 @@ mod tests {
#[test]
fn test_queue_filter_script_path_start() {
let lq = ListQueueQuery {
script_path_start: Some("f/test".to_string()),
script_path_start: Some(NegatedListFilter::positive(vec!["f/test".to_string()])),
..empty_queue_query()
};
let sqlb = filter_list_queue_query(
@@ -495,7 +686,9 @@ mod tests {
#[test]
fn test_queue_filter_script_path_exact() {
let lq = ListQueueQuery {
script_path_exact: Some("f/test/script".to_string()),
script_path_exact: Some(NegatedListFilter::positive(vec![
"f/test/script".to_string()
])),
..empty_queue_query()
};
let sqlb = filter_list_queue_query(
@@ -510,10 +703,7 @@ mod tests {
#[test]
fn test_queue_filter_running() {
let lq = ListQueueQuery {
running: Some(true),
..empty_queue_query()
};
let lq = ListQueueQuery { running: Some(true), ..empty_queue_query() };
let sqlb = filter_list_queue_query(
SqlBuilder::select_from("v2_job_queue").clone(),
&lq,
@@ -527,7 +717,10 @@ mod tests {
#[test]
fn test_queue_filter_job_kinds() {
let lq = ListQueueQuery {
job_kinds: Some("script,flow".to_string()),
job_kinds: Some(NegatedListFilter::positive(vec![
"script".to_string(),
"flow".to_string(),
])),
..empty_queue_query()
};
let sqlb = filter_list_queue_query(
@@ -543,10 +736,7 @@ mod tests {
#[test]
fn test_queue_filter_suspended() {
let lq = ListQueueQuery {
suspended: Some(true),
..empty_queue_query()
};
let lq = ListQueueQuery { suspended: Some(true), ..empty_queue_query() };
let sqlb = filter_list_queue_query(
SqlBuilder::select_from("v2_job_queue").clone(),
&lq,
@@ -559,10 +749,7 @@ mod tests {
#[test]
fn test_queue_filter_is_not_schedule() {
let lq = ListQueueQuery {
is_not_schedule: Some(true),
..empty_queue_query()
};
let lq = ListQueueQuery { is_not_schedule: Some(true), ..empty_queue_query() };
let sqlb = filter_list_queue_query(
SqlBuilder::select_from("v2_job_queue").clone(),
&lq,
@@ -575,10 +762,7 @@ mod tests {
#[test]
fn test_queue_filter_has_null_parent() {
let lq = ListQueueQuery {
has_null_parent: Some(true),
..empty_queue_query()
};
let lq = ListQueueQuery { has_null_parent: Some(true), ..empty_queue_query() };
let sqlb = filter_list_queue_query(
SqlBuilder::select_from("v2_job_queue").clone(),
&lq,
@@ -591,10 +775,7 @@ mod tests {
#[test]
fn test_queue_filter_is_flow_step_true() {
let lq = ListQueueQuery {
is_flow_step: Some(true),
..empty_queue_query()
};
let lq = ListQueueQuery { is_flow_step: Some(true), ..empty_queue_query() };
let sqlb = filter_list_queue_query(
SqlBuilder::select_from("v2_job_queue").clone(),
&lq,
@@ -607,10 +788,7 @@ mod tests {
#[test]
fn test_queue_filter_is_flow_step_false() {
let lq = ListQueueQuery {
is_flow_step: Some(false),
..empty_queue_query()
};
let lq = ListQueueQuery { is_flow_step: Some(false), ..empty_queue_query() };
let sqlb = filter_list_queue_query(
SqlBuilder::select_from("v2_job_queue").clone(),
&lq,
@@ -623,10 +801,7 @@ mod tests {
#[test]
fn test_queue_admins_all_workspaces() {
let lq = ListQueueQuery {
all_workspaces: Some(true),
..empty_queue_query()
};
let lq = ListQueueQuery { all_workspaces: Some(true), ..empty_queue_query() };
let sqlb = filter_list_queue_query(
SqlBuilder::select_from("v2_job_queue").clone(),
&lq,
@@ -639,10 +814,7 @@ mod tests {
#[test]
fn test_queue_non_admins_ignores_all_workspaces() {
let lq = ListQueueQuery {
all_workspaces: Some(true),
..empty_queue_query()
};
let lq = ListQueueQuery { all_workspaces: Some(true), ..empty_queue_query() };
let sqlb = filter_list_queue_query(
SqlBuilder::select_from("v2_job_queue").clone(),
&lq,
@@ -695,10 +867,7 @@ mod tests {
#[test]
fn test_completed_filter_success_true() {
let lq = ListCompletedQuery {
success: Some(true),
..empty_completed_query()
};
let lq = ListCompletedQuery { success: Some(true), ..empty_completed_query() };
let sqlb = filter_list_completed_query(
SqlBuilder::select_from("v2_job_completed").clone(),
&lq,
@@ -711,10 +880,7 @@ mod tests {
#[test]
fn test_completed_filter_success_false() {
let lq = ListCompletedQuery {
success: Some(false),
..empty_completed_query()
};
let lq = ListCompletedQuery { success: Some(false), ..empty_completed_query() };
let sqlb = filter_list_completed_query(
SqlBuilder::select_from("v2_job_completed").clone(),
&lq,
@@ -739,7 +905,7 @@ mod tests {
#[test]
fn test_completed_filter_label() {
let lq = ListCompletedQuery {
label: Some("deploy".to_string()),
label: Some(NegatedListFilter::positive(vec!["deploy".to_string()])),
..empty_completed_query()
};
let sqlb = filter_list_completed_query(
@@ -754,10 +920,7 @@ mod tests {
#[test]
fn test_completed_filter_is_skipped() {
let lq = ListCompletedQuery {
is_skipped: Some(true),
..empty_completed_query()
};
let lq = ListCompletedQuery { is_skipped: Some(true), ..empty_completed_query() };
let sqlb = filter_list_completed_query(
SqlBuilder::select_from("v2_job_completed").clone(),
&lq,
+47 -37
View File
@@ -27,6 +27,8 @@ use windmill_common::{
use windmill_api_sse::{Job, JobExtended};
use crate::negated_filter::NegatedListFilter;
// ------------ RunJobQuery ------------
#[derive(Debug, Deserialize, Clone, Default)]
@@ -89,10 +91,10 @@ impl RunJobQuery {
#[derive(Deserialize, Clone)]
pub struct ListQueueQuery {
pub script_path_start: Option<String>,
pub script_path_exact: Option<String>,
pub script_path_start: Option<NegatedListFilter<String>>,
pub script_path_exact: Option<NegatedListFilter<String>>,
pub script_hash: Option<String>,
pub created_by: Option<String>,
pub created_by: Option<NegatedListFilter<String>>,
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
@@ -103,12 +105,12 @@ pub struct ListQueueQuery {
pub schedule_path: Option<String>,
pub parent_job: Option<String>,
pub order_desc: Option<bool>,
pub job_kinds: Option<String>,
pub job_kinds: Option<NegatedListFilter<String>>,
pub suspended: Option<bool>,
pub worker: Option<String>,
pub worker: Option<NegatedListFilter<String>>,
// filter by matching a subset of the args using base64 encoded json subset
pub args: Option<String>,
pub tag: Option<String>,
pub tag: Option<NegatedListFilter<String>>,
pub scheduled_for_before_now: Option<bool>,
pub all_workspaces: Option<bool>,
pub is_flow_step: Option<bool>,
@@ -116,17 +118,17 @@ pub struct ListQueueQuery {
pub is_not_schedule: Option<bool>,
pub concurrency_key: Option<String>,
pub allow_wildcards: Option<bool>,
pub trigger_kind: Option<JobTriggerKind>,
pub trigger_path: Option<String>,
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
pub trigger_path: Option<NegatedListFilter<String>>,
pub include_args: Option<bool>,
}
#[derive(Deserialize, Clone)]
pub struct ListCompletedQuery {
pub script_path_start: Option<String>,
pub script_path_exact: Option<String>,
pub script_path_start: Option<NegatedListFilter<String>>,
pub script_path_exact: Option<NegatedListFilter<String>>,
pub script_hash: Option<String>,
pub created_by: Option<String>,
pub created_by: Option<NegatedListFilter<String>>,
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
@@ -142,7 +144,7 @@ pub struct ListCompletedQuery {
pub running: Option<bool>,
pub parent_job: Option<String>,
pub order_desc: Option<bool>,
pub job_kinds: Option<String>,
pub job_kinds: Option<NegatedListFilter<String>>,
pub is_skipped: Option<bool>,
pub is_flow_step: Option<bool>,
pub suspended: Option<bool>,
@@ -151,17 +153,17 @@ pub struct ListCompletedQuery {
pub args: Option<String>,
// filter by matching a subset of the result using base64 encoded json subset
pub result: Option<String>,
pub tag: Option<String>,
pub tag: Option<NegatedListFilter<String>>,
pub scheduled_for_before_now: Option<bool>,
pub all_workspaces: Option<bool>,
pub has_null_parent: Option<bool>,
pub label: Option<String>,
pub label: Option<NegatedListFilter<String>>,
pub is_not_schedule: Option<bool>,
pub concurrency_key: Option<String>,
pub worker: Option<String>,
pub worker: Option<NegatedListFilter<String>>,
pub allow_wildcards: Option<bool>,
pub trigger_kind: Option<JobTriggerKind>,
pub trigger_path: Option<String>,
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
pub trigger_path: Option<NegatedListFilter<String>>,
pub include_args: Option<bool>,
}
@@ -578,8 +580,7 @@ mod tests {
#[test]
fn test_decode_payload_valid() {
let payload = base64::engine::general_purpose::STANDARD
.encode(r#"{"key": "value"}"#);
let payload = base64::engine::general_purpose::STANDARD.encode(r#"{"key": "value"}"#);
let result: HashMap<String, serde_json::Value> = decode_payload(payload).unwrap();
assert_eq!(result["key"], json!("value"));
}
@@ -644,22 +645,15 @@ mod tests {
#[test]
fn test_run_job_query_payload_as_args_valid() {
let encoded = base64::engine::general_purpose::STANDARD
.encode(r#"{"x": 42}"#);
let q = RunJobQuery {
payload: Some(encoded),
..Default::default()
};
let encoded = base64::engine::general_purpose::STANDARD.encode(r#"{"x": 42}"#);
let q = RunJobQuery { payload: Some(encoded), ..Default::default() };
let result = q.payload_as_args().unwrap();
assert!(result.contains_key("x"));
}
#[test]
fn test_run_job_query_payload_as_args_invalid() {
let q = RunJobQuery {
payload: Some("invalid!!!".to_string()),
..Default::default()
};
let q = RunJobQuery { payload: Some("invalid!!!".to_string()), ..Default::default() };
assert!(q.payload_as_args().is_err());
}
@@ -668,10 +662,10 @@ mod tests {
#[test]
fn test_list_completed_to_queue_query_conversion() {
let lcq = ListCompletedQuery {
script_path_start: Some("f/test".to_string()),
script_path_start: Some(NegatedListFilter::positive(vec!["f/test".to_string()])),
script_path_exact: None,
script_hash: None,
created_by: Some("admin".to_string()),
created_by: Some(NegatedListFilter::positive(vec!["admin".to_string()])),
started_before: None,
started_after: None,
created_before: Some(chrono::Utc::now()),
@@ -687,14 +681,17 @@ mod tests {
running: Some(true),
parent_job: None,
order_desc: Some(true),
job_kinds: Some("script,flow".to_string()),
job_kinds: Some(NegatedListFilter::positive(vec![
"script".to_string(),
"flow".to_string(),
])),
is_skipped: None,
is_flow_step: None,
suspended: None,
schedule_path: None,
args: None,
result: None,
tag: Some("custom".to_string()),
tag: Some(NegatedListFilter::positive(vec!["custom".to_string()])),
scheduled_for_before_now: None,
all_workspaces: None,
has_null_parent: None,
@@ -709,11 +706,24 @@ mod tests {
};
let lqq: ListQueueQuery = lcq.into();
assert_eq!(lqq.script_path_start, Some("f/test".to_string()));
assert_eq!(lqq.created_by, Some("admin".to_string()));
assert_eq!(
lqq.script_path_start
.as_ref()
.and_then(|f| f.values.first().cloned()),
Some("f/test".to_string())
);
assert_eq!(
lqq.created_by
.as_ref()
.and_then(|f| f.values.first().cloned()),
Some("admin".to_string())
);
assert_eq!(lqq.running, Some(true));
assert_eq!(lqq.job_kinds, Some("script,flow".to_string()));
assert_eq!(lqq.tag, Some("custom".to_string()));
assert_eq!(lqq.job_kinds.as_ref().map(|f| f.values.len()), Some(2));
assert_eq!(
lqq.tag.as_ref().and_then(|f| f.values.first().cloned()),
Some("custom".to_string())
);
}
#[test]
+21 -2
View File
@@ -6,8 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_super_admin, ApiAuthed};
use windmill_common::DB;
use axum::{
extract::{Extension, Path, Query},
routing::{delete, get, post},
@@ -18,8 +16,10 @@ use serde::{Deserialize, Serialize};
use sql_builder::{prelude::Bind, SqlBuilder};
use sqlx::{Postgres, Transaction};
use std::str::FromStr;
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_super_admin, ApiAuthed};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::DB;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
@@ -486,8 +486,15 @@ pub struct ListScheduleQuery {
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
// filter by matching a subset of the args using base64 encoded json subset
pub args: Option<String>,
pub path_start: Option<String>,
// exact match on schedule path
pub schedule_path: Option<String>,
// filter on description (pattern match)
pub description: Option<String>,
// filter on summary (pattern match)
pub summary: Option<String>,
}
#[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
@@ -543,6 +550,18 @@ async fn list_schedule(
if let Some(path_start) = &lsq.path_start {
sqlb.and_where_like_left("path", path_start);
}
if let Some(schedule_path) = &lsq.schedule_path {
sqlb.and_where_eq("path", "?".bind(schedule_path));
}
if let Some(description) = &lsq.description {
sqlb.and_where(&format!(
"description ILIKE '%{}%'",
description.replace("'", "''")
));
}
if let Some(summary) = &lsq.summary {
sqlb.and_where(&format!("summary ILIKE '%{}%'", summary.replace("'", "''")));
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let rows = sqlx::query_as::<_, ScheduleLight>(&sql)
.fetch_all(&mut *tx)
@@ -30,7 +30,6 @@ use uuid::Uuid;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_types::s3::LargeFileStorage;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
@@ -55,6 +54,7 @@ use windmill_dep_map::scoped_dependency_map::{
DependencyDependent, DependencyMap, ScopedDependencyMap,
};
use windmill_git_sync::{handle_deployment_metadata, handle_fork_branch_creation, DeployedObject};
use windmill_types::s3::LargeFileStorage;
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
@@ -3010,8 +3010,8 @@ async fn clone_resource_types(
target_workspace_id: &str,
) -> Result<()> {
sqlx::query!(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension)
SELECT $2, name, schema, description, edited_at, created_by, format_extension
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)
SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset
FROM resource_type
WHERE workspace_id = $1",
source_workspace_id,
@@ -5254,7 +5254,7 @@ async fn compare_two_resource_types(
) -> Result<ItemComparison> {
// Get resource type from each workspace
let source_resource_type = sqlx::query!(
"SELECT schema, description, format_extension
"SELECT schema, description, format_extension, is_fileset
FROM resource_type
WHERE workspace_id = $1 AND name = $2",
source_workspace_id,
@@ -5264,7 +5264,7 @@ async fn compare_two_resource_types(
.await?;
let target_resource_type = sqlx::query!(
"SELECT schema, description, format_extension
"SELECT schema, description, format_extension, is_fileset
FROM resource_type
WHERE workspace_id = $1 AND name = $2",
fork_workspace_id,
@@ -5280,6 +5280,7 @@ async fn compare_two_resource_types(
if source.schema != target.schema
|| source.description != target.description
|| source.format_extension != target.format_extension
|| source.is_fileset != target.is_fileset
{
has_changes = true;
}
+82 -16
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.642.0
version: 1.643.0
title: Windmill API
contact:
@@ -4091,6 +4091,21 @@ paths:
in: query
schema:
type: string
- name: path
description: exact path match filter
in: query
schema:
type: string
- name: description
description: pattern match filter for description field (case-insensitive)
in: query
schema:
type: string
- name: value
description: pattern match filter for non-secret variable values (case-insensitive)
in: query
schema:
type: string
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
@@ -5090,6 +5105,21 @@ paths:
in: query
schema:
type: string
- name: path
description: exact path match filter
in: query
schema:
type: string
- name: description
description: pattern match filter for description field (case-insensitive)
in: query
schema:
type: string
- name: value
description: JSONB subset match filter using base64 encoded JSON
in: query
schema:
type: string
responses:
"200":
description: resource list
@@ -5214,10 +5244,19 @@ paths:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: map from resource type to file ext
description: map from resource type to file resource info
content:
application/json:
schema: {}
schema:
type: object
additionalProperties:
type: object
properties:
format_extension:
type: string
nullable: true
is_fileset:
type: boolean
/w/{workspace}/resources/type/delete/{path}:
delete:
@@ -11120,7 +11159,7 @@ paths:
- $ref: "#/components/parameters/PerPage"
- $ref: "#/components/parameters/ArgsFilter"
- name: path
description: filter by path
description: filter by path (script path)
in: query
schema:
type: string
@@ -11134,6 +11173,21 @@ paths:
in: query
schema:
type: string
- name: schedule_path
description: exact match on the schedule's path
in: query
schema:
type: string
- name: description
description: pattern match filter for description field (case-insensitive)
in: query
schema:
type: string
- name: summary
description: pattern match filter for summary field (case-insensitive)
in: query
schema:
type: string
responses:
"200":
description: schedule list
@@ -16885,6 +16939,16 @@ paths:
description: Filter by asset kinds (multiple values allowed)
schema:
type: string
- name: path
in: query
description: exact path match filter
schema:
type: string
- name: columns
in: query
description: JSONB subset match filter for columns using base64 encoded JSON
schema:
type: string
responses:
"200":
description: paginated assets in the workspace
@@ -17258,10 +17322,10 @@ components:
type: integer
JobTriggerKind:
name: trigger_kind
description: trigger kind (schedule, http, websocket...)
description: "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')"
in: query
schema:
$ref: "#/components/schemas/JobTriggerKind"
type: string
OrderDesc:
name: order_desc
description: order by desc order (default true)
@@ -17270,19 +17334,19 @@ components:
type: boolean
CreatedBy:
name: created_by
description: mask to filter exact matching user creator
description: "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')"
in: query
schema:
type: string
Label:
name: label
description: mask to filter exact matching job's label (job labels are completed jobs with as a result an object containing a string in the array at key 'wm_labels')
description: "filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release')"
in: query
schema:
type: string
Worker:
name: worker
description: worker this job was ran on
description: "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')"
in: query
schema:
type: string
@@ -17348,7 +17412,7 @@ components:
type: string
ScriptStartPath:
name: script_path_start
description: mask to filter matching starting path
description: "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')"
in: query
schema:
type: string
@@ -17360,13 +17424,13 @@ components:
type: string
TriggerPath:
name: trigger_path
description: mask to filter by trigger path
description: "filter by trigger path. Supports comma-separated list (e.g. 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2')"
in: query
schema:
type: string
ScriptExactPath:
name: script_path_exact
description: mask to filter exact matching path
description: "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')"
in: query
schema:
type: string
@@ -17481,7 +17545,7 @@ components:
type: string
Tag:
name: tag
description: filter on jobs with a given tag/worker group
description: "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')"
in: query
schema:
type: string
@@ -17525,9 +17589,7 @@ components:
enum: [Create, Update, Delete, Execute]
JobKinds:
name: job_kinds
description:
filter on job kind (values 'preview', 'script', 'dependencies', 'flow')
separated by,
description: "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')"
in: query
schema:
type: string
@@ -19832,6 +19894,8 @@ components:
format: date-time
format_extension:
type: string
is_fileset:
type: boolean
required:
- name
@@ -19841,6 +19905,8 @@ components:
schema: {}
description:
type: string
is_fileset:
type: boolean
Schedule:
type: object
+1 -1
View File
@@ -1933,7 +1933,7 @@ async fn count_completed_jobs_detail(
if let Some(after_s_ago) = query.completed_after_s_ago {
let after = Utc::now() - chrono::Duration::seconds(after_s_ago);
sqlb.and_where_gt("ended_at", "?".bind(&after.to_rfc3339()));
sqlb.and_where_gt("completed_at", "?".bind(&after.to_rfc3339()));
}
if let Some(success) = query.success {
+55 -15
View File
@@ -98,6 +98,7 @@ pub struct ResourceType {
pub created_by: Option<String>,
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
pub format_extension: Option<String>,
pub is_fileset: bool,
}
#[derive(Deserialize)]
@@ -106,12 +107,14 @@ pub struct CreateResourceType {
pub schema: Option<serde_json::Value>,
pub description: Option<String>,
pub format_extension: Option<String>,
pub is_fileset: Option<bool>,
}
#[derive(Deserialize)]
pub struct EditResourceType {
pub schema: Option<serde_json::Value>,
pub description: Option<String>,
pub is_fileset: Option<bool>,
}
#[derive(FromRow, Serialize, Deserialize)]
@@ -160,9 +163,13 @@ struct EditResource {
#[derive(Deserialize)]
pub struct ListResourceQuery {
resource_type: Option<String>,
resource_type_exclude: Option<String>,
path_start: Option<String>,
pub resource_type: Option<String>,
pub resource_type_exclude: Option<String>,
pub path_start: Option<String>,
pub path: Option<String>,
pub description: Option<String>,
// filter by matching a subset of the value using base64 encoded json subset
pub value: Option<String>,
}
#[derive(Serialize, FromRow)]
@@ -282,6 +289,18 @@ async fn list_resources(
sqlb.and_where_like_left("resource.path", path_start);
}
if let Some(path) = &lq.path {
sqlb.and_where_eq("resource.path", "?".bind(path));
}
if let Some(description) = &lq.description {
sqlb.and_where("resource.description ILIKE ?".bind(&format!("%{}%", description)));
}
if let Some(value) = &lq.value {
sqlb.and_where("resource.value @> ?".bind(&value.replace("'", "''")));
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableResource>(&sql)
@@ -1193,29 +1212,38 @@ async fn update_resource_value(
Ok(format!("value of resource {} updated", path))
}
#[derive(Serialize)]
pub struct FileResourceTypeInfo {
pub format_extension: Option<String>,
pub is_fileset: bool,
}
async fn file_resource_ext_to_resource_type(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<HashMap<String, String>> {
#[derive(Serialize, sqlx::FromRow)]
) -> JsonResult<HashMap<String, FileResourceTypeInfo>> {
#[derive(sqlx::FromRow)]
struct LocalFileResourceExtension {
name: String,
format_extension: Option<String>,
is_fileset: bool,
}
let r = sqlx::query_as!(LocalFileResourceExtension, "
SELECT name, format_extension FROM resource_type WHERE format_extension IS NOT NULL AND (workspace_id = $1 OR workspace_id = 'admins')", w_id)
SELECT name, format_extension, is_fileset FROM resource_type WHERE (format_extension IS NOT NULL OR is_fileset = true) AND (workspace_id = $1 OR workspace_id = 'admins')", w_id)
.fetch_all(&db)
.await?;
let hashmap: HashMap<String, String> = r
let hashmap: HashMap<String, FileResourceTypeInfo> = r
.into_iter()
.filter_map(|entry| {
if let Some(format_extension) = entry.format_extension {
Some((entry.name, format_extension))
} else {
None
}
.map(|entry| {
(
entry.name,
FileResourceTypeInfo {
format_extension: entry.format_extension,
is_fileset: entry.is_fileset,
},
)
})
.collect();
@@ -1315,16 +1343,25 @@ async fn create_resource_type(
check_rt_path_conflict(&mut tx, &w_id, &resource_type.name).await?;
let is_fileset = resource_type.is_fileset.unwrap_or(false);
if is_fileset && resource_type.format_extension.is_some() {
return Err(Error::BadRequest(
"A fileset resource type cannot have a format_extension".to_string(),
));
}
sqlx::query!(
"INSERT INTO resource_type
(workspace_id, name, schema, description, created_by, format_extension, edited_at)
VALUES ($1, $2, $3, $4, $5, $6, now())",
(workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
w_id,
resource_type.name,
resource_type.schema,
resource_type.description,
authed.username,
resource_type.format_extension,
is_fileset,
)
.execute(&mut *tx)
.await?;
@@ -1485,6 +1522,9 @@ async fn update_resource_type(
if let Some(ndesc) = ns.description {
sqlb.set_str("description", ndesc);
}
if let Some(is_fileset) = ns.is_fileset {
sqlb.set("is_fileset", if is_fileset { "TRUE" } else { "FALSE" });
}
sqlb.set_str("edited_at", "now()");
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
+72 -25
View File
@@ -96,7 +96,11 @@ async fn list_contextual_variables(
#[derive(Deserialize)]
struct ListVariableQuery {
path_start: Option<String>,
pub path_start: Option<String>,
pub path: Option<String>,
pub description: Option<String>,
// filter by matching the non-encrypted value (for non-secrets only)
pub value: Option<String>,
}
async fn list_variables(
@@ -106,33 +110,76 @@ async fn list_variables(
Query(lq): Query<ListVariableQuery>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<ListableVariable>> {
use sql_builder::{bind::Bind, SqlBuilder};
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
let mut sqlb = SqlBuilder::select_from("variable")
.fields(&[
"variable.workspace_id",
"variable.path",
"CASE WHEN is_secret IS TRUE THEN null ELSE variable.value::text END as value",
"is_secret",
"variable.description",
"variable.extra_perms",
"account",
"is_oauth",
"(now() > account.expires_at) as is_expired",
"account.refresh_error",
"resource.path IS NOT NULL as is_linked",
"account.refresh_token != '' as is_refreshed",
"variable.expires_at",
])
.left()
.join("account")
.on(&format!(
"variable.account = account.id AND account.workspace_id = '{}'",
w_id
))
.left()
.join("resource")
.on(&format!(
"resource.path = variable.path AND resource.workspace_id = '{}'",
w_id
))
.and_where("variable.workspace_id = ?".bind(&w_id))
.and_where(&format!(
"variable.path NOT LIKE 'u/' || '{}' || '/secret_arg/%'",
authed.username
))
.order_by("path", false)
.limit(per_page)
.offset(offset)
.clone();
let rows = sqlx::query_as::<_, ListableVariable>(
"SELECT variable.workspace_id, variable.path, CASE WHEN is_secret IS TRUE THEN null ELSE variable.value::text END as value,
is_secret, variable.description, variable.extra_perms, account, is_oauth, (now() > account.expires_at) as is_expired,
account.refresh_error,
resource.path IS NOT NULL as is_linked,
account.refresh_token != '' as is_refreshed,
variable.expires_at
from variable
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $1
LEFT JOIN resource ON resource.path = variable.path AND resource.workspace_id = $1
WHERE variable.workspace_id = $1 AND variable.path NOT LIKE 'u/' || $2 || '/secret_arg/%'
AND variable.path LIKE $3 || '%'
ORDER BY path
LIMIT $4 OFFSET $5
",
)
.bind(&w_id)
.bind(&authed.username)
.bind(&lq.path_start.unwrap_or_default())
.bind(per_page as i32)
.bind(offset as i32)
.fetch_all(&mut *tx)
.await?;
if let Some(path_start) = &lq.path_start {
sqlb.and_where_like_left("variable.path", path_start);
}
if let Some(path) = &lq.path {
sqlb.and_where_eq("variable.path", "?".bind(path));
}
if let Some(description) = &lq.description {
sqlb.and_where(&format!(
"variable.description ILIKE '%{}%'",
description.replace("'", "''")
));
}
if let Some(value) = &lq.value {
// Only filter on non-secret variables' value
sqlb.and_where(&format!(
"(is_secret = FALSE AND variable.value ILIKE '%{}%')",
value.replace("'", "''")
));
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableVariable>(&sql)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
+44 -46
View File
@@ -540,53 +540,48 @@ impl FlowModule {
) -> anyhow::Result<()> {
for module in modules {
cb(module)?;
match module
let module_value = module
.get_value()
.map_err(|e| anyhow::anyhow!("Module '{}': {}", module.id, e))?
{
FlowModuleValue::ForloopFlow { modules, .. }
| FlowModuleValue::WhileloopFlow { modules, .. } => {
Self::traverse_modules(&modules, cb)?;
}
FlowModuleValue::BranchOne { branches, default, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
Self::traverse_modules(&default, cb)?;
}
FlowModuleValue::BranchAll { branches, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
}
FlowModuleValue::AIAgent { tools, .. } => {
for tool in tools {
match &tool.value {
ToolValue::FlowModule(module_value) => match module_value {
FlowModuleValue::ForloopFlow { modules, .. }
| FlowModuleValue::WhileloopFlow { modules, .. } => {
Self::traverse_modules(&modules, cb)?;
}
FlowModuleValue::BranchOne { branches, default, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
Self::traverse_modules(&default, cb)?;
}
FlowModuleValue::BranchAll { branches, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
}
_ => {}
},
ToolValue::Mcp(_) => {}
ToolValue::Websearch(_) => {}
}
}
}
_ => {}
.map_err(|e| anyhow::anyhow!("Module '{}': {}", module.id, e))?;
Self::traverse_module_value(&module_value, cb)?;
}
Ok(())
}
fn traverse_module_value<C: FnMut(&FlowModule) -> anyhow::Result<()>>(
module_value: &FlowModuleValue,
cb: &mut C,
) -> anyhow::Result<()> {
match module_value {
FlowModuleValue::ForloopFlow { modules, .. }
| FlowModuleValue::WhileloopFlow { modules, .. } => {
Self::traverse_modules(modules, cb)?;
}
FlowModuleValue::BranchOne { branches, default, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
Self::traverse_modules(default, cb)?;
}
FlowModuleValue::BranchAll { branches, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
}
FlowModuleValue::AIAgent { tools, .. } => {
for tool in tools {
let Some(tool_module) = Option::<FlowModule>::from(tool) else {
continue;
};
cb(&tool_module)?;
let tool_value = tool_module
.get_value()
.map_err(|e| anyhow::anyhow!("Tool module '{}': {}", tool_module.id, e))?;
Self::traverse_module_value(&tool_value, cb)?;
}
}
_ => {}
}
Ok(())
}
@@ -1071,7 +1066,10 @@ impl Into<Box<RawValue>> for FlowModuleValue {
}
}
pub fn ordered_map<S>(value: &HashMap<String, InputTransform>, serializer: S) -> Result<S::Ok, S::Error>
pub fn ordered_map<S>(
value: &HashMap<String, InputTransform>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
+96 -50
View File
@@ -3,8 +3,8 @@ use crate::ai::types::McpToolSource;
use crate::ai::types::*;
use crate::ai::utils::{
add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow,
update_flow_status_module_with_actions, update_flow_status_module_with_actions_success,
FlowContext,
is_completed_input_transform, update_flow_status_module_with_actions,
update_flow_status_module_with_actions_success, FlowContext,
};
use crate::common::OccupancyMetrics;
use crate::result_processor::handle_non_flow_job_error;
@@ -21,7 +21,6 @@ use serde_json::value::RawValue;
use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
use windmill_common::ai_types::OpenAIToolCall;
use windmill_common::flows::InputTransform;
use windmill_common::jobs::JobPayload;
#[cfg(feature = "mcp")]
@@ -35,15 +34,15 @@ type McpClient = McpClientStub;
use windmill_common::{
client::AuthedClient,
db::DB,
error::{to_anyhow, Error},
error::Error,
flow_conversations::MessageType,
flow_status::AgentAction,
flows::FlowModuleValue,
worker::{to_raw_value, Connection},
};
use windmill_queue::{
get_mini_pulled_job, push, JobCompleted, MiniCompletedJob, MiniPulledJob, PushArgs,
PushIsolationLevel,
add_completed_job, add_completed_job_error, get_mini_pulled_job, push, MiniCompletedJob,
MiniPulledJob, PushArgs, PushIsolationLevel,
};
/// Context for tool execution containing all required references and state
@@ -54,8 +53,9 @@ pub struct ToolExecutionContext<'a> {
// Job context
pub job: &'a MiniPulledJob,
pub parent_job: &'a Uuid,
pub parent_job: Option<&'a Uuid>,
pub summary: &'a Option<&'a str>,
pub flow_step_id_override: Option<&'a str>,
// Execution parameters
pub client: &'a AuthedClient,
@@ -66,7 +66,6 @@ pub struct ToolExecutionContext<'a> {
// Runtime state
pub occupancy_metrics: &'a mut OccupancyMetrics,
pub job_completed_tx: &'a JobCompletedSender,
pub killpill_rx: &'a mut tokio::sync::broadcast::Receiver<()>,
// Optional streaming & chat
@@ -283,7 +282,9 @@ async fn execute_windmill_tool(
module_id: tool_module.id.clone(),
});
update_flow_status_module_with_actions(ctx.db, ctx.parent_job, actions).await?;
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions(ctx.db, parent_job, actions).await?;
}
let raw_tool_call_args = if tool_call.function.arguments.is_empty() {
"{}".to_string()
@@ -301,11 +302,14 @@ async fn execute_windmill_tool(
)
})?;
let tool_value = tool_module.get_value()?;
// Get input transforms given by the user and merge them with AI given args
let input_transforms = match tool_module.get_value()? {
FlowModuleValue::Script { input_transforms, .. } => input_transforms,
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
let input_transforms = match &tool_value {
FlowModuleValue::Script { input_transforms, .. }
| FlowModuleValue::RawScript { input_transforms, .. }
| FlowModuleValue::FlowScript { input_transforms, .. }
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
_ => {
return Err(Error::internal_err(format!(
"Unsupported tool: {}",
@@ -331,17 +335,8 @@ async fn execute_windmill_tool(
// Evaluate each input transform and merge with AI-provided args
for (key, transform) in input_transforms.iter() {
// We skip static empty / null values, those are the one the AI will fill in
match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
if val.is_empty() || val == "null" {
continue;
}
}
InputTransform::Ai => {
continue;
}
_ => (),
if !is_completed_input_transform(transform) {
continue;
}
let result = evaluate_input_transform::<Box<RawValue>>(
transform,
@@ -356,7 +351,7 @@ async fn execute_windmill_tool(
tool_call_args.insert(key.clone(), result);
}
let job_payload = match tool_module.get_value()? {
let job_payload = match tool_value {
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
script_to_payload(
script_hash,
@@ -380,7 +375,6 @@ async fn execute_windmill_tool(
} => {
let path = path
.unwrap_or_else(|| format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id));
raw_script_to_payload(
path,
content,
@@ -394,8 +388,7 @@ async fn execute_windmill_tool(
}
FlowModuleValue::FlowScript { id, language, concurrency_settings, tag, .. } => {
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
let payload = JobPayloadWithTag {
JobPayloadWithTag {
payload: JobPayload::FlowScript {
id,
language,
@@ -409,8 +402,29 @@ async fn execute_windmill_tool(
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
timeout: None,
on_behalf_of: None,
};
payload
}
}
FlowModuleValue::AIAgent { tools: sub_tools, .. } => {
let has_nested_agent_tools = sub_tools.iter().any(|t| {
matches!(
t.value,
windmill_common::flows::ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
)
});
if has_nested_agent_tools {
return Err(Error::internal_err(
"AI agent tools cannot be nested beyond 2 levels. The nested agent tool contains \
AIAgent sub-tools, which would exceed the maximum nesting depth.".to_string()
));
}
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
JobPayloadWithTag {
payload: JobPayload::AIAgent { path },
tag: None,
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
timeout: None,
on_behalf_of: None,
}
}
_ => {
return Err(Error::internal_err(format!(
@@ -452,8 +466,8 @@ async fn execute_windmill_tool(
None,
ctx.job.schedule_path(),
Some(ctx.job.id),
None,
None,
ctx.job.root_job.or(Some(ctx.job.id)),
ctx.job.flow_innermost_root_job.or(Some(ctx.job.id)),
Some(job_id),
false,
false,
@@ -544,7 +558,6 @@ async fn execute_windmill_tool(
ctx.occupancy_metrics.total_duration_of_running_jobs =
updated_occupancy.total_duration_of_running_jobs;
// Continue with match on handle_result
match handle_result {
Err(err) => {
handle_tool_execution_error(
@@ -627,7 +640,9 @@ async fn handle_tool_execution_error(
.await?;
}
update_flow_status_module_with_actions_success(ctx.db, ctx.parent_job, false).await?;
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
}
// Add tool message to conversation if chat_input_enabled (error case)
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false).await;
@@ -649,23 +664,50 @@ async fn handle_tool_execution_success(
let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok();
let result = if let Some(SendResult {
result: SendResultPayload::JobCompleted(JobCompleted { result, .. }),
..
}) = send_result.as_ref()
result: SendResultPayload::JobCompleted(ref jc), ..
}) = send_result
{
let result = result.clone();
ctx.job_completed_tx
.send(send_result.unwrap().result, true)
let result = jc.result.clone();
// Write tool completion to the DB inline instead of forwarding through
// the parent channel. Forwarding would deadlock for nested agents: the
// sub-tool result would fill the parent's bounded(1) channel, leaving
// no room for the agent's own completion from process_result.
if jc.success {
add_completed_job(
ctx.db,
&jc.job,
true,
false,
sqlx::types::Json(&*jc.result),
jc.result_columns.clone(),
jc.mem_peak,
jc.canceled_by.clone(),
false,
jc.duration,
jc.from_cache.unwrap_or(false),
)
.await
.map_err(to_anyhow)?;
.map_err(|e| Error::internal_err(format!("Failed to add completed job: {e}")))?;
} else {
let error_value: serde_json::Value =
serde_json::from_str(jc.result.get()).unwrap_or_else(|_| {
serde_json::json!({ "message": format!("Non serializable error: {}", jc.result.get()) })
});
add_completed_job_error(
ctx.db,
&jc.job,
jc.mem_peak,
jc.canceled_by.clone(),
error_value,
ctx.worker_name,
false,
jc.duration,
)
.await
.map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?;
}
result
} else {
if let Some(send_result) = send_result {
ctx.job_completed_tx
.send(send_result.result, true)
.await
.map_err(to_anyhow)?;
}
return Err(Error::internal_err(
"Tool job completed but no result".to_string(),
));
@@ -696,7 +738,9 @@ async fn handle_tool_execution_success(
.await?;
}
update_flow_status_module_with_actions_success(ctx.db, ctx.parent_job, success).await?;
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?;
}
// Add tool message to conversation if chat_input_enabled
let content = if success {
@@ -731,8 +775,10 @@ async fn add_tool_message_to_chat(
.and_then(|fs| fs.memory_id)
{
let db_clone = ctx.db.clone();
let step_name =
get_step_name_from_flow(ctx.summary.as_deref(), ctx.job.flow_step_id.as_deref());
let effective_step_id = ctx
.flow_step_id_override
.or(ctx.job.flow_step_id.as_deref());
let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id);
let content = content.to_string();
// Spawn task because we do not need to wait for the result
+17 -9
View File
@@ -62,6 +62,17 @@ pub fn parse_raw_script_schema(
Ok(to_raw_value(&schema))
}
pub fn is_completed_input_transform(transform: &InputTransform) -> bool {
match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
}
}
/// Filters out properties from a JSON schema that have completed input transforms.
/// This allows AI agents to only see and fill parameters that don't have user-configured values.
pub fn filter_schema_by_input_transforms(
@@ -77,14 +88,7 @@ pub fn filter_schema_by_input_transforms(
let keys_to_remove: HashSet<String> = input_transforms
.iter()
.filter_map(|(key, transform)| {
let is_completed = match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
};
let is_completed = is_completed_input_transform(transform);
if is_completed {
Some(key.clone())
} else {
@@ -123,10 +127,13 @@ pub fn filter_schema_by_input_transforms(
Ok(to_raw_value(&schema_value))
}
#[derive(Clone)]
pub struct FlowJobRunnableIdAndRawFlow {
pub runnable_id: Option<ScriptHash>,
pub raw_flow: Option<sqlx::types::Json<Box<RawValue>>>,
pub kind: JobKind,
pub parent_job: Option<Uuid>,
pub flow_step_id: Option<String>,
}
pub async fn get_flow_job_runnable_and_raw_flow(
@@ -135,7 +142,7 @@ pub async fn get_flow_job_runnable_and_raw_flow(
) -> windmill_common::error::Result<FlowJobRunnableIdAndRawFlow> {
let job = sqlx::query_as!(
FlowJobRunnableIdAndRawFlow,
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\" FROM v2_job WHERE id = $1",
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
job_id
)
.fetch_one(db)
@@ -690,6 +697,7 @@ pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
FlowModuleValue::Script { input_transforms, .. } => input_transforms,
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
_ => return false,
};
+134 -25
View File
@@ -47,7 +47,6 @@ use crate::{
},
common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier},
handle_child::run_future_with_polling_update_job_poller,
JobCompletedSender,
};
lazy_static::lazy_static! {
@@ -79,11 +78,57 @@ lazy_static::lazy_static! {
})
.unwrap_or_default()
};
static ref AI_AGENT_TOOL_SCHEMA: Box<RawValue> = to_raw_value(&serde_json::json!({
"type": "object",
"properties": {
"user_message": { "type": "string" },
},
"required": ["user_message"],
"additionalProperties": false,
}));
}
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
fn find_module_by_id(
modules: &Vec<FlowModule>,
target_id: &str,
) -> Result<Option<FlowModule>, Error> {
let mut found: Option<FlowModule> = None;
FlowModule::traverse_modules(modules, &mut |module| {
if found.is_none() && module.id == target_id {
found = Some(module.clone());
}
Ok(())
})
.map_err(|e| Error::internal_err(format!("Failed to traverse flow modules: {e}")))?;
Ok(found)
}
fn find_ai_agent_tool_module_in_parent_agent(
modules: &Vec<FlowModule>,
parent_agent_step_id: &str,
tool_module_id: &str,
) -> Result<Option<FlowModule>, Error> {
let Some(parent_agent_module) = find_module_by_id(modules, parent_agent_step_id)? else {
return Ok(None);
};
let FlowModuleValue::AIAgent { tools, .. } = parent_agent_module.get_value()? else {
return Ok(None);
};
for tool in tools {
if tool.id == tool_module_id {
return Ok(Option::<FlowModule>::from(&tool));
}
}
Ok(None)
}
pub async fn handle_ai_agent_job(
// connection
conn: &Connection,
@@ -97,7 +142,6 @@ pub async fn handle_ai_agent_job(
canceled_by: &mut Option<CanceledBy>,
mem_peak: &mut i32,
occupancy_metrics: &mut OccupancyMetrics,
job_completed_tx: &JobCompletedSender,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
@@ -117,26 +161,57 @@ pub async fn handle_ai_agent_job(
return handle_credentials_check(&args.provider).await;
}
let Some(flow_step_id) = &job.flow_step_id else {
return Err(Error::internal_err(
"AI agent job has no flow step id".to_string(),
));
};
// flow_step_id is set by the flow executor for top-level AI agents.
// For nested AI agent tools, it's not set (to avoid triggering flow step
// machinery on a parent that has no v2_job_status row), so we extract the
// tool module ID from the runnable_path which has the form ".../tools/{id}".
let flow_step_id = job
.flow_step_id
.as_deref()
.or_else(|| job.runnable_path().rsplit_once("/tools/").map(|(_, id)| id))
.ok_or_else(|| Error::internal_err("AI agent job has no flow step id".to_string()))?
.to_string();
let flow_step_id = &flow_step_id;
let Some(parent_job) = &job.parent_job else {
let Some(immediate_parent_job) = &job.parent_job else {
return Err(Error::internal_err(
"AI agent job has no parent job".to_string(),
));
};
let flow_job = get_flow_job_runnable_and_raw_flow(db, &parent_job).await?;
let mut flow_job_id = *immediate_parent_job;
let mut flow_job = get_flow_job_runnable_and_raw_flow(db, &flow_job_id).await?;
let direct_parent_job_kind = flow_job.kind;
let direct_parent_job_flow_step_id = flow_job.flow_step_id.clone();
// If the direct parent is an AI agent (nested tool case), go one level up to the flow.
if flow_job.kind == JobKind::AIAgent {
let Some(parent_job_id) = flow_job.parent_job else {
return Err(Error::internal_err(
"AI agent parent has no parent job".to_string(),
));
};
flow_job_id = parent_job_id;
flow_job = get_flow_job_runnable_and_raw_flow(db, &flow_job_id).await?;
if !matches!(
flow_job.kind,
JobKind::Flow | JobKind::FlowNode | JobKind::FlowPreview
) {
return Err(Error::internal_err(
"AI agent nesting beyond 2 levels is not supported. \
Only flow agent nested agent tool is allowed."
.to_string(),
));
}
}
let flow_data = match flow_job.kind {
JobKind::Flow | JobKind::FlowNode => {
cache::job::fetch_flow(db, &flow_job.kind, flow_job.runnable_id).await?
}
JobKind::FlowPreview => {
cache::job::fetch_preview_flow(db, &parent_job, flow_job.raw_flow).await?
cache::job::fetch_preview_flow(db, &flow_job_id, flow_job.raw_flow).await?
}
_ => {
return Err(Error::internal_err(
@@ -147,8 +222,18 @@ pub async fn handle_ai_agent_job(
let value = flow_data.value();
let module = value.modules.iter().find(|m| m.id == *flow_step_id);
let summary = module.as_ref().and_then(|m| m.summary.clone());
let module = if direct_parent_job_kind == JobKind::AIAgent {
let parent_agent_step_id = direct_parent_job_flow_step_id.as_deref().ok_or_else(|| {
Error::internal_err("Parent AI agent job has no flow_step_id".to_string())
})?;
find_ai_agent_tool_module_in_parent_agent(
&value.modules,
parent_agent_step_id,
flow_step_id,
)?
} else {
find_module_by_id(&value.modules, flow_step_id)?
};
let Some(module) = module else {
return Err(Error::internal_err(
@@ -156,6 +241,8 @@ pub async fn handle_ai_agent_job(
));
};
let summary = module.summary.clone();
let FlowModuleValue::AIAgent { tools, .. } = module.get_value()? else {
return Err(Error::internal_err(
"AI agent module is not an AI agent".to_string(),
@@ -285,6 +372,16 @@ pub async fn handle_ai_agent_job(
let schema = Some(parse_raw_script_schema(&content, &language)?);
(schema, input_transforms)
}
FlowModuleValue::AIAgent { input_transforms, .. } => {
// By convention for AIAgent tools, only user_message is expected to be AI-filled.
(
Some(
RawValue::from_string(AI_AGENT_TOOL_SCHEMA.get().to_string())
.expect("AI_AGENT_TOOL_SCHEMA should always be valid JSON"),
),
input_transforms,
)
}
_ => {
return Err(Error::internal_err(format!(
"Unsupported tool: {}",
@@ -342,18 +439,24 @@ pub async fn handle_ai_agent_job(
stream_notifier.update_flow_status_with_stream_job();
}
let flow_status_job = if direct_parent_job_kind == JobKind::AIAgent {
None
} else {
Some(flow_job_id)
};
let agent_fut = run_agent(
db,
conn,
job,
parent_job,
flow_status_job.as_ref(),
Some(flow_step_id.as_str()),
&args,
&tools,
&mcp_clients,
summary.as_deref(),
client,
&mut inner_occupancy_metrics,
job_completed_tx,
worker_dir,
base_internal_url,
worker_name,
@@ -391,7 +494,8 @@ pub async fn run_agent(
// agent job and flow data
job: &MiniPulledJob,
parent_job: &Uuid,
parent_job: Option<&Uuid>,
flow_step_id_override: Option<&str>,
args: &AIAgentArgs,
tools: &[Tool],
mcp_clients: &HashMap<String, Arc<McpClient>>,
@@ -400,7 +504,6 @@ pub async fn run_agent(
// job execution context
client: &AuthedClient,
occupancy_metrics: &mut OccupancyMetrics,
job_completed_tx: &JobCompletedSender,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
@@ -433,6 +536,10 @@ pub async fn run_agent(
vec![]
};
// Effective flow_step_id: override for nested agents, otherwise from job
let effective_flow_step_id: Option<&str> =
flow_step_id_override.or(job.flow_step_id.as_deref());
// Fetch flow context for input transforms context, chat and memory
let mut flow_context = get_flow_context(db, job).await;
@@ -479,7 +586,7 @@ pub async fn run_agent(
}
Some(Memory::Auto { context_length, .. }) => {
// Auto mode: load from memory
if let Some(step_id) = job.flow_step_id.as_deref() {
if let Some(step_id) = effective_flow_step_id {
if let Some(memory_id) = memory_id {
// Read messages from memory
match read_from_memory(db, &job.workspace_id, memory_id, step_id).await {
@@ -534,9 +641,8 @@ pub async fn run_agent(
let id_context = {
if let Some(ref flow_status) = flow_context.flow_status {
// Get the step ID from the AI agent's flow step
let previous_id = job
.flow_step_id
.clone()
let previous_id = effective_flow_step_id
.map(str::to_string)
.unwrap_or_else(|| "unknown".to_string());
Some(get_transform_context(job, &previous_id, flow_status))
@@ -649,7 +755,7 @@ pub async fn run_agent(
.and_then(|fs| fs.chat_input_enabled)
.unwrap_or(false);
let step_name = get_step_name_from_flow(summary.as_deref(), job.flow_step_id.as_deref());
let step_name = get_step_name_from_flow(summary.as_deref(), effective_flow_step_id);
let max_iterations = args
.max_iterations
@@ -881,8 +987,11 @@ pub async fn run_agent(
..Default::default()
});
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
update_flow_status_module_with_actions_success(db, parent_job, true).await?;
if let Some(parent_job) = parent_job {
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
update_flow_status_module_with_actions_success(db, parent_job, true)
.await?;
}
content = Some(OpenAIContent::Text(response_content.clone()));
@@ -940,13 +1049,13 @@ pub async fn run_agent(
job,
parent_job,
summary: &summary,
flow_step_id_override,
client,
worker_dir,
base_internal_url,
worker_name,
hostname,
occupancy_metrics,
job_completed_tx,
killpill_rx,
stream_event_processor: stream_event_processor.as_ref(),
flow_context: &mut flow_context,
@@ -1071,7 +1180,7 @@ pub async fn run_agent(
// final_messages contains the complete history (old messages + new ones)
if matches!(output_type, OutputType::Text) && !use_manual_messages {
if let Some(Memory::Auto { context_length, .. }) = &args.memory {
if let Some(step_id) = job.flow_step_id.as_deref() {
if let Some(step_id) = effective_flow_step_id {
// Extract OpenAIMessages from final_messages
let all_messages: Vec<OpenAIMessage> =
final_messages.iter().map(|m| m.message.clone()).collect();
-1
View File
@@ -3398,7 +3398,6 @@ pub async fn handle_queued_job(
&mut canceled_by,
&mut mem_peak,
&mut *occupancy_metrics,
&job_completed_tx,
worker_dir,
base_internal_url,
worker_name,
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.642.0";
export const VERSION = "v1.643.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+87 -96
View File
@@ -6,16 +6,11 @@
"": {
"name": "wmill-dev",
"dependencies": {
"@ayonli/jsext": "^1.9.0",
"@cliffy/ansi": "npm:@jsr/cliffy__ansi@1.0.0",
"@cliffy/command": "npm:@jsr/cliffy__command@1.0.0",
"@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0",
"@cliffy/table": "npm:@jsr/cliffy__table@1.0.0",
"@std/encoding": "npm:@jsr/std__encoding@1.0.10",
"@std/log": "npm:@jsr/std__log@0.224.14",
"@std/path": "npm:@jsr/std__path@1.1.4",
"@std/yaml": "npm:@jsr/std__yaml@1.0.10",
"@windmill-labs/shared-utils": "npm:@jsr/windmill-labs__shared-utils@1.0.12",
"@windmill-labs/shared-utils": "^1.0.12",
"diff": "^5.2.0",
"esbuild": "0.24.2",
"get-port": "7.1.0",
@@ -23,6 +18,7 @@
"minimatch": "^10.0.0",
"open": "^10.0.0",
"svelte": "^5.45.2",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-csharp": "*",
"windmill-parser-wasm-go": "*",
"windmill-parser-wasm-java": "*",
@@ -44,25 +40,11 @@
"devDependencies": {
"@types/diff": "^5.2.3",
"@types/node": "^22.0.0",
"@types/tar-stream": "^3.1.4",
"@types/ws": "^8.5.0",
"typescript": "^5.7.0"
}
},
"node_modules/@ayonli/jsext": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@ayonli/jsext/-/jsext-1.9.0.tgz",
"integrity": "sha512-hIu6lQhoLr5e26lmt+vzopuZffaAyb623r4+8HlN/rhXgm2ywHslzk7UHiATdfDbfPjBARkB6cfXjVEi3aav6g==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"sudo-prompt": "^9.2.1",
"ws": "^8.17.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@cliffy/ansi": {
"name": "@jsr/cliffy__ansi",
"version": "1.0.0",
@@ -623,15 +605,6 @@
"resolved": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.9.tgz",
"integrity": "sha512-YFJJMozmORj2K91c5J9opWeh0VUwrd+Mwb7Pr0FkVCAKVLu2UhT4LyvJqWiyUT+eF+MdfqQ9F7RtQj4bXn9Smw=="
},
"node_modules/@jsr/std__fs": {
"version": "1.0.23",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.23.tgz",
"integrity": "sha512-e8jspB3M44E5YhWiLCTqibBBTwVmxQaHN06WvFa/elAKm5E/LfAe8Hj5XGNC8P7a0MIPASlNJsnF1bgO/g+aqg==",
"dependencies": {
"@jsr/std__internal": "^1.0.12",
"@jsr/std__path": "^1.1.4"
}
},
"node_modules/@jsr/std__internal": {
"version": "1.0.12",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz",
@@ -671,38 +644,6 @@
"@jsr/std__regexp": "^1.0.1"
}
},
"node_modules/@std/encoding": {
"name": "@jsr/std__encoding",
"version": "1.0.10",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz",
"integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw=="
},
"node_modules/@std/log": {
"name": "@jsr/std__log",
"version": "0.224.14",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__log/0.224.14.tgz",
"integrity": "sha512-EHT7E0plakyzk/gxMrwqUf3YGCCxN3Is25QrEh7toYA7qwj46R4qY7cIaDEKy8QqI5JHOFHwWXOClcPK6goIoQ==",
"dependencies": {
"@jsr/std__fmt": "^1.0.5",
"@jsr/std__fs": "^1.0.11",
"@jsr/std__io": "^0.225.2"
}
},
"node_modules/@std/path": {
"name": "@jsr/std__path",
"version": "1.1.4",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz",
"integrity": "sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA==",
"dependencies": {
"@jsr/std__internal": "^1.0.12"
}
},
"node_modules/@std/yaml": {
"name": "@jsr/std__yaml",
"version": "1.0.10",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__yaml/1.0.10.tgz",
"integrity": "sha512-1WIM023Kvi48pvPE3UO5YcieambLgywUooLhAkkaObIcMB77F/YP2ILdl+vNfik+vElkl9znmuST9AZo8mbCpA=="
},
"node_modules/@stoplight/ordered-object-literal": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz",
@@ -784,6 +725,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/tar-stream": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.4.tgz",
"integrity": "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -852,6 +803,20 @@
"node": ">= 0.4"
}
},
"node_modules/b4a": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
"integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/balanced-match": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
@@ -861,6 +826,20 @@
"node": "20 || >=22"
}
},
"node_modules/bare-events": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
@@ -1013,12 +992,27 @@
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -1047,18 +1041,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -1263,12 +1245,6 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/set-immediate-shim": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
@@ -1278,6 +1254,17 @@
"node": ">=0.10.0"
}
},
"node_modules/streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -1287,13 +1274,6 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/sudo-prompt": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/svelte": {
"version": "5.53.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.2.tgz",
@@ -1321,6 +1301,26 @@
"node": ">=18"
}
},
"node_modules/tar-stream": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1484,15 +1484,6 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+3 -1
View File
@@ -16,6 +16,7 @@ interface HubResourceType {
schema: string;
app: string;
description: string;
is_fileset?: boolean;
}
export async function pull(opts: GlobalOptions) {
@@ -114,7 +115,8 @@ export async function pull(opts: GlobalOptions) {
y.name === x.name &&
typeof y.schema !== "string" &&
deepEqual(y.schema, x.schema) &&
y.description === x.description
y.description === x.description &&
(y.is_fileset ?? false) === (x.is_fileset ?? false)
)
) {
log.info("skipping " + x.name + " (same as current)");
@@ -24,6 +24,7 @@ import { capitalize, toCamel } from "../../utils/utils.ts";
export interface ResourceTypeFile {
schema?: any;
description?: string;
is_fileset?: boolean;
}
export async function pushResourceType(
+24 -2
View File
@@ -1,5 +1,6 @@
import { stat, writeFile } from "node:fs/promises";
import { stat, writeFile, readdir, readFile } from "node:fs/promises";
import { stringify as yamlStringify } from "yaml";
import nodePath from "node:path";
import {
GlobalOptions,
@@ -27,6 +28,24 @@ export interface ResourceFile {
is_oauth?: boolean; // deprecated
}
async function readFilesetDirectory(dirPath: string): Promise<Record<string, string>> {
const result: Record<string, string> = {};
async function walk(currentPath: string, prefix: string) {
const entries = await readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = nodePath.join(currentPath, entry.name);
const relPath = prefix ? prefix + "/" + entry.name : entry.name;
if (entry.isDirectory()) {
await walk(entryPath, relPath);
} else if (entry.isFile()) {
result[relPath] = await readFile(entryPath, "utf-8");
}
}
}
await walk(dirPath, "");
return result;
}
export async function pushResource(
workspace: string,
remotePath: string,
@@ -46,7 +65,10 @@ export async function pushResource(
// Helper function to resolve inline content
const resolveInlineContent = async () => {
if (localResource.value["content"]?.startsWith("!inline ")) {
if (typeof localResource.value === "string" && localResource.value.startsWith("!inline_fileset ")) {
const dirPath = localResource.value.split(" ")[1];
localResource.value = await readFilesetDirectory(dirPath.replaceAll("/", SEP));
} else if (localResource.value["content"]?.startsWith("!inline ")) {
const basePath = localResource.value["content"].split(" ")[1];
// If we're processing a branch-specific metadata file, read from branch-specific resource file
+132 -10
View File
@@ -38,6 +38,7 @@ import {
deepEqual,
fetchRemoteVersion,
isFileResource,
isFilesetResource,
isRawAppFile,
isWorkspaceDependencies,
} from "../../utils/utils.ts";
@@ -484,11 +485,53 @@ export function extractInlineScriptsForApps(
return [];
}
type FileResourceTypeInfo = { format_extension: string | null; is_fileset: boolean };
function parseFileResourceTypeMap(
raw: Record<string, string | FileResourceTypeInfo>,
): { formatExtMap: Record<string, string>; filesetMap: Record<string, boolean> } {
const formatExtMap: Record<string, string> = {};
const filesetMap: Record<string, boolean> = {};
for (const [k, v] of Object.entries(raw)) {
if (typeof v === "string") {
formatExtMap[k] = v;
filesetMap[k] = false;
} else {
if (v.format_extension) {
formatExtMap[k] = v.format_extension;
}
filesetMap[k] = v.is_fileset ?? false;
}
}
return { formatExtMap, filesetMap };
}
async function findFilesetResourceFile(changePath: string): Promise<string> {
// Extract the base path before .fileset/
const filesetIdx = changePath.indexOf(".fileset" + SEP);
if (filesetIdx === -1) {
throw new Error(`Not a fileset resource path: ${changePath}`);
}
const basePath = changePath.substring(0, filesetIdx);
const candidates = [basePath + ".resource.json", basePath + ".resource.yaml"];
for (const candidate of candidates) {
try {
const s = await stat(candidate);
if (s.isFile()) return candidate;
} catch {
// not found, try next
}
}
throw new Error(`No resource metadata file found for fileset resource: ${changePath}`);
}
function ZipFSElement(
zip: JSZip,
useYaml: boolean,
defaultTs: "bun" | "deno",
resourceTypeToFormatExtension: Record<string, string>,
resourceTypeToIsFileset: Record<string, boolean>,
ignoreCodebaseChanges: boolean,
): DynFSElement {
async function _internal_file(
@@ -860,10 +903,17 @@ function ZipFSElement(
log.error(`Failed to parse resource.yaml at path: ${p}`);
throw error;
}
const resourceType = parsed["resource_type"];
const formatExtension =
resourceTypeToFormatExtension[parsed["resource_type"]];
resourceTypeToFormatExtension[resourceType];
const isFileset = resourceTypeToIsFileset[resourceType] ?? false;
if (formatExtension) {
if (isFileset) {
parsed["value"] =
"!inline_fileset " +
removeSuffix(p.replaceAll(SEP, "/"), ".resource.json") +
".fileset";
} else if (formatExtension) {
parsed["value"]["content"] =
"!inline " +
removeSuffix(p.replaceAll(SEP, "/"), ".resource.json") +
@@ -918,10 +968,37 @@ function ZipFSElement(
log.error(`Failed to parse resource file content at path: ${p}`);
throw error;
}
const resourceType = parsed["resource_type"];
const formatExtension =
resourceTypeToFormatExtension[parsed["resource_type"]];
resourceTypeToFormatExtension[resourceType];
const isFileset = resourceTypeToIsFileset[resourceType] ?? false;
if (formatExtension) {
if (isFileset && typeof parsed["value"] === "object" && parsed["value"] !== null) {
const filesetBasePath =
removeSuffix(finalPath, ".resource.json") + ".fileset";
// Push directory entry for the fileset
r.push({
isDirectory: true,
path: filesetBasePath,
async *getChildren() {
for (const [relPath, fileContent] of Object.entries(parsed["value"])) {
if (typeof fileContent === "string") {
yield {
isDirectory: false,
path: path.join(filesetBasePath, relPath),
async *getChildren() {},
async getContentText() {
return fileContent;
},
};
}
}
},
async getContentText() {
throw new Error("Cannot get content of directory");
},
});
} else if (formatExtension) {
const fileContent: string = parsed["value"]["content"];
if (typeof fileContent === "string") {
r.push({
@@ -1058,6 +1135,7 @@ export async function elementsToMap(
const path = entry.path;
if (
!isFileResource(path) &&
!isFilesetResource(path) &&
!isRawAppFile(path) &&
!isWorkspaceDependencies(path)
) {
@@ -1103,7 +1181,7 @@ export async function elementsToMap(
}
}
if (skips.skipResources && isFileResource(path)) continue;
if (skips.skipResources && (isFileResource(path) || isFilesetResource(path))) continue;
const ext = json ? ".json" : ".yaml";
if (!skips.includeSchedules && path.endsWith(".schedule" + ext)) continue;
@@ -1715,10 +1793,14 @@ export async function pull(
);
let resourceTypeToFormatExtension: Record<string, string> = {};
let resourceTypeToIsFileset: Record<string, boolean> = {};
try {
resourceTypeToFormatExtension = (await wmill.fileResourceTypeToFileExtMap({
const raw = (await wmill.fileResourceTypeToFileExtMap({
workspace: workspace.workspaceId,
})) as Record<string, string>;
})) as Record<string, string | FileResourceTypeInfo>;
const parsed = parseFileResourceTypeMap(raw);
resourceTypeToFormatExtension = parsed.formatExtMap;
resourceTypeToIsFileset = parsed.filesetMap;
} catch {
// ignore
}
@@ -1745,6 +1827,7 @@ export async function pull(
!opts.json,
opts.defaultTs ?? "bun",
resourceTypeToFormatExtension,
resourceTypeToIsFileset,
true,
);
@@ -2241,10 +2324,14 @@ export async function push(
),
);
let resourceTypeToFormatExtension: Record<string, string> = {};
let resourceTypeToIsFileset: Record<string, boolean> = {};
try {
resourceTypeToFormatExtension = (await wmill.fileResourceTypeToFileExtMap({
const raw = (await wmill.fileResourceTypeToFileExtMap({
workspace: workspace.workspaceId,
})) as Record<string, string>;
})) as Record<string, string | FileResourceTypeInfo>;
const parsed = parseFileResourceTypeMap(raw);
resourceTypeToFormatExtension = parsed.formatExtMap;
resourceTypeToIsFileset = parsed.filesetMap;
} catch {
// ignore
}
@@ -2269,6 +2356,7 @@ export async function push(
!opts.json,
opts.defaultTs ?? "bun",
resourceTypeToFormatExtension,
resourceTypeToIsFileset,
false,
);
@@ -2587,6 +2675,39 @@ export async function push(
continue;
}
}
if (isFilesetResource(change.path)) {
const resourceFilePath = await findFilesetResourceFile(change.path);
if (!alreadySynced.includes(resourceFilePath)) {
alreadySynced.push(resourceFilePath);
const newObj = parseFromPath(
resourceFilePath,
await readFile(resourceFilePath, "utf-8"),
);
let serverPath = resourceFilePath;
const currentBranch = cachedBranchForPush;
if (currentBranch && isBranchSpecificFile(resourceFilePath)) {
serverPath = fromBranchSpecificPath(
resourceFilePath,
currentBranch,
);
}
await pushResource(
workspace.workspaceId,
serverPath,
undefined,
newObj,
resourceFilePath,
);
if (stateTarget) {
await writeFile(stateTarget, change.after, "utf-8");
}
continue;
}
}
const oldObj = parseFromPath(change.path, change.before);
const newObj = parseFromPath(change.path, change.after);
@@ -2619,7 +2740,8 @@ export async function push(
change.path.endsWith(".script.json") ||
change.path.endsWith(".script.yaml") ||
change.path.endsWith(".lock") ||
isFileResource(change.path)
isFileResource(change.path) ||
isFilesetResource(change.path)
) {
continue;
} else if (
+10 -2
View File
@@ -1,6 +1,6 @@
import { minimatch } from "minimatch";
import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts";
import { isFileResource } from "../utils/utils.ts";
import { isFileResource, isFilesetResource } from "../utils/utils.ts";
import { SyncOptions } from "./conf.ts";
import { TRIGGER_TYPES } from "../types.ts";
@@ -165,7 +165,7 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC
return specificItems.settings !== undefined;
}
if (isFileResource(path)) {
if (isFileResource(path) || isFilesetResource(path)) {
return specificItems.resources !== undefined;
}
@@ -219,6 +219,14 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig
}
}
if (isFilesetResource(path)) {
const basePathMatch = path.match(/^(.+?)\.fileset[/\\]/);
if (basePathMatch && specificItems.resources) {
const basePath = basePathMatch[1] + '.resource.yaml';
return matchesPatterns(basePath, specificItems.resources);
}
}
return false;
}
Regular → Executable
+1 -1
View File
@@ -65,7 +65,7 @@ export {
workspaceAdd,
};
export const VERSION = "1.642.0";
export const VERSION = "1.643.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
+2 -2
View File
@@ -14,7 +14,7 @@ import { pushResourceType } from "./commands/resource-type/resource-type.ts";
import { pushVariable } from "./commands/variable/variable.ts";
import { yamlOptions } from "./commands/sync/sync.ts";
import { showDiffs } from "./core/conf.ts";
import { deepEqual, isFileResource, isWorkspaceDependencies } from "./utils/utils.ts";
import { deepEqual, isFileResource, isFilesetResource, isWorkspaceDependencies } from "./utils/utils.ts";
import { pushSchedule } from "./commands/schedule/schedule.ts";
import { pushWorkspaceUser } from "./commands/user/user.ts";
import { pushGroup } from "./commands/user/user.ts";
@@ -333,7 +333,7 @@ export function getTypeStrFromPath(
) {
return typeEnding;
} else {
if (isFileResource(p)) {
if (isFileResource(p) || isFilesetResource(p)) {
return "resource";
}
throw new Error("Could not infer type of path " + JSON.stringify(parsed));
+5
View File
@@ -154,6 +154,11 @@ export function isFileResource(path: string): boolean {
);
}
/** Matches children inside a .fileset/ directory, not the directory itself. */
export function isFilesetResource(path: string): boolean {
return path.includes(".fileset/") || path.includes(".fileset\\");
}
export function isRawAppFile(path: string): boolean {
return isRawAppPath(path);
}
+32 -1
View File
@@ -4,7 +4,7 @@
*/
import { expect, test, describe } from "bun:test";
import { deepEqual, isFileResource, toCamel, capitalize } from "../src/utils/utils.ts";
import { deepEqual, isFileResource, isFilesetResource, toCamel, capitalize } from "../src/utils/utils.ts";
import {
getTypeStrFromPath,
removeType,
@@ -156,6 +156,32 @@ describe("isFileResource", () => {
});
});
// =============================================================================
// isFilesetResource
// =============================================================================
describe("isFilesetResource", () => {
test("detects fileset resource paths (unix separator)", () => {
expect(isFilesetResource("f/test/my_config.fileset/config.yaml")).toBe(true);
expect(isFilesetResource("u/admin/templates.fileset/path/to/file.txt")).toBe(true);
});
test("detects fileset resource paths (windows separator)", () => {
expect(isFilesetResource("f\\test\\my_config.fileset\\config.yaml")).toBe(true);
});
test("rejects non-fileset paths", () => {
expect(isFilesetResource("f/test/my_resource.resource.yaml")).toBe(false);
expect(isFilesetResource("f/test/my_file.resource.file.txt")).toBe(false);
expect(isFilesetResource("f/test/my_script.ts")).toBe(false);
});
test("rejects paths ending with .fileset (no child file)", () => {
// The directory itself is not a fileset resource file - only children are
expect(isFilesetResource("f/test/my_config.fileset")).toBe(false);
});
});
// =============================================================================
// removeType
// =============================================================================
@@ -241,6 +267,11 @@ describe("getTypeStrFromPath", () => {
expect(getTypeStrFromPath("devs.group.yaml")).toBe("group");
});
test("detects fileset resource files as resource type", () => {
expect(getTypeStrFromPath("f/test/my_config.fileset/config.yaml")).toBe("resource");
expect(getTypeStrFromPath("u/admin/templates.fileset/path/to/file.txt")).toBe("resource");
});
test("throws for unknown type", () => {
expect(() => getTypeStrFromPath("f/test/unknown.xyz.yaml")).toThrow();
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.642.0",
"version": "1.643.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.642.0",
"version": "1.643.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.642.0",
"version": "1.643.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -1,5 +1,6 @@
<script lang="ts">
import { OauthService, type ResourceType } from '$lib/gen'
import FilesetEditor from './FilesetEditor.svelte'
import { workspaceStore } from '$lib/stores'
import { emptySchema, emptyString } from '$lib/utils'
import SchemaForm from './SchemaForm.svelte'
@@ -79,7 +80,7 @@
rawCode = JSON.stringify(args, null, 2)
} else {
parseJson()
if (resourceTypeInfo?.format_extension) {
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
textFileContent = args.content
}
}
@@ -237,6 +238,11 @@
/>
{/await}
</div>
{:else if resourceTypeInfo?.is_fileset}
<h5 class="mt-1 inline-flex items-center gap-4">
Fileset
</h5>
<FilesetEditor bind:args />
{:else if resourceTypeInfo?.format_extension}
<h5 class="mt-4 inline-flex items-center gap-4">
File content ({resourceTypeInfo.format_extension})
@@ -260,6 +260,6 @@
} as any)
</script>
<div class="relative max-h-40">
<div class="relative h-44">
<Line {data} {options} />
</div>
@@ -184,13 +184,13 @@
unifiedSize="md"
wrapperClasses="h-full"
{disabled}
iconOnly
endIcon={{ icon: X }}
on:click={() => {
value = null
dispatch('clear')
}}
>
<X size={14} />
</Button>
></Button>
{/if}
<!-- <div>
<ToggleButtonGroup bind:selected={format} let:item>
@@ -0,0 +1,50 @@
<script lang="ts">
type Item = {
label: string
icon?: any
right?: string
onClick?: () => void
onHover?: (hover: boolean) => void
}
export type Props = {
closeCallback?: () => void
items: Item[]
}
let { items, closeCallback }: Props = $props()
</script>
<ul class="bg-surface-tertiary rounded-md border w-56 relative drop-shadow-base">
{#each items as item}
<li class="w-full">
<button
class="px-3 h-9 text-xs cursor-pointer hover:bg-surface-hover font-normal w-full text-left flex items-center gap-2.5"
onclick={() => {
item.onClick?.()
item.onHover?.(false)
closeCallback?.()
}}
onmouseenter={() => item.onHover?.(true)}
onmouseleave={() => item.onHover?.(false)}
>
{#if item.icon}
<item.icon size="16"></item.icon>
{/if}
<span class="flex-1">
{item.label}
</span>
{#if item.right}
<span class="text-xs text-hint">{item.right}</span>
{/if}
</button>
</li>
{/each}
{#if items.length === 0}
<li class="w-full">
<div
class="px-3 h-9 text-xs font-normal w-full text-left flex items-center gap-2.5 text-hint"
>
No actions available
</div>
</li>
{/if}
</ul>
@@ -1,38 +0,0 @@
<script lang="ts">
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { ChevronDown } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { Item } from '$lib/utils'
interface Props {
items?: Item[]
extraLabel?: import('svelte').Snippet
selected: string
selectedDisplayName?: string
btnClasses?: string
}
let { items = [], extraLabel, selected, selectedDisplayName, btnClasses }: Props = $props()
const filteredItems = $derived(items.filter((item) => item.id !== selected))
</script>
<DropdownV2 items={filteredItems}>
{#snippet buttonReplacement()}
<div
class={twMerge(
'p-2 h-8 flex flex-row items-center gap-2 border hover:bg-surface-hover cursor-pointer rounded-md',
btnClasses
)}
>
<div class="flex flex-row items-center gap-1 pr-2 justify-between w-full">
<span class="text-xs whitespace-nowrap">
{selectedDisplayName ?? items.find((item) => item.id === selected)?.displayName ?? ''}
</span>
{@render extraLabel?.()}
</div>
<ChevronDown size={12} />
</div>
{/snippet}
</DropdownV2>
@@ -157,7 +157,7 @@
variant="subtle"
startIcon={{ icon: EllipsisVertical }}
btnClasses="bg-transparent"
iconOnly
iconOnly={!btnText}
>
{btnText}
</Button>
@@ -0,0 +1,293 @@
<script lang="ts">
import Button from './common/button/Button.svelte'
import { Plus, File, Folder, FolderOpen } from 'lucide-svelte'
import FileTreeNode from './raw_apps/FileTreeNode.svelte'
import type { TreeNode } from './raw_apps/fileTreeUtils'
import { buildFileTree } from './raw_apps/fileTreeUtils'
interface Props {
/** File path → content map. Keys use / prefix (e.g. /index.html). */
files: Record<string, string>
/** Currently selected path (/-prefixed). Read-only; changes via onSelectPath callback. */
selectedPath?: string | undefined
/** Called when user clicks a path (file or folder). */
onSelectPath?: (path: string) => void
/** Extra tree nodes appended after the main tree (e.g. read-only wmill.ts). */
extraNodes?: TreeNode[]
/** Show a root / entry at the top of the tree. */
showRoot?: boolean
/** Hide the built-in header (useful when parent provides its own). */
hideHeader?: boolean
}
let {
files = $bindable({}),
selectedPath = undefined,
onSelectPath,
extraNodes,
showRoot = false,
hideHeader = false
}: Props = $props()
let pendingNewFilePath: string | undefined = $state(undefined)
let pathToEdit: string | undefined = $state(undefined)
// Empty folders exist only in the UI until a file is created inside them
let emptyFolders: string[] = $state([])
const fileTree = $derived(
buildFileTree([
...Object.keys(files ?? {}),
...emptyFolders,
...(pendingNewFilePath ? [pendingNewFilePath] : [])
])
)
function getUniquePath(basePath: string): string {
const existingPaths = new Set(
[...Object.keys(files ?? {}), ...emptyFolders, pendingNewFilePath].filter(Boolean)
)
if (!existingPaths.has(basePath)) return basePath
const isFolder = basePath.endsWith('/')
let pathWithoutTrailing = isFolder ? basePath.slice(0, -1) : basePath
const lastSlash = pathWithoutTrailing.lastIndexOf('/')
const parentPath = pathWithoutTrailing.substring(0, lastSlash + 1)
const fileName = pathWithoutTrailing.substring(lastSlash + 1)
let nameWithoutExt: string
let ext: string
if (isFolder) {
nameWithoutExt = fileName
ext = ''
} else {
const dotIndex = fileName.lastIndexOf('.')
nameWithoutExt = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName
ext = dotIndex > 0 ? fileName.substring(dotIndex) : ''
}
let counter = 1
let candidate: string
do {
const newName = `${nameWithoutExt} (${counter})${ext}`
candidate = isFolder ? `${parentPath}${newName}/` : `${parentPath}${newName}`
counter++
} while (existingPaths.has(candidate))
return candidate
}
function handleFileClick(path: string) {
onSelectPath?.(path)
}
function handleAddFile(folderPath: string) {
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const basePath = normalizedFolder + 'newfile.txt'
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
}
export function handleAddRootFile() {
let basePath: string
if (selectedPath && selectedPath !== '/') {
if (selectedPath.endsWith('/')) {
basePath = selectedPath + 'newfile.txt'
} else {
const pathParts = selectedPath.split('/').filter(Boolean)
const parentPath =
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
basePath = parentPath + 'newfile.txt'
}
} else {
basePath = '/newfile.txt'
}
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
}
function handleAddFolder(folderPath: string) {
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const basePath = normalizedFolder + 'newfolder/'
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
}
export function handleAddRootFolder() {
let basePath: string
if (selectedPath && selectedPath !== '/') {
if (selectedPath.endsWith('/')) {
basePath = selectedPath + 'newfolder/'
} else {
const pathParts = selectedPath.split('/').filter(Boolean)
const parentPath =
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
basePath = parentPath + 'newfolder/'
}
} else {
basePath = '/newfolder/'
}
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
}
function handleRename(oldPath: string, newName: string) {
const isFolder = oldPath.endsWith('/')
const pathParts = oldPath.split('/').filter(Boolean)
const parentPath = '/' + pathParts.slice(0, -1).join('/')
let newPath = parentPath === '/' ? '/' + newName : parentPath + '/' + newName
if (isFolder && !newPath.endsWith('/')) {
newPath = newPath + '/'
}
const isPendingNew = pendingNewFilePath === oldPath
if (!isPendingNew && oldPath === newPath) {
pathToEdit = undefined
return
}
const nfiles = { ...files }
if (isFolder) {
if (isPendingNew) {
// New empty folder — track in UI until a file is created inside
emptyFolders = [...emptyFolders, newPath]
pendingNewFilePath = undefined
} else {
// Rename all children under old folder path
for (const key of Object.keys(nfiles)) {
if (key === oldPath || key.startsWith(oldPath)) {
const newKey = newPath + key.substring(oldPath.length)
nfiles[newKey] = nfiles[key]
delete nfiles[key]
}
}
// Also rename in emptyFolders
emptyFolders = emptyFolders.map((f) =>
f === oldPath || f.startsWith(oldPath)
? newPath + f.substring(oldPath.length)
: f
)
}
} else {
if (isPendingNew) {
nfiles[newPath] = ''
pendingNewFilePath = undefined
} else {
nfiles[newPath] = nfiles[oldPath]
delete nfiles[oldPath]
}
// Remove empty folders that are now implicitly defined by this file path
emptyFolders = emptyFolders.filter((f) => !newPath.startsWith(f))
}
files = nfiles
pathToEdit = undefined
onSelectPath?.(newPath)
}
function handleDelete(path: string) {
const isFolder = path.endsWith('/')
const nfiles = { ...files }
if (isFolder) {
for (const key of Object.keys(nfiles)) {
if (key === path || key.startsWith(path)) {
delete nfiles[key]
}
}
emptyFolders = emptyFolders.filter((f) => f !== path && !f.startsWith(path))
} else {
delete nfiles[path]
}
files = nfiles
if (selectedPath === path || (isFolder && selectedPath?.startsWith(path))) {
const remaining = Object.keys(nfiles)
if (remaining.length > 0) {
onSelectPath?.(remaining[0])
} else {
onSelectPath?.(showRoot ? '/' : '')
}
}
}
</script>
{#if !hideHeader}
<div class="p-2 border-b flex items-center justify-between">
<span class="text-xs font-semibold text-emphasis">Files</span>
<div class="flex gap-1">
<Button
onClick={handleAddRootFile}
title="Add file"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5"
>
<Plus size={12} />
<File size={12} />
</Button>
<Button
onClick={handleAddRootFolder}
title="Add folder"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5"
>
<Plus size={12} />
<Folder size={12} />
</Button>
</div>
</div>
{/if}
<div class="flex-1 overflow-y-auto py-1 w-full">
{#if showRoot}
<button
onclick={() => onSelectPath?.('/')}
class="w-full flex items-center gap-1 px-2 py-1 text-xs hover:bg-surface-hover transition-colors rounded text-left {selectedPath ===
'/'
? 'bg-surface-accent-selected'
: ''}"
>
<FolderOpen size={12} class="flex-shrink-0 text-secondary" />
<span
class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
>/</span
>
</button>
{/if}
{#each fileTree as node (node.path)}
<FileTreeNode
{node}
onFileClick={handleFileClick}
onAddFile={handleAddFile}
onAddFolder={handleAddFolder}
onRename={handleRename}
onDelete={handleDelete}
{selectedPath}
{pathToEdit}
onRequestEdit={(path) => (pathToEdit = path)}
onCancelEdit={() => {
pathToEdit = undefined
pendingNewFilePath = undefined
}}
/>
{/each}
{#if extraNodes}
{#each extraNodes as node (node.path)}
<FileTreeNode
{node}
noEdit
onFileClick={handleFileClick}
onAddFile={handleAddFile}
onAddFolder={handleAddFolder}
{selectedPath}
/>
{/each}
{/if}
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import SimpleEditor from './SimpleEditor.svelte'
import FileExplorer from './FileExplorer.svelte'
interface Props {
args: Record<string, any>
}
let { args = $bindable({}) }: Props = $props()
// Internal files map uses /-prefixed keys (matching tree node paths).
// Compute initial files + selection together to avoid referencing $state outside reactive context.
const initialFiles = Object.fromEntries(
Object.entries(args ?? {}).map(([k, v]) => ['/' + k, String(v ?? '')])
)
const initialFile = Object.keys(initialFiles).find((k) => !k.endsWith('/'))
let files: Record<string, string> = $state(initialFiles)
let selectedPath: string | undefined = $state(initialFile ?? '/')
let editContent: string = $state(initialFile ? (initialFiles[initialFile] ?? '') : '')
// The selected file path (/-prefixed, not a folder)
const selectedFileKey: string | undefined = $derived.by(() => {
if (selectedPath != null && !selectedPath.endsWith('/')) {
return selectedPath
}
return undefined
})
// Display key without leading /
const selectedDisplayKey: string | undefined = $derived(
selectedFileKey?.replace(/^\//, '')
)
function flushEditContent() {
if (selectedFileKey != null && selectedFileKey in files && files[selectedFileKey] !== editContent) {
files = { ...files, [selectedFileKey]: editContent }
}
}
function handleSelectPath(path: string) {
flushEditContent()
selectedPath = path
if (!path.endsWith('/') && path !== '') {
editContent = files[path] ?? ''
}
}
// Sync files → args, overlaying current editContent for the active file.
// This avoids spreading a new files object on every keystroke.
$effect(() => {
const currentKey = selectedFileKey
const currentContent = editContent
const newArgs: Record<string, any> = {}
for (const [key, value] of Object.entries(files)) {
if (!key.endsWith('/')) {
const argKey = key.replace(/^\//, '')
newArgs[argKey] = key === currentKey ? currentContent : value
}
}
args = newArgs
})
function inferLang(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase()
if (!ext) return 'plaintext'
const langMap: Record<string, string> = {
json: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
ini: 'ini',
xml: 'xml',
html: 'html',
css: 'css',
js: 'javascript',
ts: 'typescript',
py: 'python',
sh: 'shell',
bash: 'shell',
sql: 'sql',
md: 'markdown',
cfg: 'ini',
conf: 'ini',
j2: 'jinja',
jinja: 'jinja'
}
return langMap[ext] ?? 'plaintext'
}
</script>
<div class="flex border rounded-md overflow-hidden h-[60vh]">
<div class="w-56 shrink-0 border-r flex flex-col bg-surface-secondary overflow-y-auto">
<FileExplorer
bind:files
{selectedPath}
onSelectPath={handleSelectPath}
showRoot
/>
</div>
<div class="flex-1 min-w-0 overflow-y-auto">
{#if selectedDisplayKey != null}
<div class="px-2 border-b text-xs text-secondary bg-surface-secondary sticky top-0 z-10 flex items-center h-[36.5px]">
{selectedDisplayKey}
</div>
{#key selectedFileKey}
<SimpleEditor
autoHeight
lang={inferLang(selectedDisplayKey)}
bind:code={editContent}
fixedOverflowWidgets={false}
/>
{/key}
{:else}
<div class="flex items-center justify-center h-full text-xs text-secondary">
Select a file or add a new one
</div>
{/if}
</div>
</div>
@@ -0,0 +1,735 @@
<script lang="ts" module>
import { z } from 'zod'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { formatDatePretty, parsePrettyDate, type IconType } from '$lib/utils'
export type FilterSchemaRec = Record<string, FilterSchema>
export type FilterSchema = (
| {
type: 'string' | 'number' | 'boolean'
allowMultiple?: boolean
format?: 'json'
}
| {
type: 'date'
mode?: 'single' | 'end' | 'start'
otherField?: string // For range display
allowMultiple?: undefined
}
| {
type: 'oneof'
options: { value: string; label?: string; description?: string }[]
allowCustomValue?: boolean
allowNegative?: boolean
allowMultiple?: boolean
}
) & {
label?: string
description?: string
icon?: IconType
}
export type FilterInstanceRec<T extends FilterSchemaRec> = {
[K in keyof T]: FilterInstance<T[K]>
}
export type FilterInstance<T extends FilterSchema> = T extends { type: 'string' }
? string
: T extends { type: 'number' }
? number
: T extends { type: 'boolean' }
? boolean
: T extends { type: 'date' }
? Date
: T extends { type: 'oneof'; options: any; allowCustomValue?: infer A }
? A extends true
? string
: T['options'] extends { value: string }[]
? _NegativeFilterInstance<T>
: never
: never
type _NegativeFilterInstance<T extends { options: { value: string }[] }> = T extends {
allowNegative: true
}
? T['options'][number]['value'] | `!${T['options'][number]['value']}`
: T['options'][number]['value']
/**
* Converts a FilterSchemaRec to a Zod schema for validation
*/
export function filterSchemaRecToZodSchema<T extends FilterSchemaRec>(
schemaRec: T
): z.ZodObject<{
[K in keyof T]: z.ZodType<FilterInstance<T[K]>>
}> {
const zodSchemaShape: Record<string, z.ZodType> = {}
for (const [key, filterSchema] of Object.entries(schemaRec)) {
let fieldSchema: z.ZodType
if (filterSchema.type === 'string') {
fieldSchema = z.string().nullable().default(null)
} else if (filterSchema.type === 'number') {
fieldSchema = z.number().nullable().default(null)
} else if (filterSchema.type === 'boolean') {
fieldSchema = z.boolean().nullable().default(null)
} else if (filterSchema.type === 'date') {
fieldSchema = z.string().nullable().default(null)
} else if (filterSchema.type === 'oneof') {
if (filterSchema.allowCustomValue) {
// If custom values are allowed, accept any string
fieldSchema = z.string().nullable().default(null)
} else {
// Extract the enum values from options
const values = filterSchema.options.map((o) => o.value) as [string, ...string[]]
fieldSchema = z.enum(values).nullable().default(null)
}
} else {
// Fallback for unknown types
fieldSchema = z.any().nullable().default(null)
}
zodSchemaShape[key] = fieldSchema
}
return z.object(zodSchemaShape) as any
}
/**
* Creates a URL-synced filter instance that automatically syncs with URL search parameters
*/
export function useUrlSyncedFilterInstance<T extends FilterSchemaRec>(
schemaRec: T
): { val: Partial<FilterInstanceRec<T>> } {
// Build the Zod schema from the filter schema
const zodSchema = filterSchemaRecToZodSchema(schemaRec)
// Create URL-synced search params
const urlFilter = useSearchParams(zodSchema) as Record<string, unknown>
// Create the filter instance object
const filterInstance: { val: Partial<FilterInstanceRec<T>> } = $state({ val: {} })
// Sync URL params to filter instance on initialization and when URL changes
for (const key of Object.keys(schemaRec)) {
let urlValue = urlFilter[key]
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
const d = new Date(urlValue)
urlValue = isNaN(d.getTime()) ? null : d
}
if (urlValue !== undefined && urlValue !== null) {
;(filterInstance.val as any)[key] = urlValue
}
}
// Sync filter instance changes back to URL params
for (const key of Object.keys(schemaRec)) {
$effect(() => {
let filterValue = (filterInstance.val as any)[key]
if (schemaRec[key].type === 'date' && filterValue instanceof Date) {
// Convert Date to ISO string for URL
filterValue = filterValue.toISOString()
}
if (untrack(() => urlFilter[key]) == filterValue) return // Avoid unnecessary updates
if (filterValue !== undefined && filterValue !== null) {
urlFilter[key] = filterValue
} else {
urlFilter[key] = null
}
})
}
return filterInstance
}
function filterToText<F extends FilterSchema>(filter: FilterInstance<F>, schema: F): string {
if (schema.type === 'date') {
const date =
typeof filter === 'string'
? new Date(filter)
: typeof filter === 'number'
? new Date(filter)
: (filter as Date)
return formatDatePretty(date)
}
return String(filter)
}
function textToFilter(text: string, schema: FilterSchema): FilterInstance<FilterSchema> | null {
if (schema.type === 'string') return text
if (schema.type === 'number') {
const num = Number(text)
return isNaN(num) ? null : (num as any)
}
if (schema.type === 'boolean') {
if (text.toLowerCase() === 'true') return true as any
if (text.toLowerCase() === 'false') return false as any
return null
}
if (schema.type === 'date') {
const date = parsePrettyDate(text)
return date ? (date as any) : null
}
if (schema.type === 'oneof') {
return text
}
return null
}
export type FilterValidationError = { fields: string[]; error: string }
/**
* Validates a filter instance against its schema.
* Returns a list of validation errors, each with the affected fields and an error message.
*/
export function validateFilterInstance<T extends FilterSchemaRec>(
schemaRec: T,
instance: Partial<FilterInstanceRec<T>>
): FilterValidationError[] {
const errors: FilterValidationError[] = []
for (const [key, rawValue] of Object.entries(instance)) {
const schema = schemaRec[key]
if (!schema) continue
if (schema.type === 'date') {
if (!rawValue || !((rawValue as any) instanceof Date) || isNaN(rawValue.getTime())) {
errors.push({ fields: [key], error: `Invalid date format` })
}
} else if (schema.type === 'oneof') {
const strValue = String(rawValue)
const elements = schema.allowMultiple ? strValue.split(',') : [strValue]
const validValues = schema.options.map((o) => o.value)
if (schema.allowMultiple && schema.allowNegative) {
const hasPositive = elements.some((v) => !v.startsWith('!'))
const hasNegative = elements.some((v) => v.startsWith('!'))
if (hasPositive && hasNegative) {
errors.push({
fields: [key],
error: `Cannot mix positive and negative values`
})
continue
}
}
if (!schema.allowCustomValue) {
const invalid = elements
.map((v) => v.replace(/^!/, ''))
.filter((v) => !validValues.includes(v))
if (invalid.length > 0) {
errors.push({
fields: [key],
error: `Invalid value${invalid.length > 1 ? 's' : ''}: ${invalid.join(', ')}`
})
}
}
} else if (schema.type === 'string' && schema.format === 'json') {
try {
JSON.parse(String(rawValue))
} catch (e) {
errors.push({ fields: [key], error: `Invalid JSON format` })
}
}
}
return errors
}
</script>
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import { inputBaseClass, inputBorderClass, inputSizeClasses } from './text_input/TextInput.svelte'
import { MinusIcon, SearchIcon } from 'lucide-svelte'
import { assignObjInPlace, clone } from '$lib/utils'
import GenericDropdown from './select/GenericDropdown.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import TaggedTextInput from './TaggedTextInput.svelte'
import { DebouncedTempValue, useTransformedSyncedValue } from '$lib/svelte5Utils.svelte'
import { untrack } from 'svelte'
import CloseButton from './common/CloseButton.svelte'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
import Badge from './common/badge/Badge.svelte'
import InlineCalendarInput, {
fromCalendarDate,
toCalendarDate
} from './common/InlineCalendarInput.svelte'
import { ButtonType } from './common'
type Props<SchemaT extends FilterSchemaRec> = {
schema: SchemaT
value: Partial<FilterInstanceRec<SchemaT>>
presets?: { name: string; value: string }[]
class?: string
placeholder?: string
}
type SchemaT = FilterSchemaRec // TODO: Generic
let {
schema,
value: valueInput = $bindable(),
presets: _presets = [],
class: className,
placeholder = 'Filter...'
}: Props<SchemaT> = $props()
let _value = new DebouncedTempValue(
() => clone(valueInput),
(v) => !errors.length && (valueInput = clone(v)),
(t) => Object.entries(t)
)
let value = $derived(_value.current)
let errors = $derived(validateFilterInstance(schema, value))
let currentTag: keyof SchemaT | undefined = $state()
let currentTextSegment = $state({ text: '', start: 0, end: 0 })
let open = $state(false)
let inputElement: HTMLDivElement | undefined = $state()
let highlightedIndex = $state(0)
let taggedTextInput: TaggedTextInput | undefined = $state()
let tags = $derived(
Object.entries(schema).map(([key, filterSchema]) => ({
regex: new RegExp(`\\b${key}:(?:\\\\.|[^\\s])*`, 'g'),
id: key,
onClear: () => (delete value[key], asText.reparse())
}))
)
let keyHighlightRegex = $derived(new RegExp(`\\b(${Object.keys(schema).join('|')}):`, 'g'))
let errorKeys = $derived(new Set(errors.flatMap((e) => e.fields)))
let errorHighlights = $derived(
[...errorKeys].map((key) => ({
regex: new RegExp(`(?<=\\b${key}:)(?:\\\\.|[^\\s])+`),
classes: 'text-red-500 dark:text-red-400'
}))
)
let menuItems = $derived.by(() => {
if (!currentTag) {
const searchText = currentTextSegment.text.trim().toLowerCase()
return Object.entries(schema)
.filter(([k, _]) => !(k in value))
.filter(([k, filterSchema]) => {
if (!searchText) return true
const label = (filterSchema.label || k).toLowerCase()
const key = k.toLowerCase()
return label.includes(searchText) || key.includes(searchText)
})
.map(([key, filterSchema]) => ({
type: 'filter' as const,
key,
filterSchema,
onClick: () => {
// Replace the text segment with the new filter tag
const before = asText.val.slice(0, currentTextSegment.start)
const after = asText.val.slice(currentTextSegment.end)
asText.val =
`${before}${before && !before.endsWith(' ') ? ' ' : ''}${key}:\\\u00A0${after}`.trim() +
'\u00A0'
}
}))
} else {
const filter = schema[currentTag]
if (filter.type === 'oneof') {
// When allowMultiple, split on comma and match against the last segment
const currentVal = String(value[currentTag!] ?? '')
let searchSuffix: string
if (filter.allowMultiple) {
const parts = currentVal.split(',')
searchSuffix = parts[parts.length - 1].replace(/^!/, '').trim()
} else {
searchSuffix = currentVal
}
// Already-selected values (for allowMultiple, to avoid suggesting duplicates)
const selectedValues = filter.allowMultiple
? currentVal
.split(',')
.slice(0, -1)
.map((v) => v.replace(/^!/, '').trim())
: []
return filter.options
.filter((o) => {
if (selectedValues.includes(o.value)) return false
if (!searchSuffix) return true
return o.value.includes(searchSuffix)
})
.map((option) => ({
type: 'option' as const,
option,
onClick: () =>
appendOrSetValueForCurrentTag((currentVal.includes('!') ? '!' : '') + option.value),
onNegativeClick: filter.allowNegative
? () => appendOrSetValueForCurrentTag('!' + option.value)
: undefined
}))
} else if (filter.type === 'boolean') {
return [
{
type: 'boolean' as const,
value: true,
label: 'True',
onClick: () => setValueForCurrentTag(true)
},
{
type: 'boolean' as const,
value: false,
label: 'False',
onClick: () => setValueForCurrentTag(false)
}
]
}
}
return []
})
// Reset highlighted index when menu items change
$effect(() => {
menuItems
open
highlightedIndex = 0
})
const kvRegex = /\b(\w+):((?:[^\s\\]|\\.)*)/g
function parseFromText(text: string): Partial<FilterInstanceRec<SchemaT>> {
const parsed: Record<string, string> = {}
let match
while ((match = kvRegex.exec(text)) !== null) {
let [_, key, val] = match
if (key in schema) {
val ??= ''
val = val.replace(/\\(.)/g, (_: string, c: string) => {
if (c === 'n') return '\n'
if (c === 'r') return '\r'
return c
}) // Unescape escaped characters
val = val.trim()
parsed[key] = textToFilter(val, schema[key]) as any
}
}
return parsed
}
function parseToText(v: Partial<FilterInstanceRec<SchemaT>>): string {
return (
Object.entries(v)
.map(([key, val]) =>
`${key}: ${filterToText(val as any, schema[key])}`
.replace(/ /g, '\\ ')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
)
.join(' ') + '\u00A0'
)
}
let asText = useTransformedSyncedValue(
[() => (Object.entries(value), value), (v) => assignObjInPlace(value, v)],
parseToText,
parseFromText
)
function setValueForCurrentTag(val: any) {
if (!currentTag) return
value[currentTag!] = val
asText.reparse()
}
/**
* For allowMultiple fields: appends a new value to the existing comma-separated list,
* replacing the last (in-progress) segment. For non-allowMultiple fields, behaves like setValueForCurrentTag.
*/
function appendOrSetValueForCurrentTag(val: string) {
if (!currentTag) return
const filter = schema[currentTag]
if (filter.allowMultiple) {
const existing = String(value[currentTag!] ?? '')
const parts = existing.split(',')
// If any existing part is negative, force the new value to be negative too
const isNegativeContext = parts.slice(0, -1).some((p) => p.startsWith('!'))
if (isNegativeContext && !val.startsWith('!')) val = '!' + val
// Replace the last in-progress segment with the selected value
parts[parts.length - 1] = val
value[currentTag!] = parts.join(',')
} else {
value[currentTag!] = val
}
asText.reparse()
}
function handleKeyDown(e: KeyboardEvent) {
if (!open) return
if (e.key === 'Escape') {
open = false
return
}
if (menuItems.length && e.key === 'ArrowDown') {
highlightedIndex = (highlightedIndex + 1) % menuItems.length
} else if (menuItems.length && e.key === 'ArrowUp') {
highlightedIndex = (highlightedIndex - 1 + menuItems.length) % menuItems.length
} else if (e.key === 'Enter') {
if (menuItems[highlightedIndex]) {
menuItems[highlightedIndex].onClick()
} else {
setValueForCurrentTag(value[currentTag!])
taggedTextInput?.focusAtEnd()
}
const currTagSchema = currentTag ? schema[currentTag] : undefined
if (currTagSchema && 'format' in currTagSchema && currTagSchema.format === 'json') {
return
}
} else {
return
}
e.preventDefault()
}
type Preset = { name: string; value: string }
let presets: Preset[] = $derived(
_presets.filter((p) => {
// Only show presets that aren't already applied in asText
return !asText.val.includes(p.value)
})
)
function appendFilterAsText(presetValue: string) {
if (!asText.val.endsWith('\u00A0') && !asText.val.endsWith(' ')) asText.val += ' '
asText.val += presetValue + '\u00A0'
}
</script>
<svelte:window onmousedown={() => (open = false)} onkeydown={handleKeyDown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class={twMerge(
'flex items-center rounded-md bg-surface-input overflow-clip',
inputBorderClass({ error: errors.length > 0, forceFocus: open }),
ButtonType.UnifiedHeightClasses.md,
className
)}
onmousedown={(e) => {
if (!open) {
e.preventDefault()
if (!asText.val.endsWith('\u00A0') && !asText.val.endsWith(' ')) asText.val += '\u00A0'
taggedTextInput?.focusAtEnd()
}
open = true
e.stopPropagation()
}}
bind:this={inputElement}
>
<TaggedTextInput
bind:this={taggedTextInput}
bind:value={asText.val}
{tags}
highlights={[
{ regex: /![a-zA-Z0-9_\-\/]+/, classes: 'text-yellow-600 dark:text-yellow-500' },
{ regex: keyHighlightRegex, classes: 'text-hint' },
{ regex: /,/, classes: 'text-hint mr-0.5' },
...errorHighlights
]}
onCurrentTagChange={(tag) => (currentTag = tag ? (tag.id as keyof SchemaT) : undefined)}
onTextSegmentAtCursorChange={(segment) => (currentTextSegment = segment)}
class={twMerge(
'overflow-x-auto !pr-24 bg-surface-input outline-none scrollbar-hidden text-nowrap flex-1 mr-2 mt-0.5',
inputBaseClass,
inputSizeClasses.md
)}
{placeholder}
/>
{#if asText.val}
<CloseButton small class="mr-1.5" onClick={() => (_value.current = {})} />
{:else}
<div class="mr-3">
<SearchIcon size={16} class="text-hint" />
</div>
{/if}
</div>
<GenericDropdown
{open}
getInputRect={() => inputElement?.getBoundingClientRect() ?? new DOMRect()}
innerClass="!max-h-[30rem]"
strictWidth
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="py-1 p-2 overflow-y-auto" onmousedown={(e) => e.stopPropagation()}>
{#if !currentTag || !schema[currentTag]}
{#if presets.length}
<div class="text-xs px-2 my-2 font-bold">Presets</div>
<div class="mb-3 px-2 flex gap-2 flex-wrap">
{#each presets as preset}
{@render presetTag(preset)}
{/each}
</div>
{/if}
<div class="text-xs px-2 my-2 font-bold">Filters</div>
{#each menuItems as item, index}
{#if item.type === 'filter' && item.filterSchema}
{@render menuItem({
Icon: item.filterSchema.icon || SearchIcon,
onClick: item.onClick,
label: item.filterSchema.label || item.key,
description: item.filterSchema.description,
highlighted: index === highlightedIndex
})}
{/if}
{/each}
{:else}
{#key currentTag}
{@render suggestion(schema[currentTag])}
{/key}
{/if}
</div>
</GenericDropdown>
{#snippet suggestion(filter: FilterSchema)}
{#if filter.description}
<div class="text-xs text-secondary px-2 my-2">{filter.description}</div>
{/if}
{#if filter.allowMultiple && (filter.type === 'string' || filter.type === 'oneof')}
<div class="text-2xs text-hint px-2 -mt-1 mb-2">Separate multiple values with commas</div>
{/if}
{#if filter.type === 'oneof'}
<div class="max-h-60 overflow-y-auto">
{#each menuItems as item, index}
{#if item.type === 'option' && item.option}
{@render menuItem({
onClick: item.onClick,
label: item.option.label || item.option.value,
highlighted: index === highlightedIndex,
onNegativeClick: item.onNegativeClick
})}
{/if}
{/each}
</div>
{:else if filter.type === 'boolean'}
{#each menuItems as item, index}
{#if item.type === 'boolean' && item.label}
{@render menuItem({
onClick: item.onClick,
label: item.label,
highlighted: index === highlightedIndex
})}
{/if}
{/each}
{:else if filter.type === 'date'}
{@const filterMode = filter.mode}
<div class="p-3 mb-1">
{#if !filterMode || filterMode === 'single'}
<InlineCalendarInput
bind:value={
() => toCalendarDate(value[currentTag!]),
(v) => {
setValueForCurrentTag(fromCalendarDate(v))
taggedTextInput?.preventCursorMoveOnNextSync()
}
}
/>
{:else}
{@const curr = toCalendarDate(value[currentTag!])}
{@const obj =
filterMode === 'end'
? { start: toCalendarDate(value[filter.otherField as keyof SchemaT]), end: curr }
: { end: toCalendarDate(value[filter.otherField as keyof SchemaT]), start: curr }}
<InlineCalendarInput
mode="range"
onClickBehavior={`set-${filterMode}`}
infiniteRange
bind:value={
() => obj,
(v) => {
setValueForCurrentTag(fromCalendarDate(v[filterMode]))
taggedTextInput?.preventCursorMoveOnNextSync()
}
}
/>
{/if}
</div>
{:else if filter.type === 'string' && filter.format === 'json'}
<div class="px-2 pb-2">
<SimpleEditor
autofocus={String(value[currentTag!] ?? '').length === 0}
lang="json"
autoHeight
small
bind:code={
() => String(value[currentTag!] ?? ''),
(v) => {
setValueForCurrentTag(v ?? '')
taggedTextInput?.preventCursorMoveOnNextSync()
}
}
class="border border-border-light rounded min-h-[4rem]"
/>
</div>
{/if}
{/snippet}
{#snippet menuItem({
Icon,
onClick,
label,
description,
highlighted = false,
onNegativeClick
}: {
Icon?: IconType
onClick: () => void
label: string
description?: string
highlighted?: boolean
onNegativeClick?: () => void
})}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class={twMerge(
'py-1.5 px-2 rounded-md hover:bg-surface-hover cursor-pointer text-sm flex items-center gap-3',
highlighted && 'bg-surface-hover'
)}
onclick={onClick}
>
{#if Icon}
<Icon size={16} class="inline" />
{/if}
<div class="inline flex-1 relative min-w-0">
<div class="text-sm ellipsize">{label}</div>
{#if description}
<div class="text-xs text-hint">{description}</div>
{/if}
</div>
{#if onNegativeClick}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<Popover openOnHover portal={null}>
{#snippet trigger()}
<Button
onClick={(e) => (e?.stopPropagation(), onNegativeClick?.())}
iconOnly
endIcon={{ icon: MinusIcon }}
unifiedSize="xs"
destructive
/>
{/snippet}
{#snippet content()}
<div class="text-xs">Exclude {label}</div>
{/snippet}
</Popover>
{/if}
</div>
{/snippet}
{#snippet presetTag({ name, value }: Preset)}
<Badge onclick={() => appendFilterAsText(value)} clickable>
{name}
</Badge>
{/snippet}
@@ -1115,7 +1115,7 @@
<div
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
>
<div class="flex w-full max-w-md gap-8 items-center">
<div class="flex w-full gap-8 items-center min-w-0">
<SummaryPathDisplay
bind:summary={flowStore.val.summary}
bind:path={$pathStore}
@@ -1124,7 +1124,7 @@
/>
</div>
<div class="gap-4 flex-row hidden md:flex w-full whitespace-nowrap max-w-md">
<div class="gap-4 flex-row hidden md:flex whitespace-nowrap">
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
{@const primaryScheduleIndex = triggersState.triggers.findIndex((t) => t.isPrimary)}
{@const scheduleIndex = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
@@ -104,14 +104,13 @@
filters: {
show_skipped: false,
path: runnableId,
success: 'running',
arg: searchArgs ? JSON.stringify(searchArgs) : '',
per_page: 5
status: 'running',
arg: searchArgs ? JSON.stringify(searchArgs) : ''
},
perPage: 5,
jobKinds: getJobKinds(runnableType),
syncQueuedRunsCount: false,
refreshRate: 10000,
computeMinAndMax: undefined,
currentWorkspace: $workspaceStore ?? '',
skip: !runnableId
}) satisfies UseJobLoaderArgs
@@ -1,5 +1,5 @@
<script>
import { FileText } from 'lucide-svelte'
import { FileText, FolderOpen } from 'lucide-svelte'
import { APP_TO_ICON_COMPONENT } from './icons'
/**
* @typedef {Object} Props
@@ -11,6 +11,7 @@
* @property {boolean} [center]
* @property {boolean} [isSelected]
* @property {any} [formatExtension]
* @property {boolean} [isFileset]
*/
/** @type {Props} */
@@ -22,7 +23,8 @@
width = '24px',
center = false,
isSelected = false,
formatExtension = undefined
formatExtension = undefined,
isFileset = false
} = $props()
let iconComponent = $derived(
@@ -45,6 +47,10 @@
<span class={isSelected ? 'text-secondary' : 'text-secondary'}>
<SvelteComponent {height} {width} size={widthInPixels} />
</span>
{:else if isFileset}
<span class={isSelected ? 'text-secondary' : 'text-secondary grayscale'}>
<FolderOpen {height} {width} />
</span>
{:else if formatExtension}
<span class={isSelected ? 'text-secondary' : 'text-secondary grayscale'}>
<FileText {height} {width} />
@@ -65,6 +65,7 @@
otherArgs?: Record<string, InputTransform>
helperScript?: DynamicInputTypes.HelperScript | undefined
isAgentTool?: boolean
allowedAiTransforms?: string[] | undefined
s3StorageConfigured?: boolean
chatInputEnabled?: boolean
}
@@ -92,6 +93,7 @@
otherArgs = {},
helperScript = undefined,
isAgentTool = false,
allowedAiTransforms = isAgentTool ? undefined : [],
s3StorageConfigured = true,
chatInputEnabled = false
}: Props = $props()
@@ -135,6 +137,11 @@
)
)
// Whether this specific field is allowed to use AI transforms
let fieldAllowsAi = $derived(
allowedAiTransforms === undefined || allowedAiTransforms.includes(argName)
)
let propertyType = $state(getPropertyType(arg))
function setExpr() {
@@ -167,7 +174,7 @@
function getPropertyType(arg: InputTransform | any): InputTransform['type'] {
// For agent tools, if static with undefined/empty value, treat as 'ai', meaning the field will be filled by the AI agent dynamically.
if (
isAgentTool &&
fieldAllowsAi &&
((arg?.type === 'static' && arg?.value === undefined) || arg?.type === 'ai')
) {
if (arg?.type === 'static') {
@@ -645,7 +652,7 @@
}}
>
{#snippet children({ item })}
{#if isAgentTool}
{#if fieldAllowsAi}
<ToggleButton
small
label="AI"
@@ -733,8 +740,8 @@
<div
class="text-sm text-tertiary italic p-3 bg-surface-secondary rounded-md border border-gray-200"
>
<span class="flex items-center gap-2">
<InfoIcon size={16} />
<span class="flex items-center gap-2 text-xs">
<InfoIcon size={13} />
This field will be filled by the AI agent dynamically
</span>
</div>
@@ -26,6 +26,7 @@
class?: string
helperScript?: DynamicInputTypes.HelperScript
isAgentTool?: boolean
allowedAiTransforms?: string[] | undefined
chatInputEnabled?: boolean
}
@@ -42,6 +43,7 @@
class: clazz = '',
helperScript = undefined,
isAgentTool = false,
allowedAiTransforms = isAgentTool ? undefined : [],
chatInputEnabled = false
}: Props = $props()
@@ -141,6 +143,7 @@
{enableAi}
{helperScript}
{isAgentTool}
{allowedAiTransforms}
{s3StorageConfigured}
{chatInputEnabled}
otherArgs={Object.fromEntries(
@@ -12,6 +12,7 @@
import { userStore, workspaceStore } from '$lib/stores'
import SchemaForm from './SchemaForm.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import FilesetEditor from './FilesetEditor.svelte'
import Toggle from './Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import TestConnection from './TestConnection.svelte'
@@ -128,7 +129,7 @@
resourceSchema.order =
resourceSchema.order ?? Object.keys(resourceSchema.properties).sort()
}
if (resourceTypeInfo?.format_extension) {
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
textFileContent = args.content
}
} catch (err) {
@@ -165,7 +166,7 @@
rawCode = JSON.stringify(args, null, 2)
} else {
parseJson()
if (resourceTypeInfo?.format_extension) {
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
textFileContent = args.content
}
}
@@ -294,9 +295,14 @@
<div>
{#if loadingSchema}
<Skeleton layout={[[4]]} />
{:else if !viewJsonSchema && resourceTypeInfo?.is_fileset}
<h5 class="mt-1 inline-flex items-center gap-4">
Fileset
</h5>
<FilesetEditor bind:args />
{:else if !viewJsonSchema && resourceSchema && resourceSchema?.properties}
{#if resourceTypeInfo?.format_extension}
<h5 class="mt-4 inline-flex items-center gap-4 pb-2">
<h5 class="mt-1 inline-flex items-center gap-4">
File content ({resourceTypeInfo.format_extension})
</h5>
<div class="">
@@ -0,0 +1,45 @@
<script lang="ts">
import { clickOutside } from '$lib/utils'
import { fly } from 'svelte/transition'
import Portal from './Portal.svelte'
import type { Snippet } from 'svelte'
type Props = {
children: Snippet
}
const { children }: Props = $props()
let _isOpen = $state(false)
let mousePos = $state({ x: 0, y: 0 })
export function open(e: MouseEvent) {
e.preventDefault()
_isOpen = true
mousePos = { x: e.clientX, y: e.clientY }
}
export function close() {
_isOpen = false
}
export function isOpen() {
return _isOpen
}
</script>
<Portal>
{#if _isOpen}
<div
in:fly={{ x: 0, y: -10, duration: 120 }}
use:clickOutside={{
onClickOutside: (e) => {
_isOpen = false
e.preventDefault()
e.stopPropagation()
}
}}
class="absolute left-0 top-0 z-[9999] w-fit"
style="transform: translate({mousePos.x + 2}px, {mousePos.y + 2}px)"
>
{@render children()}
</div>
{/if}
</Portal>
+1 -26
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import 'chartjs-adapter-date-fns'
import zoomPlugin from 'chartjs-plugin-zoom'
import Tooltip2 from '$lib/components/Tooltip.svelte'
import {
Chart as ChartJS,
Title,
@@ -17,7 +16,6 @@
} from 'chart.js'
import type { CompletedJob } from '$lib/gen'
import { getDbClockNow } from '$lib/forLater'
import Button from './common/button/Button.svelte'
import { Scatter } from '$lib/components/chartjs-wrappers/chartJs'
import DarkModeObserver from './DarkModeObserver.svelte'
@@ -28,10 +26,7 @@
maxTimeSet?: string | null
selectedIds?: string[]
canSelect?: boolean
lastFetchWentToEnd?: boolean
totalRowsFetched: number
onPointClicked: (ids: string[]) => void
onLoadExtra: () => void
onZoom: (zoom: { min: Date; max: Date }) => void
}
@@ -42,10 +37,7 @@
maxTimeSet = null,
selectedIds = $bindable([]),
canSelect = true,
lastFetchWentToEnd = false,
totalRowsFetched,
onPointClicked,
onLoadExtra,
onZoom
}: Props = $props()
@@ -299,23 +291,6 @@
<DarkModeObserver bind:darkMode />
<!-- {JSON.stringify(minTime)}
{JSON.stringify(maxTime)}
{JSON.stringify(jobs?.map((x) => x.started_at))} -->
<!-- {minTime}
{maxTime} -->
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
<div class="relative max-h-40">
{#if !lastFetchWentToEnd}
<div class="absolute top-[-28px] left-[220px]">
<Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}>
Load more
<Tooltip2>
There are more jobs to load but only the first {totalRowsFetched} were fetched
</Tooltip2>
</Button>
</div>
{/if}
<div class="relative h-44">
<Scatter {data} options={scatterOptions} />
</div>
File diff suppressed because it is too large Load Diff
@@ -4,12 +4,15 @@
const bubble = createBubbler()
import { IndexSearchService, ServiceLogsService } from '$lib/gen'
import ManuelDatePicker from './runs/ManuelDatePicker.svelte'
import TimeframeSelect, {
serviceLogsTimeframes,
useUrlSyncedTimeframe
} from './runs/TimeframeSelect.svelte'
import CalendarPicker from './common/calendarPicker/CalendarPicker.svelte'
import LogViewer from './LogViewer.svelte'
import Toggle from './Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import { onDestroy, tick, untrack } from 'svelte'
import { onDestroy, tick } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
import LogSnippetViewer from './LogSnippetViewer.svelte'
@@ -20,6 +23,7 @@
import Select from './select/Select.svelte'
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
import { watch } from 'runed'
interface Props {
searchTerm: string
@@ -32,9 +36,6 @@
let minTs: undefined | string = $state(undefined)
let maxTs: undefined | string = $state(undefined)
let minTsManual: undefined | string = $state($page.url.searchParams.get('minTs') ?? undefined)
let maxTsManual: undefined | string = $state($page.url.searchParams.get('maxTs') ?? undefined)
let max_lines: undefined | number = $state(undefined)
// let lastSeen: undefined | string = undefined
@@ -58,15 +59,17 @@
let timeout: number | undefined = $state(undefined)
let allLogs: ByMode | undefined = $state(undefined)
let manualPicker: ManuelDatePicker | undefined = $state(undefined)
let _timeframe = useUrlSyncedTimeframe(serviceLogsTimeframes)
let timeframe = $derived(_timeframe.timeframe)
let [minTsManual, maxTsManual] = $derived(
timeframe.type === 'manual' ? [timeframe.minTs ?? undefined, timeframe.maxTs ?? undefined] : []
)
let upTo: undefined | string = $state(undefined)
let upToIsLatest = $state(true)
function onManualChanges() {
getAllLogs(minTsManual ?? maxTs, maxTsManual)
}
function getAllLogs(queryMinTs: string | undefined, queryMaxTs: string | undefined) {
timeout && clearTimeout(timeout)
loading = true
@@ -151,11 +154,6 @@
if (autoRefresh && searchTerm === '' && !maxTsManual) {
timeout = setTimeout(() => {
if (searchTerm !== '') return
let minMax = manualPicker?.computeMinMax()
if (minMax) {
maxTsManual = minMax?.maxTs ?? undefined
minTsManual = minMax?.minTs ?? undefined
}
let maxTsPlus1 = maxTs ? new Date(new Date(maxTs).getTime() + 1000) : undefined
getAllLogs(maxTsPlus1?.toISOString(), undefined)
}, 5000)
@@ -315,8 +313,6 @@
) {
const params = new URLSearchParams()
if (searchTerm) params.set('searchTerm', searchTerm)
if (minTs) params.set('minTs', minTs)
if (maxTs) params.set('maxTs', maxTs)
if (selected?.mode) params.set('mode', selected.mode)
if (selected?.workerGroup) params.set('workerGroup', selected.workerGroup)
if (selected?.hostname) params.set('hostname', selected.hostname)
@@ -435,13 +431,22 @@
)
}
$effect(() => {
minTsManual || maxTsManual || untrack(() => onManualChanges())
})
$effect(() => {
;[searchTerm, selected, minTsManual, maxTsManual, allLogs]
untrack(() => searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs))
})
watch(
() => timeframe,
() => {
const ts = timeframe.computeMinMax()
minTs = undefined
maxTs = undefined
allLogs = undefined
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
}
)
watch(
() => [searchTerm, selected, timeframe, allLogs],
() => {
searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs)
}
)
</script>
<Drawer bind:this={logDrawer} bind:open={logDrawerOpen} size="1400px">
@@ -477,71 +482,19 @@
class="flex flex-col lg:flex-row gap-y-1 justify-between w-full relative pb-4 gap-x-0.5"
id="service-logs-date-pickers"
>
<div class="flex relative">
<input
type="text"
value={minTsManual
? new Date(minTsManual).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: 'min datetime'}
disabled
/>
<CalendarPicker
label="min datetime"
date={minTsManual}
on:change={({ detail }) => {
minTs = undefined
maxTs = undefined
allLogs = undefined
minTsManual = detail
getAllLogs(minTsManual, maxTsManual)
}}
placement="top-start"
/>
</div>
<ManuelDatePicker
bind:minTs={() => minTsManual ?? null, (v) => (minTsManual = v ?? undefined)}
bind:maxTs={() => maxTsManual ?? null, (v) => (maxTsManual = v ?? undefined)}
bind:this={manualPicker}
<TimeframeSelect
items={serviceLogsTimeframes}
bind:value={timeframe}
{loading}
on:loadJobs={() => {
wrapperClasses="w-full"
onClick={() => {
minTs = undefined
maxTs = undefined
allLogs = undefined
getAllLogs(minTsManual, maxTsManual)
const ts = timeframe.computeMinMax()
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
}}
serviceLogsChoices
loadText={searchTerm === '' ? 'Last 1000 logfiles' : 'All time'}
/>
<div class="flex relative">
<input
type="text"
value={maxTsManual
? new Date(maxTsManual).toLocaleTimeString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: 'max datetime'}
disabled
/>
<CalendarPicker
label="max datetime"
date={maxTsManual}
on:change={({ detail }) => {
minTs = undefined
maxTs = undefined
allLogs = undefined
maxTsManual = detail
getAllLogs(minTsManual, maxTsManual)
}}
/>
</div>
</div>
<div class="flex w-full flex-row-reverse pb-4 -mt-2 gap-2"
><Toggle
@@ -31,6 +31,7 @@
let popoverOpen = $state(false)
let own = $state(false)
let onBehalfOfEmail = $state<string | undefined>(undefined)
let summaryInput: ReturnType<typeof TextInput> | undefined = $state()
let hasChanges = $derived(editSummary !== (summary ?? '') || (own && dirtyPath))
$effect(() => {
@@ -70,16 +71,21 @@
{#if editable || onSaved}
<Popover
class="min-w-0 max-w-full"
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
openFocus={() => {
summaryInput?.focus()
return null
}}
bind:isOpen={popoverOpen}
>
{#snippet trigger()}
<div
class={'min-w-24 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'}
class={'min-w-0 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'}
>
<span class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
>{path}</span
@@ -98,6 +104,7 @@
{#if onSaved}
<Label label="Summary">
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
@@ -146,6 +153,7 @@
<label class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
@@ -177,7 +185,7 @@
{/snippet}
</Popover>
{:else}
<div class="min-w-24 truncate flex flex-col px-2">
<div class="min-w-0 truncate flex flex-col px-2">
{#if !emptyString(summary)}
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
{/if}
@@ -0,0 +1,524 @@
<script lang="ts">
let {
tags,
value = $bindable(''),
placeholder = '',
highlights,
onCurrentTagChange,
onTextSegmentAtCursorChange,
class: className = ''
}: {
tags: { regex: RegExp; id: string; onClear?: () => void }[]
value?: string
placeholder?: string
highlights?: { regex: RegExp; classes: string }[]
onCurrentTagChange?: (tag: { id: string } | null) => void
onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void
class?: string
} = $props()
let contentEditableDiv: HTMLDivElement
let isUpdating = false
$effect(() => {
if (!value.trim() && value !== '') value = ''
})
let _preventCursorMoveOnNextSync = false
// Update the displayed HTML when value changes externally
$effect(() => {
if (contentEditableDiv && !isUpdating) {
const currentText = getTextContent()
if (currentText !== value) {
updateDisplay(value)
if (!_preventCursorMoveOnNextSync) {
restoreCursor(value.length)
const cursorPos = getCursorPosition()
updateCurrentTag(cursorPos)
}
}
}
_preventCursorMoveOnNextSync = false
})
export function preventCursorMoveOnNextSync() {
_preventCursorMoveOnNextSync = true
}
function getTextContent(): string {
if (!contentEditableDiv) return ''
return contentEditableDiv.textContent || ''
}
function updateDisplay(text: string) {
if (!contentEditableDiv) return
const html = highlightText(text)
contentEditableDiv.innerHTML = html
}
/** Apply secondary highlight spans within a raw-text chunk. Returns HTML. */
function applyHighlightsToChunk(rawText: string): string {
if (!highlights || highlights.length === 0) return escapeHtml(rawText)
// Find all highlight matches in the raw text
const hlMatches: Array<{ start: number; end: number; classes: string }> = []
for (const hl of highlights) {
const regex = new RegExp(hl.regex, 'g')
let m
while ((m = regex.exec(rawText)) !== null) {
hlMatches.push({ start: m.index, end: m.index + m[0].length, classes: hl.classes })
}
}
if (hlMatches.length === 0) return escapeHtml(rawText)
// Sort and deduplicate (keep first on overlap)
hlMatches.sort((a, b) => a.start - b.start)
const filtered: typeof hlMatches = []
let lastEnd = -1
for (const m of hlMatches) {
if (m.start >= lastEnd) {
filtered.push(m)
lastEnd = m.end
}
}
let result = ''
let idx = 0
for (const m of filtered) {
if (m.start > idx) {
result += escapeHtml(rawText.slice(idx, m.start))
}
result += `<span class="${m.classes}">${escapeHtml(rawText.slice(m.start, m.end))}</span>`
idx = m.end
}
if (idx < rawText.length) {
result += escapeHtml(rawText.slice(idx))
}
return result
}
function highlightText(text: string): string {
if (!text) return ''
// Create a list of all matches with their positions
const matches: Array<{ start: number; end: number; tagIndex: number }> = []
tags.forEach((tag, tagIndex) => {
const regex = new RegExp(tag.regex, 'g')
let match
while ((match = regex.exec(text)) !== null) {
matches.push({
start: match.index,
end: match.index + match[0].length,
tagIndex
})
}
})
// Sort matches by start position
matches.sort((a, b) => a.start - b.start)
// Remove overlapping matches (keep the first one)
const filteredMatches: Array<{ start: number; end: number; tagIndex: number }> = []
let lastEnd = -1
for (const match of matches) {
if (match.start >= lastEnd) {
filteredMatches.push(match)
lastEnd = match.end
}
}
// Build HTML with highlighted segments
let html = ''
let lastIndex = 0
for (const match of filteredMatches) {
// Add text before the match (apply secondary highlights)
if (match.start > lastIndex) {
html += applyHighlightsToChunk(text.slice(lastIndex, match.start))
}
// Add highlighted match (with secondary highlights applied inside)
const matchedText = text.slice(match.start, match.end)
const tagId = tags[match.tagIndex].id
const hasClear = !!tags[match.tagIndex].onClear
const clearBtn = hasClear
? `<span data-clear-tag="${tagId}" class="inline-flex w-2.5 h-3 ml-1 cursor-pointer opacity-50 hover:opacity-100" style="vertical-align: middle;"><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>`
: ''
html += `<span class="bg-surface-sunken/50 border border-border-light py-0.5 px-1.5 rounded" data-tag-id="${tagId}">${applyHighlightsToChunk(matchedText)}${clearBtn}</span>`
lastIndex = match.end
}
// Add remaining text (apply secondary highlights)
if (lastIndex < text.length) {
html += applyHighlightsToChunk(text.slice(lastIndex))
}
return html
}
function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
let html = div.innerHTML
html = html.replace(/\\(n|r|.)/g, (match, c) => {
const display = c === 'n' ? '↵' : c === 'r' ? '↵' : c
return (
'<span style="display: inline; width: 0; height: 0; overflow: hidden; position: absolute;">\\</span>' +
display
)
})
return html
}
let lastText = ''
function applyTextUpdate(newText: string, newCursorPos: number) {
value = newText
updateDisplay(newText)
restoreCursor(newCursorPos)
updateCurrentTag(newCursorPos)
lastText = newText
isUpdating = false
}
function handleInput() {
isUpdating = true
const cursorPos = getCursorPosition()
let newText = getTextContent()
// Remove any "\." sequences that were added by browser smart punctuation
// These would only be created by macOS/browser when user double-presses space
if (newText.includes('\\.')) {
const cleanedText = newText.replace(/\\\./g, '')
const removedCount = (newText.length - cleanedText.length) / 2 // Each "\." is 2 chars
applyTextUpdate(cleanedText, cursorPos - removedCount * 2)
return
}
// Escape any literal newlines (e.g. from Shift+Enter or IME input)
if (newText.includes('\n') || newText.includes('\r')) {
const before = newText.slice(0, cursorPos)
const newlinesBefore = (before.match(/[\n\r]/g) || []).length
const cleanedText = newText.replace(/\r\n/g, '\\n').replace(/[\n\r]/g, '\\n')
// Each newline becomes 2 chars (\n), so cursor shifts by +1 per newline before it
applyTextUpdate(cleanedText, cursorPos + newlinesBefore)
return
}
// Check if user just typed an escaped character
if (
newText.length > lastText.length &&
(newText[cursorPos - 1] === ' ' ||
newText[cursorPos - 1] === '\u00A0' ||
newText[cursorPos - 1] === '\\')
) {
// Check if there's already an escaped space right before the cursor (e.g., "tag\ |")
// If user types another space, just remove the backslash instead of adding "\ \"
if (
(newText[cursorPos - 1] === ' ' || newText[cursorPos - 1] === '\u00A0') &&
newText[cursorPos - 3] === '\\' &&
(newText[cursorPos - 2] === ' ' || newText[cursorPos - 2] === '\u00A0')
) {
// Remove the backslash before the existing space
newText = newText.slice(0, cursorPos - 3) + newText.slice(cursorPos - 2)
applyTextUpdate(newText, cursorPos - 1)
return
}
// Escape the space/backslash by adding backslash before it
newText = newText.slice(0, cursorPos - 1) + '\\' + newText.slice(cursorPos - 1)
applyTextUpdate(newText, cursorPos + 1)
return
}
applyTextUpdate(newText, cursorPos)
}
function getTextSegmentAtCursor(cursorPos: number): {
text: string
start: number
end: number
} | null {
// Find all tag positions
const tagPositions: Array<{ start: number; end: number }> = []
for (const tag of tags) {
const regex = new RegExp(tag.regex, 'g')
let match
while ((match = regex.exec(value)) !== null) {
tagPositions.push({
start: match.index,
end: match.index + match[0].length
})
}
}
// Sort by start position
tagPositions.sort((a, b) => a.start - b.start)
// Find the text segment containing the cursor
let segmentStart = 0
let segmentEnd = value.length
for (const tag of tagPositions) {
if (cursorPos <= tag.start) {
// Cursor is before this tag
segmentEnd = tag.start
break
} else if (cursorPos > tag.end) {
// Cursor is after this tag
segmentStart = tag.end
} else {
// Cursor is inside a tag
return null
}
}
return {
text: value.slice(segmentStart, segmentEnd).trim(),
start: segmentStart,
end: segmentEnd
}
}
function updateCurrentTag(cursorPos: number) {
let currentTag: { id: string } | null = null
for (const tag of tags) {
const regex = new RegExp(tag.regex, 'g')
let match
while ((match = regex.exec(value)) !== null) {
const start = match.index
const end = match.index + match[0].length
if (cursorPos >= start && cursorPos <= end) {
currentTag = { id: tag.id }
onCurrentTagChange?.(currentTag)
onTextSegmentAtCursorChange?.({ text: '', start: 0, end: 0 })
return
}
}
}
onCurrentTagChange?.(null)
// Get text segment at cursor when not in a tag
const textSegment = getTextSegmentAtCursor(cursorPos)
if (textSegment) {
onTextSegmentAtCursorChange?.(textSegment)
}
}
function handleClick(e: MouseEvent) {
// Check if the click landed on a clear button
const target = e.target as HTMLElement | null
const clearTarget = target?.closest<HTMLElement>('[data-clear-tag]')
if (clearTarget) {
const tagId = clearTarget.dataset.clearTag!
const tag = tags.find((t) => t.id === tagId)
tag?.onClear?.()
return
}
const cursorPos = getCursorPosition()
updateCurrentTag(cursorPos)
}
function handleKeyup(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
const cursorPos = getCursorPosition()
updateCurrentTag(cursorPos)
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
const cursorPos = getCursorPosition()
const text = getTextContent()
// Handle Backspace key to remove escape sequences
if (e.key === 'Backspace') {
// Check if we're right after an escaped character (e.g., "abc\ |def")
// We want to remove both the backslash and the escaped character
if (cursorPos >= 2 && text[cursorPos - 2] === '\\') {
e.preventDefault()
isUpdating = true
const newText = text.slice(0, cursorPos - 2) + text.slice(cursorPos)
applyTextUpdate(newText, cursorPos - 2)
return
}
}
// Handle Delete key to remove escape sequences
if (e.key === 'Delete') {
// Check if the character at cursor position is a backslash (escape character)
if (cursorPos < text.length && text[cursorPos] === '\\' && cursorPos + 1 < text.length) {
e.preventDefault()
isUpdating = true
const newText = text.slice(0, cursorPos) + text.slice(cursorPos + 2)
applyTextUpdate(newText, cursorPos)
return
}
}
// Handle arrow key navigation to skip escape sequences
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
if (e.key === 'ArrowLeft' && cursorPos > 0) {
// Moving left: check if we're right after an escaped character (e.g., "abc\ |def")
// We want to skip over the backslash and the escaped character
if (cursorPos >= 2 && text[cursorPos - 2] === '\\') {
e.preventDefault()
isUpdating = true
restoreCursor(cursorPos - 2)
updateCurrentTag(cursorPos - 2)
isUpdating = false
return
}
} else if (e.key === 'ArrowRight') {
// Moving right: check if we're at a backslash (e.g., "abc|\ def")
// We want to skip over the backslash and the escaped character
if (cursorPos < text.length && text[cursorPos] === '\\' && cursorPos + 1 < text.length) {
e.preventDefault()
isUpdating = true
restoreCursor(cursorPos + 2)
updateCurrentTag(cursorPos + 2)
isUpdating = false
return
}
// If user pressed right arrow and is at the end, add a space if needed
if (
cursorPos === text.length &&
text.length > 0 &&
((text[text.length - 1] !== ' ' && text[text.length - 1] !== '\u00A0') ||
text[text.length - 2] === '\\')
) {
e.preventDefault()
isUpdating = true
const newText = text + '\u00A0'
applyTextUpdate(newText, newText.length)
return
}
}
}
}
function getCursorPosition(): number {
if (!contentEditableDiv) return 0
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(contentEditableDiv)
preCaretRange.setEnd(range.endContainer, range.endOffset)
return preCaretRange.toString().length
}
function restoreCursor(position: number) {
if (!contentEditableDiv) return
const selection = window.getSelection()
if (!selection) return
let currentPos = 0
let node: Node | null = null
let offset = 0
function traverse(n: Node): boolean {
if (n.nodeType === Node.TEXT_NODE) {
const textLength = n.textContent?.length || 0
if (currentPos + textLength >= position) {
node = n
offset = position - currentPos
return true
}
currentPos += textLength
} else {
for (let i = 0; i < n.childNodes.length; i++) {
if (traverse(n.childNodes[i])) {
return true
}
}
}
return false
}
traverse(contentEditableDiv)
if (node) {
const range = document.createRange()
range.setStart(node, offset)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
// Ensure cursor is visible by scrolling if needed
ensureCursorVisible()
}
}
function ensureCursorVisible() {
if (!contentEditableDiv) return
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
const containerRect = contentEditableDiv.getBoundingClientRect()
// Check if cursor is outside the visible area horizontally
if (rect.left < containerRect.left) {
// Cursor is to the left of visible area
contentEditableDiv.scrollLeft -= containerRect.left - rect.left + 10
} else if (rect.right > containerRect.right) {
// Cursor is to the right of visible area
contentEditableDiv.scrollLeft += rect.right - containerRect.right + 10
}
}
function handlePaste(e: ClipboardEvent) {
e.preventDefault()
let text = e.clipboardData?.getData('text/plain') || ''
// Escape backslashes, spaces, and newlines
text = text
.replace(/\\/g, '\\\\')
.replace(/ /g, '\\ ')
.replace(/\r\n/g, '\\n')
.replace(/[\n\r]/g, '\\n')
document.execCommand('insertText', false, text)
}
export function focusAtEnd() {
if (!contentEditableDiv) return
contentEditableDiv.focus()
restoreCursor(value.length)
updateCurrentTag(value.length)
contentEditableDiv.scrollLeft = contentEditableDiv.scrollWidth
}
</script>
<div
bind:this={contentEditableDiv}
contenteditable="true"
oninput={handleInput}
onpaste={handlePaste}
onclick={handleClick}
onkeydown={handleKeyDown}
onkeyup={handleKeyup}
class="outline-none text-nowrap pt-[0.45rem] {className}"
class:text-hint={value === ''}
data-placeholder={placeholder}
role="textbox"
tabindex="0"
spellcheck="false"
></div>
<style>
[contenteditable][data-placeholder]:empty::before {
content: attr(data-placeholder);
}
</style>
+3 -1
View File
@@ -19,6 +19,7 @@
markdownTooltip?: string | undefined
customSize?: string
class?: string
Icon?: typeof InfoIcon
children?: import('svelte').Snippet
}
@@ -31,6 +32,7 @@
markdownTooltip = undefined,
customSize = '100%',
class: classNames = '',
Icon = InfoIcon,
children
}: Props = $props()
const plugins = [gfmPlugin()]
@@ -53,7 +55,7 @@
? 'text-primary-inverse'
: 'text-primary'} {classNames} relative"
>
<InfoIcon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
<Icon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
</div>
{#snippet text()}
{#if markdownTooltip}
@@ -0,0 +1,51 @@
import { FileCode, FolderIcon, Box, Braces } from 'lucide-svelte'
import type { FilterSchemaRec } from '../FilterSearchbar.svelte'
export function buildAssetsFilterSchema({
paths,
assetKinds
}: {
paths: string[]
assetKinds: string[]
}) {
return {
asset_path: {
type: 'string' as const,
label: 'Asset path pattern',
icon: FolderIcon,
description: 'Filter by asset path pattern (case-insensitive)'
},
asset_kinds: {
type: 'oneof' as const,
options: assetKinds.map((s) => ({ label: s, value: s })),
allowCustomValue: false,
allowNegative: false,
allowMultiple: true,
label: 'Asset kind',
icon: Box,
description: 'Filter by asset kind (s3object, resource, variable, etc.)'
},
usage_path: {
type: 'string' as const,
label: 'Usage path pattern',
icon: FileCode,
description: 'Filter by usage path pattern (case-insensitive)'
},
path: {
type: 'oneof' as const,
options: paths.map((s) => ({ label: s, value: s })),
allowCustomValue: true,
allowNegative: false,
allowMultiple: false,
label: 'Asset path',
icon: FileCode,
description: 'Filter by exact asset path'
},
columns: {
type: 'string' as const,
label: 'Columns',
icon: Braces,
description: 'Filter by comma-separated column names (e.g., col1,col2,col3)'
}
} satisfies FilterSchemaRec
}

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