Compare commits
102 Commits
fileset-re
...
removeplug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90782336c8 | ||
|
|
b121f4388b | ||
|
|
5ebaa43aa1 | ||
|
|
7a5e487878 | ||
|
|
cfc8ab5b2d | ||
|
|
758b35f8eb | ||
|
|
b34ba965c1 | ||
|
|
889c98b38b | ||
|
|
db44b8be74 | ||
|
|
fca94f88dd | ||
|
|
c70307d3f2 | ||
|
|
89f835727b | ||
|
|
6eca08480a | ||
|
|
36353359f6 | ||
|
|
7d6f4fdabb | ||
|
|
7a32abec96 | ||
|
|
4f5a804091 | ||
|
|
faf190f12d | ||
|
|
86182ed2e9 | ||
|
|
7f6e9fec0c | ||
|
|
13daebf88a | ||
|
|
c98db016b6 | ||
|
|
d4673c2e91 | ||
|
|
59e51ac097 | ||
|
|
278983c4fd | ||
|
|
d933446a9e | ||
|
|
ba48d70157 | ||
|
|
cd2cf0c39e | ||
|
|
bd9ff03010 | ||
|
|
c424b1a961 | ||
|
|
0776de6b21 | ||
|
|
762fd3d993 | ||
|
|
83aee49978 | ||
|
|
095505136c | ||
|
|
257734b9ab | ||
|
|
5d58a87a7f | ||
|
|
b68ff965dd | ||
|
|
ff180de4de | ||
|
|
7728475fc9 | ||
|
|
7d9d16a6a3 | ||
|
|
cdc0543747 | ||
|
|
b9e3e053e4 | ||
|
|
3a552c5b95 | ||
|
|
c8d99d7fc9 | ||
|
|
f1d8568831 | ||
|
|
ef84ce24ab | ||
|
|
99c01bca38 | ||
|
|
427bc6410b | ||
|
|
eeb823b0b5 | ||
|
|
4e1ae276b0 | ||
|
|
01c7270cda | ||
|
|
cf7f704a91 | ||
|
|
0d55079c92 | ||
|
|
e27e89a2b0 | ||
|
|
16a6d5e7af | ||
|
|
408c5af6d8 | ||
|
|
23d5e872a9 | ||
|
|
7bb450edbf | ||
|
|
0bee3c1197 | ||
|
|
09970cd22b | ||
|
|
f33e67b07f | ||
|
|
af2aca56b0 | ||
|
|
cff9e2c5c2 | ||
|
|
a9968d0aed | ||
|
|
1a2e110512 | ||
|
|
0c204b69bd | ||
|
|
07ddcd2a08 | ||
|
|
02d5447e1d | ||
|
|
36d5a59ed5 | ||
|
|
88696ec29e | ||
|
|
c7c828b56e | ||
|
|
935b0058e2 | ||
|
|
1c9ac97f87 | ||
|
|
8e7ba9b33d | ||
|
|
f4e9603f3e | ||
|
|
7ac93f6ee3 | ||
|
|
6943bb6a7f | ||
|
|
bc672555a7 | ||
|
|
5730009404 | ||
|
|
328a52bca4 | ||
|
|
a482a3fac1 | ||
|
|
ecf099436b | ||
|
|
ff583bfb44 | ||
|
|
c0d136658f | ||
|
|
71acd88f2a | ||
|
|
0a06485f51 | ||
|
|
27571457a1 | ||
|
|
d4e711e337 | ||
|
|
55c172cc59 | ||
|
|
d883f647ed | ||
|
|
6a7811bdd0 | ||
|
|
8ff2340c0c | ||
|
|
835db5d290 | ||
|
|
b59d60378c | ||
|
|
8869fde737 | ||
|
|
90a6db72a2 | ||
|
|
3aba0ed250 | ||
|
|
207dcdb4f7 | ||
|
|
b97216cf37 | ||
|
|
b3ac0249de | ||
|
|
c15b9abe5e | ||
|
|
302fea683c |
21
.claude/hooks/guard-main-branch.sh
Executable file
21
.claude/hooks/guard-main-branch.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: block destructive git operations when on the main branch.
|
||||
# Non-git tool calls and read-only git commands pass through silently.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input="$(cat)"
|
||||
tool_name="$(echo "$input" | jq -r '.tool_name // empty')"
|
||||
|
||||
# Only care about Bash tool calls
|
||||
[[ "$tool_name" == "Bash" ]] || exit 0
|
||||
|
||||
command="$(echo "$input" | jq -r '.tool_input.command // empty')"
|
||||
|
||||
# Only care about git write commands
|
||||
if [[ "$command" =~ ^git\ (push|reset|revert|checkout|merge|rebase|commit|add) ]]; then
|
||||
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
if [[ "$branch" == "main" ]]; then
|
||||
echo "BLOCK: You are on the main branch. Create or switch to a feature branch first."
|
||||
fi
|
||||
fi
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve _ee.rs symlinks to actual files so Claude can read them
|
||||
# This script runs before each user prompt is processed
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
|
||||
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
|
||||
|
||||
# Find all _ee.rs symlinks and store their targets
|
||||
find "$PROJECT_DIR" -name "*_ee.rs" -type l 2>/dev/null | while read -r symlink; do
|
||||
target=$(readlink -f "$symlink" 2>/dev/null) || continue
|
||||
|
||||
# Only process if target file exists
|
||||
if [[ -f "$target" ]]; then
|
||||
# Store symlink path and target in manifest
|
||||
echo "$symlink|$target" >> "$MANIFEST_FILE.tmp"
|
||||
|
||||
# Replace symlink with actual file content
|
||||
rm "$symlink"
|
||||
cp "$target" "$symlink"
|
||||
fi
|
||||
done
|
||||
|
||||
# Atomically replace manifest
|
||||
if [[ -f "$MANIFEST_FILE.tmp" ]]; then
|
||||
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore _ee.rs symlinks after Claude finishes processing
|
||||
# This script runs when Claude stops
|
||||
# IMPORTANT: Copies any modifications back to the target before restoring symlinks
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
|
||||
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
|
||||
|
||||
# Check if manifest exists
|
||||
if [[ ! -f "$MANIFEST_FILE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read manifest and restore symlinks
|
||||
while IFS='|' read -r symlink target; do
|
||||
if [[ -n "$symlink" && -n "$target" ]]; then
|
||||
# If the file exists (not a symlink) and target exists, copy changes back
|
||||
if [[ -f "$symlink" && ! -L "$symlink" && -e "$target" ]]; then
|
||||
# Copy the potentially modified file back to the target
|
||||
cp "$symlink" "$target"
|
||||
fi
|
||||
|
||||
# Remove the regular file (which was a copy)
|
||||
rm -f "$symlink" 2>/dev/null || true
|
||||
|
||||
# Recreate the symlink
|
||||
ln -s "$target" "$symlink" 2>/dev/null || true
|
||||
fi
|
||||
done < "$MANIFEST_FILE"
|
||||
|
||||
# Clean up manifest
|
||||
rm -f "$MANIFEST_FILE"
|
||||
|
||||
exit 0
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"additionalDirectories": [
|
||||
"../windmill-ee-private"
|
||||
],
|
||||
"allow": [
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
@@ -27,7 +30,15 @@
|
||||
"Bash(cargo check:*)",
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(npm run generate-backend-client:*)",
|
||||
"Bash(npm run check:*)"
|
||||
"Bash(npm run check:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(git revert:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(.env)",
|
||||
@@ -52,46 +63,19 @@
|
||||
"Bash(chown:*)",
|
||||
"Bash(truncate:*)",
|
||||
"Bash(shred:*)",
|
||||
"Bash(unlink:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(git revert:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(git rebase:*)"
|
||||
"Bash(unlink:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"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
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-main-branch.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -126,8 +110,7 @@
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"rust-analyzer-lsp@claude-plugins-official": true,
|
||||
"typescript-lsp@claude-plugins-official": true,
|
||||
"code-review@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
}
|
||||
39
.claude/skills/refine/SKILL.md
Normal file
39
.claude/skills/refine/SKILL.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: refine
|
||||
user_invocable: true
|
||||
description: End-of-session reflection. Reviews friction encountered during the session and proposes updates to docs/ to capture lessons learned.
|
||||
---
|
||||
|
||||
# Refine Skill
|
||||
|
||||
Reflect on the current session and update documentation with lessons learned.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Identify friction**: Review what happened in this session:
|
||||
- Run `git diff main...HEAD --stat` to see what files were touched
|
||||
- Think about: what was slow, what failed, what required multiple attempts, what information was missing or hard to find
|
||||
|
||||
2. **Read current docs**: Read the docs that were relevant to this session:
|
||||
- `docs/validation.md`
|
||||
- `docs/enterprise.md`
|
||||
- `docs/autonomous-mode.md`
|
||||
- Any skills that were invoked
|
||||
|
||||
3. **Propose updates**: For each piece of friction, decide if it warrants a doc update:
|
||||
- **Missing knowledge**: Information you had to discover that should be documented
|
||||
- **Wrong guidance**: Instructions that led you astray
|
||||
- **Missing validation rule**: A check that should be in the validation matrix
|
||||
- **New pattern**: A codebase pattern worth capturing for next time
|
||||
|
||||
4. **Apply updates**: Edit the relevant `docs/` files. Keep changes minimal and specific — add only what would have saved time this session.
|
||||
|
||||
5. **Report**: Summarize what was added/changed and why.
|
||||
|
||||
## Rules
|
||||
|
||||
- Only add knowledge confirmed by this session — no speculative additions
|
||||
- Keep docs concise — add a line or two, not a paragraph
|
||||
- If a whole new doc is needed, create it in `docs/` and add a pointer in `CLAUDE.md`
|
||||
- Don't update skills unless a coding pattern was genuinely wrong
|
||||
- Don't add things Claude already knows — only Windmill-specific knowledge
|
||||
@@ -3,493 +3,105 @@ name: rust-backend
|
||||
description: Rust coding guidelines for the Windmill backend. MUST use when writing or modifying Rust code in the backend directory.
|
||||
---
|
||||
|
||||
# Rust Backend Coding Guidelines
|
||||
# Windmill Rust Patterns
|
||||
|
||||
Apply these patterns when writing or modifying Rust code in the `backend/` directory.
|
||||
|
||||
## Data Structure Design
|
||||
|
||||
Choose between `struct`, `enum`, or `newtype` based on domain needs:
|
||||
|
||||
- Use `enum` for state machines instead of boolean flags or loosely related fields
|
||||
- Model invariants explicitly using types (e.g., `NonZeroU32`, `Duration`, custom enums)
|
||||
- Consider ownership of each field:
|
||||
- Use `&str` vs `String`, slices vs vectors
|
||||
- Use `Arc<T>` when sharing across threads
|
||||
- Use `Cow<'a, T>` for flexible ownership
|
||||
|
||||
```rust
|
||||
// State machine with enum
|
||||
enum JobState {
|
||||
Pending { scheduled_for: DateTime<Utc> },
|
||||
Running { started_at: DateTime<Utc>, worker: String },
|
||||
Completed { result: JobResult, duration_ms: i64 },
|
||||
Failed { error: String, retries: u32 },
|
||||
}
|
||||
|
||||
// Avoid multiple booleans
|
||||
struct Job {
|
||||
is_pending: bool, // Don't do this
|
||||
is_running: bool,
|
||||
is_completed: bool,
|
||||
}
|
||||
```
|
||||
|
||||
## Impl Block Organization
|
||||
|
||||
Place `impl` blocks immediately below the struct/enum they modify. Group methods logically:
|
||||
|
||||
```rust
|
||||
struct JobQueue {
|
||||
jobs: Vec<Job>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl JobQueue {
|
||||
// Constructors first
|
||||
pub fn new(capacity: usize) -> Self { ... }
|
||||
pub fn with_jobs(jobs: Vec<Job>) -> Self { ... }
|
||||
|
||||
// Getters
|
||||
pub fn len(&self) -> usize { ... }
|
||||
pub fn is_empty(&self) -> bool { ... }
|
||||
|
||||
// Mutation methods
|
||||
pub fn push(&mut self, job: Job) -> Result<()> { ... }
|
||||
pub fn pop(&mut self) -> Option<Job> { ... }
|
||||
|
||||
// Domain logic
|
||||
pub fn next_scheduled(&self) -> Option<&Job> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Iterator Chains Over For-Loops
|
||||
|
||||
Prefer functional iterator chains (`.filter().map().collect()`) over imperative for-loops:
|
||||
|
||||
```rust
|
||||
// Preferred
|
||||
let results: Vec<_> = items
|
||||
.iter()
|
||||
.filter(|item| item.is_valid())
|
||||
.map(|item| item.transform())
|
||||
.collect();
|
||||
|
||||
// Avoid
|
||||
let mut results = Vec::new();
|
||||
for item in items.iter() {
|
||||
if item.is_valid() {
|
||||
results.push(item.transform());
|
||||
}
|
||||
}
|
||||
```
|
||||
Apply these Windmill-specific patterns when writing Rust code in `backend/`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use the `Error` type from `windmill_common::error`. Return `Result<T, Error>` or `JsonResult<T>` for fallible functions:
|
||||
Use `Error` from `windmill_common::error`. Return `Result<T, Error>` or `JsonResult<T>`:
|
||||
|
||||
```rust
|
||||
use windmill_common::error::{Error, Result};
|
||||
|
||||
// Use ? operator for propagation
|
||||
pub async fn get_job(db: &DB, id: Uuid) -> Result<Job> {
|
||||
let job = sqlx::query_as!(Job, "SELECT ... WHERE id = $1", id)
|
||||
sqlx::query_as!(Job, "SELECT id, workspace_id FROM v2_job WHERE id = $1", id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound("job not found".to_string()))?;
|
||||
Ok(job)
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `if let` for optional handling. Use `let...else` when early return makes code clearer:
|
||||
Never panic in library code. Reserve `.unwrap()` for compile-time guarantees.
|
||||
|
||||
## SQLx Patterns
|
||||
|
||||
**Never use `SELECT *`** — always list columns explicitly. Critical for backwards compatibility when workers lag behind API version:
|
||||
|
||||
```rust
|
||||
let Some(config) = get_config() else {
|
||||
return Err(Error::MissingConfig);
|
||||
};
|
||||
// Correct
|
||||
sqlx::query_as!(Job, "SELECT id, workspace_id, path FROM v2_job WHERE id = $1", id)
|
||||
|
||||
// Wrong — breaks when columns are added
|
||||
sqlx::query_as!(Job, "SELECT * FROM v2_job WHERE id = $1", id)
|
||||
```
|
||||
|
||||
Never panic in library code. Reserve `.unwrap()` for cases with compile-time guarantees. Keep functions short to help lifetime inference and clarity.
|
||||
|
||||
## Early Returns
|
||||
|
||||
Return early to avoid deep nesting. Handle error cases and edge conditions first:
|
||||
Use batch operations to avoid N+1:
|
||||
|
||||
```rust
|
||||
// Preferred - early returns
|
||||
fn process_job(job: Option<Job>) -> Result<Output> {
|
||||
let Some(job) = job else {
|
||||
return Ok(Output::default());
|
||||
};
|
||||
|
||||
if !job.is_valid() {
|
||||
return Err(Error::InvalidJob);
|
||||
}
|
||||
|
||||
if job.is_cached() {
|
||||
return Ok(job.cached_result());
|
||||
}
|
||||
|
||||
// Main logic at the end, not nested
|
||||
execute_job(job)
|
||||
}
|
||||
|
||||
// Avoid - deep nesting
|
||||
fn process_job(job: Option<Job>) -> Result<Output> {
|
||||
if let Some(job) = job {
|
||||
if job.is_valid() {
|
||||
if !job.is_cached() {
|
||||
execute_job(job)
|
||||
} else {
|
||||
Ok(job.cached_result())
|
||||
}
|
||||
} else {
|
||||
Err(Error::InvalidJob)
|
||||
}
|
||||
} else {
|
||||
Ok(Output::default())
|
||||
}
|
||||
}
|
||||
// Preferred — single query with IN clause
|
||||
sqlx::query!("SELECT ... WHERE id = ANY($1)", &ids[..]).fetch_all(db).await?
|
||||
```
|
||||
|
||||
## Variable Shadowing
|
||||
|
||||
Shadow variables instead of creating new names with prefixes:
|
||||
|
||||
```rust
|
||||
// Preferred
|
||||
let data = fetch_raw_data();
|
||||
let data = parse(data);
|
||||
let data = validate(data)?;
|
||||
|
||||
// Avoid
|
||||
let raw_data = fetch_raw_data();
|
||||
let parsed_data = parse(raw_data);
|
||||
let validated_data = validate(parsed_data)?;
|
||||
```
|
||||
|
||||
## Minimal Comments
|
||||
|
||||
- No inline comments explaining obvious code
|
||||
- No TODO/FIXME comments in committed code
|
||||
- Doc comments (`///`) only on public items
|
||||
- Let code be self-documenting through clear naming
|
||||
|
||||
## Type Safety
|
||||
|
||||
Use enums over boolean flags for clarity:
|
||||
|
||||
```rust
|
||||
// Preferred
|
||||
enum JobStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
}
|
||||
|
||||
// Avoid
|
||||
struct Job {
|
||||
is_running: bool,
|
||||
is_completed: bool,
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
Prefer explicit matching. Use wildcards strategically for fallback cases or ignored fields:
|
||||
|
||||
```rust
|
||||
// Explicit matching preferred
|
||||
match status {
|
||||
JobStatus::Pending => handle_pending(),
|
||||
JobStatus::Running => handle_running(),
|
||||
JobStatus::Completed => handle_completed(),
|
||||
}
|
||||
|
||||
// Wildcards OK for fallback
|
||||
match result {
|
||||
Ok(value) => process(value),
|
||||
Err(_) => return default_value(),
|
||||
}
|
||||
|
||||
// Wildcards OK for ignoring fields in destructuring
|
||||
let Point { x, y, .. } = point;
|
||||
```
|
||||
|
||||
## Destructuring in Function Signatures
|
||||
|
||||
Destructure structs directly in function parameters:
|
||||
|
||||
```rust
|
||||
// Preferred
|
||||
async fn process_job(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace, job_id)): Path<(String, Uuid)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<Job>> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Avoid
|
||||
async fn process_job(
|
||||
db_ext: Extension<DB>,
|
||||
path: Path<(String, Uuid)>,
|
||||
query: Query<Pagination>,
|
||||
) -> Result<Json<Job>> {
|
||||
let Extension(db) = db_ext;
|
||||
let Path((workspace, job_id)) = path;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Trait Implementations
|
||||
|
||||
Use standard trait implementations to simplify conversions and reduce boilerplate:
|
||||
|
||||
```rust
|
||||
// Implement From/Into for type conversions
|
||||
impl From<DbJob> for ApiJob {
|
||||
fn from(db: DbJob) -> Self {
|
||||
ApiJob {
|
||||
id: db.id,
|
||||
status: db.status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use TryFrom for fallible conversions
|
||||
impl TryFrom<String> for JobKind {
|
||||
type Error = Error;
|
||||
fn try_from(s: String) -> Result<Self, Self::Error> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Apply `derive` macros to reduce boilerplate:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Job { ... }
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
- Use `pub(crate)` instead of `pub` when possible; expose only what needs exposing
|
||||
- Keep APIs small and expressive; avoid leaking internal types
|
||||
- Organize code into modules reflecting ownership and domain boundaries
|
||||
|
||||
```rust
|
||||
// Prefer restricted visibility
|
||||
pub(crate) fn internal_helper() { ... }
|
||||
|
||||
// Only pub for external API
|
||||
pub fn create_job(...) -> Result<Job> { ... }
|
||||
```
|
||||
|
||||
## Code Navigation
|
||||
|
||||
Always use rust-analyzer LSP for:
|
||||
- Go to definition
|
||||
- Find references
|
||||
- Type information
|
||||
- Import resolution
|
||||
|
||||
Do not guess at module paths or type definitions.
|
||||
Use transactions for multi-step operations. Parameterize all queries.
|
||||
|
||||
## JSON Handling
|
||||
|
||||
Prefer `Box<serde_json::value::RawValue>` over `serde_json::Value` when:
|
||||
- Storing JSON in the database (JSONB columns)
|
||||
- Passing JSON through without modification
|
||||
- The JSON structure doesn't need inspection
|
||||
Prefer `Box<serde_json::value::RawValue>` over `serde_json::Value` when storing/passing JSON without inspection:
|
||||
|
||||
```rust
|
||||
// Preferred - avoids parsing/serialization overhead
|
||||
pub struct Job {
|
||||
pub id: Uuid,
|
||||
pub args: Option<Box<serde_json::value::RawValue>>,
|
||||
}
|
||||
|
||||
// Only use Value when you need to inspect/modify JSON
|
||||
let value: serde_json::Value = serde_json::from_str(&json)?;
|
||||
if let Some(field) = value.get("field") {
|
||||
// modify or inspect
|
||||
}
|
||||
```
|
||||
|
||||
## Serde Optimizations
|
||||
Only use `serde_json::Value` when you need to inspect or modify the JSON.
|
||||
|
||||
Use serde attributes to optimize serialization:
|
||||
## Serde Optimizations
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Job {
|
||||
#[serde(rename = "jobId")]
|
||||
pub id: Uuid,
|
||||
|
||||
#[serde(default)]
|
||||
pub priority: i32,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_job: Option<Uuid>,
|
||||
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub priority: i32,
|
||||
}
|
||||
```
|
||||
|
||||
Prefer borrowing for zero-copy deserialization when lifetimes allow:
|
||||
## Async & Concurrency
|
||||
|
||||
Never block the async runtime. Use `spawn_blocking` for CPU-intensive work:
|
||||
|
||||
```rust
|
||||
#[derive(Deserialize)]
|
||||
pub struct JobInput<'a> {
|
||||
#[serde(borrow)]
|
||||
pub workspace_id: Cow<'a, str>,
|
||||
|
||||
#[serde(borrow)]
|
||||
pub script_path: &'a str,
|
||||
}
|
||||
let result = tokio::task::spawn_blocking(move || expensive_computation(&data)).await?;
|
||||
```
|
||||
|
||||
## SQLx Patterns
|
||||
**Mutex selection**: Prefer `std::sync::Mutex` (or `parking_lot::Mutex`) for data protection. Only use `tokio::sync::Mutex` when holding locks across `.await` points.
|
||||
|
||||
**Never use `SELECT *`** - always list columns explicitly. This is critical for backwards compatibility when workers run behind the API server version:
|
||||
Use `tokio::sync::mpsc` (bounded) for channels. Avoid `std::thread::sleep` in async contexts.
|
||||
|
||||
## Module Structure & Visibility
|
||||
|
||||
- Use `pub(crate)` instead of `pub` when possible
|
||||
- Place new code in the appropriate crate based on functionality
|
||||
- API endpoints go in `windmill-api/src/` organized by domain
|
||||
- Shared functionality goes in `windmill-common/src/`
|
||||
|
||||
## Code Navigation
|
||||
|
||||
Always use rust-analyzer LSP for go-to-definition, find-references, and type info. Do not guess at module paths.
|
||||
|
||||
## Axum Handlers
|
||||
|
||||
Destructure extractors directly in function signatures:
|
||||
|
||||
```rust
|
||||
// Preferred - explicit columns
|
||||
sqlx::query_as!(
|
||||
Job,
|
||||
"SELECT id, workspace_id, path, created_at FROM v2_job WHERE id = $1",
|
||||
job_id
|
||||
)
|
||||
|
||||
// Avoid - breaks when columns are added
|
||||
sqlx::query_as!(Job, "SELECT * FROM v2_job WHERE id = $1", job_id)
|
||||
async fn process_job(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace, job_id)): Path<(String, Uuid)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<Job>> { ... }
|
||||
```
|
||||
|
||||
Use batch operations to minimize round trips:
|
||||
|
||||
```rust
|
||||
// Preferred - single query with multiple values
|
||||
sqlx::query!(
|
||||
"INSERT INTO job_logs (job_id, logs) VALUES ($1, $2), ($3, $4)",
|
||||
id1, log1, id2, log2
|
||||
)
|
||||
|
||||
// Avoid N+1 queries
|
||||
for id in ids {
|
||||
sqlx::query!("SELECT ... WHERE id = $1", id).fetch_one(db).await?;
|
||||
}
|
||||
|
||||
// Preferred - single query with IN clause
|
||||
sqlx::query!("SELECT ... WHERE id = ANY($1)", &ids[..]).fetch_all(db).await?
|
||||
```
|
||||
|
||||
Use transactions for multi-step operations and parameterize all queries.
|
||||
|
||||
## Async & Tokio Patterns
|
||||
|
||||
Never block the async runtime. Use `spawn_blocking` for CPU-intensive or blocking I/O:
|
||||
|
||||
```rust
|
||||
// Preferred - offload blocking work
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
expensive_computation(&data)
|
||||
}).await?;
|
||||
|
||||
// Avoid - blocks the runtime
|
||||
let result = expensive_computation(&data); // Don't do this in async
|
||||
```
|
||||
|
||||
Use tokio primitives for sleep and channels:
|
||||
|
||||
```rust
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::sleep;
|
||||
|
||||
// Avoid in async contexts
|
||||
use std::thread::sleep; // Blocks the runtime
|
||||
```
|
||||
|
||||
Use bounded channels for backpressure:
|
||||
|
||||
```rust
|
||||
// Preferred - bounded channel prevents overwhelming
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(100);
|
||||
|
||||
// Be careful with unbounded
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
```
|
||||
|
||||
## Mutex Selection in Async Code
|
||||
|
||||
**Prefer `std::sync::Mutex` (or `parking_lot::Mutex`) over `tokio::sync::Mutex`** for protecting data in async code. The async mutex is more expensive and only needed when holding locks across `.await` points.
|
||||
|
||||
```rust
|
||||
// Preferred for data protection - std mutex is faster
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct Cache {
|
||||
data: Mutex<HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
fn get(&self, key: &str) -> Option<Value> {
|
||||
self.data.lock().unwrap().get(key).cloned()
|
||||
}
|
||||
|
||||
fn insert(&self, key: String, value: Value) {
|
||||
self.data.lock().unwrap().insert(key, value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use `tokio::sync::Mutex` only when you must hold the lock across `.await` points**, typically for IO resources like database connections:
|
||||
|
||||
```rust
|
||||
use tokio::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Async mutex for IO resources held across await points
|
||||
let conn = Arc::new(Mutex::new(db_connection));
|
||||
|
||||
async fn execute_query(conn: Arc<Mutex<DbConn>>, query: &str) {
|
||||
let mut lock = conn.lock().await;
|
||||
lock.execute(query).await; // Lock held across .await
|
||||
}
|
||||
```
|
||||
|
||||
**Common pattern**: Wrap `Arc<Mutex<...>>` in a struct with non-async methods that lock internally, keeping lock scope minimal:
|
||||
|
||||
```rust
|
||||
struct SharedState {
|
||||
inner: std::sync::Mutex<StateInner>,
|
||||
}
|
||||
|
||||
impl SharedState {
|
||||
fn update(&self, value: i32) {
|
||||
self.inner.lock().unwrap().value = value;
|
||||
}
|
||||
|
||||
fn get(&self) -> i32 {
|
||||
self.inner.lock().unwrap().value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative for IO resources**: Spawn a dedicated task to manage the resource and communicate via message passing:
|
||||
|
||||
```rust
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
handle_io_command(&mut resource, cmd).await;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Build & Tooling
|
||||
|
||||
Build speed tips:
|
||||
- Use `cargo check` during rapid iteration over `cargo build`
|
||||
- Minimize unnecessary dependencies and feature flags
|
||||
@@ -3,316 +3,78 @@ name: svelte-frontend
|
||||
description: Svelte coding guidelines for the Windmill frontend. MUST use when writing or modifying code in the frontend directory.
|
||||
---
|
||||
|
||||
# Svelte 5 Best Practices
|
||||
# Windmill Svelte Patterns
|
||||
|
||||
This guide outlines best practices for developing with Svelte 5, incorporating the new Runes API and other modern Svelte features. These rules MUST NOT be applied on svelte 4 files unless explicitly asked to do so.
|
||||
Apply these Windmill-specific patterns when writing Svelte code in `frontend/`. For general Svelte 5 syntax (runes, snippets, event handling), use the Svelte MCP server.
|
||||
|
||||
## Reactivity with Runes
|
||||
## Windmill UI Components (MUST use)
|
||||
|
||||
Svelte 5 introduces Runes for more explicit and flexible reactivity.
|
||||
Always use Windmill's design-system components. Never use raw HTML elements.
|
||||
|
||||
1. **Embrace Runes for State Management**:
|
||||
* Use `$state` for reactive local component state.
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
function increment() {
|
||||
count += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={increment}>
|
||||
Clicked {count} {count === 1 ? 'time' : 'times'}
|
||||
</button>
|
||||
```
|
||||
* Use `$derived` for computed values based on other reactive state.
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
const doubled = $derived(count * 2);
|
||||
</script>
|
||||
|
||||
<p>{count} * 2 = {doubled}</p>
|
||||
```
|
||||
* Use `$effect` for side effects that need to run when reactive values change (e.g., logging, manual DOM manipulation, data fetching). Remember `$effect` does not run on the server.
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
console.log('The count is now', count);
|
||||
if (count > 5) {
|
||||
alert('Count is too high!');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
2. **Props with `$props`**:
|
||||
* Declare component props using `$props()`. This offers better clarity and flexibility compared to `export let`.
|
||||
```svelte
|
||||
<script>
|
||||
// ChildComponent.svelte
|
||||
let { name, age = $state(30) } = $props();
|
||||
</script>
|
||||
|
||||
<p>Name: {name}</p>
|
||||
<p>Age: {age}</p>
|
||||
```
|
||||
* For bindable props, use `$bindable`.
|
||||
```svelte
|
||||
<script>
|
||||
// MyInput.svelte
|
||||
let { value = $bindable() } = $props();
|
||||
</script>
|
||||
|
||||
<input bind:value />
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
* **Use direct event attributes**: Svelte 5 moves away from `on:` directives for DOM events.
|
||||
* **Do**: `<button onclick={handleClick}>...</button>`
|
||||
* **Don't**: `<button on:click={handleClick}>...</button>`
|
||||
* **For component events, prefer callback props**: Instead of `createEventDispatcher`, pass functions as props.
|
||||
```svelte
|
||||
<!-- Parent.svelte -->
|
||||
<script>
|
||||
import Child from './Child.svelte';
|
||||
let message = $state('');
|
||||
function handleChildEvent(detail) {
|
||||
message = detail;
|
||||
}
|
||||
</script>
|
||||
<Child onCustomEvent={handleChildEvent} />
|
||||
<p>Message from child: {message}</p>
|
||||
|
||||
<!-- Child.svelte -->
|
||||
<script>
|
||||
let { onCustomEvent } = $props();
|
||||
function emitEvent() {
|
||||
onCustomEvent('Hello from child!');
|
||||
}
|
||||
</script>
|
||||
<button onclick={emitEvent}>Send Event</button>
|
||||
```
|
||||
|
||||
## Snippets for Content Projection
|
||||
|
||||
* **Use `{#snippet ...}` and `{@render ...}` instead of slots**: Snippets are more powerful and flexible.
|
||||
```svelte
|
||||
<!-- Parent.svelte -->
|
||||
<script>
|
||||
import Card from './Card.svelte';
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
{#snippet title()}
|
||||
My Awesome Title
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<p>Some interesting content here.</p>
|
||||
{/snippet}
|
||||
</Card>
|
||||
|
||||
<!-- Card.svelte -->
|
||||
<script>
|
||||
let { title, content } = $props();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<header>{@render title()}</header>
|
||||
<div>{@render content()}</div>
|
||||
</article>
|
||||
```
|
||||
* Default content is passed via the `children` prop (which is a snippet).
|
||||
```svelte
|
||||
<!-- Wrapper.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Component Design
|
||||
|
||||
1. **Create Small, Reusable Components**: Break down complex UIs into smaller, focused components. Each component should have a single responsibility. This also aids performance by limiting the scope of reactivity updates.
|
||||
2. **Descriptive Naming**: Use clear and descriptive names for variables, functions, and components.
|
||||
3. **Minimize Logic in Components**: Move complex business logic to utility functions or services. Keep components focused on presentation and interaction.
|
||||
|
||||
## State Management (Stores)
|
||||
|
||||
1. **Segment Stores**: Avoid a single global store. Create multiple stores, each responsible for a specific piece of global state (e.g., `userStore.js`, `themeStore.js`). This can help limit reactivity updates to only the parts of the UI that depend on specific state segments.
|
||||
2. **Use Custom Stores for Complex Logic**: For stores with related methods, create custom stores.
|
||||
```javascript
|
||||
// counterStore.js
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
function createCounter() {
|
||||
const { subscribe, set, update } = writable(0);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
increment: () => update(n => n + 1),
|
||||
decrement: () => update(n => n - 1),
|
||||
reset: () => set(0)
|
||||
};
|
||||
}
|
||||
export const counter = createCounter();
|
||||
```
|
||||
3. **Use Context API for Localized State**: For state shared within a component subtree, consider Svelte's context API (`setContext`, `getContext`) instead of global stores when the state doesn't need to be truly global.
|
||||
|
||||
## Performance Optimizations (Svelte 5)
|
||||
|
||||
When generating Svelte 5 code, prioritize frontend performance by applying the following principles:
|
||||
|
||||
### General Svelte 5 Principles
|
||||
|
||||
- **Leverage the Compiler:** Trust Svelte's compiler to generate optimized JavaScript. Avoid manual DOM manipulation (`document.querySelector`, etc.) unless absolutely necessary for integrating third-party libraries that lack Svelte adapters.
|
||||
- **Keep Components Small and Focused:** Reinforcing from Component Design, smaller components lead to less complex reactivity graphs and more targeted, efficient updates.
|
||||
|
||||
### Reactivity & State Management
|
||||
|
||||
- **Optimize Computations with `$derived`:** Always use `$derived` for computed values that depend on other state. This ensures the computation only runs when its specific dependencies change, avoiding unnecessary work compared to recomputing derived values in `$effect` or less efficient methods.
|
||||
- **Minimize `$effect` Usage:** Use `$effect` sparingly and only for true side effects that interact with the outside world or non-Svelte state. Avoid putting complex logic or state updates *within* an `$effect` unless those updates are explicitly intended as a reaction to external changes or non-Svelte state. Excessive or complex effects can impact rendering performance.
|
||||
- **Structure State for Fine-Grained Updates:** Design your `$state` objects or variables such that updates affect only the necessary parts of the UI. Avoid putting too much unrelated state into a single large object that gets frequently updated, as this can potentially trigger broader updates than necessary. Consider normalizing complex, nested state.
|
||||
|
||||
### List Rendering (`{#each}`)
|
||||
|
||||
- **Mandate `key` Attribute:** Always use a `key` attribute (`{#each items as item (item.id)}`) that refers to a unique, stable identifier for each item in a list. This is critical for allowing Svelte to efficiently update, reorder, add, or remove list items without destroying and re-creating unnecessary DOM elements and component instances.
|
||||
|
||||
### Component Loading & Bundling
|
||||
|
||||
- **Implement Lazy Loading/Code Splitting:** For routes, components, or modules that are not immediately needed on page load, use dynamic imports (`import(...)`) to split the code bundle. SvelteKit handles this automatically for routes, but it can be applied manually to components using helper patterns if needed.
|
||||
- **Be Mindful of Third-Party Libraries:** When incorporating external libraries, import only the necessary functions or components to minimize the final bundle size. Prefer libraries designed to be tree-shakeable.
|
||||
|
||||
### Rendering & DOM
|
||||
|
||||
- **Use CSS for Animations/Transitions:** Prefer CSS animations or transitions where possible for performance. Svelte's built-in `transition:` directive is also highly optimized and should be used for complex state-driven transitions, but simple cases can often use plain CSS.
|
||||
- **Optimize Image Loading:** Implement best practices for images: use optimized formats (WebP, AVIF), lazy loading (`loading="lazy"`), and responsive images (`<picture>`, `srcset`) to avoid loading unnecessarily large images.
|
||||
|
||||
### Server-Side Rendering (SSR) & Hydration
|
||||
|
||||
- **Ensure SSR Compatibility:** Write components that can be rendered on the server for faster initial page loads. Avoid relying on browser-specific APIs (like `window` or `document`) in the main `<script>` context. If necessary, use `$effect` or check `if (browser)` inside effects to run browser-specific code only on the client.
|
||||
- **Minimize Work During Hydration:** Structure components and data fetching such that minimal complex setup or computation is required when the client-side Svelte code takes over from the server-rendered HTML. Heavy synchronous work during hydration can block the main thread.
|
||||
|
||||
## General Clean Code Practices
|
||||
|
||||
1. **Organized File Structure**: Group related files together. A common structure:
|
||||
```
|
||||
/src
|
||||
|-- /routes // Page components (if using a router like SvelteKit)
|
||||
|-- /lib // Utility functions, services, constants (SvelteKit often uses this)
|
||||
| |-- /stores
|
||||
| |-- /utils
|
||||
| |-- /services
|
||||
| |-- /components // Reusable UI components
|
||||
|-- App.svelte
|
||||
|-- main.js (or main.ts)
|
||||
```
|
||||
2. **Scoped Styles**: Keep CSS scoped to components to avoid unintended side effects and improve maintainability. Avoid `:global` where possible.
|
||||
3. **Immutability**: With Svelte 5 and `$state`, direct assignments to properties of `$state` objects (`obj.prop = value;`) are generally fine as Svelte's reactivity system handles updates. However, for non-rune state or when interacting with other systems, understanding and sometimes preferring immutable updates (creating new objects/arrays) can still be relevant.
|
||||
4. **Use `class:` and `style:` directives**: For dynamic classes and styles, use Svelte's built-in directives for cleaner templates and potentially optimized updates.
|
||||
```svelte
|
||||
<script>
|
||||
let isActive = $state(true);
|
||||
let color = $state('blue');
|
||||
</script>
|
||||
|
||||
<div class:active={isActive} style:color={color}>
|
||||
Hello
|
||||
</div>
|
||||
```
|
||||
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
|
||||
|
||||
## Windmill UI Component Rules (MUST follow)
|
||||
|
||||
Always use Windmill's own design-system components instead of raw HTML elements. Using raw HTML elements produces inconsistent styling and breaks the design language.
|
||||
|
||||
### Icons — use `lucide-svelte`
|
||||
|
||||
**Never** write inline SVGs. Import icons from `lucide-svelte`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-svelte'
|
||||
</script>
|
||||
|
||||
<ChevronLeft size={16} />
|
||||
```
|
||||
|
||||
### Buttons — use `<Button>`
|
||||
|
||||
**Never** use `<button>`. Import and use `Button` from `$lib/components/common`.
|
||||
### Buttons — `<Button>`
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
|
||||
import { ChevronLeft } 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} />
|
||||
<Button startIcon={{ icon: ChevronLeft }} iconOnly onclick={prev} />
|
||||
```
|
||||
|
||||
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`
|
||||
Props: `variant?: 'accent' | 'accent-secondary' | 'default' | 'subtle'`, `unifiedSize?: 'sm' | 'md' | 'lg'`, `startIcon?: { icon: SvelteComponent }`, `iconOnly?: boolean`, `disabled?: boolean`
|
||||
|
||||
### Text inputs — use `<TextInput>`
|
||||
|
||||
**Never** use `<input>`. Import and use `TextInput` from `$lib/components/common`.
|
||||
### Text inputs — `<TextInput>`
|
||||
|
||||
```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>`
|
||||
Props: `value?: string | number` (bindable), `placeholder?: string`, `disabled?: boolean`, `error?: string | boolean`, `size?: 'sm' | 'md' | 'lg'`
|
||||
|
||||
### Selects — use `<Select>`
|
||||
|
||||
**Never** use `<select>`. Import and use `Select` from `$lib/components/select/Select.svelte`.
|
||||
### Selects — `<Select>`
|
||||
|
||||
```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} />
|
||||
<Select items={[{ label: 'Jan', value: 1 }]} bind:value={selected} />
|
||||
```
|
||||
|
||||
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'`
|
||||
Props: `items?: Array<{ label?: string; value: any }>`, `value` (bindable), `placeholder?: string`, `clearable?: boolean`, `size?: 'sm' | 'md' | 'lg'`
|
||||
|
||||
### Icons — `lucide-svelte`
|
||||
|
||||
Never write inline SVGs. Import from `lucide-svelte`:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { ChevronLeft, X } from 'lucide-svelte'
|
||||
</script>
|
||||
<ChevronLeft size={16} />
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
Form components (TextInput, Toggle, Select, etc.) should use the unified size system when placed together.
|
||||
|
||||
## Styling
|
||||
|
||||
- Use Tailwind CSS for all styling — no custom CSS
|
||||
- Use Windmill's theming classes for colors/surfaces (see `frontend/brand-guidelines.md`)
|
||||
- Read component props JSDoc before using them
|
||||
|
||||
## Svelte MCP Server
|
||||
|
||||
Use the Svelte MCP tools when working on Svelte code:
|
||||
|
||||
1. **list-sections**: Call first to discover available docs
|
||||
2. **get-documentation**: Fetch relevant sections based on use_cases
|
||||
3. **svelte-autofixer**: MUST use on all Svelte code before finalizing — keep calling until no issues
|
||||
4. **playground-link**: Only after user confirms and code was NOT written to project files
|
||||
|
||||
2
.github/DockerfileBackendTests
vendored
2
.github/DockerfileBackendTests
vendored
@@ -42,7 +42,7 @@ RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VER
|
||||
RUN /usr/local/bin/python3 -m pip install pip-tools
|
||||
|
||||
# Bun
|
||||
COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
# Install windmill CLI
|
||||
RUN bun install -g windmill-cli \
|
||||
|
||||
3
.github/change-versions-mac.sh
vendored
3
.github/change-versions-mac.sh
vendored
@@ -15,11 +15,8 @@ sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescrip
|
||||
sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
|
||||
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
|
||||
sed -i '' -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
|
||||
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
|
||||
sed -i '' -e "/^[[:space:]]*ModuleVersion[[:space:]]*=/s/= .*/= '$VERSION'/" ${root_dirpath}/powershell-client/WindmillClient/WindmillClient.psd1
|
||||
# sed -i '' -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill_pg/pyproject.toml
|
||||
sed -i '' -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
|
||||
sed -i '' -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
|
||||
|
||||
sed -i '' -E "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
|
||||
|
||||
|
||||
3
.github/change-versions.sh
vendored
3
.github/change-versions.sh
vendored
@@ -16,11 +16,8 @@ sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-c
|
||||
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
|
||||
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
|
||||
sed -i -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
|
||||
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
|
||||
sed -i -e "/^[[:space:]]*ModuleVersion[[:space:]]*=/s/= .*/= '$VERSION'/" ${root_dirpath}/powershell-client/WindmillClient/WindmillClient.psd1
|
||||
# sed -i -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
|
||||
sed -i -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
|
||||
sed -i -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
|
||||
|
||||
sed -i -zE "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
|
||||
|
||||
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -31,9 +31,3 @@ updates:
|
||||
directory: "/python-client/wmill"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Maintain dependencies for wmill_pg python client
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/python-client/wmill_pg"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
25
.github/workflows/backend-test.yml
vendored
25
.github/workflows/backend-test.yml
vendored
@@ -19,7 +19,7 @@ defaults:
|
||||
|
||||
jobs:
|
||||
cargo_test:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: ubicloud-standard-16
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
go-version: 1.21.5
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.8
|
||||
bun-version: 1.3.10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
@@ -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
|
||||
@@ -253,4 +238,4 @@ jobs:
|
||||
run: |
|
||||
deno --version && bun -v && node --version && go version && python3 --version && php --version && ruby --version && pwsh --version && dotnet --version
|
||||
cd windmill-duckdb-ffi-internal && ./build_dev.sh && cd ..
|
||||
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp --all -- --nocapture --test-threads=10
|
||||
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline --all -- --nocapture --test-threads=10
|
||||
|
||||
22
.github/workflows/discord-notification.yml
vendored
22
.github/workflows/discord-notification.yml
vendored
@@ -9,9 +9,7 @@ on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
pull_request_review_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
notify_discord_when_pr_opened:
|
||||
@@ -53,23 +51,7 @@ jobs:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
COMMENT_URL: ${{ github.event.comment.html_url }}
|
||||
DISCORD_CHANNEL_ID: "1372204995868491786"
|
||||
DISCORD_GUILD_ID: "930051556043276338"
|
||||
secrets:
|
||||
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}
|
||||
|
||||
notify_discord_on_review_comment:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review_comment'
|
||||
&& github.event.comment.user.login != 'cloudflare-workers-and-pages[bot]'
|
||||
&& github.event.comment.user.login != 'ellipsis-dev[bot]'
|
||||
uses: ./.github/workflows/shareable-discord-notification.yml
|
||||
with:
|
||||
PR_STATUS: "comment"
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
COMMENT_URL: ${{ github.event.comment.html_url }}
|
||||
COMMENT_IS_EDIT: ${{ github.event.action == 'edited' }}
|
||||
DISCORD_CHANNEL_ID: "1372204995868491786"
|
||||
DISCORD_GUILD_ID: "930051556043276338"
|
||||
secrets:
|
||||
|
||||
@@ -36,6 +36,10 @@ on:
|
||||
description: "The comment URL"
|
||||
type: string
|
||||
default: ""
|
||||
COMMENT_IS_EDIT:
|
||||
description: "Whether this is an edit of an existing comment"
|
||||
type: string
|
||||
default: "false"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK_URL:
|
||||
description: "Discord Webhook URL"
|
||||
@@ -135,7 +139,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.PR_STATUS == 'comment' }}
|
||||
steps:
|
||||
- name: Post comment to Discord thread
|
||||
- name: Post or update comment in Discord thread
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
|
||||
CHANNEL_ID: ${{ inputs.DISCORD_CHANNEL_ID }}
|
||||
@@ -144,6 +148,7 @@ jobs:
|
||||
COMMENT_BODY: ${{ inputs.COMMENT_BODY }}
|
||||
COMMENT_AUTHOR: ${{ inputs.COMMENT_AUTHOR }}
|
||||
COMMENT_URL: ${{ inputs.COMMENT_URL }}
|
||||
COMMENT_IS_EDIT: ${{ inputs.COMMENT_IS_EDIT }}
|
||||
run: |
|
||||
# 1) Find the thread by PR number
|
||||
threads=$(curl -s -H "Authorization: Bot $BOT_TOKEN" \
|
||||
@@ -172,10 +177,36 @@ jobs:
|
||||
truncated_body="$COMMENT_BODY"
|
||||
fi
|
||||
|
||||
# 3) Post the comment to the thread
|
||||
message=$(printf '**%s** [commented](%s):\n%s' "$COMMENT_AUTHOR" "$COMMENT_URL" "$truncated_body")
|
||||
# 3) Build the message content
|
||||
if [ "$COMMENT_IS_EDIT" = "true" ]; then
|
||||
message=$(printf '**%s** [edited comment](%s):\n%s' "$COMMENT_AUTHOR" "$COMMENT_URL" "$truncated_body")
|
||||
else
|
||||
message=$(printf '**%s** [commented](%s):\n%s' "$COMMENT_AUTHOR" "$COMMENT_URL" "$truncated_body")
|
||||
fi
|
||||
payload=$(jq -n --arg content "$message" '{content: $content, flags: 4, allowed_mentions: {parse: []}}')
|
||||
|
||||
# 4) If this is an edit, try to find and update the existing Discord message
|
||||
if [ "$COMMENT_IS_EDIT" = "true" ]; then
|
||||
# Search recent messages in the thread for one containing the comment URL
|
||||
messages=$(curl -s -H "Authorization: Bot $BOT_TOKEN" \
|
||||
"https://discord.com/api/v10/channels/${thread_id}/messages?limit=100")
|
||||
existing_msg_id=$(echo "$messages" | jq -r \
|
||||
--arg url "$COMMENT_URL" \
|
||||
'[.[] | select(.content | contains($url))] | first | .id // empty')
|
||||
|
||||
if [ -n "$existing_msg_id" ]; then
|
||||
echo "Updating existing Discord message $existing_msg_id"
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Bot $BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"https://discord.com/api/v10/channels/${thread_id}/messages/${existing_msg_id}"
|
||||
exit 0
|
||||
fi
|
||||
echo "Original Discord message not found, posting as new message"
|
||||
fi
|
||||
|
||||
# 5) Post a new message to the thread
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bot $BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,6 +17,9 @@ rust-client/Cargo.toml
|
||||
# Worktree-generated port isolation
|
||||
.env.local
|
||||
|
||||
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Symlinked cache directories (for git worktrees)
|
||||
backend/target
|
||||
frontend/node_modules
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
"svelte": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
106
.wmdev.yaml
Normal file
106
.wmdev.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
services:
|
||||
- name: BE
|
||||
portEnv: BACKEND_PORT
|
||||
- name: FE
|
||||
portEnv: FRONTEND_PORT
|
||||
|
||||
profiles:
|
||||
default:
|
||||
name: default
|
||||
|
||||
sandbox:
|
||||
name: sandbox
|
||||
image: windmill-sandbox
|
||||
envPassthrough:
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
- R2_ENDPOINT
|
||||
- R2_BUCKET
|
||||
- R2_PUBLIC_URL
|
||||
extraMounts:
|
||||
- hostPath: ~/.ssh
|
||||
guestPath: /root/.ssh
|
||||
writable: true
|
||||
- hostPath: ~/.codex
|
||||
guestPath: /root/.codex
|
||||
writable: true
|
||||
- hostPath: ~/windmill-ee-private
|
||||
writable: true
|
||||
- hostPath: ~/windmill-ee-private__worktrees
|
||||
writable: true
|
||||
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 in PR descriptions using markdown image syntax.
|
||||
|
||||
--- Terminal Recordings (asciinema) ---
|
||||
You can record terminal sessions and upload them for sharing.
|
||||
asciinema is available on PATH.
|
||||
|
||||
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
|
||||
|
||||
--- Mermaid Diagrams ---
|
||||
You can render Mermaid diagrams to SVG using the pre-installed mmdc CLI.
|
||||
The puppeteer config (no-sandbox + Chromium path) is at /root/.puppeteerrc.json.
|
||||
|
||||
1) Write a .mmd file with your diagram:
|
||||
cat > /tmp/diagram.mmd << 'EOF'
|
||||
graph TD
|
||||
A[Start] --> B[End]
|
||||
EOF
|
||||
|
||||
2) Render to SVG (the -p flag is required):
|
||||
mmdc -i /tmp/diagram.mmd -o /tmp/diagram.svg -p /root/.puppeteerrc.json
|
||||
|
||||
3) Upload to R2:
|
||||
aws s3 cp /tmp/diagram.svg
|
||||
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/diagram.svg"
|
||||
--endpoint-url "$(printenv R2_ENDPOINT)"
|
||||
|
||||
4) The public URL will be:
|
||||
$(printenv R2_PUBLIC_URL)/<branch>/diagram.svg
|
||||
|
||||
5) Include in PR descriptions using markdown image syntax.
|
||||
|
||||
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
|
||||
|
||||
linkedRepos:
|
||||
- repo: windmill-labs/windmill-ee-private
|
||||
alias: ee
|
||||
@@ -11,7 +11,7 @@ worktree_prefix: ""
|
||||
window_prefix: "wm-"
|
||||
|
||||
auto_name:
|
||||
model: "claude-sonnet-4.6"
|
||||
model: "gemini-2.5-flash-lite"
|
||||
system_prompt: |
|
||||
Generate a concise git branch name based on the task description.
|
||||
|
||||
@@ -46,11 +46,21 @@ pre_remove:
|
||||
- ./scripts/worktree-cleanup
|
||||
|
||||
panes:
|
||||
- command: <agent>
|
||||
- command: >-
|
||||
claude --dangerously-skip-permissions --append-system-prompt
|
||||
"You are running inside a tmux session with other panes running services.\n
|
||||
Pane layout (current window):\n
|
||||
- Pane 0: this pane (claude agent)\n
|
||||
- Pane 1: backend (cargo watch -x run)\n
|
||||
- Pane 2: frontend (npm run dev)\n\n
|
||||
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).\n
|
||||
When restarting backend or frontend, make sure to use the ports listed in .env.local.\n
|
||||
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check.\n\n
|
||||
IMPORTANT: Read docs/autonomous-mode.md before starting any work."
|
||||
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'
|
||||
- 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 ${CARGO_FEATURES:+--features $CARGO_FEATURES}"'
|
||||
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 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:
|
||||
|
||||
127
CHANGELOG.md
127
CHANGELOG.md
@@ -1,5 +1,132 @@
|
||||
# Changelog
|
||||
|
||||
## [1.647.2](https://github.com/windmill-labs/windmill/compare/v1.647.1...v1.647.2) (2026-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update oracle instant client arm64 download url ([#8179](https://github.com/windmill-labs/windmill/issues/8179)) ([758b35f](https://github.com/windmill-labs/windmill/commit/758b35f8ebbf78e1473a8fd83dbc795d58b23b80))
|
||||
|
||||
## [1.647.1](https://github.com/windmill-labs/windmill/compare/v1.647.0...v1.647.1) (2026-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing display_name and tenant fields to instance config OAuthClient ([#8176](https://github.com/windmill-labs/windmill/issues/8176)) ([db44b8b](https://github.com/windmill-labs/windmill/commit/db44b8be74e1709dbf759dd391bdb3861b3c711b))
|
||||
* add missing grant_types field to instance config OAuth structs ([#8175](https://github.com/windmill-labs/windmill/issues/8175)) ([fca94f8](https://github.com/windmill-labs/windmill/commit/fca94f88dd796db66e0c5bd0225e23b92efce4a7))
|
||||
* show sync endpoint timeout setting on all instances ([#8170](https://github.com/windmill-labs/windmill/issues/8170)) ([c70307d](https://github.com/windmill-labs/windmill/commit/c70307d3f2dfe61a0250dd12234470a25baf2d1b))
|
||||
|
||||
## [1.647.0](https://github.com/windmill-labs/windmill/compare/v1.646.0...v1.647.0) (2026-03-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* populate baseUrl and userId in Nextcloud resource from OAuth ([#8132](https://github.com/windmill-labs/windmill/issues/8132)) ([5d58a87](https://github.com/windmill-labs/windmill/commit/5d58a87a7f02c4f7775bd02c885071495a5f686d))
|
||||
* runScript inline for path and hash ([#8019](https://github.com/windmill-labs/windmill/issues/8019)) ([7d9d16a](https://github.com/windmill-labs/windmill/commit/7d9d16a6a3357981e5692023982ca1e670acfaae))
|
||||
* slow stream warnings, batch size control, and fix result/skipped filters ([#8154](https://github.com/windmill-labs/windmill/issues/8154)) ([7a32abe](https://github.com/windmill-labs/windmill/commit/7a32abec96124f96a1dbac11e03162cca68f3286))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* : persist show schedules and show future jobs toggles in local storage ([#8125](https://github.com/windmill-labs/windmill/issues/8125)) ([f1d8568](https://github.com/windmill-labs/windmill/commit/f1d8568831bf69ee790def4f90df8f32c59a94e0)), closes [#8123](https://github.com/windmill-labs/windmill/issues/8123)
|
||||
* add partial index for fast failure filtering on runs page ([#8150](https://github.com/windmill-labs/windmill/issues/8150)) ([d4673c2](https://github.com/windmill-labs/windmill/commit/d4673c2e91168dcdb0aca9d6c039df0d9c52bb28))
|
||||
* copy deps and remove user auto-add on workspace fork ([#8142](https://github.com/windmill-labs/windmill/issues/8142)) ([0776de6](https://github.com/windmill-labs/windmill/commit/0776de6b2173075f533fd59a49efb111000da5df))
|
||||
* fix custom TS Monaco worker not reloading on file uri change ([#8130](https://github.com/windmill-labs/windmill/issues/8130)) ([b68ff96](https://github.com/windmill-labs/windmill/commit/b68ff965dd4f67046fae7e8cf756c8b3e15c2643))
|
||||
* Handle CTEs and local tables in SQL asset parser ([#8131](https://github.com/windmill-labs/windmill/issues/8131)) ([0955051](https://github.com/windmill-labs/windmill/commit/095505136c2b3e03f656ace20a5c1bbe142fa63f))
|
||||
* prevent wm-cursor from hanging on stale cursor IPC sockets ([b9e3e05](https://github.com/windmill-labs/windmill/commit/b9e3e053e4914e753bbb806e6b748c791edb92d2))
|
||||
* process deletes before adds in CLI sync push to avoid conflicts ([#8148](https://github.com/windmill-labs/windmill/issues/8148)) ([278983c](https://github.com/windmill-labs/windmill/commit/278983c4fd38d67a14a8c208178c04db05ee1880))
|
||||
* remove review comments from discord notifications and support comment edits ([cdc0543](https://github.com/windmill-labs/windmill/commit/cdc0543747680267e30974037a2eb180a19062d9))
|
||||
* restore email domain (MX) setting in instance settings UI ([#8152](https://github.com/windmill-labs/windmill/issues/8152)) ([13daebf](https://github.com/windmill-labs/windmill/commit/13daebf88ac1abcb833646490073f922ac7c050e))
|
||||
* sync flow on_behalf_of_email on load ([#8149](https://github.com/windmill-labs/windmill/issues/8149)) ([faf190f](https://github.com/windmill-labs/windmill/commit/faf190f12d96cd75ba9eda10ab3e6f26d2eed813))
|
||||
* validate tarball URL host against registry to prevent SSRF and token exfiltration ([#8153](https://github.com/windmill-labs/windmill/issues/8153)) ([86182ed](https://github.com/windmill-labs/windmill/commit/86182ed2e999f018fc72343308e7df8e9de6c189))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* batch large job list requests and fix loadExtraJobs cursor ([#8151](https://github.com/windmill-labs/windmill/issues/8151)) ([4f5a804](https://github.com/windmill-labs/windmill/commit/4f5a8040912e18f34401a6e3a95dea6f97d1d24c))
|
||||
* lazy-load heavy deps (graphql, openapi-parser, sha256) ([#8145](https://github.com/windmill-labs/windmill/issues/8145)) ([ba48d70](https://github.com/windmill-labs/windmill/commit/ba48d7015741eb6bbbe04088a957c37499cd8471))
|
||||
* lazy-load markdown in Tooltip components ([#8143](https://github.com/windmill-labs/windmill/issues/8143)) ([bd9ff03](https://github.com/windmill-labs/windmill/commit/bd9ff03010f75557dcc315d10e9208b4e9cafece))
|
||||
|
||||
## [1.646.0](https://github.com/windmill-labs/windmill/compare/v1.645.0...v1.646.0) (2026-02-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add force_branch parameter to git sync settings ([#8089](https://github.com/windmill-labs/windmill/issues/8089)) ([4e1ae27](https://github.com/windmill-labs/windmill/commit/4e1ae276b006992e06ae755ec9315dbfadf4f838))
|
||||
* add wmill docs CLI command for querying documentation ([#8114](https://github.com/windmill-labs/windmill/issues/8114)) ([01c7270](https://github.com/windmill-labs/windmill/commit/01c7270cdaa0d5dbee2e15aa5dd08551cff60c70))
|
||||
* Broad filters for search ([#8112](https://github.com/windmill-labs/windmill/issues/8112)) ([16a6d5e](https://github.com/windmill-labs/windmill/commit/16a6d5e7afe9323b2f2c7a93828518f5d924cc69))
|
||||
* change on behalf selector to allow picking any user + select value in target by default if possible ([#8113](https://github.com/windmill-labs/windmill/issues/8113)) ([408c5af](https://github.com/windmill-labs/windmill/commit/408c5af6d8352f1e205e4543772ce5d060556ffc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove duplicate job loading on chart zoom ([#8121](https://github.com/windmill-labs/windmill/issues/8121)) ([99c01bc](https://github.com/windmill-labs/windmill/commit/99c01bca3863ac9b2882948bb5914f051a7716a4))
|
||||
* runs page date picker query parameter handling ([#8120](https://github.com/windmill-labs/windmill/issues/8120)) ([427bc64](https://github.com/windmill-labs/windmill/commit/427bc6410be7fda132fc91991164e9b38b32c7e3))
|
||||
|
||||
## [1.645.0](https://github.com/windmill-labs/windmill/compare/v1.644.0...v1.645.0) (2026-02-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add resume and cancel button text options to Slack approval API + formatted args + typo ([#8095](https://github.com/windmill-labs/windmill/issues/8095)) ([c7c828b](https://github.com/windmill-labs/windmill/commit/c7c828b56e7a5f877ef0a78498018ed930bccb23))
|
||||
* Data table as pg resource / trigger ([#8088](https://github.com/windmill-labs/windmill/issues/8088)) ([8e7ba9b](https://github.com/windmill-labs/windmill/commit/8e7ba9b33da2ddba0eba8341219b9a3576a9d95d))
|
||||
* option to preserve on_behalf_of and edited_by for admins and users in the new wm_deployers group ([#8079](https://github.com/windmill-labs/windmill/issues/8079)) ([7ac93f6](https://github.com/windmill-labs/windmill/commit/7ac93f6ee30eb8dfa6ddb9c19697cde93bf7e134))
|
||||
* per-worktree database isolation and Claude Code auto-trust ([09970cd](https://github.com/windmill-labs/windmill/commit/09970cd22b8f19c6d01351f9a9bf4aac170116c2))
|
||||
* show triggers in fork deploy to parent UI. ([#8094](https://github.com/windmill-labs/windmill/issues/8094)) ([935b005](https://github.com/windmill-labs/windmill/commit/935b0058e2b8056e07f8dd8f80ef6de78ca8331f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** fix skip check crash when flow-level skip_expr triggers on first module with skip_if ([#8111](https://github.com/windmill-labs/windmill/issues/8111)) ([7bb450e](https://github.com/windmill-labs/windmill/commit/7bb450edbfccd5c21dc5dbc1e7bf2f2ecc4c779c))
|
||||
* **backend:** pass parent_path for trigger renames in git sync ([#8059](https://github.com/windmill-labs/windmill/issues/8059)) ([5730009](https://github.com/windmill-labs/windmill/commit/5730009404171cbffb67d0296baf9c0aa2858816))
|
||||
* correct asset node x offset inside loops and branches ([#8093](https://github.com/windmill-labs/windmill/issues/8093)) ([1c9ac97](https://github.com/windmill-labs/windmill/commit/1c9ac97f876a82c6ce3b18e30ffdeea79ccd4481))
|
||||
* delete non-session tokens on workspace archive and reject token creation for archived workspaces ([#8082](https://github.com/windmill-labs/windmill/issues/8082)) ([bc67255](https://github.com/windmill-labs/windmill/commit/bc672555a77f3b78ff324a26603d2ab7839df77e))
|
||||
* improve Anthropic API proxy handling and update default models ([#8105](https://github.com/windmill-labs/windmill/issues/8105)) ([a9968d0](https://github.com/windmill-labs/windmill/commit/a9968d0aed446a090b158c3269ffeb6907330933))
|
||||
* optimize slow list_assets query for recents loading ([#8103](https://github.com/windmill-labs/windmill/issues/8103)) ([0c204b6](https://github.com/windmill-labs/windmill/commit/0c204b69bdd319af2706c1add552622678cd343f))
|
||||
* remove duplicate num_columns in test_parse_relation test ([cff9e2c](https://github.com/windmill-labs/windmill/commit/cff9e2c5c22b3c1a0b5891839fe59e4058ded888))
|
||||
* resolve Vite dependency pre-bundling errors ([#8102](https://github.com/windmill-labs/windmill/issues/8102)) ([07ddcd2](https://github.com/windmill-labs/windmill/commit/07ddcd2a08c103246b2b60f9df1ffb477ff97006))
|
||||
* use @-prefixed LIKE pattern for email domain matching ([#8101](https://github.com/windmill-labs/windmill/issues/8101)) ([02d5447](https://github.com/windmill-labs/windmill/commit/02d5447e1d567a18b0d6eb24f3423bd675f6cbe8))
|
||||
* use main runtime handle in QuickJS eval to prevent connection pool poisoning ([#8106](https://github.com/windmill-labs/windmill/issues/8106)) ([af2aca5](https://github.com/windmill-labs/windmill/commit/af2aca56b04c7a3fd25f096f2471292489923431))
|
||||
|
||||
## [1.644.0](https://github.com/windmill-labs/windmill/compare/v1.643.0...v1.644.0) (2026-02-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cli:** detect missing folders on sync push and add 'wmill folder add-missing' ([#8011](https://github.com/windmill-labs/windmill/issues/8011)) ([835db5d](https://github.com/windmill-labs/windmill/commit/835db5d290a151f38f4e879ed7ffbda5d1c4b24f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent concurrent index migrations from re-running on every startup ([#8069](https://github.com/windmill-labs/windmill/issues/8069)) ([8ff2340](https://github.com/windmill-labs/windmill/commit/8ff2340c0c08ce49a809c8958a9862ffb1681642))
|
||||
|
||||
## [1.643.0](https://github.com/windmill-labs/windmill/compare/v1.642.0...v1.643.0) (2026-02-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add fileset resource type support ([32c4b47](https://github.com/windmill-labs/windmill/commit/32c4b474f92f3dbbd2077fab70bdf9e407581626))
|
||||
* add fileset resource type support ([#8063](https://github.com/windmill-labs/windmill/issues/8063)) ([c15b9ab](https://github.com/windmill-labs/windmill/commit/c15b9abe5eb2a1566a7ce4b18784c961d178a669))
|
||||
* add light mode for navigation sidebar ([#8057](https://github.com/windmill-labs/windmill/issues/8057)) ([0935bf9](https://github.com/windmill-labs/windmill/commit/0935bf9fc460c03c6d8469b93036e43714517ef2))
|
||||
* **aiagent:** handle ai agent as tool ([#8031](https://github.com/windmill-labs/windmill/issues/8031)) ([de6fd16](https://github.com/windmill-labs/windmill/commit/de6fd160d56c1037adbbe785f195483c25982e1c))
|
||||
* Unified filters and new runs page ([#8027](https://github.com/windmill-labs/windmill/issues/8027)) ([9b28c85](https://github.com/windmill-labs/windmill/commit/9b28c85469d6b2a8590810b313b030d9f00ee9e3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address code review findings for fileset feature ([1b4489a](https://github.com/windmill-labs/windmill/commit/1b4489acac3b050f0a783548bacfc9bdf33ee593))
|
||||
* address second round of review findings ([753c05a](https://github.com/windmill-labs/windmill/commit/753c05a03089b95b4ade68d3bf61c8818de422ce))
|
||||
* **backend:** decimal between 0 and -1 in mssql ([#8051](https://github.com/windmill-labs/windmill/issues/8051)) ([9686608](https://github.com/windmill-labs/windmill/commit/9686608355615a50c8395f6e2fd51dcc25498226))
|
||||
* **backend:** use filename instead of content_type to detect file fields in multipart form data ([#8054](https://github.com/windmill-labs/windmill/issues/8054)) ([0aa885d](https://github.com/windmill-labs/windmill/commit/0aa885db67d77202205fc1609e841b8ffd9a8121))
|
||||
* exclude app_theme resources from workspace tab ([9c513b2](https://github.com/windmill-labs/windmill/commit/9c513b2c62acc369179fb9e404e1f4007cd854c6))
|
||||
* fileset editor takes full height with matching header ([9ac0789](https://github.com/windmill-labs/windmill/commit/9ac07897cf99f3af27801e435c7376a46ef760c9))
|
||||
* prevent iframe from overriding file selection after file creation ([7f3ddd7](https://github.com/windmill-labs/windmill/commit/7f3ddd7edd3ea993642aadd55cdba0ac2ea1eb9f))
|
||||
* resolve svelte warnings and type error in fileset components ([4c06d74](https://github.com/windmill-labs/windmill/commit/4c06d74bd01ca2dda848be421d70dd5268520992))
|
||||
* restore full-width file tree items in raw app sidebar ([5bac8b0](https://github.com/windmill-labs/windmill/commit/5bac8b093dbe913a563b02573959c64dd405ff61))
|
||||
* suppress iframe setActiveDocument during file population ([1abfeea](https://github.com/windmill-labs/windmill/commit/1abfeea81a645c59934d62257ad869ed7b475634))
|
||||
* update git sync init script to hub version 28158 ([#8061](https://github.com/windmill-labs/windmill/issues/8061)) ([705e186](https://github.com/windmill-labs/windmill/commit/705e186f3d4c7d8f8a88fc84b379ed9fe800a6b2))
|
||||
* use correct column name completed_at instead of ended_at in count_completed_jobs_detail ([#8066](https://github.com/windmill-labs/windmill/issues/8066)) ([3aba0ed](https://github.com/windmill-labs/windmill/commit/3aba0ed2508debdc78a6631e49b074a97635f21d))
|
||||
|
||||
## [1.642.0](https://github.com/windmill-labs/windmill/compare/v1.641.0...v1.642.0) (2026-02-22)
|
||||
|
||||
|
||||
|
||||
79
CLAUDE.md
79
CLAUDE.md
@@ -1,68 +1,33 @@
|
||||
# Windmill Development Guide
|
||||
# Windmill
|
||||
|
||||
## Overview
|
||||
Open-source platform for internal tools, workflows, API integrations, background jobs, and UIs. Rust backend + Svelte 5 frontend.
|
||||
|
||||
Windmill is an open-source developer platform for building internal tools, workflows, API integrations, background jobs, workflows, and user interfaces. See @windmill-overview.mdc for full platform details.
|
||||
## Workflow
|
||||
|
||||
## New Feature Implementation Guidelines
|
||||
1. **Understand**: Before coding, read relevant docs from `docs/` to understand the area you're changing
|
||||
2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages
|
||||
3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`)
|
||||
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`
|
||||
|
||||
When implementing new features in Windmill, follow these best practices:
|
||||
## Documentation
|
||||
|
||||
- **Clean Code First**: Write clean, readable, and maintainable code. Prioritize clarity over cleverness.
|
||||
- **Avoid Duplication at All Costs**: Before writing new code, thoroughly search for existing implementations that can be reused or extended.
|
||||
- **Adapt Existing Code**: Refactor and generalize existing code when necessary to avoid logic duplication. Extract common patterns into reusable utilities.
|
||||
- **Follow Established Patterns**: Study existing code patterns in the codebase and maintain consistency with established conventions.
|
||||
- **Single Responsibility**: Each function, component, and module should have a single, well-defined responsibility.
|
||||
- **Incremental Implementation**: Break large features into smaller, reviewable chunks that can be implemented and tested incrementally.
|
||||
|
||||
## Language-Specific Guides
|
||||
|
||||
- Backend (Rust): see `backend/CLAUDE.md` and the `rust-backend` skill: `.claude/skills/rust-backend/SKILL.md`
|
||||
- Frontend (Svelte 5): see `frontend/CLAUDE.md` and the `svelte-frontend` skill: `.claude/skills/svelte-frontend/SKILL.md`
|
||||
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
|
||||
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
|
||||
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
|
||||
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code
|
||||
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
|
||||
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
|
||||
|
||||
## Dev Environment
|
||||
|
||||
- **Backend**: `cargo run` from `backend/` (API at http://localhost:8000)
|
||||
- **Frontend**: `REMOTE=http://localhost:8000 npm run dev` from `frontend/`
|
||||
- The `REMOTE` env var configures the Vite proxy target. Without it, API calls proxy to `https://app.windmill.dev` instead of the local backend.
|
||||
- The dev server starts on port 3000 (or 3001+ if 3000 is in use).
|
||||
- **Default login**: `admin@windmill.dev` / `changeme`
|
||||
- **Instance settings**: navigate to `/#superadmin-settings` (opens the drawer overlay)
|
||||
- **Frontend**: `REMOTE=http://localhost:8000 npm run dev` from `frontend/` (port 3000+)
|
||||
- **DB**: `psql postgres://postgres:changeme@localhost:5432/windmill`
|
||||
- **Login**: `admin@windmill.dev` / `changeme`
|
||||
- **Instance settings**: navigate to `/#superadmin-settings`
|
||||
|
||||
## UI Testing with Playwright MCP
|
||||
## Core Principles
|
||||
|
||||
When testing the frontend with the Playwright MCP tools:
|
||||
|
||||
1. **Start servers**: Launch backend (`cargo run`) and frontend (`REMOTE=http://localhost:8000 npm run dev`) as background tasks
|
||||
2. **Wait for readiness**: Backend takes ~60s to compile; check output for `health check completed`. Frontend starts in ~5s.
|
||||
3. **Login flow**: Navigate to `/user/login`, click "Log in without third-party", fill email/password, submit
|
||||
4. **Instance settings drawer**: Navigate to `/#superadmin-settings` to open the drawer directly
|
||||
5. **Toggle components**: The YAML toggle uses a custom `<Toggle>` component where the checkbox is visually hidden (`sr-only`). Click the wrapper `<label>` element (the parent container with `cursor=pointer`), not the checkbox ref directly.
|
||||
6. **Console errors to ignore**: `critical_alerts` 404s are expected on CE builds (EE-only endpoint). VSCode worker 404s are dev-mode artifacts.
|
||||
|
||||
## Code Validation (MUST DO)
|
||||
|
||||
After making code changes, you MUST run the appropriate checks and fix all errors before considering the work done:
|
||||
|
||||
- **Backend**: Run `cargo check` from the `backend/` directory. Only enable the feature flags needed for the code you changed — check `backend/Cargo.toml` `[features]` section to identify which flags gate the crates/modules you modified. For example: `cargo check --features enterprise,parquet` if you only touched enterprise and parquet code.
|
||||
- **Frontend**: Run `npm run check` from the `frontend/` directory.
|
||||
|
||||
## Querying the Database
|
||||
|
||||
`backend/summarized_schema.txt` provides a compact overview of all tables, columns, types, ENUMs, and foreign keys. Use it to quickly understand the data model and relationships. Note: this file is a simplified summary — it omits indexes, constraints details, and other metadata.
|
||||
|
||||
For exact table definitions (indexes, constraints, column defaults, etc.), query the database directly:
|
||||
|
||||
```bash
|
||||
psql postgres://postgres:changeme@localhost:5432/windmill
|
||||
```
|
||||
|
||||
Useful psql commands:
|
||||
- `\d <table_name>` — full table definition with indexes and constraints
|
||||
- `\di <table_name>*` — list indexes for a table
|
||||
- `\d+ <table_name>` — extended table info including storage and descriptions
|
||||
|
||||
This is also helpful for:
|
||||
- Inspecting database state during development
|
||||
- Testing queries before implementing them in Rust
|
||||
- Debugging data-related issues
|
||||
- Search for existing code to reuse before writing new code
|
||||
- Follow established patterns in the codebase
|
||||
- Keep changes focused — don't refactor beyond what's asked
|
||||
|
||||
@@ -58,7 +58,7 @@ FROM node:24-alpine as frontend
|
||||
|
||||
# install dependencies
|
||||
WORKDIR /frontend
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/.npmrc ./
|
||||
COPY ./frontend/scripts/ ./scripts/
|
||||
RUN npm ci
|
||||
|
||||
@@ -126,7 +126,7 @@ ARG POWERSHELL_DEB_VERSION=7.5.0-1
|
||||
ARG KUBECTL_VERSION=1.28.7
|
||||
ARG HELM_VERSION=3.14.3
|
||||
# NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte
|
||||
ARG GO_VERSION=1.25.0
|
||||
ARG GO_VERSION=1.26.0
|
||||
ARG APP=/usr/src/app
|
||||
ARG WITH_POWERSHELL=true
|
||||
ARG WITH_KUBECTL=true
|
||||
@@ -256,7 +256,7 @@ COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/t
|
||||
|
||||
COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno
|
||||
|
||||
COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
# Install windmill CLI
|
||||
RUN bun install -g windmill-cli \
|
||||
|
||||
@@ -65,7 +65,7 @@ Setting up zsh autocomplete is also recommended — see the [workmux docs](https
|
||||
Each worktree is assigned a **slot** that determines its ports:
|
||||
|
||||
| Slot | Backend | Frontend |
|
||||
|------|---------|----------|
|
||||
| ---- | ------- | -------- |
|
||||
| 0 | 8000 | 3000 |
|
||||
| 1 | 8010 | 3010 |
|
||||
| 2 | 8020 | 3020 |
|
||||
@@ -170,7 +170,27 @@ The setup is defined in `.workmux.yaml` at the repo root. Key sections:
|
||||
- **`post_create`**: Runs `scripts/worktree-env` to generate `.env.local` with port assignments
|
||||
- **`panes`**: Defines the tmux layout (agent, backend, frontend)
|
||||
- **`files.copy`**: Copies `backend/.env` and `scripts/` into each worktree
|
||||
- **`files.symlink`**: Symlinks `node_modules` and `.svelte-kit` to avoid reinstalling per worktree
|
||||
|
||||
The `post_create` hook also copies `frontend/node_modules` using `cp -a` (preserves `.bin/` symlinks that `cp -r` would dereference).
|
||||
|
||||
## 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`)
|
||||
|
||||
@@ -198,6 +218,7 @@ 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.
|
||||
4. **Adds `eval "$(wmc completions)"`** to `~/.zshrc` — provides tab-completion for subcommands and worktree names (for `open`, `open-ee`, and `close`).
|
||||
|
||||
After setup, reopen Cursor's terminal to pick up the new profile.
|
||||
|
||||
@@ -219,6 +240,14 @@ This runs `workmux add`, creates a grouped tmux session, writes `.vscode/setting
|
||||
wmc open my-feature
|
||||
```
|
||||
|
||||
**Open the EE worktree in Cursor (no tmux session):**
|
||||
|
||||
```bash
|
||||
wmc open-ee my-feature
|
||||
```
|
||||
|
||||
This finds the matching `windmill-ee-private__worktrees/<name>` directory and opens it in a new Cursor window.
|
||||
|
||||
**Close a worktree's Cursor window and tmux window (keeps the worktree):**
|
||||
|
||||
```bash
|
||||
@@ -227,6 +256,34 @@ 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`).
|
||||
|
||||
## Cargo Features
|
||||
|
||||
To build the backend with specific Cargo features (e.g., `enterprise`, `parquet`), pass them via `CARGO_FEATURES`. The backend pane reads this from `.env.local` and appends `--features <value>` to the `cargo watch` command.
|
||||
|
||||
**With `wm` (workmux):**
|
||||
|
||||
Set `CARGO_FEATURES` as an environment variable before creating the worktree:
|
||||
|
||||
```bash
|
||||
CARGO_FEATURES="enterprise,parquet" wm add my-feature
|
||||
```
|
||||
|
||||
This gets written to `.env.local` by the `post_create` hook (`scripts/worktree-env`), and the backend pane picks it up automatically.
|
||||
|
||||
**With `wmc` (wm-cursor):**
|
||||
|
||||
Use the `--features` flag:
|
||||
|
||||
```bash
|
||||
# Create a new worktree with features
|
||||
wmc add --features "enterprise,parquet" -A -p "implement feature X"
|
||||
|
||||
# Open an existing worktree with different features
|
||||
wmc open my-feature --features "enterprise,parquet"
|
||||
```
|
||||
|
||||
The `--features` flag exports `CARGO_FEATURES` so the `post_create` hook writes it to `.env.local`. When using `wmc open`, it updates the existing `.env.local` with the new features.
|
||||
|
||||
## Login
|
||||
|
||||
Default credentials: `admin@windmill.dev` / `changeme`
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
29
backend/.sqlx/query-075d4749299af2cb81162bf396bec6aa89de43ec201c911196763e03e644ca7a.json
generated
Normal file
29
backend/.sqlx/query-075d4749299af2cb81162bf396bec6aa89de43ec201c911196763e03e644ca7a.json
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "075d4749299af2cb81162bf396bec6aa89de43ec201c911196763e03e644ca7a"
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM workspace_settings WHERE teams_team_id = $1 AND teams_command_script IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slack_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "slack_command_script",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "slack_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "customer_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "plan",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "webhook",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "deploy_to",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "ai_config",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "git_sync",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "default_app",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "default_scripts",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "teams_command_script",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "teams_team_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "teams_team_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "git_app_installations",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "ducklake",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "slack_oauth_client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "slack_oauth_client_secret",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "auto_invite",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "error_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "08f288d2781d823e109a9e5b8848234ca7d1efeee9661f3901f298da375e73f7"
|
||||
}
|
||||
29
backend/.sqlx/query-17aafb72843659df9594d6d2466d2afaf26e666ffe52e0ea85792ea31b63410c.json
generated
Normal file
29
backend/.sqlx/query-17aafb72843659df9594d6d2466d2afaf26e666ffe52e0ea85792ea31b63410c.json
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "17aafb72843659df9594d6d2466d2afaf26e666ffe52e0ea85792ea31b63410c"
|
||||
}
|
||||
22
backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json
generated
Normal file
22
backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91"
|
||||
}
|
||||
28
backend/.sqlx/query-34721bce20aa8b2a2c6b9bd5455735f1a2270f23d73de95101e6350f6df40acc.json
generated
Normal file
28
backend/.sqlx/query-34721bce20aa8b2a2c6b9bd5455735f1a2270f23d73de95101e6350f6df40acc.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, teams_command_script FROM workspace_settings WHERE teams_team_id = $1 AND teams_command_script IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "teams_command_script",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "34721bce20aa8b2a2c6b9bd5455735f1a2270f23d73de95101e6350f6df40acc"
|
||||
}
|
||||
29
backend/.sqlx/query-8311a553c44221751ffdbbe6a997d6feba8d43292daf6c5433b66bd8450e8854.json
generated
Normal file
29
backend/.sqlx/query-8311a553c44221751ffdbbe6a997d6feba8d43292daf6c5433b66bd8450e8854.json
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8311a553c44221751ffdbbe6a997d6feba8d43292daf6c5433b66bd8450e8854"
|
||||
}
|
||||
23
backend/.sqlx/query-85a6a85fd126a8bfedd65d6b38d22c65911ab9cf0414c33a3321a1d43af49795.json
generated
Normal file
23
backend/.sqlx/query-85a6a85fd126a8bfedd65d6b38d22c65911ab9cf0414c33a3321a1d43af49795.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "on_behalf_of_email",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "85a6a85fd126a8bfedd65d6b38d22c65911ab9cf0414c33a3321a1d43af49795"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )",
|
||||
"query": "SELECT email FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%@', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0ef37117c369f03236e18f9dbb1f3d52776c8cb73f2507199c6ca16d4d2405ba"
|
||||
"hash": "886a921adc115f0a9c6f3a68381bd8f5a16866135120175d9073b9b2c41bbd51"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM _sqlx_migrations WHERE\n version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR\n version=20250201145631 OR version=20250201145632 OR version=20251006143821 OR\n version=20260207000001 OR version=20260207000002 OR version=20260207000003 OR version=20260207000004",
|
||||
"query": "DELETE FROM _sqlx_migrations WHERE\n version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR\n version=20250201145631 OR version=20250201145632 OR version=20251006143821",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c6bcf0d9e211bc03e3338682295f4995e1d622917367c478742addd073245ad5"
|
||||
"hash": "8d4ad4ee75fb149c36a9f6a0c4cf5fd981473f45d1b71b4b8236e021f7c8682d"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip\n ",
|
||||
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = COALESCE($24, email),\n edited_by = $25\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -183,6 +183,8 @@
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
@@ -220,5 +222,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4144c87c25a939aafb2f57da189d94d038bcad7a36fbf87e0403c89a979c5b3f"
|
||||
"hash": "987d79f7c6d7bc148cc8aab67e47161cfca045966e995e28c7a7ad090cffeda0"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) \n VALUES ($1, $2, $3, $4::text::json, $5)\n RETURNING id",
|
||||
"query": "INSERT INTO flow_version (workspace_id, path, value, schema, created_by)\n VALUES ($1, $2, $3, $4::text::json, $5)\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,5 +22,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "07f5290e90533eac50b890a0d7f4a5e73ac111c838f687fe8647636827aae8b5"
|
||||
"hash": "a9c805423e700b0acceb7c3dc43d1d3f9d4f56da25f588d281638e449d99a0d9"
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
23
backend/.sqlx/query-b12fba75788e44daefd9b3540a3aebe9167431aaa0a902b4558bc141c85ed825.json
generated
Normal file
23
backend/.sqlx/query-b12fba75788e44daefd9b3540a3aebe9167431aaa0a902b4558bc141c85ed825.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "policy",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b12fba75788e44daefd9b3540a3aebe9167431aaa0a902b4558bc141c85ed825"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator)\n SELECT $1::text, email, false, $3 FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )\n ON CONFLICT DO NOTHING",
|
||||
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator)\n SELECT $1::text, email, false, $3 FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%@', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )\n ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,5 +12,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2e1d1c59bfc53d58962251822c85cf9a26e3b2888702e5e9d5fc1b082901df09"
|
||||
"hash": "c0fad64e5d707ffa29d236f558e23b608168dc3a1b3857d2ad33ec20627acbff"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator)\n SELECT $1, email, is_admin, operator\n FROM usr\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cd399a3a797d1733fb9071ebca3f5928a3c7eba2983431844581fd2393312a2e"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)",
|
||||
"query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar\n WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630"
|
||||
"hash": "d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a"
|
||||
}
|
||||
14
backend/.sqlx/query-dda45bcc53e94659838e98b6b9e7a55be0e31aee3008d5190f09c1f15e5b47dd.json
generated
Normal file
14
backend/.sqlx/query-dda45bcc53e94659838e98b6b9e7a55be0e31aee3008d5190f09c1f15e5b47dd.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO group_\n VALUES ($1, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dda45bcc53e94659838e98b6b9e7a55be0e31aee3008d5190f09c1f15e5b47dd"
|
||||
}
|
||||
23
backend/.sqlx/query-e1f43cb65201b4f0965a4e18f0c918ae51fee667472d0cc2796ffdba4138d2ee.json
generated
Normal file
23
backend/.sqlx/query-e1f43cb65201b4f0965a4e18f0c918ae51fee667472d0cc2796ffdba4138d2ee.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "on_behalf_of_email",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e1f43cb65201b4f0965a4e18f0c918ae51fee667472d0cc2796ffdba4138d2ee"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes, workspace_id)\n VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
"query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7\n WHERE $7::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $7 AND deleted = true\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -16,5 +16,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c624f15f3e321b1eecf123da9bf0b18e8c1d16ef25ffb9d04e5447d0d583d55c"
|
||||
"hash": "e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0"
|
||||
}
|
||||
23
backend/.sqlx/query-e8d948274840699c5f7485ee4bc00b72c11bd226f99eade7e9a0da4605539283.json
generated
Normal file
23
backend/.sqlx/query-e8d948274840699c5f7485ee4bc00b72c11bd226f99eade7e9a0da4605539283.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "on_behalf_of_email",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e8d948274840699c5f7485ee4bc00b72c11bd226f99eade7e9a0da4605539283"
|
||||
}
|
||||
@@ -1,98 +1,8 @@
|
||||
# Backend Development (Rust)
|
||||
# Backend (Rust)
|
||||
|
||||
## Project Structure
|
||||
|
||||
Windmill uses a workspace-based architecture with multiple crates:
|
||||
|
||||
- **windmill-api**: API server functionality
|
||||
- **windmill-worker**: Job execution
|
||||
- **windmill-common**: Shared code used by all crates
|
||||
- **windmill-queue**: Job & flow queuing
|
||||
- **windmill-audit**: Audit logging
|
||||
- Other specialized crates (git-sync, autoscaling, etc.)
|
||||
|
||||
## Key References (MUST FOLLOW THESE)
|
||||
|
||||
- You MUST follow best-practices by using the `rust-backend` skill, everytime you write RUST code.
|
||||
- When working with the database: read `summarized_schema.txt` before starting
|
||||
- When working with the API routes: you can read `windmill-api/src/lib.rs` to get started
|
||||
|
||||
## Adding New Code
|
||||
|
||||
### Module Organization
|
||||
|
||||
- Place new code in the appropriate crate based on functionality
|
||||
- For API endpoints, create or modify files in `windmill-api/src/` organized by domain
|
||||
- For shared functionality, use `windmill-common/src/`
|
||||
- Follow existing patterns for file structure and organization
|
||||
|
||||
### API Endpoints
|
||||
|
||||
- Follow existing patterns in the `windmill-api` crate
|
||||
- Use axum's routing system and extractors
|
||||
- Update `backend/windmill-api/openapi.yaml` after modifying API endpoints
|
||||
|
||||
### Database Changes
|
||||
|
||||
- Update database schema with migration if necessary
|
||||
- Use `sqlx` for database operations with prepared statements
|
||||
- Use transactions for multi-step operations
|
||||
- To apply pending migrations: `sqlx migrate run` (never manually run .sql files)
|
||||
- **Never use `SQLX_OFFLINE=true`** — a live database is always available for compilation
|
||||
- After all code changes are done, run `./update-sqlx` to regenerate the offline query cache
|
||||
|
||||
## Enterprise Features
|
||||
|
||||
- Enterprise files use the `*_ee.rs` suffix
|
||||
- Enterprise source is in `windmill-ee-private` folder (sibling directory at `../../windmill-ee-private` or `~/windmill-ee-private`), symlinked into each crate's `src/`
|
||||
- The `_ee.rs` files are gitignored in the main repo — they are tracked only in the `windmill-ee-private` repo
|
||||
- You can and should modify `windmill-ee-private` directly when needed (e.g., when creating new crates that need EE code, mirror the package structure there)
|
||||
- Use feature flags: `#[cfg(feature = "enterprise")]`
|
||||
- Isolate enterprise code in separate modules
|
||||
|
||||
### EE PR Workflow (MUST DO when modifying `*_ee.rs` files)
|
||||
|
||||
When you modify any `*_ee.rs` file and create a PR on the windmill repo, you **MUST** also:
|
||||
|
||||
1. **Create a matching branch** in the `windmill-ee-private` repo (use the same branch name). If using worktrees, the EE worktree is at `~/windmill-ee-private__worktrees/<branch-name>/`
|
||||
2. **Commit and push** the `_ee.rs` changes in that branch
|
||||
3. **Create a PR** on `windmill-ee-private` with a link to the companion windmill PR
|
||||
4. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` to write the latest EE commit hash. **Important**: the script may fall back to `~/windmill-ee-private` (main branch) instead of the worktree — verify it wrote the correct commit hash from your branch, not from main. If wrong, manually write the correct hash.
|
||||
5. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref
|
||||
|
||||
## Code Validation (MUST DO)
|
||||
|
||||
After making backend changes, you MUST run `cargo check` and fix all errors and warnings before considering the work done.
|
||||
|
||||
Only enable the feature flags relevant to your changes — do NOT use `all_sqlx_features` as it compiles the entire codebase and is very slow. Check the `[features]` section in `Cargo.toml` to identify which flags gate the crates/modules you modified.
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Changed core code (no feature-gated modules)
|
||||
cargo check
|
||||
|
||||
# Changed code behind the enterprise feature
|
||||
cargo check --features enterprise
|
||||
|
||||
# Changed kafka trigger code
|
||||
cargo check --features kafka
|
||||
```
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- **Never push directly to main** — always create a branch and open a pull request
|
||||
|
||||
## Testing
|
||||
|
||||
- Write unit tests for core functionality
|
||||
- Use the `#[cfg(test)]` module for test code
|
||||
- For database tests, use the existing test utilities
|
||||
|
||||
## Common Crates
|
||||
|
||||
- **tokio**: Async runtime
|
||||
- **axum**: Web server and routing
|
||||
- **sqlx**: Database operations
|
||||
- **serde**: Serialization/deserialization
|
||||
- **tracing**: Logging and diagnostics
|
||||
- **reqwest**: HTTP client
|
||||
- **Coding patterns**: MUST use the `rust-backend` skill when writing Rust code
|
||||
- **Validation**: `docs/validation.md` — which `cargo check` flags to use
|
||||
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
|
||||
- **DB schema**: `backend/summarized_schema.txt`
|
||||
- **API routes entry point**: `windmill-api/src/lib.rs`
|
||||
- **OpenAPI spec**: `windmill-api/openapi.yaml`
|
||||
|
||||
250
backend/Cargo.lock
generated
250
backend/Cargo.lock
generated
@@ -490,7 +490,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"num",
|
||||
"regex",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -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",
|
||||
@@ -3507,7 +3507,7 @@ dependencies = [
|
||||
"log",
|
||||
"recursive",
|
||||
"regex",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5526,7 +5526,7 @@ checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5537,7 +5537,7 @@ checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -6254,7 +6254,7 @@ dependencies = [
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8049,13 +8049,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"libc",
|
||||
"redox_syscall 0.7.1",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8092,9 +8093,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 +8117,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"
|
||||
@@ -8599,9 +8600,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moka"
|
||||
version = "0.12.13"
|
||||
version = "0.12.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e"
|
||||
checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"crossbeam-channel",
|
||||
@@ -9729,9 +9730,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"
|
||||
@@ -10085,18 +10086,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
|
||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.10"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -10105,9 +10106,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
@@ -10159,6 +10160,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
@@ -10855,9 +10862,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
@@ -10989,9 +10996,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
]
|
||||
@@ -11047,7 +11054,7 @@ dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11058,7 +11065,7 @@ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11075,9 +11082,9 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.9"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "relative-path"
|
||||
@@ -11598,14 +11605,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",
|
||||
]
|
||||
|
||||
@@ -12586,9 +12593,9 @@ checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b"
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a"
|
||||
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -13774,7 +13781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
"utf8-ranges",
|
||||
]
|
||||
|
||||
@@ -13838,14 +13845,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 +13871,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",
|
||||
]
|
||||
|
||||
@@ -14708,7 +14715,7 @@ checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
@@ -15725,7 +15732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15789,7 +15796,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15802,7 +15809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15940,7 +15947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15963,7 +15970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15976,7 +15983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16002,7 +16009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -16012,7 +16019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16029,7 +16036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -16052,7 +16059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16075,7 +16082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16091,7 +16098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16111,7 +16118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16131,7 +16138,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16145,7 +16152,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16167,11 +16174,12 @@ dependencies = [
|
||||
"windmill-common",
|
||||
"windmill-native-triggers",
|
||||
"windmill-test-utils",
|
||||
"windmill-worker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16196,7 +16204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"flate2",
|
||||
@@ -16207,13 +16215,14 @@ dependencies = [
|
||||
"tar",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"url",
|
||||
"windmill-api-auth",
|
||||
"windmill-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16234,7 +16243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16254,7 +16263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16284,7 +16293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16311,7 +16320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16323,7 +16332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.7.9",
|
||||
@@ -16346,7 +16355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16360,7 +16369,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16390,7 +16399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16404,7 +16413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16423,7 +16432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -16522,7 +16531,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -16541,7 +16550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16556,7 +16565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16580,7 +16589,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16597,7 +16606,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16613,7 +16622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16634,7 +16643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16665,7 +16674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-oauth2",
|
||||
@@ -16689,7 +16698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -16723,7 +16732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16741,7 +16750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16750,7 +16759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16762,7 +16771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16774,7 +16783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16786,7 +16795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16798,7 +16807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16810,7 +16819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16821,7 +16830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16832,7 +16841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16845,7 +16854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16869,7 +16878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16883,7 +16892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16900,7 +16909,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16915,7 +16924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16934,7 +16943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16945,7 +16954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16982,7 +16991,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -17020,8 +17029,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
"windmill-parser",
|
||||
@@ -17030,7 +17040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17059,7 +17069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -17082,7 +17092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17115,7 +17125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17135,7 +17145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17169,7 +17179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17204,7 +17214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17227,7 +17237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17251,7 +17261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -17275,7 +17285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17310,7 +17320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17338,7 +17348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17361,7 +17371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.4",
|
||||
@@ -17379,7 +17389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -18252,7 +18262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -18349,18 +18359,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -76,7 +76,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.642.0"
|
||||
version = "1.647.2"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -159,12 +159,12 @@ all_languages = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "
|
||||
# For windows we have another set of languages enabled
|
||||
all_languages_windows = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql-winauth", "bigquery", "csharp", "nu", "php", "java"]
|
||||
# Edition meta-features: shared groups
|
||||
inline_preview = ["windmill-api/inline_preview"]
|
||||
run_inline = ["windmill-api/run_inline"]
|
||||
oss_core = [
|
||||
"embedding", "parquet", "openidconnect", "license",
|
||||
"http_trigger", "zip", "oauth2", "postgres_trigger",
|
||||
"mqtt_trigger", "websocket", "smtp", "native_trigger",
|
||||
"static_frontend", "mcp", "bedrock", "inline_preview",
|
||||
"static_frontend", "mcp", "bedrock", "run_inline",
|
||||
"quickjs"
|
||||
]
|
||||
ce_core = ["oss_core", "private", "operator"]
|
||||
@@ -351,7 +351,7 @@ tower-cookies = "^0.10"
|
||||
serde = "=1.0.219"
|
||||
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
|
||||
serde_yml = "0.0.12"
|
||||
uuid = { version = "^1", features = ["serde", "v4"] }
|
||||
uuid = { version = "^1", features = ["serde", "v4", "js"] }
|
||||
thiserror = "^2"
|
||||
anyhow = "^1"
|
||||
chrono = { version = "^0.4", features = ["serde"] }
|
||||
|
||||
@@ -1 +1 @@
|
||||
0fede4b1086bc1456be9cc55b203228c979c5c5e
|
||||
8ffae1f43b31dc8136714fa612d22b6301773e27
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
|
||||
SELECT id, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace', '{}'::jsonb
|
||||
FROM workspace
|
||||
WHERE NOT deleted
|
||||
ON CONFLICT (workspace_id, name) DO UPDATE SET summary = EXCLUDED.summary;
|
||||
@@ -0,0 +1,10 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN
|
||||
ALTER ROLE custom_instance_user NOREPLICATION;
|
||||
END IF;
|
||||
EXCEPTION
|
||||
WHEN others THEN
|
||||
RAISE NOTICE 'Error revoking REPLICATION from custom_instance_user: %', SQLERRM;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,10 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN
|
||||
ALTER ROLE custom_instance_user REPLICATION;
|
||||
END IF;
|
||||
EXCEPTION
|
||||
WHEN others THEN
|
||||
RAISE NOTICE 'Error granting REPLICATION to custom_instance_user: %', SQLERRM;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS idx_asset_ws_path_kind_recent;
|
||||
|
||||
-- Restore the dropped indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_workspace_created_id ON asset (workspace_id, created_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_kind_path ON asset (workspace_id, kind, path);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Covering index for the list_assets CTE: GROUP BY (path, kind) + MAX(created_at, id) + ORDER BY
|
||||
-- Includes usage_kind and usage_path to allow full index-only scan (avoiding heap lookups for filter conditions)
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_ws_path_kind_recent
|
||||
ON asset (workspace_id, path, kind, created_at DESC, id DESC)
|
||||
INCLUDE (usage_kind, usage_path);
|
||||
|
||||
-- Drop indexes now subsumed by idx_asset_ws_path_kind_recent:
|
||||
-- idx_asset_workspace_created_id (workspace_id, created_at DESC, id DESC) - only used by list_assets CTE
|
||||
-- idx_asset_kind_path (workspace_id, kind, path) - only used by list_assets CTE/outer join, covered by new index + PK
|
||||
DROP INDEX IF EXISTS idx_asset_workspace_created_id;
|
||||
DROP INDEX IF EXISTS idx_asset_kind_path;
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS ix_v2_job_completed_failure_workspace;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Partial index for fast failure/canceled filtering on the runs page.
|
||||
-- When failures are sparse (<1%) this avoids scanning millions of successful jobs.
|
||||
-- The query orders by completed_at DESC (switched from created_at when success=false),
|
||||
-- so this index provides both filtering and ordering in a single scan.
|
||||
CREATE INDEX IF NOT EXISTS ix_v2_job_completed_failure_workspace
|
||||
ON v2_job_completed (workspace_id, completed_at DESC)
|
||||
WHERE status IN ('failure', 'canceled');
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use sqlparser::{
|
||||
ast::{
|
||||
@@ -45,6 +45,10 @@ struct AssetCollector {
|
||||
var_identifiers: BTreeMap<String, (AssetKind, String)>,
|
||||
// e.g USE dl;
|
||||
currently_used_asset: Option<(AssetKind, String)>,
|
||||
// CTE names in scope (stack for nested queries)
|
||||
cte_name_stack: Vec<HashSet<String>>,
|
||||
// Locally created tables (not attached to an asset)
|
||||
local_table_names: HashSet<String>,
|
||||
}
|
||||
|
||||
impl AssetCollector {
|
||||
@@ -54,9 +58,30 @@ impl AssetCollector {
|
||||
current_access_type_stack: Vec::with_capacity(8),
|
||||
var_identifiers: BTreeMap::new(),
|
||||
currently_used_asset: None,
|
||||
cte_name_stack: Vec::new(),
|
||||
local_table_names: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// If the name resolves to an attached asset, record it. Otherwise, register it as a local
|
||||
/// table/view so that subsequent references are not mistakenly attributed to the active asset.
|
||||
fn track_table_definition(&mut self, name: &ObjectName) {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(W)) {
|
||||
self.assets.push(asset);
|
||||
} else if let Some(simple_name) = get_trivial_obj_name(name) {
|
||||
self.local_table_names.insert(simple_name.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_locally_defined(&self, name: &str) -> bool {
|
||||
let name_lower = name.to_lowercase();
|
||||
self.local_table_names.contains(&name_lower)
|
||||
|| self
|
||||
.cte_name_stack
|
||||
.iter()
|
||||
.any(|set| set.contains(&name_lower))
|
||||
}
|
||||
|
||||
// Detect when we do 'a.b' and 'a' is associated with an asset in var_identifiers
|
||||
// Or when we access 'b' and we did USE a;
|
||||
fn get_associated_asset_from_obj_name(
|
||||
@@ -72,6 +97,14 @@ impl AssetCollector {
|
||||
return None;
|
||||
}
|
||||
|
||||
if name.0.len() == 1 {
|
||||
if let Some(ident) = name.0.first().and_then(|id| id.as_ident()) {
|
||||
if self.is_locally_defined(&ident.value) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name.0.len() == 1 || name.0.len() == 2 {
|
||||
if name
|
||||
.0
|
||||
@@ -452,6 +485,7 @@ impl Visitor for AssetCollector {
|
||||
) -> std::ops::ControlFlow<Self::Break> {
|
||||
match statement {
|
||||
sqlparser::ast::Statement::Query(q) => {
|
||||
self.cte_name_stack.push(collect_cte_names(q));
|
||||
if let Some(select) = q.body.as_select() {
|
||||
// First, handle table references (adds table-level assets)
|
||||
for t in &select.from {
|
||||
@@ -612,17 +646,11 @@ impl Visitor for AssetCollector {
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::CreateTable(create_table) => {
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(&create_table.name, Some(W))
|
||||
{
|
||||
self.assets.push(asset);
|
||||
}
|
||||
self.track_table_definition(&create_table.name);
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::CreateView { name, .. } => {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(W)) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
self.track_table_definition(name);
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Copy { target: CopyTarget::File { filename }, .. } => {
|
||||
@@ -672,16 +700,20 @@ impl Visitor for AssetCollector {
|
||||
|
||||
fn post_visit_statement(
|
||||
&mut self,
|
||||
_statement: &sqlparser::ast::Statement,
|
||||
statement: &sqlparser::ast::Statement,
|
||||
) -> std::ops::ControlFlow<Self::Break> {
|
||||
if matches!(statement, sqlparser::ast::Statement::Query(_)) {
|
||||
self.cte_name_stack.pop();
|
||||
}
|
||||
std::ops::ControlFlow::Continue(())
|
||||
}
|
||||
|
||||
fn pre_visit_query(
|
||||
&mut self,
|
||||
_query: &sqlparser::ast::Query,
|
||||
query: &sqlparser::ast::Query,
|
||||
) -> std::ops::ControlFlow<Self::Break> {
|
||||
self.current_access_type_stack.push(R);
|
||||
self.cte_name_stack.push(collect_cte_names(query));
|
||||
std::ops::ControlFlow::Continue(())
|
||||
}
|
||||
|
||||
@@ -690,12 +722,22 @@ impl Visitor for AssetCollector {
|
||||
_query: &sqlparser::ast::Query,
|
||||
) -> std::ops::ControlFlow<Self::Break> {
|
||||
self.current_access_type_stack.pop();
|
||||
self.cte_name_stack.pop();
|
||||
std::ops::ControlFlow::Continue(())
|
||||
}
|
||||
|
||||
// We do not use pre_visit_relation because we cannot know if an ObjectName is a table or a function
|
||||
}
|
||||
|
||||
fn collect_cte_names(query: &sqlparser::ast::Query) -> HashSet<String> {
|
||||
query.with.as_ref().map_or_else(HashSet::new, |with| {
|
||||
with.cte_tables
|
||||
.iter()
|
||||
.map(|cte| cte.alias.name.value.to_lowercase())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn is_read_fn(fname: &str) -> bool {
|
||||
fname.eq_ignore_ascii_case("read_parquet")
|
||||
|| fname.eq_ignore_ascii_case("read_csv")
|
||||
@@ -1509,6 +1551,235 @@ mod tests {
|
||||
assert!(result[0].columns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_cte_not_treated_as_asset() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
WITH tmp AS (SELECT 1 AS x)
|
||||
SELECT * FROM tmp;
|
||||
SELECT * FROM real_table;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/real_table".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_cte_scope_does_not_leak() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
WITH tmp AS (SELECT 1) SELECT * FROM tmp;
|
||||
SELECT * FROM tmp;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/tmp".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_multiple_ctes() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
WITH cte1 AS (SELECT 1), cte2 AS (SELECT 2)
|
||||
SELECT * FROM cte1 JOIN cte2 ON true;
|
||||
SELECT * FROM real_table;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/real_table".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_local_create_table_overrides_asset() {
|
||||
let input = r#"
|
||||
CREATE TABLE local_tbl (id INT);
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
SELECT * FROM local_tbl;
|
||||
SELECT * FROM asset_table;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/asset_table".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_create_table_with_use_is_still_asset() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake' AS dl; USE dl;
|
||||
CREATE TABLE friends (
|
||||
name text,
|
||||
age int
|
||||
);
|
||||
INSERT INTO friends VALUES ($name, $age);
|
||||
SELECT * FROM friends;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/friends".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_local_create_view_overrides_asset() {
|
||||
let input = r#"
|
||||
CREATE VIEW my_view AS SELECT 1;
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
SELECT * FROM my_view;
|
||||
SELECT * FROM asset_table;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/asset_table".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_create_view_with_use_is_still_asset() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
CREATE VIEW my_view AS SELECT 1;
|
||||
SELECT * FROM my_view;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/my_view".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_cte_mixed_with_asset_tables() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
WITH tmp AS (SELECT 1 AS x)
|
||||
SELECT * FROM tmp JOIN real_table ON true;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/real_table".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_local_table_insert_and_select() {
|
||||
let input = r#"
|
||||
CREATE TABLE staging (id INT, val TEXT);
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
INSERT INTO staging VALUES (1, 'a');
|
||||
SELECT * FROM staging;
|
||||
INSERT INTO real_table VALUES (2, 'b');
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/real_table".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_qualified_ref_bypasses_local() {
|
||||
// Even if 'tbl' is local, 'dl.tbl' is an explicit asset reference
|
||||
let input = r#"
|
||||
CREATE TABLE tbl (id INT);
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT * FROM dl.tbl;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/tbl".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_cte_case_insensitive() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
WITH MyTable AS (SELECT 1)
|
||||
SELECT * FROM mytable;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl".to_string(),
|
||||
access_type: None,
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_s3_read_csv_columns() {
|
||||
let input = r#"
|
||||
|
||||
@@ -58,16 +58,23 @@ pub fn parse_oracledb_sig(code: &str) -> anyhow::Result<MainArgSignature> {
|
||||
}
|
||||
|
||||
pub fn parse_pgsql_sig(code: &str) -> anyhow::Result<MainArgSignature> {
|
||||
let (sig, _) = parse_pgsql_sig_with_typed_schema(code)?;
|
||||
Ok(sig)
|
||||
}
|
||||
|
||||
pub fn parse_pgsql_sig_with_typed_schema(code: &str) -> anyhow::Result<(MainArgSignature, bool)> {
|
||||
let parsed = parse_pg_file(&code)?;
|
||||
if let Some(x) = parsed {
|
||||
let args = x;
|
||||
Ok(MainArgSignature {
|
||||
star_args: false,
|
||||
star_kwargs: false,
|
||||
args,
|
||||
no_main_func: None,
|
||||
has_preprocessor: None,
|
||||
})
|
||||
if let Some((args, typed_schema)) = parsed {
|
||||
Ok((
|
||||
MainArgSignature {
|
||||
star_args: false,
|
||||
star_kwargs: false,
|
||||
args,
|
||||
no_main_func: None,
|
||||
has_preprocessor: None,
|
||||
},
|
||||
typed_schema,
|
||||
))
|
||||
} else {
|
||||
Err(anyhow!("Error parsing sql".to_string()))
|
||||
}
|
||||
@@ -216,7 +223,7 @@ lazy_static::lazy_static! {
|
||||
static ref RE_ARG_MYSQL: Regex = Regex::new(r#"(?m)^-- \? (\w+) \((\w+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
|
||||
pub static ref RE_ARG_MYSQL_NAMED: Regex = Regex::new(r#"(?m)^-- :([a-z_][a-z0-9_]*) \((\w+(?:\([\w, ]+\))?)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
|
||||
|
||||
static ref RE_ARG_PGSQL: Regex = Regex::new(r#"(?m)^-- \$(\d+) (\w+)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
|
||||
static ref RE_ARG_PGSQL: Regex = Regex::new(r#"(?m)^-- \$(\d+) (\w+)(?: \(([A-Za-z0-9_\[\]]+)\))?(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
|
||||
|
||||
// -- @name (type) = default
|
||||
static ref RE_ARG_BIGQUERY: Regex = Regex::new(r#"(?m)^-- @(\w+) \((\w+(?:\[\])?)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
|
||||
@@ -478,21 +485,62 @@ pub fn parse_pg_statement_arg_indices(code: &str) -> HashSet<i32> {
|
||||
arg_indices
|
||||
}
|
||||
|
||||
fn parse_pg_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
fn parse_pg_file(code: &str) -> anyhow::Result<Option<(Vec<Arg>, bool)>> {
|
||||
let mut args = vec![];
|
||||
|
||||
// Track which args have explicit types in declaration comments
|
||||
let mut explicitly_typed_args: HashSet<i32> = HashSet::new();
|
||||
|
||||
// First pass: collect args from declaration comments (-- $1 argName (type))
|
||||
for cap in RE_ARG_PGSQL.captures_iter(code) {
|
||||
let idx = cap
|
||||
.get(1)
|
||||
.and_then(|x| x.as_str().parse::<i32>().ok())
|
||||
.ok_or_else(|| anyhow!("Impossible to parse arg digit"))?;
|
||||
|
||||
let name = cap.get(2).map(|x| x.as_str().to_string()).unwrap();
|
||||
let explicit_type = cap.get(3).map(|x| x.as_str().to_string().to_lowercase());
|
||||
let default = cap.get(4).map(|x| x.as_str().to_string());
|
||||
let has_default = default.is_some();
|
||||
|
||||
if let Some(typ) = explicit_type {
|
||||
// If explicitly typed, use that type and don't infer from usage
|
||||
explicitly_typed_args.insert(idx);
|
||||
let parsed_typ = parse_pg_typ(typ.as_str());
|
||||
let parsed_default = default.and_then(|x| parsed_default(&parsed_typ, x));
|
||||
|
||||
args.push(Arg {
|
||||
name,
|
||||
typ: parsed_typ,
|
||||
default: parsed_default,
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: Some(idx),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: infer types from usage for non-explicitly-typed args
|
||||
let mut hm: HashMap<i32, String> = HashMap::new();
|
||||
for cap in RE_CODE_PGSQL.captures_iter(code) {
|
||||
let idx = cap
|
||||
.get(1)
|
||||
.and_then(|x| x.as_str().parse::<i32>().ok())
|
||||
.ok_or_else(|| anyhow!("Impossible to parse arg digit"))?;
|
||||
|
||||
// Skip if this arg was explicitly typed in declaration
|
||||
if explicitly_typed_args.contains(&idx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let typ = cap
|
||||
.get(2)
|
||||
.map(|cap| transform_types_with_spaces(&cap, &code))
|
||||
.unwrap_or("text");
|
||||
hm.insert(
|
||||
cap.get(1)
|
||||
.and_then(|x| x.as_str().parse::<i32>().ok())
|
||||
.ok_or_else(|| anyhow!("Impossible to parse arg digit"))?,
|
||||
typ.to_string(),
|
||||
);
|
||||
hm.insert(idx, typ.to_string());
|
||||
}
|
||||
|
||||
// Add inferred args
|
||||
for (i, v) in hm.iter() {
|
||||
let typ = v.to_lowercase();
|
||||
args.push(Arg {
|
||||
@@ -504,19 +552,28 @@ fn parse_pg_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
oidx: Some(*i),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by index
|
||||
args.sort_by(|a, b| a.oidx.unwrap().cmp(&b.oidx.unwrap()));
|
||||
|
||||
// Third pass: update names and defaults for inferred args
|
||||
for cap in RE_ARG_PGSQL.captures_iter(code) {
|
||||
let i = cap
|
||||
.get(1)
|
||||
.and_then(|x| x.as_str().parse::<i32>().ok())
|
||||
.map(|x| x);
|
||||
|
||||
// Skip explicitly typed args (already handled)
|
||||
if i.is_some_and(|idx| explicitly_typed_args.contains(&idx)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(arg_pos) = args
|
||||
.iter()
|
||||
.position(|x| i.is_some_and(|i| x.oidx.unwrap() == i))
|
||||
{
|
||||
let name = cap.get(2).map(|x| x.as_str().to_string()).unwrap();
|
||||
let default = cap.get(3).map(|x| x.as_str().to_string());
|
||||
let default = cap.get(4).map(|x| x.as_str().to_string());
|
||||
let has_default = default.is_some();
|
||||
let oarg = args[arg_pos].clone();
|
||||
let parsed_default = default.and_then(|x| parsed_default(&oarg.typ, x));
|
||||
@@ -532,8 +589,10 @@ fn parse_pg_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
}
|
||||
}
|
||||
|
||||
let typed_schema = !explicitly_typed_args.is_empty();
|
||||
|
||||
args.append(&mut parse_sql_sanitized_interpolation(code));
|
||||
Ok(Some(args))
|
||||
Ok(Some((args, typed_schema)))
|
||||
}
|
||||
|
||||
// The regex doesn't parse types with space such as "character varying"
|
||||
@@ -1306,4 +1365,186 @@ SELECT * FROM table_name WHERE thing = :name4;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pgsql_explicit_type_at_declaration() -> anyhow::Result<()> {
|
||||
let code = r#"
|
||||
-- $1 user_id (bigint)
|
||||
-- $2 email
|
||||
SELECT * FROM users WHERE id = $1 AND email = $2::text;
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_pgsql_sig(code)?,
|
||||
MainArgSignature {
|
||||
star_args: false,
|
||||
star_kwargs: false,
|
||||
args: vec![
|
||||
Arg {
|
||||
otyp: Some("bigint".to_string()),
|
||||
name: "user_id".to_string(),
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
name: "email".to_string(),
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(2),
|
||||
},
|
||||
],
|
||||
no_main_func: None,
|
||||
has_preprocessor: None
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pgsql_explicit_type_with_default() -> anyhow::Result<()> {
|
||||
let code = r#"
|
||||
-- $1 limit (integer) = 10
|
||||
-- $2 offset (bigint) = 0
|
||||
SELECT * FROM users LIMIT $1 OFFSET $2;
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_pgsql_sig(code)?,
|
||||
MainArgSignature {
|
||||
star_args: false,
|
||||
star_kwargs: false,
|
||||
args: vec![
|
||||
Arg {
|
||||
otyp: Some("integer".to_string()),
|
||||
name: "limit".to_string(),
|
||||
typ: Typ::Int,
|
||||
default: Some(json!(10)),
|
||||
has_default: true,
|
||||
oidx: Some(1),
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("bigint".to_string()),
|
||||
name: "offset".to_string(),
|
||||
typ: Typ::Int,
|
||||
default: Some(json!(0)),
|
||||
has_default: true,
|
||||
oidx: Some(2),
|
||||
},
|
||||
],
|
||||
no_main_func: None,
|
||||
has_preprocessor: None
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pgsql_mixed_explicit_and_inferred() -> anyhow::Result<()> {
|
||||
let code = r#"
|
||||
-- $1 user_id (bigint)
|
||||
-- $2 status
|
||||
-- $3 created_at (timestamptz)
|
||||
SELECT * FROM users
|
||||
WHERE id = $1
|
||||
AND status = $2::text
|
||||
AND created_at > $3;
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_pgsql_sig(code)?,
|
||||
MainArgSignature {
|
||||
star_args: false,
|
||||
star_kwargs: false,
|
||||
args: vec![
|
||||
Arg {
|
||||
otyp: Some("bigint".to_string()),
|
||||
name: "user_id".to_string(),
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
name: "status".to_string(),
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(2),
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("timestamptz".to_string()),
|
||||
name: "created_at".to_string(),
|
||||
typ: Typ::Datetime,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(3),
|
||||
},
|
||||
],
|
||||
no_main_func: None,
|
||||
has_preprocessor: None
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pgsql_explicit_type_array() -> anyhow::Result<()> {
|
||||
let code = r#"
|
||||
-- $1 ids (bigint[])
|
||||
SELECT * FROM users WHERE id = ANY($1);
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_pgsql_sig(code)?,
|
||||
MainArgSignature {
|
||||
star_args: false,
|
||||
star_kwargs: false,
|
||||
args: vec![Arg {
|
||||
otyp: Some("bigint[]".to_string()),
|
||||
name: "ids".to_string(),
|
||||
typ: Typ::List(Box::new(Typ::Int)),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
},],
|
||||
no_main_func: None,
|
||||
has_preprocessor: None
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pgsql_explicit_type_does_not_infer_from_usage() -> anyhow::Result<()> {
|
||||
// Even though $1 is used as ::integer in the query,
|
||||
// the explicit type (text) should take precedence
|
||||
let code = r#"
|
||||
-- $1 value (text)
|
||||
SELECT $1::integer;
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_pgsql_sig(code)?,
|
||||
MainArgSignature {
|
||||
star_args: false,
|
||||
star_kwargs: false,
|
||||
args: vec![Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
name: "value".to_string(),
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
},],
|
||||
no_main_func: None,
|
||||
has_preprocessor: None
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,7 @@ wasm-bindgen-test.workspace = true
|
||||
[dependencies]
|
||||
windmill-parser.workspace = true
|
||||
windmill-parser-sql.workspace = true
|
||||
wasm-bindgen.workspace = true
|
||||
wasm-bindgen.workspace = true
|
||||
# getrandom 0.3 is pulled in transitively by rand 0.9 (via windmill-types).
|
||||
# It requires the "wasm_js" feature to work on wasm32-unknown-unknown.
|
||||
getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
|
||||
@@ -447,6 +447,7 @@ def main():
|
||||
deployment_message: None,
|
||||
visible_to_runner_only: None,
|
||||
on_behalf_of_email: None,
|
||||
preserve_on_behalf_of: None,
|
||||
ws_error_handler_muted: None,
|
||||
})
|
||||
.send()
|
||||
@@ -508,6 +509,7 @@ def main():
|
||||
policy: None,
|
||||
deployment_message: None,
|
||||
custom_path: None,
|
||||
preserve_on_behalf_of: None,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
|
||||
186
backend/tests/fixtures/preserve_on_behalf_of.sql
vendored
Normal file
186
backend/tests/fixtures/preserve_on_behalf_of.sql
vendored
Normal file
@@ -0,0 +1,186 @@
|
||||
-- Fixture for preserve_on_behalf_of integration tests
|
||||
-- Extends base.sql with a deployer user in the wm_deployers group
|
||||
|
||||
-- Include all base setup (workspace, admin user, etc.)
|
||||
INSERT INTO workspace
|
||||
(id, name, owner)
|
||||
VALUES ('test-workspace', 'test-workspace', 'test-user')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
|
||||
('test-workspace', 'cloud', 'test-key')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||
('test-workspace')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('test-workspace', 'all', 'All users', '{}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Create the wm_deployers group
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('test-workspace', 'wm_deployers', 'Users allowed to deploy and preserve on_behalf_of', '{}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
|
||||
VALUES ('test@windmill.dev', 'not-a-real-hash', 'password', true, true, 'Test User', 'test-user')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('test2@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Test User 2')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Deployer user (non-admin but in wm_deployers group)
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('deployer@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Deployer User')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Original user whose on_behalf_of should be preserved
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('original@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Original User')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('test-workspace', 'test2@windmill.dev', 'test-user-2', false, 'User')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Deployer user in workspace
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('test-workspace', 'deployer@windmill.dev', 'deployer-user', false, 'User')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Original user in workspace (whose on_behalf_of should be preserved)
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('test-workspace', 'original@windmill.dev', 'original-user', false, 'User')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Add deployer user to wm_deployers group
|
||||
INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES
|
||||
('test-workspace', 'wm_deployers', 'deployer-user')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Tokens for all users
|
||||
INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false)
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO token(token, email, label, super_admin) VALUES ('DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false)
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO token(token, email, label, super_admin) VALUES ('ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "notify_insert_on_completed_job" ()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('completed', NEW.id::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE PLPGSQL;
|
||||
|
||||
DROP TRIGGER IF EXISTS "notify_insert_on_completed_job" ON "v2_job_completed";
|
||||
CREATE TRIGGER "notify_insert_on_completed_job"
|
||||
AFTER INSERT ON "v2_job_completed"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "notify_insert_on_completed_job" ();
|
||||
|
||||
CREATE OR REPLACE FUNCTION "notify_queue" ()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('queued', NEW.id::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE PLPGSQL;
|
||||
|
||||
DROP TRIGGER IF EXISTS "notify_queue_after_insert" ON "v2_job_queue";
|
||||
CREATE TRIGGER "notify_queue_after_insert"
|
||||
AFTER INSERT ON "v2_job_queue"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "notify_queue" ();
|
||||
|
||||
DROP TRIGGER IF EXISTS "notify_queue_after_flow_status_update" ON "v2_job_status";
|
||||
CREATE TRIGGER "notify_queue_after_flow_status_update"
|
||||
AFTER UPDATE ON "v2_job_status"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.flow_status IS DISTINCT FROM OLD.flow_status)
|
||||
EXECUTE FUNCTION "notify_queue" ();
|
||||
|
||||
-- Apply phase 4:
|
||||
DROP FUNCTION IF EXISTS v2_job_after_update CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_completed_before_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_completed_before_update CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_queue_after_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_queue_before_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_queue_before_update CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_runtime_before_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_runtime_before_update CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_status_before_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_status_before_update CASCADE;
|
||||
|
||||
DROP VIEW IF EXISTS completed_job, completed_job_view, job, queue, queue_view CASCADE;
|
||||
|
||||
ALTER TABLE v2_job_queue
|
||||
DROP COLUMN IF EXISTS __parent_job CASCADE,
|
||||
DROP COLUMN IF EXISTS __created_by CASCADE,
|
||||
DROP COLUMN IF EXISTS __script_hash CASCADE,
|
||||
DROP COLUMN IF EXISTS __script_path CASCADE,
|
||||
DROP COLUMN IF EXISTS __args CASCADE,
|
||||
DROP COLUMN IF EXISTS __logs CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_code CASCADE,
|
||||
DROP COLUMN IF EXISTS __canceled CASCADE,
|
||||
DROP COLUMN IF EXISTS __last_ping CASCADE,
|
||||
DROP COLUMN IF EXISTS __job_kind CASCADE,
|
||||
DROP COLUMN IF EXISTS __env_id CASCADE,
|
||||
DROP COLUMN IF EXISTS __schedule_path CASCADE,
|
||||
DROP COLUMN IF EXISTS __permissioned_as CASCADE,
|
||||
DROP COLUMN IF EXISTS __flow_status CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_flow CASCADE,
|
||||
DROP COLUMN IF EXISTS __is_flow_step CASCADE,
|
||||
DROP COLUMN IF EXISTS __language CASCADE,
|
||||
DROP COLUMN IF EXISTS __same_worker CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_lock CASCADE,
|
||||
DROP COLUMN IF EXISTS __pre_run_error CASCADE,
|
||||
DROP COLUMN IF EXISTS __email CASCADE,
|
||||
DROP COLUMN IF EXISTS __visible_to_owner CASCADE,
|
||||
DROP COLUMN IF EXISTS __mem_peak CASCADE,
|
||||
DROP COLUMN IF EXISTS __root_job CASCADE,
|
||||
DROP COLUMN IF EXISTS __leaf_jobs CASCADE,
|
||||
DROP COLUMN IF EXISTS __concurrent_limit CASCADE,
|
||||
DROP COLUMN IF EXISTS __concurrency_time_window_s CASCADE,
|
||||
DROP COLUMN IF EXISTS __timeout CASCADE,
|
||||
DROP COLUMN IF EXISTS __flow_step_id CASCADE,
|
||||
DROP COLUMN IF EXISTS __cache_ttl CASCADE;
|
||||
|
||||
LOCK TABLE v2_job_queue IN ACCESS EXCLUSIVE MODE;
|
||||
ALTER TABLE v2_job_completed
|
||||
DROP COLUMN IF EXISTS __parent_job CASCADE,
|
||||
DROP COLUMN IF EXISTS __created_by CASCADE,
|
||||
DROP COLUMN IF EXISTS __created_at CASCADE,
|
||||
DROP COLUMN IF EXISTS __success CASCADE,
|
||||
DROP COLUMN IF EXISTS __script_hash CASCADE,
|
||||
DROP COLUMN IF EXISTS __script_path CASCADE,
|
||||
DROP COLUMN IF EXISTS __args CASCADE,
|
||||
DROP COLUMN IF EXISTS __logs CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_code CASCADE,
|
||||
DROP COLUMN IF EXISTS __canceled CASCADE,
|
||||
DROP COLUMN IF EXISTS __job_kind CASCADE,
|
||||
DROP COLUMN IF EXISTS __env_id CASCADE,
|
||||
DROP COLUMN IF EXISTS __schedule_path CASCADE,
|
||||
DROP COLUMN IF EXISTS __permissioned_as CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_flow CASCADE,
|
||||
DROP COLUMN IF EXISTS __is_flow_step CASCADE,
|
||||
DROP COLUMN IF EXISTS __language CASCADE,
|
||||
DROP COLUMN IF EXISTS __is_skipped CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_lock CASCADE,
|
||||
DROP COLUMN IF EXISTS __email CASCADE,
|
||||
DROP COLUMN IF EXISTS __visible_to_owner CASCADE,
|
||||
DROP COLUMN IF EXISTS __tag CASCADE,
|
||||
DROP COLUMN IF EXISTS __priority CASCADE;
|
||||
2275
backend/tests/preserve_on_behalf_of.rs
Normal file
2275
backend/tests/preserve_on_behalf_of.rs
Normal file
File diff suppressed because it is too large
Load Diff
306
backend/tests/protection_rules.rs
Normal file
306
backend/tests/protection_rules.rs
Normal file
@@ -0,0 +1,306 @@
|
||||
//! Integration tests for workspace protection rulesets.
|
||||
//!
|
||||
//! Tests verify that DisableDirectDeployment protection rules correctly
|
||||
//! block/allow operations based on user permissions.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::workspaces::invalidate_protection_rules_cache;
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
fn new_script(path: &str, summary: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": summary,
|
||||
"description": "",
|
||||
"content": "export async function main() { return 42; }",
|
||||
"language": "deno",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn new_flow(path: &str, summary: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": summary,
|
||||
"description": "",
|
||||
"value": { "modules": [] },
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Comprehensive test for protection rules functionality.
|
||||
/// Tests all essential cases in a single test to avoid cache interference.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_protection_rules(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
invalidate_protection_rules_cache("test-workspace");
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
// ========================================
|
||||
// 1. Without protection rule, non-admin can create scripts and flows
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&new_script("u/test-user-2/script_no_rule", "No rule"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"Should create script without rule: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/flows/create")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&new_flow("u/test-user-2/flow_no_rule", "No rule"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"Should create flow without rule: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 2. Non-admin cannot create protection rules
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": "unauthorized-rule",
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
!resp.status().is_success(),
|
||||
"Non-admin should not create rules: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 3. Admin creates protection rule
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": "test-rule",
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"Admin should create rule: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 4. With rule, non-admin is blocked from creating scripts/flows
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&new_script("u/test-user-2/blocked_script", "Blocked"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
!resp.status().is_success(),
|
||||
"Non-admin should be blocked from scripts: {}",
|
||||
resp.status()
|
||||
);
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
body.contains("blocked") || body.contains("Blocked"),
|
||||
"Error should mention blocking: {}",
|
||||
body
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/flows/create")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&new_flow("u/test-user-2/blocked_flow", "Blocked"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
!resp.status().is_success(),
|
||||
"Non-admin should be blocked from flows: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 5. Admin bypasses protection rule
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&new_script("u/test-user/admin_script", "Admin"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"Admin should bypass rule: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 6. Update rule to bypass test-user-2
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules/test-rule")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": ["test-user-2"],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"Should update rule: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Invalidate cache to pick up the update
|
||||
invalidate_protection_rules_cache("test-workspace");
|
||||
|
||||
// ========================================
|
||||
// 7. Bypassed user can now create
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&new_script("u/test-user-2/bypassed_script", "Bypassed"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"Bypassed user should create: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 8. Non-bypassed user (test-user-3) is still blocked
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN_3",
|
||||
)
|
||||
.json(&new_script("u/test-user-3/still_blocked", "Blocked"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
!resp.status().is_success(),
|
||||
"Non-bypassed user should be blocked: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 9. Delete rule
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().delete(format!("{base}/workspaces/protection_rules/test-rule")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"Should delete rule: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Invalidate cache to pick up the deletion
|
||||
invalidate_protection_rules_cache("test-workspace");
|
||||
|
||||
// ========================================
|
||||
// 10. After deletion, non-admin can create again
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN_3",
|
||||
)
|
||||
.json(&new_script("u/test-user-3/after_delete", "After delete"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"Should create after rule deletion: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 11. Verify rule list is empty
|
||||
// ========================================
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!("{base}/workspaces/protection_rules")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let rules: Vec<serde_json::Value> = resp.json().await?;
|
||||
assert!(rules.is_empty(), "Should have no rules after deletion");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use windmill_common::{
|
||||
assets::{AssetKind, AssetUsageKind},
|
||||
db::UserDB,
|
||||
error::JsonResult,
|
||||
utils::escape_ilike_pattern,
|
||||
};
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
@@ -34,6 +35,7 @@ struct ListAssetsQuery {
|
||||
pub path: Option<String>,
|
||||
// Filter by matching a subset of the columns using base64 encoded json subset
|
||||
pub columns: Option<String>,
|
||||
pub broad_filter: Option<String>,
|
||||
}
|
||||
|
||||
fn default_per_page() -> i64 {
|
||||
@@ -128,6 +130,14 @@ async fn list_assets(
|
||||
asset_summary_filters.push(format!("asset.kind = ANY(${})", param_count));
|
||||
}
|
||||
|
||||
if query.broad_filter.is_some() {
|
||||
param_count += 1;
|
||||
asset_summary_filters.push(format!(
|
||||
"(asset.path ILIKE ${p} OR asset.kind::text ILIKE ${p})",
|
||||
p = param_count
|
||||
));
|
||||
}
|
||||
|
||||
let asset_summary_where = asset_summary_filters.join(" AND ");
|
||||
|
||||
// Build cursor condition
|
||||
@@ -144,7 +154,7 @@ async fn list_assets(
|
||||
format!(
|
||||
r#"FROM asset
|
||||
LEFT JOIN v2_job job_cte ON asset.usage_kind = 'job'
|
||||
AND asset.usage_path = job_cte.id::text
|
||||
AND job_cte.id = CASE WHEN asset.usage_kind = 'job' THEN asset.usage_path::uuid END
|
||||
AND job_cte.workspace_id = $1"#
|
||||
)
|
||||
} else {
|
||||
@@ -209,7 +219,7 @@ async fn list_assets(
|
||||
) = resource.path
|
||||
AND resource.workspace_id = $1
|
||||
LEFT JOIN v2_job job ON asset.usage_kind = 'job'
|
||||
AND asset.usage_path = job.id::text
|
||||
AND job.id = CASE WHEN asset.usage_kind = 'job' THEN asset.usage_path::uuid END
|
||||
AND job.workspace_id = $1
|
||||
WHERE asset.workspace_id = $1
|
||||
AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)
|
||||
@@ -224,7 +234,7 @@ async fn list_assets(
|
||||
let mut query_builder = sqlx::query(&sql).bind(&w_id).bind(limit);
|
||||
|
||||
if let Some(ref asset_path) = query.asset_path {
|
||||
query_builder = query_builder.bind(format!("%{}%", asset_path));
|
||||
query_builder = query_builder.bind(format!("%{}%", escape_ilike_pattern(asset_path)));
|
||||
}
|
||||
|
||||
if let Some(ref path) = query.path {
|
||||
@@ -242,7 +252,7 @@ async fn list_assets(
|
||||
}
|
||||
|
||||
if let Some(ref usage_path) = query.usage_path {
|
||||
query_builder = query_builder.bind(format!("%{}%", usage_path));
|
||||
query_builder = query_builder.bind(format!("%{}%", escape_ilike_pattern(usage_path)));
|
||||
}
|
||||
|
||||
if let Some(ref asset_kinds) = asset_kinds {
|
||||
@@ -251,6 +261,10 @@ async fn list_assets(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref broad_filter) = query.broad_filter {
|
||||
query_builder = query_builder.bind(format!("%{}%", escape_ilike_pattern(broad_filter)));
|
||||
}
|
||||
|
||||
if let (Some(cursor_created_at), Some(cursor_id)) = (query.cursor_created_at, query.cursor_id) {
|
||||
query_builder = query_builder.bind(cursor_created_at).bind(cursor_id);
|
||||
}
|
||||
|
||||
@@ -534,10 +534,13 @@ pub async fn create_token_internal(
|
||||
));
|
||||
}
|
||||
}
|
||||
sqlx::query!(
|
||||
let rows = sqlx::query!(
|
||||
"INSERT INTO token
|
||||
(token, email, label, expiration, super_admin, scopes, workspace_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
SELECT $1, $2, $3, $4, $5, $6, $7
|
||||
WHERE $7::varchar IS NULL OR NOT EXISTS(
|
||||
SELECT 1 FROM workspace WHERE id = $7 AND deleted = true
|
||||
)",
|
||||
token,
|
||||
authed.email,
|
||||
token_config.label,
|
||||
@@ -548,6 +551,11 @@ pub async fn create_token_internal(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if rows.rows_affected() == 0 {
|
||||
return Err(Error::BadRequest(
|
||||
"Cannot create a token for an archived workspace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
|
||||
@@ -13,7 +13,7 @@ default = []
|
||||
enterprise = ["dep:windmill-autoscaling"]
|
||||
private = []
|
||||
python = []
|
||||
inline_preview = ["dep:windmill-worker", "dep:itertools"]
|
||||
run_inline = ["dep:windmill-worker", "dep:itertools"]
|
||||
|
||||
[dependencies]
|
||||
windmill-api-auth.workspace = true
|
||||
|
||||
@@ -283,14 +283,14 @@ async fn native_kubernetes_autoscaling_healthcheck() -> Result<(), error::Error>
|
||||
}
|
||||
|
||||
async fn list_available_python_versions() -> error::JsonResult<Vec<String>> {
|
||||
#[cfg(not(all(feature = "python", feature = "inline_preview")))]
|
||||
#[cfg(not(all(feature = "python", feature = "run_inline")))]
|
||||
return Err(error::Error::BadRequest(
|
||||
"Python listing available only with 'python' feature enabled".to_string(),
|
||||
));
|
||||
|
||||
#[cfg(all(feature = "python", feature = "inline_preview"))]
|
||||
#[cfg(all(feature = "python", feature = "run_inline"))]
|
||||
use itertools::Itertools;
|
||||
#[cfg(all(feature = "python", feature = "inline_preview"))]
|
||||
#[cfg(all(feature = "python", feature = "run_inline"))]
|
||||
return Ok(Json(
|
||||
windmill_worker::PyV::list_available_python_versions()
|
||||
.await
|
||||
|
||||
@@ -503,7 +503,11 @@ async fn create_flow(
|
||||
nf.tag,
|
||||
nf.dedicated_worker,
|
||||
nf.visible_to_runner_only.unwrap_or(false),
|
||||
nf.on_behalf_of_email.and(Some(&authed.email)),
|
||||
windmill_common::resolve_on_behalf_of_email(
|
||||
nf.on_behalf_of_email.as_deref(),
|
||||
nf.preserve_on_behalf_of.unwrap_or(false),
|
||||
&authed,
|
||||
),
|
||||
nf.ws_error_handler_muted.unwrap_or(false),
|
||||
sqlx::types::Json(&nf.value) as _,
|
||||
schema_str,
|
||||
@@ -513,7 +517,7 @@ async fn create_flow(
|
||||
.await?;
|
||||
|
||||
let version = sqlx::query_scalar!(
|
||||
"INSERT INTO flow_version (workspace_id, path, value, schema, created_by)
|
||||
"INSERT INTO flow_version (workspace_id, path, value, schema, created_by)
|
||||
VALUES ($1, $2, $3, $4::text::json, $5)
|
||||
RETURNING id",
|
||||
w_id,
|
||||
@@ -555,6 +559,29 @@ async fn create_flow(
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation(
|
||||
nf.on_behalf_of_email.as_deref(),
|
||||
nf.preserve_on_behalf_of.unwrap_or(false),
|
||||
&authed,
|
||||
&authed.email,
|
||||
) {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"flows.on_behalf_of",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&nf.path),
|
||||
Some(
|
||||
[
|
||||
("on_behalf_of", on_behalf_of.as_str()),
|
||||
("action", "create"),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
|
||||
if let Some(dm) = nf.deployment_message {
|
||||
@@ -940,7 +967,11 @@ async fn update_flow(
|
||||
nf.tag,
|
||||
nf.dedicated_worker,
|
||||
nf.visible_to_runner_only.unwrap_or(false),
|
||||
nf.on_behalf_of_email.and(Some(&authed.email)),
|
||||
windmill_common::resolve_on_behalf_of_email(
|
||||
nf.on_behalf_of_email.as_deref(),
|
||||
nf.preserve_on_behalf_of.unwrap_or(false),
|
||||
&authed,
|
||||
),
|
||||
nf.ws_error_handler_muted.unwrap_or(false),
|
||||
sqlx::types::Json(&nf.value) as _,
|
||||
schema_str,
|
||||
@@ -1103,6 +1134,29 @@ async fn update_flow(
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation(
|
||||
nf.on_behalf_of_email.as_deref(),
|
||||
nf.preserve_on_behalf_of.unwrap_or(false),
|
||||
&authed,
|
||||
&authed.email,
|
||||
) {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"flows.on_behalf_of",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&nf.path),
|
||||
Some(
|
||||
[
|
||||
("on_behalf_of", on_behalf_of.as_str()),
|
||||
("action", "update"),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
webhook.send_message(
|
||||
w_id.clone(),
|
||||
|
||||
@@ -14,6 +14,7 @@ private = ["windmill-test-utils/private", "dep:aws-config", "dep:aws-credential-
|
||||
enterprise = ["windmill-test-utils/enterprise", "dep:base64"]
|
||||
deno_core = ["windmill-test-utils/deno_core"]
|
||||
mcp = []
|
||||
run_inline = ["dep:windmill-worker", "windmill-test-utils/run_inline", "windmill-test-utils/duckdb"]
|
||||
|
||||
[dependencies]
|
||||
windmill-test-utils.workspace = true
|
||||
@@ -21,6 +22,7 @@ windmill-api-client.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-native-triggers = { workspace = true, features = ["native_trigger"] }
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-worker = { workspace = true, optional = true }
|
||||
sqlx.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
258
backend/windmill-api-integration-tests/tests/run_inline.rs
Normal file
258
backend/windmill-api-integration-tests/tests/run_inline.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
async fn init_inline_utils(port: u16) -> anyhow::Result<()> {
|
||||
use std::sync::Once;
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
INIT.call_once(|| {
|
||||
let (killpill_tx, killpill_rx) = windmill_common::KillpillSender::new(1);
|
||||
let base_internal_url = format!("http://localhost:{}", port);
|
||||
windmill_worker::init_worker_internal_server_inline_utils(killpill_rx, base_internal_url)
|
||||
.expect("Failed to initialize inline utils");
|
||||
// Keep killpill_tx alive for the test duration
|
||||
std::mem::forget(killpill_tx);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_inline_url(port: u16, endpoint: &str) -> String {
|
||||
format!("http://localhost:{port}/api/w/test-workspace/jobs/run_inline/{endpoint}")
|
||||
}
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn new_script(
|
||||
path: &str,
|
||||
summary: &str,
|
||||
content: &str,
|
||||
language: &str,
|
||||
schema_properties: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": summary,
|
||||
"description": "",
|
||||
"content": content,
|
||||
"language": language,
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": schema_properties,
|
||||
"required": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_run_inline_by_path(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
// Initialize inline utils for script execution
|
||||
init_inline_utils(port).await?;
|
||||
|
||||
// Create a DuckDB script (one of the languages that supports inline execution)
|
||||
// DuckDB requires parameter declarations in comments: -- $param_name (type)
|
||||
let script_path = "u/test-user/inline_test";
|
||||
let script_content = "-- $x (integer)
|
||||
-- $y (integer)
|
||||
SELECT $x + $y as result";
|
||||
|
||||
let resp = authed(client().post(format!("{base}/scripts/create")))
|
||||
.json(&new_script(
|
||||
script_path,
|
||||
"Inline test script",
|
||||
script_content,
|
||||
"duckdb",
|
||||
json!({
|
||||
"x": {"type": "integer"},
|
||||
"y": {"type": "integer"}
|
||||
}),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?);
|
||||
|
||||
// Test run_inline by path with args
|
||||
let resp = authed(client().post(run_inline_url(port, &format!("p/{script_path}"))))
|
||||
.json(&json!({
|
||||
"args": {
|
||||
"x": 5,
|
||||
"y": 15
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200, "run_inline by path with args failed");
|
||||
let result = resp.json::<serde_json::Value>().await?;
|
||||
// DuckDB query should return array with one row containing result field
|
||||
// The result structure is [{"result": 20}]
|
||||
assert!(result.is_array(), "expected array result, got: {}", result);
|
||||
let rows = result.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1, "expected 1 row, got: {}", rows.len());
|
||||
assert_eq!(
|
||||
rows[0]["result"],
|
||||
json!(20),
|
||||
"expected result 20, got: {}",
|
||||
rows[0]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_run_inline_by_hash(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
// Initialize inline utils for script execution
|
||||
init_inline_utils(port).await?;
|
||||
|
||||
// Create a DuckDB script and get its hash
|
||||
let script_path = "u/test-user/inline_hash_test";
|
||||
let script_content = "-- $a (integer)
|
||||
-- $b (integer)
|
||||
SELECT $a * $b as product";
|
||||
|
||||
let resp = authed(client().post(format!("{base}/scripts/create")))
|
||||
.json(&new_script(
|
||||
script_path,
|
||||
"Inline hash test script",
|
||||
script_content,
|
||||
"duckdb",
|
||||
json!({
|
||||
"a": {"type": "integer"},
|
||||
"b": {"type": "integer"}
|
||||
}),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?);
|
||||
|
||||
// Get the script to retrieve its hash
|
||||
let resp = authed(client().get(format!("{base}/scripts/get/p/{script_path}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let script_data = resp.json::<serde_json::Value>().await?;
|
||||
let hash = script_data["hash"]
|
||||
.as_str()
|
||||
.expect("hash should be present");
|
||||
|
||||
// Test run_inline by hash with args
|
||||
let resp = authed(client().post(run_inline_url(port, &format!("h/{hash}"))))
|
||||
.json(&json!({
|
||||
"args": {
|
||||
"a": 7,
|
||||
"b": 3
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200, "run_inline by hash with args failed");
|
||||
let result = resp.json::<serde_json::Value>().await?;
|
||||
// Should return array with one row: [{"product": 21}]
|
||||
assert!(result.is_array(), "expected array result");
|
||||
let rows = result.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1, "expected 1 row");
|
||||
assert_eq!(
|
||||
rows[0]["product"],
|
||||
json!(21),
|
||||
"expected product 21, got: {}",
|
||||
rows[0]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_run_inline_preview(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// Initialize inline utils for script execution
|
||||
init_inline_utils(port).await?;
|
||||
|
||||
// Test run_inline preview with direct DuckDB content
|
||||
let resp = authed(client().post(run_inline_url(port, "preview")))
|
||||
.json(&json!({
|
||||
"content": "-- $msg (text)\nSELECT 'Hello, ' || $msg || '!' as greeting",
|
||||
"language": "duckdb",
|
||||
"args": {
|
||||
"msg": "World"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200, "run_inline preview failed");
|
||||
let result = resp.json::<serde_json::Value>().await?;
|
||||
// Should return array with one row: [{"greeting": "Hello, World!"}]
|
||||
assert!(result.is_array(), "expected array result");
|
||||
let rows = result.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1, "expected 1 row");
|
||||
assert_eq!(
|
||||
rows[0]["greeting"],
|
||||
json!("Hello, World!"),
|
||||
"expected 'Hello, World!', got: {}",
|
||||
rows[0]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_run_inline_nonexistent_script(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// Initialize inline utils
|
||||
init_inline_utils(port).await?;
|
||||
|
||||
// Test run_inline by path with non-existent script - should return an error
|
||||
let resp = authed(client().post(run_inline_url(port, "p/u/test-user/nonexistent_script")))
|
||||
.json(&json!({
|
||||
"args": null
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should return an error (script not found)
|
||||
assert!(
|
||||
resp.status().is_client_error() || resp.status().is_server_error(),
|
||||
"expected error status for nonexistent script, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -225,6 +225,7 @@ async fn get_concurrent_intervals(
|
||||
allow_wildcards: None,
|
||||
trigger_kind: _,
|
||||
include_args: _,
|
||||
broad_filter: _,
|
||||
} => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use sql_builder::prelude::*;
|
||||
use sql_builder::SqlBuilder;
|
||||
use windmill_common::utils::{paginate_without_limits, Pagination};
|
||||
use windmill_common::utils::{escape_ilike_pattern, paginate_without_limits, Pagination};
|
||||
|
||||
use crate::types::{ListCompletedQuery, ListQueueQuery};
|
||||
|
||||
@@ -229,6 +229,14 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bf) = &lq.broad_filter {
|
||||
let pat = format!("%{}%", escape_ilike_pattern(bf));
|
||||
sqlb.and_where(
|
||||
"(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ?)"
|
||||
.bind(&pat).bind(&pat).bind(&pat).bind(&pat)
|
||||
);
|
||||
}
|
||||
|
||||
sqlb
|
||||
}
|
||||
|
||||
@@ -524,6 +532,15 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bf) = &lq.broad_filter {
|
||||
let pat = format!("%{}%", escape_ilike_pattern(bf));
|
||||
sqlb.and_where(
|
||||
"(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ? \
|
||||
OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl ILIKE ?))"
|
||||
.bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat)
|
||||
);
|
||||
}
|
||||
|
||||
sqlb
|
||||
}
|
||||
|
||||
@@ -539,7 +556,10 @@ pub fn list_completed_jobs_query(
|
||||
let mut sqlb = SqlBuilder::select_from("v2_job_completed")
|
||||
.fields(fields)
|
||||
.order_by(
|
||||
if lq.completed_before.is_some() || lq.completed_after.is_some() {
|
||||
if lq.completed_before.is_some()
|
||||
|| lq.completed_after.is_some()
|
||||
|| lq.success == Some(false)
|
||||
{
|
||||
"v2_job_completed.completed_at"
|
||||
} else {
|
||||
"v2_job.created_at"
|
||||
@@ -598,6 +618,7 @@ mod tests {
|
||||
trigger_kind: None,
|
||||
trigger_path: None,
|
||||
include_args: None,
|
||||
broad_filter: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +662,7 @@ mod tests {
|
||||
trigger_kind: None,
|
||||
trigger_path: None,
|
||||
include_args: None,
|
||||
broad_filter: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ pub struct ListQueueQuery {
|
||||
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub include_args: Option<bool>,
|
||||
pub broad_filter: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
@@ -165,6 +166,7 @@ pub struct ListCompletedQuery {
|
||||
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub include_args: Option<bool>,
|
||||
pub broad_filter: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ListCompletedQuery> for ListQueueQuery {
|
||||
@@ -199,6 +201,7 @@ impl From<ListCompletedQuery> for ListQueueQuery {
|
||||
trigger_kind: lcq.trigger_kind,
|
||||
trigger_path: lcq.trigger_path,
|
||||
include_args: lcq.include_args,
|
||||
broad_filter: lcq.broad_filter,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -703,6 +706,7 @@ mod tests {
|
||||
trigger_kind: None,
|
||||
trigger_path: None,
|
||||
include_args: None,
|
||||
broad_filter: None,
|
||||
};
|
||||
|
||||
let lqq: ListQueueQuery = lcq.into();
|
||||
@@ -770,6 +774,7 @@ mod tests {
|
||||
trigger_kind: None,
|
||||
trigger_path: None,
|
||||
include_args: None,
|
||||
broad_filter: None,
|
||||
};
|
||||
|
||||
let lqq: ListQueueQuery = lcq.into();
|
||||
|
||||
@@ -20,3 +20,4 @@ sqlx.workspace = true
|
||||
tar.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
@@ -131,12 +131,29 @@ pub fn workspaced_service() -> Router {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_registry_request(url: &str, auth_token: &Option<String>) -> reqwest::RequestBuilder {
|
||||
fn build_registry_request(
|
||||
url: &str,
|
||||
auth_token: &Option<String>,
|
||||
registry_base_url: &str,
|
||||
) -> Result<reqwest::RequestBuilder> {
|
||||
let parsed_url =
|
||||
url::Url::parse(url).map_err(|e| Error::BadRequest(format!("Invalid URL: {}", e)))?;
|
||||
let parsed_base = url::Url::parse(registry_base_url)
|
||||
.map_err(|e| Error::BadRequest(format!("Invalid registry URL: {}", e)))?;
|
||||
|
||||
if parsed_url.host_str() != parsed_base.host_str() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Tarball URL host '{}' does not match registry host '{}'",
|
||||
parsed_url.host_str().unwrap_or("unknown"),
|
||||
parsed_base.host_str().unwrap_or("unknown"),
|
||||
)));
|
||||
}
|
||||
|
||||
let mut req = HTTP_CLIENT.get(url);
|
||||
if let Some(token) = auth_token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
req
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
/// Get package metadata (versions and tags) from the private registry
|
||||
@@ -153,7 +170,7 @@ async fn get_package_metadata(
|
||||
|
||||
tracing::info!("Fetching package metadata from: {}", package_url);
|
||||
|
||||
let response = build_registry_request(&package_url, &auth_token)
|
||||
let response = build_registry_request(&package_url, &auth_token, ®istry_url)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?;
|
||||
@@ -204,7 +221,7 @@ async fn resolve_package_version(
|
||||
|
||||
tracing::info!("Resolving package version from: {}", package_url);
|
||||
|
||||
let response = build_registry_request(&package_url, &auth_token)
|
||||
let response = build_registry_request(&package_url, &auth_token, ®istry_url)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?;
|
||||
@@ -258,7 +275,7 @@ async fn get_package_filetree(
|
||||
|
||||
tracing::info!("Fetching package filetree from: {}", package_url);
|
||||
|
||||
let response = build_registry_request(&package_url, &auth_token)
|
||||
let response = build_registry_request(&package_url, &auth_token, ®istry_url)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?;
|
||||
@@ -283,7 +300,7 @@ async fn get_package_filetree(
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?;
|
||||
|
||||
let tarball_response = build_registry_request(tarball_url, &auth_token)
|
||||
let tarball_response = build_registry_request(tarball_url, &auth_token, ®istry_url)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?;
|
||||
@@ -329,7 +346,7 @@ async fn get_package_file(
|
||||
|
||||
tracing::info!("Fetching package file from: {}", package_url);
|
||||
|
||||
let response = build_registry_request(&package_url, &auth_token)
|
||||
let response = build_registry_request(&package_url, &auth_token, ®istry_url)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?;
|
||||
@@ -354,7 +371,7 @@ async fn get_package_file(
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?;
|
||||
|
||||
let tarball_response = build_registry_request(tarball_url, &auth_token)
|
||||
let tarball_response = build_registry_request(tarball_url, &auth_token, ®istry_url)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?;
|
||||
|
||||
@@ -21,15 +21,57 @@ use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
can_preserve_on_behalf_of,
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
schedule::Schedule,
|
||||
utils::{not_found_if_none, paginate, Pagination, ScheduleType, StripPath},
|
||||
utils::{
|
||||
escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath,
|
||||
},
|
||||
worker::to_raw_value,
|
||||
};
|
||||
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
use windmill_queue::schedule::push_scheduled_job;
|
||||
|
||||
/// Resolves the email to use for a schedule based on preservation settings.
|
||||
/// When preserving, looks up the email from the provided username.
|
||||
async fn resolve_email(
|
||||
username: Option<&String>,
|
||||
preserve_email: Option<bool>,
|
||||
authed: &ApiAuthed,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
) -> Result<String> {
|
||||
if let Some(username) = username {
|
||||
if preserve_email.unwrap_or(false) && can_preserve_on_behalf_of(authed) {
|
||||
let email = sqlx::query_scalar!(
|
||||
"SELECT email FROM usr WHERE username = $1 AND workspace_id = $2",
|
||||
username,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
if let Some(email) = email {
|
||||
return Ok(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(authed.email.clone())
|
||||
}
|
||||
|
||||
fn resolve_edited_by(
|
||||
username: Option<&String>,
|
||||
preserve_edited_by: Option<bool>,
|
||||
authed: &ApiAuthed,
|
||||
) -> String {
|
||||
if let Some(username) = username {
|
||||
if preserve_edited_by.unwrap_or(false) && can_preserve_on_behalf_of(authed) {
|
||||
return username.clone();
|
||||
}
|
||||
}
|
||||
authed.username.clone()
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_schedule))
|
||||
@@ -75,6 +117,8 @@ pub struct NewSchedule {
|
||||
pub paused_until: Option<DateTime<Utc>>,
|
||||
pub cron_version: Option<String>,
|
||||
pub dynamic_skip: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub preserve_email: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -200,6 +244,8 @@ async fn create_schedule(
|
||||
validate_dynamic_skip(&mut tx, &w_id, handler_path).await?;
|
||||
}
|
||||
|
||||
let resolved_edited_by = resolve_edited_by(ns.email.as_ref(), ns.preserve_email, &authed);
|
||||
|
||||
let schedule = sqlx::query_as!(
|
||||
Schedule,
|
||||
r#"
|
||||
@@ -257,13 +303,13 @@ async fn create_schedule(
|
||||
ns.path,
|
||||
ns.schedule,
|
||||
ns.timezone,
|
||||
authed.username,
|
||||
resolved_edited_by,
|
||||
ns.script_path,
|
||||
ns.is_flow,
|
||||
to_json_raw_opt(ns.args.as_ref())
|
||||
as Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
|
||||
ns.enabled.unwrap_or(false),
|
||||
authed.email,
|
||||
resolve_email(ns.email.as_ref(), ns.preserve_email, &authed, &db, &w_id).await?,
|
||||
ns.on_failure,
|
||||
ns.on_failure_times,
|
||||
ns.on_failure_exact,
|
||||
@@ -308,6 +354,29 @@ async fn create_schedule(
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation(
|
||||
ns.email.as_deref(),
|
||||
ns.preserve_email.unwrap_or(false),
|
||||
&authed,
|
||||
&authed.username,
|
||||
) {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"schedule.on_behalf_of",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&ns.path),
|
||||
Some(
|
||||
[
|
||||
("on_behalf_of", on_behalf_of.as_str()),
|
||||
("action", "create"),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if ns.enabled.unwrap_or(true) {
|
||||
tx = push_scheduled_job(&db, tx, &schedule, Some(&authed.clone().into()), None).await?
|
||||
@@ -351,6 +420,9 @@ async fn edit_schedule(
|
||||
}
|
||||
|
||||
clear_schedule(&mut tx, path, &w_id).await?;
|
||||
|
||||
let resolved_edited_by = resolve_edited_by(es.email.as_ref(), es.preserve_email, &authed);
|
||||
|
||||
let schedule = sqlx::query_as!(
|
||||
Schedule,
|
||||
r#"
|
||||
@@ -377,7 +449,9 @@ async fn edit_schedule(
|
||||
workspace_id = $20,
|
||||
cron_version = COALESCE($21, cron_version),
|
||||
description = $22,
|
||||
dynamic_skip = $23
|
||||
dynamic_skip = $23,
|
||||
email = COALESCE($24, email),
|
||||
edited_by = $25
|
||||
WHERE path = $19 AND workspace_id = $20
|
||||
RETURNING
|
||||
workspace_id,
|
||||
@@ -438,7 +512,9 @@ async fn edit_schedule(
|
||||
w_id,
|
||||
es.cron_version,
|
||||
es.description,
|
||||
es.dynamic_skip
|
||||
es.dynamic_skip,
|
||||
Some(resolve_email(es.email.as_ref(), es.preserve_email, &authed, &db, &w_id).await?),
|
||||
resolved_edited_by
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
@@ -459,6 +535,29 @@ async fn edit_schedule(
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation(
|
||||
es.email.as_deref(),
|
||||
es.preserve_email.unwrap_or(false),
|
||||
&authed,
|
||||
&authed.username,
|
||||
) {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"schedule.on_behalf_of",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(path),
|
||||
Some(
|
||||
[
|
||||
("on_behalf_of", on_behalf_of.as_str()),
|
||||
("action", "update"),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if schedule.enabled {
|
||||
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
|
||||
@@ -495,6 +594,7 @@ pub struct ListScheduleQuery {
|
||||
pub description: Option<String>,
|
||||
// filter on summary (pattern match)
|
||||
pub summary: Option<String>,
|
||||
pub broad_filter: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -554,13 +654,19 @@ async fn list_schedule(
|
||||
sqlb.and_where_eq("path", "?".bind(schedule_path));
|
||||
}
|
||||
if let Some(description) = &lsq.description {
|
||||
sqlb.and_where(&format!(
|
||||
"description ILIKE '%{}%'",
|
||||
description.replace("'", "''")
|
||||
));
|
||||
let pat = format!("%{}%", escape_ilike_pattern(description));
|
||||
sqlb.and_where("description ILIKE ?".bind(&pat));
|
||||
}
|
||||
if let Some(summary) = &lsq.summary {
|
||||
sqlb.and_where(&format!("summary ILIKE '%{}%'", summary.replace("'", "''")));
|
||||
let pat = format!("%{}%", escape_ilike_pattern(summary));
|
||||
sqlb.and_where("summary ILIKE ?".bind(&pat));
|
||||
}
|
||||
if let Some(broad_filter) = &lsq.broad_filter {
|
||||
let pat = format!("%{}%", escape_ilike_pattern(broad_filter));
|
||||
sqlb.and_where(
|
||||
"(path ILIKE ? OR script_path ILIKE ? OR description ILIKE ? OR summary ILIKE ? OR schedule ILIKE ?)"
|
||||
.bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat)
|
||||
);
|
||||
}
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let rows = sqlx::query_as::<_, ScheduleLight>(&sql)
|
||||
@@ -1073,6 +1179,8 @@ pub struct EditSchedule {
|
||||
pub paused_until: Option<DateTime<Utc>>,
|
||||
pub cron_version: Option<String>,
|
||||
pub dynamic_skip: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub preserve_email: Option<bool>,
|
||||
}
|
||||
|
||||
pub use windmill_queue::schedule::clear_schedule;
|
||||
|
||||
@@ -927,11 +927,11 @@ async fn create_script_internal<'c>(
|
||||
no_main_func.filter(|x: &bool| *x), // should be Some(true) or None
|
||||
codebase,
|
||||
has_preprocessor.filter(|x: &bool| *x), // should be Some(true) or None
|
||||
if ns.on_behalf_of_email.is_some() {
|
||||
Some(&authed.email)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
windmill_common::resolve_on_behalf_of_email(
|
||||
ns.on_behalf_of_email.as_deref(),
|
||||
ns.preserve_on_behalf_of.unwrap_or(false),
|
||||
&authed,
|
||||
),
|
||||
validate_schema,
|
||||
ns.assets.as_ref().and_then(|a| serde_json::to_value(a).ok()),
|
||||
guarded_debounce_key,
|
||||
@@ -1027,6 +1027,29 @@ async fn create_script_internal<'c>(
|
||||
Some([("hash", hash.to_string().as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation(
|
||||
ns.on_behalf_of_email.as_deref(),
|
||||
ns.preserve_on_behalf_of.unwrap_or(false),
|
||||
&authed,
|
||||
&authed.email,
|
||||
) {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"scripts.on_behalf_of",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&ns.path),
|
||||
Some(
|
||||
[
|
||||
("on_behalf_of", on_behalf_of.as_str()),
|
||||
("action", "update"),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
webhook.send_message(
|
||||
w_id.clone(),
|
||||
WebhookMessage::UpdateScript {
|
||||
@@ -1052,6 +1075,29 @@ async fn create_script_internal<'c>(
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation(
|
||||
ns.on_behalf_of_email.as_deref(),
|
||||
ns.preserve_on_behalf_of.unwrap_or(false),
|
||||
&authed,
|
||||
&authed.email,
|
||||
) {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"scripts.on_behalf_of",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&ns.path),
|
||||
Some(
|
||||
[
|
||||
("on_behalf_of", on_behalf_of.as_str()),
|
||||
("action", "create"),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
webhook.send_message(
|
||||
w_id.clone(),
|
||||
WebhookMessage::CreateScript {
|
||||
|
||||
@@ -12,24 +12,24 @@ use std::{collections::HashMap, time::Duration};
|
||||
mod ee;
|
||||
pub mod ee_oss;
|
||||
|
||||
use windmill_api_auth::{require_super_admin, ApiAuthed};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_api_auth::require_devops_role;
|
||||
use windmill_api_auth::{require_super_admin, ApiAuthed};
|
||||
use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT;
|
||||
use windmill_common::DB;
|
||||
|
||||
use ee_oss::validate_license_key;
|
||||
use windmill_common::usernames::generate_instance_username_for_all_users;
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::{get, post},
|
||||
body::Body,
|
||||
response::Response,
|
||||
Json, Router,
|
||||
};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use axum::extract::Query;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Extension, Path},
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -43,9 +43,8 @@ use windmill_common::{
|
||||
get_database_url,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING,
|
||||
HUB_BASE_URL_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
|
||||
ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
},
|
||||
instance_config::{self, ApplyMode, InstanceConfig},
|
||||
server::Smtp,
|
||||
@@ -171,7 +170,9 @@ pub async fn test_s3_bucket(
|
||||
.await?
|
||||
.store;
|
||||
|
||||
let mut list = client.list(Some(&windmill_object_store::object_store_reexports::Path::from("".to_string())));
|
||||
let mut list = client.list(Some(
|
||||
&windmill_object_store::object_store_reexports::Path::from("".to_string()),
|
||||
));
|
||||
let first_file = list.next().await;
|
||||
if first_file.is_some() {
|
||||
if let Err(e) = first_file.as_ref().unwrap() {
|
||||
@@ -189,7 +190,10 @@ pub async fn test_s3_bucket(
|
||||
));
|
||||
tracing::info!("Testing blob storage at path: {path}");
|
||||
client
|
||||
.put(&path, windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"))
|
||||
.put(
|
||||
&path,
|
||||
windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?;
|
||||
let content = client
|
||||
@@ -290,17 +294,17 @@ pub async fn set_global_setting_internal(
|
||||
match value {
|
||||
serde_json::Value::Null => {
|
||||
if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) {
|
||||
return Err(error::Error::BadRequest(
|
||||
format!("{key} is a protected setting and cannot be deleted"),
|
||||
));
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"{key} is a protected setting and cannot be deleted"
|
||||
)));
|
||||
}
|
||||
delete_global_setting(db, &key).await?;
|
||||
}
|
||||
serde_json::Value::String(x) if x.is_empty() => {
|
||||
if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) {
|
||||
return Err(error::Error::BadRequest(
|
||||
format!("{key} is a protected setting and cannot be set to empty"),
|
||||
));
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"{key} is a protected setting and cannot be set to empty"
|
||||
)));
|
||||
}
|
||||
delete_global_setting(db, &key).await?;
|
||||
}
|
||||
@@ -437,7 +441,8 @@ async fn get_instance_config_yaml(
|
||||
let config = InstanceConfig::from_db(&db)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
let yaml = config.to_sorted_yaml()
|
||||
let yaml = config
|
||||
.to_sorted_yaml()
|
||||
.map_err(|e| error::Error::internal_err(e))?;
|
||||
Response::builder()
|
||||
.header("content-type", "application/yaml")
|
||||
@@ -478,8 +483,7 @@ async fn set_instance_config(
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.clone(),
|
||||
serde_json::to_value(v)
|
||||
.expect("WorkerGroupConfig serialization cannot fail"),
|
||||
serde_json::to_value(v).expect("WorkerGroupConfig serialization cannot fail"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -489,8 +493,7 @@ async fn set_instance_config(
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.clone(),
|
||||
serde_json::to_value(v)
|
||||
.expect("WorkerGroupConfig serialization cannot fail"),
|
||||
serde_json::to_value(v).expect("WorkerGroupConfig serialization cannot fail"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -944,7 +947,8 @@ async fn setup_custom_instance_pg_database_inner(
|
||||
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;
|
||||
ALTER ROLE custom_instance_user CREATEROLE;"
|
||||
ALTER ROLE custom_instance_user CREATEROLE;
|
||||
ALTER ROLE custom_instance_user REPLICATION;"
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -1083,19 +1087,16 @@ async fn sync_cached_resource_types(
|
||||
use windmill_common::worker::HUB_RT_CACHE_DIR;
|
||||
let cache_path = format!("{}/resource_types.json", HUB_RT_CACHE_DIR);
|
||||
|
||||
let content = tokio::fs::read_to_string(&cache_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error::Error::NotFound(format!(
|
||||
"No cached resource types found at {}: {}",
|
||||
cache_path, e
|
||||
))
|
||||
})?;
|
||||
let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| {
|
||||
error::Error::NotFound(format!(
|
||||
"No cached resource types found at {}: {}",
|
||||
cache_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let cached_types: Vec<CachedResourceType> =
|
||||
serde_json::from_str(&content).map_err(|e| {
|
||||
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
|
||||
})?;
|
||||
let cached_types: Vec<CachedResourceType> = serde_json::from_str(&content).map_err(|e| {
|
||||
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
|
||||
})?;
|
||||
|
||||
let mut synced_count = 0;
|
||||
|
||||
@@ -1176,7 +1177,10 @@ mod tests {
|
||||
deserialized.global_settings.base_url.as_deref(),
|
||||
Some("https://windmill.example.com")
|
||||
);
|
||||
assert_eq!(deserialized.global_settings.retention_period_secs, Some(86400));
|
||||
assert_eq!(
|
||||
deserialized.global_settings.retention_period_secs,
|
||||
Some(86400)
|
||||
);
|
||||
assert_eq!(deserialized.global_settings.expose_metrics, Some(true));
|
||||
let wc = &deserialized.worker_configs["default"];
|
||||
assert_eq!(
|
||||
@@ -1208,9 +1212,7 @@ mod tests {
|
||||
let retention_pos = yaml.find("retention_period_secs:").unwrap();
|
||||
|
||||
assert!(
|
||||
base_url_pos < email_pos
|
||||
&& email_pos < expose_pos
|
||||
&& expose_pos < retention_pos,
|
||||
base_url_pos < email_pos && email_pos < expose_pos && expose_pos < retention_pos,
|
||||
"global_settings keys should be alphabetically sorted, got yaml:\n{yaml}"
|
||||
);
|
||||
}
|
||||
@@ -1220,22 +1222,34 @@ mod tests {
|
||||
let config = InstanceConfig {
|
||||
global_settings: GlobalSettings::default(),
|
||||
worker_configs: BTreeMap::from([
|
||||
("gpu".to_string(), WorkerGroupConfig {
|
||||
init_bash: Some("echo gpu".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
("native".to_string(), WorkerGroupConfig {
|
||||
init_bash: Some("echo native".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
("default".to_string(), WorkerGroupConfig {
|
||||
init_bash: Some("echo default".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
("alpha".to_string(), WorkerGroupConfig {
|
||||
init_bash: Some("echo alpha".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
(
|
||||
"gpu".to_string(),
|
||||
WorkerGroupConfig {
|
||||
init_bash: Some("echo gpu".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"native".to_string(),
|
||||
WorkerGroupConfig {
|
||||
init_bash: Some("echo native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"default".to_string(),
|
||||
WorkerGroupConfig {
|
||||
init_bash: Some("echo default".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"alpha".to_string(),
|
||||
WorkerGroupConfig {
|
||||
init_bash: Some("echo alpha".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -1264,26 +1278,40 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
worker_configs: BTreeMap::from([
|
||||
("default".to_string(), WorkerGroupConfig {
|
||||
worker_tags: Some(vec!["deno".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
("native".to_string(), WorkerGroupConfig {
|
||||
init_bash: Some("echo hi".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
(
|
||||
"default".to_string(),
|
||||
WorkerGroupConfig {
|
||||
worker_tags: Some(vec!["deno".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"native".to_string(),
|
||||
WorkerGroupConfig {
|
||||
init_bash: Some("echo hi".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
let yaml = config.to_sorted_yaml().unwrap();
|
||||
let deserialized: InstanceConfig = serde_yml::from_str(&yaml).unwrap();
|
||||
|
||||
assert_eq!(deserialized.global_settings.base_url.as_deref(), Some("https://rt.test"));
|
||||
assert_eq!(deserialized.global_settings.retention_period_secs, Some(7200));
|
||||
assert_eq!(
|
||||
deserialized.global_settings.base_url.as_deref(),
|
||||
Some("https://rt.test")
|
||||
);
|
||||
assert_eq!(
|
||||
deserialized.global_settings.retention_period_secs,
|
||||
Some(7200)
|
||||
);
|
||||
assert_eq!(deserialized.global_settings.expose_metrics, Some(false));
|
||||
assert_eq!(deserialized.worker_configs.len(), 2);
|
||||
assert_eq!(
|
||||
deserialized.worker_configs["default"].worker_tags.as_deref(),
|
||||
deserialized.worker_configs["default"]
|
||||
.worker_tags
|
||||
.as_deref(),
|
||||
Some(["deno".to_string()].as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -2788,6 +2788,14 @@ async fn create_workspace(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO group_
|
||||
VALUES ($1, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace')",
|
||||
nw.id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO usr_to_group
|
||||
VALUES ($1, 'all', $2)",
|
||||
@@ -2855,9 +2863,8 @@ async fn clone_workspace_data(
|
||||
// Clone workspace runnable dependencies and dependency map
|
||||
clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?;
|
||||
|
||||
// TODO: Enable when git sync is implemented for workspace dependencies.
|
||||
// // Clone workspace dependencies
|
||||
// clone_workspace_dependencies(tx, source_workspace_id, target_workspace_id).await?;
|
||||
// Clone workspace dependencies
|
||||
clone_workspace_dependencies(tx, source_workspace_id, target_workspace_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3354,13 +3361,12 @@ async fn clone_workspace_runnable_dependencies(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn clone_workspace_dependencies(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
// Clone workspace_runnable_dependencies
|
||||
// Clone workspace_dependencies
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_dependencies (workspace_id, language, name, description, content, archived, created_at)
|
||||
SELECT $1, language, name, description, content, archived, created_at
|
||||
@@ -3494,17 +3500,6 @@ async fn create_workspace_fork(
|
||||
// Clone all data from the parent workspace using Rust implementation
|
||||
clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_invite (workspace_id, email, is_admin, operator)
|
||||
SELECT $1, email, is_admin, operator
|
||||
FROM usr
|
||||
WHERE workspace_id = $2",
|
||||
&forked_id,
|
||||
&parent_workspace_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -3558,7 +3553,7 @@ pub(crate) async fn archive_workspace_impl(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
username: &str,
|
||||
) -> Result<(usize, usize)> {
|
||||
) -> Result<(usize, usize, usize)> {
|
||||
// Step 1: Disable all schedules and clear their queued jobs
|
||||
let mut tx = db.begin().await?;
|
||||
let disabled_schedules = sqlx::query_scalar!(
|
||||
@@ -3580,6 +3575,20 @@ pub(crate) async fn archive_workspace_impl(
|
||||
windmill_queue::schedule::clear_schedule(&mut tx, schedule_path, w_id).await?;
|
||||
}
|
||||
|
||||
// Delete non-session tokens scoped to this workspace
|
||||
let deleted_tokens = sqlx::query_scalar!(
|
||||
"DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token",
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
"Deleted {} non-session tokens in workspace {}",
|
||||
deleted_tokens.len(),
|
||||
w_id
|
||||
);
|
||||
|
||||
// Mark workspace as archived
|
||||
sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", w_id)
|
||||
.execute(&mut *tx)
|
||||
@@ -3618,7 +3627,7 @@ pub(crate) async fn archive_workspace_impl(
|
||||
0
|
||||
};
|
||||
|
||||
Ok((schedules_count, canceled_count))
|
||||
Ok((schedules_count, canceled_count, deleted_tokens.len()))
|
||||
}
|
||||
|
||||
async fn archive_workspace(
|
||||
@@ -3628,7 +3637,7 @@ async fn archive_workspace(
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let (schedules_count, canceled_count) =
|
||||
let (schedules_count, canceled_count, deleted_tokens_count) =
|
||||
archive_workspace_impl(&db, &w_id, &authed.username).await?;
|
||||
|
||||
// Audit log
|
||||
@@ -3636,6 +3645,7 @@ async fn archive_workspace(
|
||||
let mut audit_params = HashMap::new();
|
||||
audit_params.insert("disabled_schedules", schedules_count.to_string());
|
||||
audit_params.insert("canceled_jobs", canceled_count.to_string());
|
||||
audit_params.insert("deleted_tokens", deleted_tokens_count.to_string());
|
||||
let audit_params_refs: HashMap<&str, &str> =
|
||||
audit_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
|
||||
@@ -3652,8 +3662,8 @@ async fn archive_workspace(
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!(
|
||||
"Archived workspace {}, disabled {} schedules and canceled {} jobs",
|
||||
&w_id, schedules_count, canceled_count
|
||||
"Archived workspace {}, disabled {} schedules, canceled {} jobs and deleted {} tokens",
|
||||
&w_id, schedules_count, canceled_count, deleted_tokens_count
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -641,7 +641,7 @@ pub(crate) async fn change_workspace_id(
|
||||
// Archive old workspace: disable schedules, cancel remaining jobs, set deleted=true
|
||||
// Note: schedules were already moved to new workspace, so this will find 0 schedules
|
||||
info!("Archiving old workspace");
|
||||
let (_schedules_count, canceled_count) =
|
||||
let (_schedules_count, canceled_count, _deleted_tokens_count) =
|
||||
archive_workspace_impl(&db, &old_id, &authed.username).await?;
|
||||
|
||||
info!(
|
||||
|
||||
@@ -13,7 +13,7 @@ default = []
|
||||
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-email?/private"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise"]
|
||||
stripe = []
|
||||
inline_preview = ["dep:windmill-worker", "windmill-api-configs/inline_preview"]
|
||||
run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"]
|
||||
agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"]
|
||||
enterprise_saml = ["dep:samael", "dep:libxml"]
|
||||
benchmark = []
|
||||
|
||||
@@ -28049,6 +28049,7 @@ components:
|
||||
name: trigger_kind
|
||||
description: trigger kind (schedule, http, websocket...)
|
||||
in: query
|
||||
x-go-name: JobTriggerKindParam
|
||||
schema: *ref_160
|
||||
OrderDesc:
|
||||
name: order_desc
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.642.0
|
||||
version: 1.647.2
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -4106,6 +4106,11 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: broad_filter
|
||||
description: broad search across multiple fields (case-insensitive substring match)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
responses:
|
||||
@@ -5120,6 +5125,11 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: broad_filter
|
||||
description: broad search across multiple fields (case-insensitive substring match)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: resource list
|
||||
@@ -8215,6 +8225,9 @@ paths:
|
||||
type: string
|
||||
custom_path:
|
||||
type: string
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -8260,6 +8273,9 @@ paths:
|
||||
type: string
|
||||
custom_path:
|
||||
type: string
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -8571,6 +8587,9 @@ paths:
|
||||
type: string
|
||||
custom_path:
|
||||
type: string
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it."
|
||||
responses:
|
||||
"200":
|
||||
description: app updated
|
||||
@@ -8610,6 +8629,9 @@ paths:
|
||||
type: string
|
||||
custom_path:
|
||||
type: string
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it."
|
||||
js:
|
||||
type: string
|
||||
css:
|
||||
@@ -9151,6 +9173,54 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/jobs/run_inline/p/{path}:
|
||||
post:
|
||||
summary: run script by path without starting a new job
|
||||
operationId: runScriptByPathInline
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
requestBody:
|
||||
description: script args
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InlineScriptArgs"
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: script result
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/jobs/run_inline/h/{hash}:
|
||||
post:
|
||||
summary: run script by hash without starting a new job
|
||||
operationId: runScriptByHashInline
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptHash"
|
||||
requestBody:
|
||||
description: script args
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InlineScriptArgs"
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: script result
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/jobs/run_wait_result/preview:
|
||||
post:
|
||||
summary: run script preview and wait for result
|
||||
@@ -9984,6 +10054,11 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: broad_filter
|
||||
description: broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: All jobs
|
||||
@@ -10668,6 +10743,16 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: resume_button_text
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: cancel_button_text
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Interactive slack approval message sent successfully
|
||||
@@ -10714,6 +10799,16 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: resume_button_text
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: cancel_button_text
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Interactive slack approval message sent successfully
|
||||
@@ -11188,6 +11283,11 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: broad_filter
|
||||
description: broad search across multiple fields (case-insensitive substring match)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: schedule list
|
||||
@@ -16949,6 +17049,11 @@ paths:
|
||||
description: JSONB subset match filter for columns using base64 encoded JSON
|
||||
schema:
|
||||
type: string
|
||||
- name: broad_filter
|
||||
description: broad search across multiple fields (case-insensitive substring match)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: paginated assets in the workspace
|
||||
@@ -17324,6 +17429,7 @@ components:
|
||||
name: trigger_kind
|
||||
description: "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')"
|
||||
in: query
|
||||
x-go-name: JobTriggerKindParam
|
||||
schema:
|
||||
type: string
|
||||
OrderDesc:
|
||||
@@ -18496,6 +18602,9 @@ components:
|
||||
type: boolean
|
||||
on_behalf_of_email:
|
||||
type: string
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of_email value instead of overwriting it."
|
||||
assets:
|
||||
type: array
|
||||
items:
|
||||
@@ -19750,6 +19859,12 @@ components:
|
||||
$ref: "#/components/schemas/ScriptLang"
|
||||
required: [content, args, language]
|
||||
|
||||
InlineScriptArgs:
|
||||
type: object
|
||||
properties:
|
||||
args:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
|
||||
WorkflowTask:
|
||||
type: object
|
||||
properties:
|
||||
@@ -20150,6 +20265,12 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who the scheduled jobs run as. Used during deployment to preserve the original schedule owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- schedule
|
||||
@@ -20237,6 +20358,12 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who the scheduled jobs run as. Used during deployment to preserve the original schedule owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- schedule
|
||||
- timezone
|
||||
@@ -20593,6 +20720,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -20679,6 +20812,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- script_path
|
||||
@@ -20841,6 +20980,12 @@ components:
|
||||
retry:
|
||||
description: Retry configuration for failed executions
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -20903,6 +21048,12 @@ components:
|
||||
retry:
|
||||
description: Retry configuration for failed executions
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -21072,6 +21223,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- script_path
|
||||
@@ -21126,6 +21283,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- script_path
|
||||
@@ -21262,6 +21425,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: "Retry configuration for failed executions."
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- script_path
|
||||
@@ -21424,6 +21593,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- queue_url
|
||||
- aws_resource_path
|
||||
@@ -21470,6 +21645,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- queue_url
|
||||
- aws_resource_path
|
||||
@@ -21628,6 +21809,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- script_path
|
||||
@@ -21670,6 +21857,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- script_path
|
||||
@@ -21777,6 +21970,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -21830,6 +22029,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -21931,6 +22136,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -21980,6 +22191,12 @@ components:
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: Retry configuration for failed executions
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- script_path
|
||||
@@ -22028,6 +22245,12 @@ components:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
mode:
|
||||
$ref: "#/components/schemas/TriggerMode"
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
|
||||
required:
|
||||
- path
|
||||
@@ -22054,6 +22277,12 @@ components:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
retry:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
email:
|
||||
type: string
|
||||
description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner.
|
||||
preserve_email:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
- script_path
|
||||
@@ -22473,6 +22702,9 @@ components:
|
||||
type: boolean
|
||||
on_behalf_of_email:
|
||||
type: string
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of_email value instead of overwriting it."
|
||||
required:
|
||||
- path
|
||||
|
||||
@@ -23059,6 +23291,8 @@ components:
|
||||
type: boolean
|
||||
group_by_folder:
|
||||
type: boolean
|
||||
force_branch:
|
||||
type: string
|
||||
collapsed:
|
||||
type: boolean
|
||||
settings:
|
||||
|
||||
@@ -663,12 +663,30 @@ async fn global_proxy(
|
||||
|
||||
let base_url = provider.get_base_url(None, &db).await?;
|
||||
|
||||
let url = format!("{}/{}", base_url, ai_path);
|
||||
let is_anthropic = provider.is_anthropic();
|
||||
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
|
||||
|
||||
let url = if is_anthropic_sdk {
|
||||
let truncated_base_url = base_url.trim_end_matches("/v1");
|
||||
format!("{}/{}", truncated_base_url, ai_path)
|
||||
} else {
|
||||
format!("{}/{}", base_url, ai_path)
|
||||
};
|
||||
|
||||
let mut request = HTTP_CLIENT
|
||||
.request(method, url)
|
||||
.header("content-type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key));
|
||||
.header("Authorization", format!("Bearer {}", &api_key));
|
||||
|
||||
if is_anthropic {
|
||||
request = request.header("X-API-Key", &api_key);
|
||||
}
|
||||
|
||||
for (header_name, header_value) in headers.iter() {
|
||||
if header_name.to_string().starts_with("anthropic-") {
|
||||
request = request.header(header_name, header_value);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom headers from AI_HTTP_HEADERS environment variable
|
||||
for (header_name, header_value) in AI_HTTP_HEADERS.iter() {
|
||||
|
||||
@@ -83,6 +83,12 @@ pub struct QueryDynamicEnumJson {
|
||||
pub dynamic_enums_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct QueryButtonText {
|
||||
pub resume_button_text: Option<String>,
|
||||
pub cancel_button_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApprovalFormDetails {
|
||||
pub message_str: String,
|
||||
@@ -266,7 +272,12 @@ pub async fn get_approval_form_details(
|
||||
})
|
||||
});
|
||||
|
||||
let args_str = args.map_or("None".to_string(), |a| a.get().to_string());
|
||||
let args_str = args.map_or("None".to_string(), |a| {
|
||||
serde_json::from_str::<serde_json::Value>(a.get())
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
||||
.unwrap_or_else(|| a.get().to_string())
|
||||
});
|
||||
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
|
||||
let script_path_str = script_path.as_deref().unwrap_or("None");
|
||||
|
||||
@@ -282,7 +293,7 @@ pub async fn get_approval_form_details(
|
||||
{}: {created_by}\n\n\
|
||||
{}: {created_at_formatted}\n\n\
|
||||
{}: {script_path_str}\n\n\
|
||||
{}: {args_str}\n\n\
|
||||
{}:\n```\n{args_str}\n```\n\n\
|
||||
{}: {parent_job_id_str}\n\n",
|
||||
bold_format.replace("{}", "Created by"),
|
||||
bold_format.replace("{}", "Created at"),
|
||||
|
||||
@@ -39,8 +39,6 @@ use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use magic_crypt::MagicCryptTrait;
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_object_store::object_store_reexports::{Attribute, Attributes};
|
||||
#[cfg(feature = "parquet")]
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, value::RawValue};
|
||||
@@ -67,6 +65,8 @@ use windmill_common::{
|
||||
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
|
||||
HUB_BASE_URL,
|
||||
};
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_object_store::object_store_reexports::{Attribute, Attributes};
|
||||
use windmill_store::resources::get_resource_value_interpolated_internal;
|
||||
|
||||
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
@@ -75,11 +75,7 @@ use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
|
||||
#[cfg(feature = "parquet")]
|
||||
use hmac::Mac;
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_common::{
|
||||
jwt,
|
||||
oauth2::HmacSha256,
|
||||
variables::get_workspace_key,
|
||||
};
|
||||
use windmill_common::{jwt, oauth2::HmacSha256, variables::get_workspace_key};
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_types::s3::{S3Object, S3Permission};
|
||||
|
||||
@@ -279,6 +275,7 @@ pub struct CreateApp {
|
||||
pub draft_only: Option<bool>,
|
||||
pub deployment_message: Option<String>,
|
||||
pub custom_path: Option<String>,
|
||||
pub preserve_on_behalf_of: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -289,6 +286,7 @@ pub struct EditApp {
|
||||
pub policy: Option<Policy>,
|
||||
pub deployment_message: Option<String>,
|
||||
pub custom_path: Option<String>,
|
||||
pub preserve_on_behalf_of: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -443,7 +441,9 @@ async fn get_raw_app_data(
|
||||
if let Some(os) = object_store {
|
||||
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
|
||||
let stream = os
|
||||
.get(&windmill_object_store::object_store_reexports::Path::from(path))
|
||||
.get(&windmill_object_store::object_store_reexports::Path::from(
|
||||
path,
|
||||
))
|
||||
.await
|
||||
.map_err(windmill_object_store::object_store_error_to_error)?
|
||||
.bytes()
|
||||
@@ -958,7 +958,10 @@ async fn store_raw_app_file<'a>(
|
||||
|
||||
if let Some(os) = object_store {
|
||||
if let Err(e) = os
|
||||
.put(&windmill_object_store::object_store_reexports::Path::from(path.clone()), data.into())
|
||||
.put(
|
||||
&windmill_object_store::object_store_reexports::Path::from(path.clone()),
|
||||
data.into(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to put snapshot to s3 at {path}: {:?}", e);
|
||||
@@ -1178,8 +1181,14 @@ async fn create_app_internal<'a>(
|
||||
}
|
||||
}
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
|
||||
app.policy.on_behalf_of_email = Some(authed.email.clone());
|
||||
let should_preserve = app.preserve_on_behalf_of.unwrap_or(false)
|
||||
&& windmill_common::can_preserve_on_behalf_of(&authed)
|
||||
&& app.policy.on_behalf_of.is_some();
|
||||
|
||||
if !should_preserve {
|
||||
app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
|
||||
app.policy.on_behalf_of_email = Some(authed.email.clone());
|
||||
}
|
||||
let path = app.path.clone();
|
||||
if &app.path == "" {
|
||||
return Err(Error::BadRequest("App path cannot be empty".to_string()));
|
||||
@@ -1270,6 +1279,22 @@ async fn create_app_internal<'a>(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if should_preserve {
|
||||
if let Some(ref obo_email) = app.policy.on_behalf_of_email {
|
||||
if obo_email != &authed.email {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"apps.on_behalf_of",
|
||||
ActionKind::Create,
|
||||
w_id,
|
||||
Some(&app.path),
|
||||
Some([("on_behalf_of", obo_email.as_str()), ("action", "create")].into()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
|
||||
if let Some(dm) = &app.deployment_message {
|
||||
args.insert("deployment_message".to_string(), to_raw_value(&dm));
|
||||
@@ -1599,6 +1624,7 @@ async fn update_app_internal<'a>(
|
||||
use sql_builder::prelude::*;
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
|
||||
let mut preserved_on_behalf_of: Option<String> = None;
|
||||
let npath = if ns.policy.is_some()
|
||||
|| ns.path.is_some()
|
||||
|| ns.summary.is_some()
|
||||
@@ -1664,8 +1690,20 @@ async fn update_app_internal<'a>(
|
||||
}
|
||||
|
||||
if let Some(mut npolicy) = ns.policy {
|
||||
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
|
||||
npolicy.on_behalf_of_email = Some(authed.email.clone());
|
||||
let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false)
|
||||
&& windmill_common::can_preserve_on_behalf_of(&authed)
|
||||
&& npolicy.on_behalf_of.is_some();
|
||||
|
||||
if should_preserve {
|
||||
if let Some(ref obo_email) = npolicy.on_behalf_of_email {
|
||||
if obo_email != &authed.email {
|
||||
preserved_on_behalf_of = Some(obo_email.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
|
||||
npolicy.on_behalf_of_email = Some(authed.email.clone());
|
||||
}
|
||||
sqlb.set(
|
||||
"policy",
|
||||
quote(serde_json::to_string(&json!(npolicy)).map_err(|e| {
|
||||
@@ -1747,6 +1785,24 @@ async fn update_app_internal<'a>(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if let Some(on_behalf_of) = preserved_on_behalf_of {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"apps.on_behalf_of",
|
||||
ActionKind::Update,
|
||||
w_id,
|
||||
Some(&npath),
|
||||
Some(
|
||||
[
|
||||
("on_behalf_of", on_behalf_of.as_str()),
|
||||
("action", "update"),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let tx = PushIsolationLevel::Transaction(tx);
|
||||
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
|
||||
if let Some(dm) = ns.deployment_message {
|
||||
|
||||
@@ -15,7 +15,10 @@ use sqlx::{
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
pub use windmill_common::db::DB;
|
||||
use windmill_common::{error::Error, utils::{generate_lock_id, GIT_VERSION}};
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
utils::{generate_lock_id, GIT_VERSION},
|
||||
};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use windmill_api_auth::{ApiAuthed, OptJobAuthed};
|
||||
@@ -75,6 +78,12 @@ lazy_static::lazy_static! {
|
||||
(20260207000004, include_str!(
|
||||
"../../migrations/20260207000004_concurrent_indexes_other.up.sql"
|
||||
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
|
||||
(20260225100000, include_str!(
|
||||
"../../migrations/20260225100000_asset_covering_index.up.sql"
|
||||
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
|
||||
(20260228000000, include_str!(
|
||||
"../../migrations/20260228000000_v2_job_completed_failure_index.up.sql"
|
||||
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
|
||||
].into_iter().collect();
|
||||
}
|
||||
|
||||
@@ -255,8 +264,7 @@ pub async fn migrate(
|
||||
if let Err(err) = sqlx::query!(
|
||||
"DELETE FROM _sqlx_migrations WHERE
|
||||
version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR
|
||||
version=20250201145631 OR version=20250201145632 OR version=20251006143821 OR
|
||||
version=20260207000001 OR version=20260207000002 OR version=20260207000003 OR version=20260207000004"
|
||||
version=20250201145631 OR version=20250201145632 OR version=20251006143821"
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
@@ -264,6 +272,31 @@ pub async fn migrate(
|
||||
tracing::info!("Could not remove sqlx migrations: {err:#}");
|
||||
}
|
||||
|
||||
// For migrations that were replaced (same version, new content), only delete if
|
||||
// the stored checksum doesn't match the current file — i.e., it's a stale record
|
||||
// from the old broken version. Once the new migration is applied, the checksum
|
||||
// matches and the record is kept, avoiding expensive re-application on every start.
|
||||
let migrator = sqlx::migrate!("../migrations");
|
||||
let potentially_stale: &[i64] = &[
|
||||
20260207000001,
|
||||
20260207000002,
|
||||
20260207000003,
|
||||
20260207000004,
|
||||
];
|
||||
for m in migrator.migrations.iter() {
|
||||
if potentially_stale.contains(&m.version) {
|
||||
if let Err(err) =
|
||||
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = $1 AND checksum != $2")
|
||||
.bind(m.version)
|
||||
.bind(&*m.checksum)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::info!("Could not clean up stale migration {}: {err:#}", m.version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = killpill_rx.recv() => {
|
||||
tracing::info!("Killpill received, stopping migration");
|
||||
@@ -309,12 +342,11 @@ pub async fn wait_for_migrations(
|
||||
let mut attempts = 0;
|
||||
|
||||
loop {
|
||||
let is_applied: Result<Option<bool>, sqlx::Error> = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = $1)",
|
||||
)
|
||||
.bind(latest_version)
|
||||
.fetch_one(db)
|
||||
.await;
|
||||
let is_applied: Result<Option<bool>, sqlx::Error> =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = $1)")
|
||||
.bind(latest_version)
|
||||
.fetch_one(db)
|
||||
.await;
|
||||
|
||||
match is_applied {
|
||||
Ok(Some(true)) => {
|
||||
|
||||
@@ -27,20 +27,22 @@ use url::Url;
|
||||
#[cfg(all(feature = "enterprise", feature = "smtp"))]
|
||||
use windmill_common::auth::is_super_admin_email;
|
||||
use windmill_common::auth::TOKEN_PREFIX_LEN;
|
||||
#[cfg(feature = "inline_preview")]
|
||||
#[cfg(feature = "run_inline")]
|
||||
use windmill_common::client::AuthedClient;
|
||||
use windmill_common::db::UserDbWithAuthed;
|
||||
use windmill_common::error::JsonResult;
|
||||
use windmill_common::flow_status::{JobResult, RestartedFrom};
|
||||
#[cfg(feature = "inline_preview")]
|
||||
use windmill_common::jobs::RunInlinePreviewScriptFnParams;
|
||||
use windmill_common::jobs::{
|
||||
format_completed_job_result, format_result, DynamicInput, ENTRYPOINT_OVERRIDE,
|
||||
};
|
||||
#[cfg(feature = "run_inline")]
|
||||
use windmill_common::jobs::{
|
||||
InlineScriptTarget, RunInlinePreviewScriptFnParams, RunInlineScriptFnParams,
|
||||
};
|
||||
use windmill_common::runnable_settings::{
|
||||
ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings,
|
||||
};
|
||||
#[cfg(feature = "inline_preview")]
|
||||
#[cfg(feature = "run_inline")]
|
||||
use windmill_common::runtime_assets::{register_runtime_asset, InsertRuntimeAssetParams};
|
||||
use windmill_common::scripts::ScriptRunnableSettingsInline;
|
||||
use windmill_common::triggers::TriggerMetadata;
|
||||
@@ -53,15 +55,15 @@ use windmill_common::DYNAMIC_INPUT_CACHE;
|
||||
#[cfg(all(feature = "enterprise", feature = "smtp"))]
|
||||
use windmill_common::{email_oss::send_email_html, server::load_smtp_config};
|
||||
use windmill_object_store::upload_artifact_to_store;
|
||||
#[cfg(feature = "inline_preview")]
|
||||
#[cfg(feature = "run_inline")]
|
||||
use windmill_parser::asset_parser::AssetKind;
|
||||
use windmill_types::s3::BundleFormat;
|
||||
#[cfg(feature = "inline_preview")]
|
||||
#[cfg(feature = "run_inline")]
|
||||
use windmill_worker::get_worker_internal_server_inline_utils;
|
||||
|
||||
use windmill_common::variables::get_workspace_key;
|
||||
|
||||
#[cfg(feature = "inline_preview")]
|
||||
#[cfg(feature = "run_inline")]
|
||||
use crate::db::OptJobAuthed;
|
||||
use crate::triggers::trigger_helpers::{FlowId, ScriptId};
|
||||
use crate::{
|
||||
@@ -242,6 +244,11 @@ pub fn workspaced_service() -> Router {
|
||||
)
|
||||
.route("/run/preview", post(run_preview_script))
|
||||
.route("/run_inline/preview", post(run_inline_preview_script))
|
||||
.route(
|
||||
"/run_inline/p/*script_path",
|
||||
post(run_inline_script_by_path),
|
||||
)
|
||||
.route("/run_inline/h/:hash", post(run_inline_script_by_hash))
|
||||
.route(
|
||||
"/run_wait_result/preview",
|
||||
post(run_wait_result_preview_script),
|
||||
@@ -1933,7 +1940,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 {
|
||||
@@ -2055,6 +2062,8 @@ async fn list_jobs(
|
||||
|
||||
let sql = if lq.success.is_none()
|
||||
&& lq.label.is_none()
|
||||
&& lq.result.is_none()
|
||||
&& !lq.is_skipped.unwrap_or(false)
|
||||
&& lq.created_before.is_none()
|
||||
&& lq.started_before.is_none()
|
||||
&& lq.created_or_started_before.is_none()
|
||||
@@ -2853,7 +2862,7 @@ struct Preview {
|
||||
flow_path: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "inline_preview")]
|
||||
#[cfg(feature = "run_inline")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PreviewInline {
|
||||
content: String,
|
||||
@@ -2861,6 +2870,12 @@ struct PreviewInline {
|
||||
language: ScriptLang,
|
||||
}
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InlineScriptArgs {
|
||||
args: Option<HashMap<String, Box<JsonRawValue>>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkflowTask {
|
||||
pub args: Option<HashMap<String, Box<JsonRawValue>>>,
|
||||
@@ -4573,7 +4588,7 @@ async fn run_preview_script(
|
||||
Ok((StatusCode::CREATED, uuid.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(feature = "inline_preview")]
|
||||
#[cfg(feature = "run_inline")]
|
||||
async fn run_inline_preview_script(
|
||||
OptJobAuthed { authed, job_id }: OptJobAuthed,
|
||||
Tokened { token }: Tokened,
|
||||
@@ -4610,14 +4625,120 @@ async fn run_inline_preview_script(
|
||||
Ok(Json(to_raw_value(&result)).into_response())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "inline_preview"))]
|
||||
#[cfg(not(feature = "run_inline"))]
|
||||
async fn run_inline_preview_script() -> error::Result<Response> {
|
||||
Err(error::Error::InternalErr(
|
||||
"inline preview requires the worker feature".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "inline_preview")]
|
||||
#[cfg(feature = "run_inline")]
|
||||
async fn run_inline_script_by_path(
|
||||
OptJobAuthed { authed, .. }: OptJobAuthed,
|
||||
Tokened { token }: Tokened,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, script_path)): Path<(String, StripPath)>,
|
||||
Json(body): Json<InlineScriptArgs>,
|
||||
) -> error::Result<Response> {
|
||||
let script_path_str = script_path.to_path();
|
||||
check_scopes(&authed, || format!("jobs:run:scripts:{script_path_str}"))?;
|
||||
run_inline_script_inner(
|
||||
authed,
|
||||
token,
|
||||
db,
|
||||
w_id,
|
||||
InlineScriptTarget::Path(script_path.to_path().to_string()),
|
||||
body.args,
|
||||
Some(user_db),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "run_inline"))]
|
||||
async fn run_inline_script_by_path() -> error::Result<Response> {
|
||||
Err(error::Error::InternalErr(
|
||||
"inline script by path requires the worker feature".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
async fn run_inline_script_by_hash(
|
||||
OptJobAuthed { authed, .. }: OptJobAuthed,
|
||||
Tokened { token }: Tokened,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
|
||||
Json(body): Json<InlineScriptArgs>,
|
||||
) -> error::Result<Response> {
|
||||
// Resolve the script path from the hash and check scopes properly
|
||||
let hash = script_hash.0;
|
||||
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
let ScriptHashInfo { path, .. } =
|
||||
get_script_info_for_hash(Some(userdb_authed), &db, &w_id, hash)
|
||||
.await?
|
||||
.prefetch_cached(&db)
|
||||
.await?;
|
||||
|
||||
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
|
||||
|
||||
run_inline_script_inner(
|
||||
authed,
|
||||
token,
|
||||
db,
|
||||
w_id,
|
||||
InlineScriptTarget::Hash(hash),
|
||||
body.args,
|
||||
Some(user_db),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "run_inline"))]
|
||||
async fn run_inline_script_by_hash() -> error::Result<Response> {
|
||||
Err(error::Error::InternalErr(
|
||||
"inline script by hash requires the worker feature".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
async fn run_inline_script_inner(
|
||||
authed: ApiAuthed,
|
||||
token: String,
|
||||
db: DB,
|
||||
w_id: String,
|
||||
target: InlineScriptTarget,
|
||||
args: Option<HashMap<String, Box<JsonRawValue>>>,
|
||||
user_db: Option<UserDB>,
|
||||
) -> error::Result<Response> {
|
||||
let utils = get_worker_internal_server_inline_utils()?;
|
||||
let authed_owned: windmill_common::db::Authed = authed.clone().into();
|
||||
let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams {
|
||||
target,
|
||||
args,
|
||||
workspace_id: w_id.clone(),
|
||||
base_internal_url: utils.base_internal_url.clone(),
|
||||
killpill_rx: utils.killpill_rx.resubscribe(),
|
||||
created_by: authed.display_username().to_string(),
|
||||
permissioned_as: username_to_permissioned_as(&authed.username),
|
||||
permissioned_as_email: authed.email.clone(),
|
||||
job_dir: "".to_string(),
|
||||
worker_name: "".to_string(),
|
||||
worker_dir: "".to_string(),
|
||||
client: AuthedClient {
|
||||
base_internal_url: utils.base_internal_url.clone(),
|
||||
force_client: None,
|
||||
token,
|
||||
workspace: w_id,
|
||||
},
|
||||
conn: windmill_common::worker::Connection::Sql(db),
|
||||
user_db: user_db.map(|udb| (udb, authed_owned)),
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(to_raw_value(&result)).into_response())
|
||||
}
|
||||
|
||||
#[cfg(feature = "run_inline")]
|
||||
fn register_potential_assets_on_inline_execution(
|
||||
job_id: Uuid,
|
||||
w_id: &str,
|
||||
|
||||
@@ -387,10 +387,11 @@ async fn handle_authorization_code_grant(
|
||||
let token_family = sqlx::types::Uuid::new_v4();
|
||||
let scopes = auth_code.scopes;
|
||||
|
||||
// Create access token
|
||||
if let Err(e) = sqlx::query!(
|
||||
// Create access token (rejects archived workspaces inline)
|
||||
let rows = sqlx::query!(
|
||||
"INSERT INTO token (token, email, label, expiration, scopes, workspace_id)
|
||||
VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)",
|
||||
SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)",
|
||||
access_token,
|
||||
auth_code.user_email,
|
||||
format!("mcp-oauth-{}", auth_code.client_id),
|
||||
@@ -400,10 +401,13 @@ async fn handle_authorization_code_grant(
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to create access token: {}", e);
|
||||
return Err(OAuthTokenError::server_error(
|
||||
"Failed to create access token",
|
||||
OAuthTokenError::server_error("Failed to create access token")
|
||||
})?;
|
||||
if rows.rows_affected() == 0 {
|
||||
return Err(OAuthTokenError::invalid_grant(
|
||||
"Cannot create a token for an archived workspace",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -514,10 +518,11 @@ async fn handle_refresh_token_grant(
|
||||
let new_refresh_token = rd_string(32);
|
||||
let scopes = token_row.scopes;
|
||||
|
||||
// Create new access token
|
||||
if let Err(e) = sqlx::query!(
|
||||
// Create new access token (rejects archived workspaces inline)
|
||||
let rows = sqlx::query!(
|
||||
"INSERT INTO token (token, email, label, expiration, scopes, workspace_id)
|
||||
VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)",
|
||||
SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)",
|
||||
new_access_token,
|
||||
token_row.user_email,
|
||||
format!("mcp-oauth-{}", token_row.client_id),
|
||||
@@ -527,10 +532,13 @@ async fn handle_refresh_token_grant(
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to create new access token: {}", e);
|
||||
return Err(OAuthTokenError::server_error(
|
||||
"Failed to create access token",
|
||||
OAuthTokenError::server_error("Failed to create access token")
|
||||
})?;
|
||||
if rows.rows_affected() == 0 {
|
||||
return Err(OAuthTokenError::invalid_grant(
|
||||
"Cannot create a token for an archived workspace",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ use crate::jobs::{QueryApprover, ResumeUrls};
|
||||
use crate::{
|
||||
approvals::{
|
||||
extract_w_id_from_resume_url, handle_resume_action, ApprovalFormDetails, FieldType,
|
||||
MessageFormat, QueryDefaultArgsJson, QueryDynamicEnumJson, QueryFlowStepId, QueryMessage,
|
||||
ResumeFormField, ResumeSchema,
|
||||
MessageFormat, QueryButtonText, QueryDefaultArgsJson, QueryDynamicEnumJson,
|
||||
QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
|
||||
},
|
||||
auth::OptTokened,
|
||||
};
|
||||
@@ -107,6 +107,8 @@ struct ModalActionValue {
|
||||
flow_step_id: Option<String>,
|
||||
default_args_json: Option<String>,
|
||||
dynamic_enums_json: Option<String>,
|
||||
resume_button_text: Option<String>,
|
||||
cancel_button_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -200,6 +202,8 @@ pub async fn slack_app_callback_handler(
|
||||
container,
|
||||
default_args_json.as_ref(),
|
||||
dynamic_enums_json.as_ref(),
|
||||
parsed_value.resume_button_text.as_deref(),
|
||||
parsed_value.cancel_button_text.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
@@ -229,6 +233,7 @@ pub async fn request_slack_approval(
|
||||
Query(flow_step_id): Query<QueryFlowStepId>,
|
||||
Query(default_args_json): Query<QueryDefaultArgsJson>,
|
||||
Query(dynamic_enums_json): Query<QueryDynamicEnumJson>,
|
||||
Query(button_text): Query<QueryButtonText>,
|
||||
) -> Result<StatusCode, Error> {
|
||||
let slack_resource_path = slack_resource_path.slack_resource_path;
|
||||
let channel_id = channel_id.channel_id;
|
||||
@@ -255,6 +260,8 @@ pub async fn request_slack_approval(
|
||||
flow_step_id.as_str(),
|
||||
default_args_json.default_args_json.as_ref(),
|
||||
dynamic_enums_json.dynamic_enums_json.as_ref(),
|
||||
button_text.resume_button_text.as_deref(),
|
||||
button_text.cancel_button_text.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
@@ -752,6 +759,8 @@ async fn send_slack_message(
|
||||
flow_step_id: &str,
|
||||
default_args_json: Option<&serde_json::Value>,
|
||||
dynamic_enums_json: Option<&serde_json::Value>,
|
||||
resume_button_text: Option<&str>,
|
||||
cancel_button_text: Option<&str>,
|
||||
) -> Result<StatusCode, Box<dyn std::error::Error>> {
|
||||
let url = "https://slack.com/api/chat.postMessage";
|
||||
|
||||
@@ -779,6 +788,14 @@ async fn send_slack_message(
|
||||
value["dynamic_enums_json"] = dynamic_enums_json.clone();
|
||||
}
|
||||
|
||||
if let Some(resume_button_text) = resume_button_text {
|
||||
value["resume_button_text"] = serde_json::json!(resume_button_text);
|
||||
}
|
||||
|
||||
if let Some(cancel_button_text) = cancel_button_text {
|
||||
value["cancel_button_text"] = serde_json::json!(cancel_button_text);
|
||||
}
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": "A flow has been suspended. Please approve or reject the flow.",
|
||||
@@ -842,6 +859,8 @@ async fn get_modal_blocks(
|
||||
container: Container,
|
||||
default_args_json: Option<&serde_json::Value>,
|
||||
dynamic_enums_json: Option<&serde_json::Value>,
|
||||
resume_button_text: Option<&str>,
|
||||
cancel_button_text: Option<&str>,
|
||||
) -> Result<axum::Json<serde_json::Value>, Error> {
|
||||
let approval_details = crate::approvals::get_approval_form_details(
|
||||
db,
|
||||
@@ -895,6 +914,8 @@ async fn get_modal_blocks(
|
||||
&urls.resume,
|
||||
resource_path,
|
||||
container,
|
||||
resume_button_text,
|
||||
cancel_button_text,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -905,6 +926,8 @@ fn construct_payload(
|
||||
resume_url: &str,
|
||||
resource_path: &str,
|
||||
container: Container,
|
||||
resume_button_text: Option<&str>,
|
||||
cancel_button_text: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let mut view = serde_json::json!({
|
||||
"type": "modal",
|
||||
@@ -912,12 +935,12 @@ fn construct_payload(
|
||||
"notify_on_close": true,
|
||||
"title": {
|
||||
"type": "plain_text",
|
||||
"text": "Worfklow Suspended"
|
||||
"text": "Workflow Suspended"
|
||||
},
|
||||
"blocks": blocks,
|
||||
"submit": {
|
||||
"type": "plain_text",
|
||||
"text": "Resume Workflow"
|
||||
"text": resume_button_text.unwrap_or("Resume Workflow")
|
||||
},
|
||||
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel }).to_string(),
|
||||
});
|
||||
@@ -925,7 +948,7 @@ fn construct_payload(
|
||||
if !hide_cancel {
|
||||
view["close"] = serde_json::json!({
|
||||
"type": "plain_text",
|
||||
"text": "Cancel Workflow"
|
||||
"text": cancel_button_text.unwrap_or("Cancel Workflow")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -949,6 +972,8 @@ async fn open_modal_with_blocks(
|
||||
container: Container,
|
||||
default_args_json: Option<&serde_json::Value>,
|
||||
dynamic_enums_json: Option<&serde_json::Value>,
|
||||
resume_button_text: Option<&str>,
|
||||
cancel_button_text: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let resume_id = rand::random::<u32>();
|
||||
let blocks_json = match get_modal_blocks(
|
||||
@@ -964,6 +989,8 @@ async fn open_modal_with_blocks(
|
||||
container,
|
||||
default_args_json,
|
||||
dynamic_enums_json,
|
||||
resume_button_text,
|
||||
cancel_button_text,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -179,13 +179,12 @@ pub async fn benchmark_verify(benchmark_jobs: i32, db: &DB) {
|
||||
let canceled = row.canceled.unwrap_or(0);
|
||||
let total = succeeded + failed + canceled;
|
||||
|
||||
let remaining_in_queue = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = 'admins'",
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.expect("benchmark verify queue query failed")
|
||||
.unwrap_or(0);
|
||||
let remaining_in_queue =
|
||||
sqlx::query_scalar!("SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = 'admins'",)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.expect("benchmark verify queue query failed")
|
||||
.unwrap_or(0);
|
||||
|
||||
println!("=== BENCHMARK VERIFICATION ===");
|
||||
println!(" kind: {benchmark_kind}");
|
||||
@@ -248,10 +247,12 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up job_perms: {e:#}"));
|
||||
sqlx::query!("DELETE FROM concurrency_key WHERE key LIKE 'bench_%' OR key LIKE 'u/admin/bench_%'")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up concurrency_key: {e:#}"));
|
||||
sqlx::query!(
|
||||
"DELETE FROM concurrency_key WHERE key LIKE 'bench_%' OR key LIKE 'u/admin/bench_%'"
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up concurrency_key: {e:#}"));
|
||||
sqlx::query!("DELETE FROM concurrency_counter WHERE concurrency_id LIKE 'bench_%' OR concurrency_id LIKE 'u/admin/bench_%'")
|
||||
.execute(db)
|
||||
.await
|
||||
@@ -637,9 +638,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &noop_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed noop queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &noop_uuids)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&noop_uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed noop runtime"));
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert mixed noop runtime"));
|
||||
|
||||
// 2) sequentialflow jobs
|
||||
if portion > 0 {
|
||||
@@ -661,9 +666,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sf_uuids, "admins", "flow")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sf_uuids)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&sf_uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow runtime"));
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow runtime"));
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2",
|
||||
&sf_uuids,
|
||||
@@ -693,9 +702,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sl_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sl_uuids)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&sl_uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs runtime"));
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs runtime"));
|
||||
}
|
||||
|
||||
// 4) concurrencylimit jobs
|
||||
@@ -720,9 +733,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &cl_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &cl_uuids)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&cl_uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit runtime"));
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit runtime"));
|
||||
let cl_concurrency_id = "u/admin/bench_conclimit";
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
|
||||
@@ -763,9 +780,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &ck_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &ck_uuids)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&ck_uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey runtime"));
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey runtime"));
|
||||
let ck_concurrency_id = "bench_shared_concurrency_key";
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
|
||||
@@ -807,9 +828,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &noop_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &noop_uuids)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&noop_uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop runtime"));
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop runtime"));
|
||||
|
||||
// 2) sequentialflow jobs
|
||||
if portion > 0 {
|
||||
@@ -831,9 +856,15 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sf_uuids, "admins", "flow")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sf_uuids)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&sf_uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow runtime"));
|
||||
.await
|
||||
.unwrap_or_else(|_e| {
|
||||
panic!("failed to insert mixed_no_cc sequentialflow runtime")
|
||||
});
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2",
|
||||
&sf_uuids,
|
||||
@@ -863,9 +894,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sl_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sl_uuids)
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&sl_uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs runtime"));
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs runtime"));
|
||||
}
|
||||
}
|
||||
"none" => {}
|
||||
|
||||
@@ -17,6 +17,21 @@ pub struct Authed {
|
||||
pub token_prefix: Option<String>,
|
||||
}
|
||||
|
||||
impl Authed {
|
||||
pub fn to_authed_ref(&self) -> AuthedRef<'_> {
|
||||
AuthedRef {
|
||||
email: &self.email,
|
||||
username: &self.username,
|
||||
is_admin: &self.is_admin,
|
||||
is_operator: &self.is_operator,
|
||||
groups: &self.groups,
|
||||
folders: &self.folders,
|
||||
scopes: &self.scopes,
|
||||
token_prefix: &self.token_prefix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash)]
|
||||
pub struct AuthedRef<'a> {
|
||||
pub email: &'a str,
|
||||
|
||||
@@ -413,13 +413,19 @@ pub struct OAuthClient {
|
||||
pub id: String,
|
||||
pub secret: StringOrSecretRef,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub connect_config: Option<OAuthConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub login_config: Option<OAuthConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tenant: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub share_with_workspaces: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub grant_types: Vec<String>,
|
||||
}
|
||||
|
||||
/// OAuth provider endpoint configuration.
|
||||
@@ -438,6 +444,8 @@ pub struct OAuthConfig {
|
||||
pub extra_params_callback: Option<BTreeMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub req_body_auth: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub grant_types: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2231,10 +2239,13 @@ mod tests {
|
||||
secret: StringOrSecretRef::EnvRef(EnvRefWrapper {
|
||||
env_ref: "__WM_TEST_OAUTH_SECRET".to_string(),
|
||||
}),
|
||||
display_name: None,
|
||||
allowed_domains: None,
|
||||
connect_config: None,
|
||||
login_config: None,
|
||||
tenant: None,
|
||||
share_with_workspaces: None,
|
||||
grant_types: vec![],
|
||||
},
|
||||
);
|
||||
m
|
||||
|
||||
@@ -326,6 +326,28 @@ pub struct RunInlinePreviewScriptFnParams {
|
||||
pub killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
}
|
||||
|
||||
pub enum InlineScriptTarget {
|
||||
Path(String),
|
||||
Hash(i64),
|
||||
}
|
||||
|
||||
pub struct RunInlineScriptFnParams {
|
||||
pub workspace_id: String,
|
||||
pub target: InlineScriptTarget,
|
||||
pub args: Option<HashMap<String, Box<RawValue>>>,
|
||||
pub created_by: String,
|
||||
pub permissioned_as: String,
|
||||
pub permissioned_as_email: String,
|
||||
pub base_internal_url: String,
|
||||
pub worker_name: String,
|
||||
pub conn: crate::worker::Connection,
|
||||
pub client: AuthedClient,
|
||||
pub job_dir: String,
|
||||
pub worker_dir: String,
|
||||
pub killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
pub user_db: Option<(crate::db::UserDB, crate::db::Authed)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkerInternalServerInlineUtils {
|
||||
pub killpill_rx: Arc<tokio::sync::broadcast::Receiver<()>>,
|
||||
@@ -337,6 +359,13 @@ pub struct WorkerInternalServerInlineUtils {
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
pub run_inline_script: Arc<
|
||||
dyn Fn(
|
||||
RunInlineScriptFnParams,
|
||||
) -> Pin<Box<dyn Future<Output = error::Result<Box<RawValue>>> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
}
|
||||
// To run a script inline, bypassing the db and job queue, windmill-api uses these functions.
|
||||
// They should only be called by the internal server of a worker.
|
||||
|
||||
@@ -109,6 +109,50 @@ pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5;
|
||||
pub const DEFAULT_HUB_BASE_URL: &str = "https://hub.windmill.dev";
|
||||
pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
|
||||
pub const SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
|
||||
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
|
||||
|
||||
/// Checks if the user is allowed to preserve on_behalf_of values (admin or deployer).
|
||||
pub fn can_preserve_on_behalf_of(authed: &impl db::Authable) -> bool {
|
||||
authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP)
|
||||
}
|
||||
|
||||
/// Checks if on-behalf-of preservation actually happened (the target user differs from the acting user).
|
||||
/// Returns Some(target_identifier) if preservation occurred, None otherwise.
|
||||
pub fn check_on_behalf_of_preservation(
|
||||
on_behalf_of_identifier: Option<&str>,
|
||||
preserve: bool,
|
||||
authed: &impl db::Authable,
|
||||
authed_identifier: &str,
|
||||
) -> Option<String> {
|
||||
if preserve && can_preserve_on_behalf_of(authed) {
|
||||
if let Some(id) = on_behalf_of_identifier {
|
||||
if id != authed_identifier {
|
||||
return Some(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Determines the on_behalf_of_email value to use when creating/updating a flow or script.
|
||||
/// - If `on_behalf_of_email` is None, returns None
|
||||
/// - If `preserve` is true and the user is admin or in the deployers group, returns the original value
|
||||
/// - Otherwise, returns the authenticated user's email
|
||||
pub fn resolve_on_behalf_of_email<'a>(
|
||||
on_behalf_of_email: Option<&'a str>,
|
||||
preserve: bool,
|
||||
authed: &'a impl db::Authable,
|
||||
) -> Option<&'a str> {
|
||||
if on_behalf_of_email.is_some() {
|
||||
if preserve && can_preserve_on_behalf_of(authed) {
|
||||
on_behalf_of_email
|
||||
} else {
|
||||
Some(authed.email())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! add_time {
|
||||
|
||||
@@ -52,7 +52,10 @@ fn extract_assets_from_raw_value(
|
||||
if prefix {
|
||||
let s = serde_json::from_str::<String>(value.get()).ok()?;
|
||||
let (kind, path) = parse_asset_syntax(&s, false)?;
|
||||
assets.push(RuntimeAsset { path: path.to_string(), kind: crate::assets::asset_kind_from_parser(kind) });
|
||||
assets.push(RuntimeAsset {
|
||||
path: path.to_string(),
|
||||
kind: crate::assets::asset_kind_from_parser(kind),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -49,9 +49,7 @@ pub fn extract_workspace_dependencies_annotated_refs(
|
||||
Some(&RE_PYTHON),
|
||||
runnable_path,
|
||||
),
|
||||
Go => {
|
||||
WorkspaceDependenciesAnnotatedRefs::parse("//", "go_mod", code, None, runnable_path)
|
||||
}
|
||||
Go => WorkspaceDependenciesAnnotatedRefs::parse("//", "go_mod", code, None, runnable_path),
|
||||
Php => WorkspaceDependenciesAnnotatedRefs::parse(
|
||||
"//",
|
||||
"composer_json",
|
||||
@@ -67,11 +65,8 @@ pub async fn prefetch_cached_script(
|
||||
script: Script<ScriptRunnableSettingsHandle>,
|
||||
db: &DB,
|
||||
) -> crate::error::Result<Script<ScriptRunnableSettingsInline>> {
|
||||
let rs = runnable_settings::from_handle(
|
||||
script.runnable_settings.runnable_settings_handle,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
let rs = runnable_settings::from_handle(script.runnable_settings.runnable_settings_handle, db)
|
||||
.await?;
|
||||
let (debouncing_settings, concurrency_settings) =
|
||||
runnable_settings::prefetch_cached(&rs, db).await?;
|
||||
|
||||
@@ -379,11 +374,8 @@ pub async fn clone_script<'c>(
|
||||
)));
|
||||
};
|
||||
|
||||
let rs = runnable_settings::from_handle(
|
||||
s.runnable_settings.runnable_settings_handle,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
let rs =
|
||||
runnable_settings::from_handle(s.runnable_settings.runnable_settings_handle, db).await?;
|
||||
let (debouncing_settings, concurrency_settings) =
|
||||
runnable_settings::prefetch_cached(&rs, db).await?;
|
||||
|
||||
@@ -424,6 +416,7 @@ pub async fn clone_script<'c>(
|
||||
codebase: s.codebase,
|
||||
has_preprocessor: s.has_preprocessor,
|
||||
on_behalf_of_email: s.on_behalf_of_email,
|
||||
preserve_on_behalf_of: None,
|
||||
assets: s.assets,
|
||||
};
|
||||
|
||||
|
||||
@@ -202,6 +202,15 @@ impl StripPath {
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape ILIKE special characters (`%`, `_`, `\`) so user input is matched
|
||||
/// literally. Use this when building `ILIKE '%…%'` patterns from user-supplied
|
||||
/// strings to prevent wildcard injection.
|
||||
pub fn escape_ilike_pattern(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
pub fn require_admin(is_admin: bool, username: &str) -> Result<()> {
|
||||
if !is_admin {
|
||||
Err(Error::RequireAdmin(username.to_string()))
|
||||
|
||||
@@ -384,8 +384,11 @@ impl WorkspaceDependenciesPrefetched {
|
||||
|
||||
Box::pin(async {
|
||||
let r = if let Some(wdar) =
|
||||
crate::scripts::extract_workspace_dependencies_annotated_refs(&language, code, runnable_path)
|
||||
{
|
||||
crate::scripts::extract_workspace_dependencies_annotated_refs(
|
||||
&language,
|
||||
code,
|
||||
runnable_path,
|
||||
) {
|
||||
tracing::debug!(workspace_id, ?language, "found explicit annotations");
|
||||
|
||||
let expanded = wdar
|
||||
@@ -507,7 +510,7 @@ impl WorkspaceDependenciesPrefetched {
|
||||
// external.get(0).map(|wd| dbg!(wd.content.clone())).or(Some(
|
||||
// "
|
||||
// module mymod
|
||||
// go 1.25
|
||||
// go 1.26
|
||||
// require ()
|
||||
// "
|
||||
// .to_owned(),
|
||||
|
||||
@@ -157,6 +157,8 @@ pub struct GitRepositorySettings {
|
||||
pub use_individual_branch: Option<bool>,
|
||||
pub group_by_folder: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub force_branch: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settings: Option<GitSyncSettings>,
|
||||
}
|
||||
|
||||
|
||||
1491
backend/windmill-duckdb-ffi-internal/Cargo.lock
generated
1491
backend/windmill-duckdb-ffi-internal/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user