Compare commits
102 Commits
fileset-re
...
draft-pr-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afadad4770 | ||
|
|
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -130,4 +114,4 @@
|
||||
"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`
|
||||
|
||||
1
TEST_SHELL_QUOTING.md
Normal file
1
TEST_SHELL_QUOTING.md
Normal file
@@ -0,0 +1 @@
|
||||
# Test PR for shell-quoting bug verification
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user