mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-17 00:02:31 +00:00
Compare commits
69
Commits
| 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 |
Executable
+21
@@ -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
|
||||
+22
-8
@@ -30,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)",
|
||||
@@ -55,17 +63,23 @@
|
||||
"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": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-main-branch.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -238,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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
+31
-4
@@ -18,6 +18,9 @@ profiles:
|
||||
- R2_BUCKET
|
||||
- R2_PUBLIC_URL
|
||||
extraMounts:
|
||||
- hostPath: ~/.ssh
|
||||
guestPath: /root/.ssh
|
||||
writable: true
|
||||
- hostPath: ~/.codex
|
||||
guestPath: /root/.codex
|
||||
writable: true
|
||||
@@ -50,12 +53,11 @@ profiles:
|
||||
--endpoint-url "$(printenv R2_ENDPOINT)"
|
||||
3) The public URL will be:
|
||||
$(printenv R2_PUBLIC_URL)/<branch>/screenshot.png
|
||||
4) Include screenshots in PR descriptions as markdown images:
|
||||
/<branch>/screenshot.png)
|
||||
4) Include in PR descriptions using markdown image syntax.
|
||||
|
||||
--- Terminal Recordings (asciinema) ---
|
||||
You can record terminal sessions and upload them for sharing.
|
||||
asciinema is pre-installed at /usr/local/bin/asciinema.
|
||||
asciinema is available on PATH.
|
||||
|
||||
1) Write a shell script with the commands to demo. Add sleep
|
||||
delays for readable pacing:
|
||||
@@ -74,6 +76,31 @@ profiles:
|
||||
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
|
||||
alias: ee
|
||||
|
||||
+2
-1
@@ -55,7 +55,8 @@ panes:
|
||||
- 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."
|
||||
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 ${CARGO_FEATURES:+--features $CARGO_FEATURES}"'
|
||||
split: horizontal
|
||||
|
||||
@@ -1,5 +1,93 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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 \
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
git \
|
||||
iptables \
|
||||
gosu \
|
||||
sudo \
|
||||
unzip \
|
||||
# Rust native build deps (for cargo check)
|
||||
pkg-config \
|
||||
cmake \
|
||||
clang \
|
||||
mold \
|
||||
libtool \
|
||||
libssl-dev \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxslt1-dev \
|
||||
libffi-dev \
|
||||
zlib1g-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libclang-dev \
|
||||
libkrb5-dev \
|
||||
libsasl2-dev \
|
||||
# PostgreSQL (for local DB during development)
|
||||
postgresql \
|
||||
postgresql-client \
|
||||
# Node.js 22 (for npm run check / frontend dev)
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
# Container runs as arbitrary UIDs (--user uid:gid). These three lines make
|
||||
# sudo work for any UID:
|
||||
# 1) NOPASSWD rule so sudo never prompts for a password
|
||||
# 2) Writable passwd/group so the entrypoint can register the dynamic UID
|
||||
# 3) Writable shadow so unix_chkpwd can validate the account (without this,
|
||||
# sudo fails with "account validation failure, is your account locked?")
|
||||
&& echo "ALL ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/sandbox \
|
||||
&& chmod 0440 /etc/sudoers.d/sandbox \
|
||||
&& chmod 666 /etc/passwd /etc/group /etc/shadow
|
||||
|
||||
# ── GitHub CLI (for PR creation) ──────────────────────────────────────────────
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Rust toolchain ────────────────────────────────────────────────────────────
|
||||
# Install under /usr/local/lib/ so bins are world-readable with default umask.
|
||||
# CARGO_HOME is overridden to /tmp/.cargo at the end for mutable runtime state.
|
||||
ENV RUSTUP_HOME=/usr/local/lib/rustup CARGO_HOME=/usr/local/lib/cargo
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --default-toolchain stable --profile minimal && \
|
||||
ln -s /usr/local/lib/cargo/bin/* /usr/local/bin/
|
||||
RUN cargo install sqlx-cli --no-default-features --features native-tls,postgres && \
|
||||
cargo install cargo-watch && \
|
||||
cargo install --locked --git https://github.com/asciinema/asciinema && \
|
||||
ln -sf /usr/local/lib/cargo/bin/sqlx /usr/local/bin/sqlx && \
|
||||
ln -sf /usr/local/lib/cargo/bin/cargo-watch /usr/local/bin/cargo-watch && \
|
||||
ln -sf /usr/local/lib/cargo/bin/asciinema /usr/local/bin/asciinema
|
||||
|
||||
# ── Register dynamic runtime users ───────────────────────────────────────────
|
||||
RUN cat <<'SCRIPT' > /usr/local/bin/register-dynamic-user.sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
uid="${1:-}"
|
||||
gid="${2:-}"
|
||||
|
||||
if [ -z "$uid" ] || [ -z "$gid" ]; then
|
||||
echo "register-dynamic-user: usage: register-dynamic-user <uid> <gid>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! getent group "$gid" >/dev/null 2>&1; then
|
||||
echo "sandbox:x:${gid}:" >> /etc/group
|
||||
fi
|
||||
|
||||
if ! getent passwd "$uid" >/dev/null 2>&1; then
|
||||
echo "sandbox:x:${uid}:${gid}:sandbox:/tmp:/bin/sh" >> /etc/passwd
|
||||
fi
|
||||
|
||||
# Add a shadow entry ("*" = no password) so unix_chkpwd doesn't reject sudo.
|
||||
if ! grep -q "^sandbox:" /etc/shadow 2>/dev/null; then
|
||||
echo "sandbox:*:19000:0:99999:7:::" >> /etc/shadow
|
||||
fi
|
||||
SCRIPT
|
||||
RUN chmod +x /usr/local/bin/register-dynamic-user.sh
|
||||
|
||||
# ── Network init script (iptables firewall + privilege drop) ──────────────────
|
||||
RUN cat <<'SCRIPT' > /usr/local/bin/network-init.sh
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${WM_PROXY_HOST:-}" ] && [ -n "${WM_PROXY_PORT:-}" ]; then
|
||||
# Resolve hostnames to ALL IPs (multi-A records, round-robin DNS)
|
||||
PROXY_IPS=$(getent ahostsv4 "$WM_PROXY_HOST" | awk '{print $1}' | sort -u)
|
||||
RPC_HOST="${WM_RPC_HOST:-$WM_PROXY_HOST}"
|
||||
RPC_IPS=$(getent ahostsv4 "$RPC_HOST" | awk '{print $1}' | sort -u)
|
||||
|
||||
if [ -z "$PROXY_IPS" ] || [ -z "$RPC_IPS" ]; then
|
||||
echo "network-init: failed to resolve proxy/RPC host" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# IPv4: default deny outbound
|
||||
iptables -P OUTPUT DROP
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Allow DNS (UDP/TCP 53) to configured nameservers.
|
||||
if [ -f /etc/resolv.conf ]; then
|
||||
grep '^nameserver' /etc/resolv.conf | awk '{print $2}' | while read -r ns; do
|
||||
iptables -A OUTPUT -d "$ns" -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -d "$ns" -p tcp --dport 53 -j ACCEPT
|
||||
done
|
||||
fi
|
||||
|
||||
# Allow ALL resolved proxy IPs (handles multi-A DNS)
|
||||
for ip in $PROXY_IPS; do
|
||||
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_PROXY_PORT" -j ACCEPT
|
||||
done
|
||||
|
||||
# Allow ALL resolved RPC IPs
|
||||
if [ -n "${WM_RPC_PORT:-}" ]; then
|
||||
for ip in $RPC_IPS; do
|
||||
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_RPC_PORT" -j ACCEPT
|
||||
done
|
||||
fi
|
||||
|
||||
# Reject (not drop) everything else to fail fast instead of hanging
|
||||
iptables -A OUTPUT -j REJECT
|
||||
|
||||
# IPv6: block entirely to prevent leaks (fail closed)
|
||||
if ip6tables -L -n >/dev/null 2>&1; then
|
||||
ip6tables -P OUTPUT DROP
|
||||
ip6tables -A OUTPUT -o lo -j ACCEPT
|
||||
ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
ip6tables -A OUTPUT -j REJECT
|
||||
else
|
||||
if ! sysctl -w net.ipv6.conf.all.disable_ipv6=1 2>/dev/null; then
|
||||
echo "network-init: failed to block IPv6 (neither ip6tables nor sysctl available)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add sandbox user/group so sudo works after dropping privileges.
|
||||
if [ -z "${WM_TARGET_UID:-}" ] || [ -z "${WM_TARGET_GID:-}" ]; then
|
||||
echo "network-init: WM_TARGET_UID and WM_TARGET_GID are required" >&2
|
||||
exit 1
|
||||
fi
|
||||
/usr/local/bin/register-dynamic-user.sh "${WM_TARGET_UID}" "${WM_TARGET_GID}"
|
||||
|
||||
# Fix PTY ownership so the unprivileged user can read/write the terminal.
|
||||
if [ -t 0 ]; then
|
||||
chown "${WM_TARGET_UID}:${WM_TARGET_GID}" "$(tty)"
|
||||
fi
|
||||
|
||||
# Drop privileges and exec the user command.
|
||||
exec gosu "${WM_TARGET_UID}:${WM_TARGET_GID}" env HOME=/tmp "$@"
|
||||
SCRIPT
|
||||
RUN chmod +x /usr/local/bin/network-init.sh
|
||||
|
||||
# ── workmux (sandbox RPC) ────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://raw.githubusercontent.com/raine/workmux/main/scripts/install.sh | bash
|
||||
|
||||
# ── Claude Code ───────────────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash && \
|
||||
target="$(readlink -f /root/.local/bin/claude)" && \
|
||||
mv /root/.local/share/claude /usr/local/lib/claude && \
|
||||
ln -s "/usr/local/lib/claude/versions/$(basename "$target")" /usr/local/bin/claude && \
|
||||
mkdir -p /tmp/.local/bin && \
|
||||
ln -s /usr/local/bin/claude /tmp/.local/bin/claude && \
|
||||
chmod -R a+rwX /tmp/.local
|
||||
|
||||
# ── Codex ─────────────────────────────────────────────────────────────────────
|
||||
RUN npm i -g @openai/codex
|
||||
|
||||
# ── Bun ───────────────────────────────────────────────────────────────────────
|
||||
ENV BUN_INSTALL=/usr/local/lib/bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||
ln -s /usr/local/lib/bun/bin/bun /usr/local/bin/bun && \
|
||||
ln -s /usr/local/lib/bun/bin/bunx /usr/local/bin/bunx
|
||||
|
||||
# ── Playwright + Chromium (for screenshots) ──────────────────────────────────
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/lib/playwright-browsers
|
||||
RUN bun add -g @playwright/test \
|
||||
&& bunx playwright install chromium --with-deps \
|
||||
&& chmod -R a+rwX /usr/local/lib/playwright-browsers \
|
||||
&& chmod -R a+rwX /usr/local/lib/bun/install \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/bunx-*
|
||||
|
||||
# ── AWS CLI (for S3-compatible uploads to R2) ─────────────────────────────────
|
||||
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
|
||||
&& unzip -q /tmp/awscliv2.zip -d /tmp \
|
||||
&& /tmp/aws/install \
|
||||
&& rm -rf /tmp/aws /tmp/awscliv2.zip
|
||||
|
||||
ENV AWS_DEFAULT_REGION=auto
|
||||
|
||||
# ── Runtime env for arbitrary UID ─────────────────────────────────────────────
|
||||
# Mutable state goes to /tmp (writable by any UID). Toolchains stay read-only.
|
||||
ENV CARGO_HOME=/tmp/.cargo BUN_TMPDIR=/tmp
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────────────
|
||||
RUN cat <<'ENTRY' > /usr/local/bin/entrypoint.sh
|
||||
#!/bin/sh
|
||||
/usr/local/bin/register-dynamic-user.sh "$(id -u)" "$(id -g)"
|
||||
|
||||
# Start PostgreSQL (unix socket in /tmp, owned by postgres user)
|
||||
mkdir -p /tmp/pgdata && sudo chown postgres:postgres /tmp/pgdata
|
||||
if [ ! -f /tmp/pgdata/PG_VERSION ]; then
|
||||
sudo -u postgres /usr/lib/postgresql/15/bin/initdb -D /tmp/pgdata --auth=trust
|
||||
fi
|
||||
sudo -u postgres /usr/lib/postgresql/15/bin/pg_ctl -D /tmp/pgdata -l /tmp/pg.log start -o "-k /tmp"
|
||||
sudo -u postgres psql -h /tmp -c "CREATE ROLE sandbox SUPERUSER LOGIN" 2>/dev/null || true
|
||||
sudo -u postgres createdb -h /tmp windmill 2>/dev/null || true
|
||||
|
||||
# Run database migrations so sqlx compile-time checks work
|
||||
if [ -d "$PWD/backend/migrations" ]; then
|
||||
DATABASE_URL="postgres://sandbox@localhost/windmill?host=/tmp" \
|
||||
sqlx migrate run --source "$PWD/backend/migrations" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install frontend dependencies and generate backend client
|
||||
if [ -d "$PWD/frontend" ]; then
|
||||
(cd "$PWD/frontend" && npm install && npm run generate-backend-client) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
ENTRY
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1 @@
|
||||
# Test PR for shell-quoting bug verification
|
||||
+2
-1
@@ -20,7 +20,8 @@
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
-202
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"TextArray",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c624f15f3e321b1eecf123da9bf0b18e8c1d16ef25ffb9d04e5447d0d583d55c"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+7
-97
@@ -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`
|
||||
|
||||
Generated
+112
-102
@@ -490,7 +490,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"num",
|
||||
"regex",
|
||||
"regex-syntax 0.8.9",
|
||||
"regex-syntax 0.8.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -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]]
|
||||
@@ -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]]
|
||||
@@ -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",
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15789,7 +15796,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15802,7 +15809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15940,7 +15947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15963,7 +15970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15976,7 +15983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16002,7 +16009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -16012,7 +16019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16029,7 +16036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.644.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.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16075,7 +16082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16091,7 +16098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16111,7 +16118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16131,7 +16138,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16145,7 +16152,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.644.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.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16196,7 +16204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.644.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.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16234,7 +16243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16254,7 +16263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16284,7 +16293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16311,7 +16320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16323,7 +16332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.7.9",
|
||||
@@ -16346,7 +16355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16360,7 +16369,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16390,7 +16399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16404,7 +16413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16423,7 +16432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -16522,7 +16531,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -16541,7 +16550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16556,7 +16565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16580,7 +16589,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16597,7 +16606,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16613,7 +16622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16634,7 +16643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16665,7 +16674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-oauth2",
|
||||
@@ -16689,7 +16698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -16723,7 +16732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16741,7 +16750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16750,7 +16759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16762,7 +16771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16774,7 +16783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16786,7 +16795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16798,7 +16807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16810,7 +16819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16821,7 +16830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16832,7 +16841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16845,7 +16854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16869,7 +16878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16883,7 +16892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16900,7 +16909,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16915,7 +16924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16934,7 +16943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16945,7 +16954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16982,7 +16991,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -17020,8 +17029,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.644.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.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17059,7 +17069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -17082,7 +17092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17115,7 +17125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17135,7 +17145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17169,7 +17179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17204,7 +17214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17227,7 +17237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17251,7 +17261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -17275,7 +17285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17310,7 +17320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17338,7 +17348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17361,7 +17371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.4",
|
||||
@@ -17379,7 +17389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.644.0"
|
||||
version = "1.647.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -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",
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.644.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.644.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 @@
|
||||
a4546264d41ce7122dfd127f4b17f724eb63c40a
|
||||
8ffae1f43b31dc8136714fa612d22b6301773e27
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))?;
|
||||
|
||||
@@ -25,7 +25,9 @@ use windmill_common::{
|
||||
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};
|
||||
@@ -592,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)]
|
||||
@@ -651,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)
|
||||
|
||||
@@ -2863,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(())
|
||||
}
|
||||
|
||||
@@ -3362,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
|
||||
@@ -3502,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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.644.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
|
||||
@@ -9163,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
|
||||
@@ -9996,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
|
||||
@@ -11220,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
|
||||
@@ -16981,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
|
||||
@@ -19786,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:
|
||||
@@ -23212,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() {
|
||||
|
||||
@@ -78,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -510,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>,
|
||||
}
|
||||
|
||||
|
||||
+1257
-234
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.41"
|
||||
duckdb = { rev = "fe0702529de6ec5a568337726bba9355503157d2", git = "https://github.com/windmill-labs/duckdb-rs.git", features = ["bundled"] }
|
||||
duckdb = { version = "1.4.4", features = ["bundled"] }
|
||||
rust_decimal = "1.37.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::{
|
||||
ptr::null_mut,
|
||||
};
|
||||
|
||||
use duckdb::{Row, params_from_iter, types::TimeUnit};
|
||||
use duckdb::{Row, core::LogicalTypeId, params_from_iter, types::TimeUnit};
|
||||
use rust_decimal::{Decimal, prelude::FromPrimitive};
|
||||
use serde::Deserialize;
|
||||
use serde_json::value::RawValue;
|
||||
@@ -267,7 +267,10 @@ fn do_duckdb_inner(
|
||||
(0..stmt.column_count())
|
||||
.map(|i| {
|
||||
let logical_type = stmt.column_logical_type(i);
|
||||
if logical_type.is_invalid() {
|
||||
let logical_type_id = logical_type.id();
|
||||
let invalid = logical_type_id == LogicalTypeId::Invalid
|
||||
|| logical_type_id == LogicalTypeId::Unsupported;
|
||||
if invalid {
|
||||
None
|
||||
} else {
|
||||
logical_type.get_alias()
|
||||
|
||||
@@ -274,16 +274,19 @@ pub async fn eval_timeout_quickjs(
|
||||
|
||||
let expr_clone = expr.clone();
|
||||
|
||||
// Run the QuickJS evaluation with a timeout
|
||||
// Run the QuickJS evaluation with a timeout.
|
||||
// Use the current runtime handle rather than creating an independent
|
||||
// current-thread runtime so that HTTP connections opened by the global
|
||||
// reqwest client (HTTP_CLIENT) are managed by the main runtime.
|
||||
// Creating a child runtime caused connection-pool dispatch tasks to be
|
||||
// dropped when the child runtime exited, poisoning pooled connections
|
||||
// and producing spurious "DispatchGone" / "runtime dropped the dispatch
|
||||
// task" errors on subsequent requests from the main runtime.
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Create a new tokio runtime for async operations within the blocking context
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
rt.block_on(async move {
|
||||
handle.block_on(async move {
|
||||
eval_quickjs_inner(
|
||||
&expr_clone,
|
||||
filtered_context,
|
||||
@@ -300,7 +303,9 @@ pub async fn eval_timeout_quickjs(
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("The expression evaluation `{expr}` took too long to execute (>{EVAL_TIMEOUT_MS}ms)")
|
||||
anyhow::anyhow!(
|
||||
"The expression evaluation `{expr}` took too long to execute (>{EVAL_TIMEOUT_MS}ms)"
|
||||
)
|
||||
})??
|
||||
}
|
||||
|
||||
@@ -787,13 +792,11 @@ pub async fn eval_simple_js(
|
||||
expr: String,
|
||||
globals: HashMap<String, serde_json::Value>,
|
||||
) -> anyhow::Result<Box<RawValue>> {
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
rt.block_on(async move {
|
||||
handle.block_on(async move {
|
||||
let runtime = AsyncRuntime::new()?;
|
||||
runtime.set_memory_limit(QUICKJS_MEMORY_LIMIT).await;
|
||||
let context = AsyncContext::full(&runtime).await?;
|
||||
|
||||
@@ -34,8 +34,8 @@ use windmill_api_auth::ApiAuthed;
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use crate::{
|
||||
decrypt_oauth_data, delete_token_by_prefix, delete_workspace_integration, resolve_endpoint,
|
||||
store_workspace_integration, ServiceName,
|
||||
decrypt_oauth_data, delete_token_by_prefix, delete_workspace_integration,
|
||||
nextcloud::OcsResponse, resolve_endpoint, store_workspace_integration, ServiceName,
|
||||
};
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
@@ -233,6 +233,21 @@ async fn try_delete_nextcloud_webhook(base_url: &str, access_token: &str, extern
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn fetch_nextcloud_user_id(base_url: &str, access_token: &str) -> anyhow::Result<String> {
|
||||
let url = format!("{}/ocs/v2.php/cloud/user", base_url);
|
||||
let resp = HTTP_CLIENT
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let ocs: OcsResponse<NextcloudUserData> = resp.json().await?;
|
||||
Ok(ocs.ocs.data.id)
|
||||
}
|
||||
|
||||
/// Delete all native triggers for a workspace+service, including remote webhook cleanup.
|
||||
/// This is best-effort: errors during remote cleanup or token deletion are logged but ignored.
|
||||
#[cfg(feature = "native_trigger")]
|
||||
@@ -445,6 +460,12 @@ struct OAuthCallbackBody {
|
||||
resource_path: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NextcloudUserData {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn oauth_callback(
|
||||
authed: ApiAuthed,
|
||||
@@ -542,7 +563,27 @@ async fn oauth_callback(
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to create variable: {}", e)))?;
|
||||
|
||||
// 3. Create resource pointing to the variable
|
||||
let resource_value = json!({ "token": format!("$var:{}", resource_path) });
|
||||
let resource_value = if service_name == ServiceName::Nextcloud {
|
||||
let token_value = format!("$var:{}", resource_path);
|
||||
let base_url = &oauth_config.base_url;
|
||||
let user_id = fetch_nextcloud_user_id(base_url, &token_response.access_token).await;
|
||||
match user_id {
|
||||
Ok(user_id) => json!({
|
||||
"token": token_value,
|
||||
"baseUrl": base_url,
|
||||
"userId": user_id,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch Nextcloud user info: {e}");
|
||||
json!({
|
||||
"token": token_value,
|
||||
"baseUrl": base_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json!({ "token": format!("$var:{}", resource_path) })
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource (workspace_id, path, value, resource_type, description, created_by)
|
||||
|
||||
@@ -2285,6 +2285,59 @@ pub struct MiniPulledJob {
|
||||
pub runnable_settings_handle: Option<i64>,
|
||||
}
|
||||
|
||||
impl MiniPulledJob {
|
||||
pub fn new_inline(
|
||||
workspace_id: String,
|
||||
args: Option<HashMap<String, Box<RawValue>>>,
|
||||
created_by: String,
|
||||
permissioned_as: String,
|
||||
permissioned_as_email: String,
|
||||
runnable_path: Option<String>,
|
||||
kind: JobKind,
|
||||
runnable_id: Option<ScriptHash>,
|
||||
tag: String,
|
||||
script_lang: Option<ScriptLang>,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace_id,
|
||||
id: Uuid::new_v4(),
|
||||
args: args.map(Json),
|
||||
parent_job: None,
|
||||
created_by,
|
||||
scheduled_for: chrono::Utc::now(),
|
||||
started_at: None,
|
||||
runnable_path,
|
||||
kind,
|
||||
runnable_id,
|
||||
canceled_reason: None,
|
||||
canceled_by: None,
|
||||
permissioned_as,
|
||||
permissioned_as_email,
|
||||
flow_status: None,
|
||||
tag,
|
||||
script_lang,
|
||||
same_worker: true,
|
||||
pre_run_error: None,
|
||||
flow_innermost_root_job: None,
|
||||
root_job: None,
|
||||
timeout: None,
|
||||
flow_step_id: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
priority: None,
|
||||
preprocessed: None,
|
||||
script_entrypoint_override: None,
|
||||
trigger: None,
|
||||
trigger_kind: None,
|
||||
visible_to_owner: false,
|
||||
permissioned_as_end_user_email: None,
|
||||
runnable_settings_handle: None,
|
||||
concurrent_limit: None,
|
||||
concurrency_time_window_s: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MiniCompletedJob {
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -17,7 +17,7 @@ use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, R
|
||||
|
||||
use crate::secret_backend_ext::rename_vault_secret;
|
||||
use crate::var_resource_cache::{cache_resource, get_cached_resource};
|
||||
use windmill_common::utils::BulkDeleteRequest;
|
||||
use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest};
|
||||
use windmill_common::webhook::{WebhookMessage, WebhookShared};
|
||||
|
||||
use axum::{
|
||||
@@ -170,6 +170,7 @@ pub struct ListResourceQuery {
|
||||
pub description: Option<String>,
|
||||
// filter by matching a subset of the value using base64 encoded json subset
|
||||
pub value: Option<String>,
|
||||
pub broad_filter: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -294,13 +295,22 @@ async fn list_resources(
|
||||
}
|
||||
|
||||
if let Some(description) = &lq.description {
|
||||
sqlb.and_where("resource.description ILIKE ?".bind(&format!("%{}%", description)));
|
||||
let pat = format!("%{}%", escape_ilike_pattern(description));
|
||||
sqlb.and_where("resource.description ILIKE ?".bind(&pat));
|
||||
}
|
||||
|
||||
if let Some(value) = &lq.value {
|
||||
sqlb.and_where("resource.value @> ?".bind(&value.replace("'", "''")));
|
||||
}
|
||||
|
||||
if let Some(broad_filter) = &lq.broad_filter {
|
||||
let pat = format!("%{}%", escape_ilike_pattern(broad_filter));
|
||||
sqlb.and_where(
|
||||
"(resource.path ILIKE ? OR resource.description ILIKE ? OR resource_type ILIKE ? OR resource.value::text ILIKE ?)"
|
||||
.bind(&pat).bind(&pat).bind(&pat).bind(&pat)
|
||||
);
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query_as::<_, ListableResource>(&sql)
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::secret_backend_ext::{
|
||||
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
|
||||
store_secret_value,
|
||||
};
|
||||
use windmill_common::utils::BulkDeleteRequest;
|
||||
use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest};
|
||||
use windmill_common::webhook::{WebhookMessage, WebhookShared};
|
||||
|
||||
use axum::{
|
||||
@@ -101,6 +101,7 @@ struct ListVariableQuery {
|
||||
pub description: Option<String>,
|
||||
// filter by matching the non-encrypted value (for non-secrets only)
|
||||
pub value: Option<String>,
|
||||
pub broad_filter: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_variables(
|
||||
@@ -161,18 +162,22 @@ async fn list_variables(
|
||||
}
|
||||
|
||||
if let Some(description) = &lq.description {
|
||||
sqlb.and_where(&format!(
|
||||
"variable.description ILIKE '%{}%'",
|
||||
description.replace("'", "''")
|
||||
));
|
||||
let pat = format!("%{}%", escape_ilike_pattern(description));
|
||||
sqlb.and_where("variable.description ILIKE ?".bind(&pat));
|
||||
}
|
||||
|
||||
if let Some(value) = &lq.value {
|
||||
// Only filter on non-secret variables' value
|
||||
sqlb.and_where(&format!(
|
||||
"(is_secret = FALSE AND variable.value ILIKE '%{}%')",
|
||||
value.replace("'", "''")
|
||||
));
|
||||
let pat = format!("%{}%", escape_ilike_pattern(value));
|
||||
sqlb.and_where("(is_secret = FALSE AND variable.value ILIKE ?)".bind(&pat));
|
||||
}
|
||||
|
||||
if let Some(broad_filter) = &lq.broad_filter {
|
||||
let pat = format!("%{}%", escape_ilike_pattern(broad_filter));
|
||||
sqlb.and_where(
|
||||
"(variable.path ILIKE ? OR variable.description ILIKE ? OR (is_secret = FALSE AND variable.value ILIKE ?))"
|
||||
.bind(&pat).bind(&pat).bind(&pat)
|
||||
);
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
|
||||
@@ -15,6 +15,8 @@ enterprise = []
|
||||
python = ["windmill-common/python"]
|
||||
deno_core = ["dep:windmill-runtime-nativets"]
|
||||
agent_worker_server = ["dep:windmill-api-agent-workers"]
|
||||
run_inline = ["windmill-api/run_inline"]
|
||||
duckdb = ["windmill-worker/duckdb"]
|
||||
|
||||
[dependencies]
|
||||
windmill-api = { workspace = true, default-features = false }
|
||||
|
||||
@@ -753,8 +753,7 @@ mod tests {
|
||||
buf.extend_from_slice(b"users\0"); // name
|
||||
buf.push(REPLICA_IDENTITY_DEFAULT_BYTE as u8); // replica identity
|
||||
buf.extend_from_slice(&1i16.to_be_bytes()); // num columns
|
||||
buf.extend_from_slice(&1i16.to_be_bytes()); // num columns
|
||||
// column: flags=0, name="id", type_oid=23 (INT4), type_modifier=-1
|
||||
// column: flags=0, name="id", type_oid=23 (INT4), type_modifier=-1
|
||||
buf.push(0); // flags
|
||||
buf.extend_from_slice(b"id\0"); // name
|
||||
buf.extend_from_slice(&23u32.to_be_bytes()); // type_oid (INT4)
|
||||
|
||||
@@ -24,16 +24,16 @@ use tokio_postgres::{
|
||||
use uuid::Uuid;
|
||||
use windmill_common::error::to_anyhow;
|
||||
use windmill_common::error::{self, Error};
|
||||
use windmill_object_store::convert_json_line_stream;
|
||||
use windmill_common::worker::{
|
||||
to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
|
||||
use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult};
|
||||
use windmill_object_store::convert_json_line_stream;
|
||||
use windmill_parser::{Arg, Typ};
|
||||
use windmill_parser_sql::{
|
||||
parse_db_resource, parse_pg_statement_arg_indices, parse_pgsql_sig, parse_s3_mode,
|
||||
parse_sql_blocks,
|
||||
parse_db_resource, parse_pg_statement_arg_indices, parse_pgsql_sig_with_typed_schema,
|
||||
parse_s3_mode, parse_sql_blocks,
|
||||
};
|
||||
use windmill_queue::{CanceledBy, MiniPulledJob};
|
||||
|
||||
@@ -58,6 +58,42 @@ lazy_static! {
|
||||
pub static ref LAST_QUERY: AtomicU64 = AtomicU64::new(0);
|
||||
}
|
||||
|
||||
fn otyp_to_pg_type(otyp: &str) -> error::Result<Type> {
|
||||
let base = otyp.trim_end_matches("[]");
|
||||
let is_array = otyp.ends_with("[]");
|
||||
|
||||
let (scalar, array) = match base {
|
||||
"bool" | "boolean" => (Type::BOOL, Type::BOOL_ARRAY),
|
||||
"char" | "character" => (Type::CHAR, Type::CHAR_ARRAY),
|
||||
"smallint" | "smallserial" | "int2" | "serial2" => (Type::INT2, Type::INT2_ARRAY),
|
||||
"int" | "integer" | "int4" | "serial" => (Type::INT4, Type::INT4_ARRAY),
|
||||
"bigint" | "bigserial" | "int8" | "serial8" => (Type::INT8, Type::INT8_ARRAY),
|
||||
"real" | "float4" => (Type::FLOAT4, Type::FLOAT4_ARRAY),
|
||||
"double" | "float8" => (Type::FLOAT8, Type::FLOAT8_ARRAY),
|
||||
"numeric" | "decimal" => (Type::NUMERIC, Type::NUMERIC_ARRAY),
|
||||
"text" => (Type::TEXT, Type::TEXT_ARRAY),
|
||||
"varchar" | "character varying" => (Type::VARCHAR, Type::VARCHAR_ARRAY),
|
||||
"uuid" => (Type::UUID, Type::UUID_ARRAY),
|
||||
"date" => (Type::DATE, Type::DATE_ARRAY),
|
||||
"time" => (Type::TIME, Type::TIME_ARRAY),
|
||||
"timetz" => (Type::TIMETZ, Type::TIMETZ_ARRAY),
|
||||
"timestamp" => (Type::TIMESTAMP, Type::TIMESTAMP_ARRAY),
|
||||
"timestamptz" => (Type::TIMESTAMPTZ, Type::TIMESTAMPTZ_ARRAY),
|
||||
"json" => (Type::JSON, Type::JSON_ARRAY),
|
||||
"jsonb" => (Type::JSONB, Type::JSONB_ARRAY),
|
||||
"bytea" => (Type::BYTEA, Type::BYTEA_ARRAY),
|
||||
"oid" => (Type::OID, Type::OID_ARRAY),
|
||||
_ => {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Unsupported PostgreSQL type for typed schema: {}",
|
||||
otyp
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(if is_array { array } else { scalar })
|
||||
}
|
||||
|
||||
fn do_postgresql_inner<'a>(
|
||||
mut query: String,
|
||||
param_idx_to_arg_and_value: &HashMap<i32, (&Arg, Option<&Value>)>,
|
||||
@@ -67,8 +103,10 @@ fn do_postgresql_inner<'a>(
|
||||
skip_collect: bool,
|
||||
first_row_only: bool,
|
||||
s3: Option<S3ModeWorkerData>,
|
||||
typed_schema: bool,
|
||||
) -> error::Result<BoxFuture<'a, error::Result<Vec<Box<RawValue>>>>> {
|
||||
let mut query_params = vec![];
|
||||
let mut param_types = vec![];
|
||||
|
||||
let arg_indices = parse_pg_statement_arg_indices(&query);
|
||||
|
||||
@@ -86,13 +124,14 @@ fn do_postgresql_inner<'a>(
|
||||
let typ = &arg.typ;
|
||||
let param = convert_val(value, arg_t, typ)?;
|
||||
query_params.push(param);
|
||||
if typed_schema {
|
||||
param_types.push(otyp_to_pg_type(arg_t)?);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let result_f = async move {
|
||||
// Now we can execute a simple statement that just returns its parameter.
|
||||
|
||||
let mut res: Vec<Box<serde_json::value::RawValue>> = vec![];
|
||||
|
||||
let query_params = query_params
|
||||
@@ -100,14 +139,23 @@ fn do_postgresql_inner<'a>(
|
||||
.map(|p| &**p as &(dyn ToSql + Sync))
|
||||
.collect_vec();
|
||||
|
||||
let statement = if typed_schema {
|
||||
client
|
||||
.prepare_typed(&query, ¶m_types)
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
} else {
|
||||
client.prepare(&query).await.map_err(to_anyhow)?
|
||||
};
|
||||
|
||||
if skip_collect {
|
||||
client
|
||||
.execute_raw(&query, query_params)
|
||||
.execute_raw(&statement, query_params)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
} else if let Some(ref s3) = s3 {
|
||||
let rows_stream = client
|
||||
.query_raw(&query, query_params)
|
||||
.query_raw(&statement, query_params)
|
||||
.map_err(to_anyhow)
|
||||
.await?
|
||||
.map_err(to_anyhow)
|
||||
@@ -121,7 +169,7 @@ fn do_postgresql_inner<'a>(
|
||||
return Ok(vec![to_raw_value(&s3.to_return_s3_obj())]);
|
||||
} else {
|
||||
let rows = client
|
||||
.query_raw(&query, query_params)
|
||||
.query_raw(&statement, query_params)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
|
||||
@@ -272,7 +320,8 @@ pub async fn do_postgresql(
|
||||
(Some((client, handle)), None)
|
||||
};
|
||||
|
||||
let sig = parse_pgsql_sig(&query).map_err(|x| Error::ExecutionErr(x.to_string()))?;
|
||||
let (sig, typed_schema) = parse_pgsql_sig_with_typed_schema(&query)
|
||||
.map_err(|x| Error::ExecutionErr(x.to_string()))?;
|
||||
|
||||
let reserved_variables =
|
||||
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
|
||||
@@ -346,6 +395,7 @@ pub async fn do_postgresql(
|
||||
&& i < queries.len() - 1,
|
||||
collection_strategy.collect_first_row_only(),
|
||||
s3.clone(),
|
||||
typed_schema,
|
||||
)?
|
||||
.await?;
|
||||
results.push(result);
|
||||
|
||||
@@ -14,6 +14,10 @@ use futures::TryFutureExt;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
use windmill_common::client::AuthedClient;
|
||||
use windmill_common::db::UserDbWithAuthed;
|
||||
use windmill_common::get_latest_deployed_hash_for_path;
|
||||
use windmill_common::jobs::InlineScriptTarget;
|
||||
use windmill_common::jobs::RunInlineScriptFnParams;
|
||||
use windmill_common::jobs::WorkerInternalServerInlineUtils;
|
||||
use windmill_common::jobs::WORKER_INTERNAL_SERVER_INLINE_UTILS;
|
||||
use windmill_common::runtime_assets::init_runtime_asset_loop;
|
||||
@@ -4705,43 +4709,18 @@ pub fn init_worker_internal_server_inline_utils(
|
||||
base_internal_url,
|
||||
killpill_rx: Arc::new(killpill_rx),
|
||||
run_inline_preview_script: Arc::new(|params| {
|
||||
let job = MiniPulledJob {
|
||||
workspace_id: params.workspace_id,
|
||||
id: Uuid::new_v4(),
|
||||
args: params.args.map(Json),
|
||||
parent_job: None,
|
||||
created_by: params.created_by,
|
||||
scheduled_for: chrono::Utc::now(),
|
||||
started_at: None,
|
||||
runnable_path: None,
|
||||
kind: JobKind::Preview,
|
||||
runnable_id: None,
|
||||
canceled_reason: None,
|
||||
canceled_by: None,
|
||||
permissioned_as: params.permissioned_as,
|
||||
permissioned_as_email: params.permissioned_as_email,
|
||||
flow_status: None,
|
||||
tag: "inline_preview".to_string(),
|
||||
script_lang: Some(params.lang),
|
||||
same_worker: true,
|
||||
pre_run_error: None,
|
||||
flow_innermost_root_job: None,
|
||||
root_job: None,
|
||||
timeout: None,
|
||||
flow_step_id: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
priority: None,
|
||||
preprocessed: None,
|
||||
script_entrypoint_override: None,
|
||||
trigger: None,
|
||||
trigger_kind: None,
|
||||
visible_to_owner: false,
|
||||
permissioned_as_end_user_email: None,
|
||||
runnable_settings_handle: None,
|
||||
concurrent_limit: None,
|
||||
concurrency_time_window_s: None,
|
||||
};
|
||||
let job = MiniPulledJob::new_inline(
|
||||
params.workspace_id,
|
||||
params.args,
|
||||
params.created_by,
|
||||
params.permissioned_as,
|
||||
params.permissioned_as_email,
|
||||
None,
|
||||
JobKind::Preview,
|
||||
None,
|
||||
"inline_preview".to_string(),
|
||||
Some(params.lang),
|
||||
);
|
||||
Box::pin(async move {
|
||||
let mut mem_peak: i32 = -1;
|
||||
let mut canceled_by: Option<CanceledBy> = None;
|
||||
@@ -4778,6 +4757,86 @@ pub fn init_worker_internal_server_inline_utils(
|
||||
.await
|
||||
})
|
||||
}),
|
||||
run_inline_script: Arc::new(|params: RunInlineScriptFnParams| {
|
||||
Box::pin(async move {
|
||||
let (script_hash, runnable_path) = match params.target {
|
||||
InlineScriptTarget::Path(ref path) => {
|
||||
let db = params
|
||||
.conn
|
||||
.as_sql()
|
||||
.ok_or_else(|| {
|
||||
error::Error::InternalErr(
|
||||
"run_inline_script by path requires a SQL connection"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let authed_ref = params.user_db.as_ref().map(|(_, a)| a.to_authed_ref());
|
||||
let user_db_authed =
|
||||
params.user_db.as_ref().zip(authed_ref.as_ref()).map(
|
||||
|((udb, _), ar)| UserDbWithAuthed { db: udb.clone(), authed: ar },
|
||||
);
|
||||
let script_hash_info = get_latest_deployed_hash_for_path(
|
||||
user_db_authed,
|
||||
db,
|
||||
¶ms.workspace_id,
|
||||
path,
|
||||
)
|
||||
.await?;
|
||||
(ScriptHash(script_hash_info.hash), Some(path.clone()))
|
||||
}
|
||||
InlineScriptTarget::Hash(hash) => (ScriptHash(hash), None),
|
||||
};
|
||||
let content_info =
|
||||
get_script_content_by_hash(&script_hash, ¶ms.workspace_id, ¶ms.conn)
|
||||
.await?;
|
||||
let job = MiniPulledJob::new_inline(
|
||||
params.workspace_id,
|
||||
params.args,
|
||||
params.created_by,
|
||||
params.permissioned_as,
|
||||
params.permissioned_as_email,
|
||||
runnable_path,
|
||||
JobKind::Script,
|
||||
Some(script_hash),
|
||||
"inline_run".to_string(),
|
||||
content_info.language,
|
||||
);
|
||||
let mut mem_peak: i32 = -1;
|
||||
let mut canceled_by: Option<CanceledBy> = None;
|
||||
let mut column_order: Option<Vec<String>> = None;
|
||||
let mut new_args: Option<HashMap<String, Box<RawValue>>> = None;
|
||||
let mut occupancy_metrics = OccupancyMetrics::new(Instant::now());
|
||||
let mut has_stream: bool = false;
|
||||
let mut killpill_rx = params.killpill_rx;
|
||||
|
||||
run_language_executor(
|
||||
&job,
|
||||
¶ms.conn,
|
||||
¶ms.client,
|
||||
None,
|
||||
¶ms.job_dir,
|
||||
¶ms.worker_dir,
|
||||
&mut mem_peak,
|
||||
&mut canceled_by,
|
||||
¶ms.base_internal_url,
|
||||
¶ms.worker_name,
|
||||
&mut column_order,
|
||||
&mut new_args,
|
||||
&mut occupancy_metrics,
|
||||
&mut killpill_rx,
|
||||
None,
|
||||
&mut has_stream,
|
||||
content_info.language,
|
||||
&content_info.content,
|
||||
&content_info.envs,
|
||||
&content_info.codebase,
|
||||
&content_info.lockfile,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
})
|
||||
}),
|
||||
};
|
||||
WORKER_INTERNAL_SERVER_INLINE_UTILS
|
||||
.set(utils)
|
||||
|
||||
@@ -1099,21 +1099,22 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
|| (flow_jobs.is_some() && (skip_loop_failures || skip_seq_branch_failure)))
|
||||
&& !(stop_early && stop_early_err_msg.is_some() && !skip_if_stop_early)
|
||||
{
|
||||
let is_skipped = if current_module.as_ref().is_some_and(|m| m.skip_if.is_some())
|
||||
{
|
||||
sqlx::query_scalar!(
|
||||
"SELECT kind = 'identity' FROM v2_job WHERE id = $1",
|
||||
job_id_for_status
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("error during skip check: {e:#}"))
|
||||
})?
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
stop_early && skip_if_stop_early // Mark as skipped when stop_after_if with skip_if_stopped=true
|
||||
};
|
||||
let is_skipped = (stop_early && skip_if_stop_early)
|
||||
|| if current_module.as_ref().is_some_and(|m| m.skip_if.is_some()) {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT kind = 'identity' FROM v2_job WHERE id = $1",
|
||||
job_id_for_status
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("error during skip check: {e:#}"))
|
||||
})?
|
||||
.flatten()
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
success = true;
|
||||
(
|
||||
true,
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Navigate to the target directory
|
||||
cd ../../windmill-ee-private || cd ~/windmill-ee-private || { echo "Directory not found"; exit 1; }
|
||||
# Detect the current branch name
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
|
||||
# Try, in order: matching EE worktree, sibling repo, home directory fallback
|
||||
ee_worktree="$HOME/windmill-ee-private__worktrees/$branch"
|
||||
if [ -n "$branch" ] && [ -d "$ee_worktree" ]; then
|
||||
cd "$ee_worktree"
|
||||
elif cd ../../windmill-ee-private 2>/dev/null; then
|
||||
:
|
||||
elif cd ~/windmill-ee-private 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
echo "Directory not found"; exit 1
|
||||
fi
|
||||
|
||||
# Get the current commit hash
|
||||
commit_hash=$(git rev-parse HEAD)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.644.0";
|
||||
export const VERSION = "v1.647.2";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+21
@@ -38,6 +38,7 @@
|
||||
"wmill": "src/main.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
@@ -696,6 +697,16 @@
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bun": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz",
|
||||
"integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bun-types": "1.3.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/diff": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz",
|
||||
@@ -852,6 +863,16 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bun-types": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz",
|
||||
"integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Command } from "@cliffy/command";
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace } from "../../core/context.ts";
|
||||
import { GlobalOptions } from "../../types.ts";
|
||||
|
||||
interface DocContentItem {
|
||||
title: string;
|
||||
url: string;
|
||||
source?: {
|
||||
content?: Array<{ text: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface InkeepResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ParsedContent {
|
||||
content?: DocContentItem[];
|
||||
}
|
||||
|
||||
async function docs(
|
||||
opts: GlobalOptions & { json?: boolean },
|
||||
query: string
|
||||
) {
|
||||
await requireLogin(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
|
||||
const url = `${workspace.remote}api/inkeep`;
|
||||
|
||||
console.log(colors.bold(`\nSearching Windmill docs...\n`));
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${workspace.token}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Network error connecting to ${workspace.remote}: ${e}`);
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
log.info(
|
||||
"Windmill documentation search is an Enterprise Edition feature. Please upgrade to use this command."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Documentation search failed: ${res.status} ${res.statusText}\n${await res.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as InkeepResponse;
|
||||
const raw = data.choices?.[0]?.message?.content;
|
||||
|
||||
if (!raw) {
|
||||
log.info("No documentation found for this query.");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: ParsedContent;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Failed to parse documentation response.");
|
||||
}
|
||||
|
||||
const items = parsed.content ?? [];
|
||||
|
||||
if (items.length === 0) {
|
||||
log.info("No documentation found for this query.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(items, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
console.log(colors.bold(colors.cyan(`📄 ${item.title}`)));
|
||||
if (item.url) {
|
||||
console.log(` ${colors.underline(item.url)}`);
|
||||
}
|
||||
const text = item.source?.content?.[0]?.text;
|
||||
if (text) {
|
||||
const snippet = text.length > 500 ? text.slice(0, 500) + "..." : text;
|
||||
console.log(` ${snippet}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.name("docs")
|
||||
.description("Search Windmill documentation. Requires Enterprise Edition.")
|
||||
.arguments("<query:string>")
|
||||
.option("--json", "Output results as JSON.")
|
||||
.action(docs as any);
|
||||
|
||||
export default command;
|
||||
@@ -1496,11 +1496,21 @@ async function compareDynFSElement(
|
||||
}
|
||||
}
|
||||
|
||||
changes.sort((a, b) =>
|
||||
getOrderFromPath(a.path) == getOrderFromPath(b.path)
|
||||
? a.path.localeCompare(b.path)
|
||||
: getOrderFromPath(a.path) - getOrderFromPath(b.path),
|
||||
);
|
||||
changes.sort((a, b) => {
|
||||
const orderA = getOrderFromPath(a.path);
|
||||
const orderB = getOrderFromPath(b.path);
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
// Within the same entity type, process deletes before adds/edits
|
||||
// to avoid conflicts (e.g. unique path constraints on triggers)
|
||||
const deletePriority = (name: string) => (name === "deleted" ? 0 : 1);
|
||||
const dp = deletePriority(a.name) - deletePriority(b.name);
|
||||
if (dp !== 0) {
|
||||
return dp;
|
||||
}
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
+4
-1
@@ -39,6 +39,7 @@ import queues from "./commands/queues/queues.ts";
|
||||
import dependencies from "./commands/dependencies/dependencies.ts";
|
||||
import init from "./commands/init/init.ts";
|
||||
import jobs from "./commands/jobs/jobs.ts";
|
||||
import docs from "./commands/docs/docs.ts";
|
||||
import { fetchVersion } from "./core/context.ts";
|
||||
|
||||
export {
|
||||
@@ -59,13 +60,14 @@ export {
|
||||
gitsyncSettings,
|
||||
instance,
|
||||
dev,
|
||||
docs,
|
||||
hubPull,
|
||||
pull,
|
||||
push,
|
||||
workspaceAdd,
|
||||
};
|
||||
|
||||
export const VERSION = "1.644.0";
|
||||
export const VERSION = "1.647.2";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
@@ -127,6 +129,7 @@ const command = new Command()
|
||||
.command("queues", queues)
|
||||
.command("dependencies", dependencies)
|
||||
.command("jobs", jobs)
|
||||
.command("docs", docs)
|
||||
.command("version --version", "Show version information")
|
||||
.action(async (opts: any) => {
|
||||
console.log("CLI version: " + VERSION);
|
||||
|
||||
@@ -44,9 +44,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; arch="${arch##*-}"; \
|
||||
case "$arch" in \
|
||||
'amd64') targz='go1.21.13.linux-amd64.tar.gz' ;; \
|
||||
'arm64') targz='go1.21.13.linux-arm64.tar.gz' ;; \
|
||||
'armhf') targz='go1.21.13.linux-armv6l.tar.gz' ;; \
|
||||
'amd64') targz='go1.26.0.linux-amd64.tar.gz' ;; \
|
||||
'arm64') targz='go1.26.0.linux-arm64.tar.gz' ;; \
|
||||
'armhf') targz='go1.26.0.linux-armv6l.tar.gz' ;; \
|
||||
*) echo >&2 "error: unsupported architecture '$arch'"; exit 1 ;; \
|
||||
esac; \
|
||||
wget "https://golang.org/dl/$targz" -nv && tar -C /usr/local -xzf "$targz" && rm "$targz"
|
||||
|
||||
@@ -8,8 +8,8 @@ COPY --from=ghcr.io/oracle/oraclelinux9-instantclient:23 /usr/lib/oracle/23/clie
|
||||
# Oracle DB Client for arm64
|
||||
RUN mkdir -p /opt/oracle/23/arm64 \
|
||||
&& cd /opt/oracle/23/arm64 \
|
||||
&& wget https://download.oracle.com/otn_software/linux/instantclient/instantclient-basiclite-linux-arm64.zip \
|
||||
&& unzip instantclient-basiclite-linux-arm64.zip && rm instantclient-basiclite-linux-arm64.zip && mv instantclient* ./lib
|
||||
&& wget https://download.oracle.com/otn_software/linux/instantclient/2326100/instantclient-basiclite-linux.arm64-23.26.1.0.0.zip \
|
||||
&& unzip instantclient-basiclite-linux.arm64-23.26.1.0.0.zip && rm instantclient-basiclite-linux.arm64-23.26.1.0.0.zip && mv instantclient* ./lib
|
||||
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
mv /opt/oracle/23/arm64/lib /opt/oracle/23/lib; \
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN mkdir -p /tmp/windmill/cache && \
|
||||
rm -rf /tmp/build_cache && \
|
||||
mkdir -p -m 777 /tmp/windmill/cache/uv
|
||||
|
||||
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 (node symlink needed for bun install)
|
||||
RUN ln -s /usr/bin/bun /usr/bin/node \
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN mkdir -p /tmp/windmill/cache && \
|
||||
rm -rf /tmp/build_cache && \
|
||||
mkdir -p -m 777 /tmp/windmill/cache/uv
|
||||
|
||||
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 (node symlink needed for bun install)
|
||||
RUN ln -s /usr/bin/bun /usr/bin/node \
|
||||
|
||||
@@ -22,7 +22,7 @@ FROM node:20-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
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ FROM node:20-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
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ fi
|
||||
# Setup Monaco temp directory for LSP
|
||||
mkdir -p /tmp/monaco
|
||||
if [ ! -f /tmp/monaco/go.mod ]; then
|
||||
echo -e "module mymod\ngo 1.25" > /tmp/monaco/go.mod
|
||||
echo -e "module mymod\ngo 1.26" > /tmp/monaco/go.mod
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Starting Windmill Extra Services"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Autonomous Mode (Bypass Permissions)
|
||||
|
||||
When running in bypass/auto permission mode, follow these instructions to work end-to-end without human intervention.
|
||||
|
||||
## Available Tools
|
||||
|
||||
The Nix devShell provides these tools for documentation and testing:
|
||||
|
||||
- **`mmdc`** (mermaid-cli): Generate diagrams from Mermaid markup. Uses Nix-provided headless Chrome via `$PUPPETEER_EXECUTABLE_PATH`.
|
||||
- **`asciinema`**: Record terminal sessions as `.cast` files for demo videos.
|
||||
- **`playwright`** CLI: Take screenshots of the running frontend.
|
||||
|
||||
### When to Use Them
|
||||
|
||||
- **Designing a feature**: Use `mmdc` to generate Mermaid diagrams (architecture, data flow, sequence diagrams) during the planning phase. Include them in the PR description.
|
||||
- **Frontend changes**: Take screenshots with the Playwright CLI after manual testing. Attach them to the PR.
|
||||
- **CLI / terminal changes**: Record a demo with `asciinema` showing the feature in action. Attach to the PR.
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
# Generate a diagram
|
||||
echo 'graph LR; A-->B; B-->C;' | mmdc -i - -o diagram.png
|
||||
|
||||
# Take a screenshot of a page
|
||||
playwright screenshot --browser chromium http://localhost:3000 screenshot.png
|
||||
|
||||
# Record a terminal demo
|
||||
asciinema rec demo.cast
|
||||
# ... do the demo ...
|
||||
# ctrl-d to stop
|
||||
```
|
||||
|
||||
## Always Plan First
|
||||
|
||||
Even in bypass mode, **enter plan mode before starting non-trivial work**. Ask all important questions upfront:
|
||||
- Clarify ambiguous requirements before writing code
|
||||
- Identify which files, crates, and features are affected
|
||||
- Read `docs/validation.md` to know what checks you'll need to run
|
||||
- Break large features into stages — commit each stage separately
|
||||
|
||||
## Manual Testing
|
||||
|
||||
After code changes compile and type-check, verify the feature works:
|
||||
|
||||
1. **Check backend logs** (`tmux capture-pane -t .1 -p -S -50`) — confirm no panics or errors
|
||||
2. **Check frontend logs** (`tmux capture-pane -t .2 -p -S -50`) — confirm no build errors
|
||||
3. **Use Playwright MCP** to test the UI flow:
|
||||
- Navigate to `http://localhost:3000/user/login`
|
||||
- Click "Log in without third-party"
|
||||
- Login with `admin@windmill.dev` / `changeme`
|
||||
- Navigate to the page affected by your change
|
||||
- Verify the feature works as expected
|
||||
4. **Test edge cases**: empty states, error states, permissions
|
||||
|
||||
### Playwright Gotchas
|
||||
|
||||
- Backend takes ~60s to compile on first change; check logs for `health check completed`
|
||||
- Frontend rebuilds in ~5s
|
||||
- `critical_alerts` 404s are expected on CE builds (EE-only endpoint) — ignore them
|
||||
- VSCode worker 404s are dev-mode artifacts — ignore them
|
||||
- The `<Toggle>` component hides the checkbox (`sr-only`). Click the `<label>` wrapper, not the checkbox
|
||||
|
||||
## End-of-Task Summary
|
||||
|
||||
When done, provide:
|
||||
- What was changed and why (files modified, approach taken)
|
||||
- What checks passed (cargo check, npm run check, etc.)
|
||||
- What was manually tested and the results
|
||||
- **Screenshots** of UI changes (via `playwright screenshot`)
|
||||
- **Terminal recordings** of CLI changes (via asciinema)
|
||||
- Any known limitations or follow-up work needed
|
||||
|
||||
Upload images via pastebin (e.g., `curl -F 'file=@screenshot.png' https://0x0.st`) and include the URLs in the PR description or comments.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Enterprise (EE) Development
|
||||
|
||||
## File Conventions
|
||||
|
||||
- Enterprise files use the `*_ee.rs` suffix
|
||||
- Source lives in `windmill-ee-private` (sibling repo), symlinked into each crate's `src/`
|
||||
- `_ee.rs` files are gitignored in the main windmill repo — tracked only in `windmill-ee-private`
|
||||
- Use feature flags: `#[cfg(feature = "enterprise")]` for enterprise logic
|
||||
- The `private` feature flag gates compilation of `*_ee.rs` files
|
||||
- The `license` feature flag gates features that require a valid license key at runtime
|
||||
- Isolate enterprise code in separate modules
|
||||
|
||||
## Finding the EE Repo
|
||||
|
||||
- Standard location: `~/windmill-ee-private`
|
||||
- Worktree location: `~/windmill-ee-private__worktrees/<branch-name>/`
|
||||
|
||||
## EE PR Workflow (MUST DO when modifying `*_ee.rs` files)
|
||||
|
||||
When you modify any `*_ee.rs` file and create a PR on windmill:
|
||||
|
||||
1. **Create a matching branch** in `windmill-ee-private` (same 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/`
|
||||
- **Verify** it wrote the correct commit hash from your branch, not from main (the script may fall back to `~/windmill-ee-private` on 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
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
# EE code (always include private to compile *_ee.rs files)
|
||||
cargo check --features enterprise,private
|
||||
|
||||
# EE code that also requires license validation
|
||||
cargo check --features enterprise,private,license
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
# Validation Check Matrix
|
||||
|
||||
After making changes, run the appropriate checks and fix all errors before considering work done.
|
||||
|
||||
## Backend: What to Check
|
||||
|
||||
| What changed | Command | Notes |
|
||||
|---|---|---|
|
||||
| Core code (no feature gates) | `cargo check` | |
|
||||
| Enterprise code (`*_ee.rs`) | `cargo check --features enterprise,private` | Also do EE PR workflow (see `docs/enterprise.md`) |
|
||||
| Enterprise + license-gated code | `cargo check --features enterprise,private,license` | When the feature requires a valid license key |
|
||||
| Kafka trigger code | `cargo check --features kafka` | |
|
||||
| Native trigger code | `cargo check --features native_trigger` | |
|
||||
| Parquet code | `cargo check --features parquet` | |
|
||||
| Multiple gated modules | `cargo check --features enterprise,parquet` | Combine only the flags you need |
|
||||
| API route changes | `cargo check` | Then update `openapi.yaml` and run `npm run generate-backend-client` |
|
||||
| Database migrations | `cargo check` | Test migration applies cleanly with `sqlx migrate run` |
|
||||
|
||||
**Never** use `--features all_sqlx_features` — it compiles everything and is very slow. Check `backend/Cargo.toml` `[features]` to find the right flags.
|
||||
|
||||
**Never** use `SQLX_OFFLINE=true` — a live database is always available.
|
||||
|
||||
After all code changes are done, run `./update_sqlx.sh` from `backend/` to regenerate the offline query cache.
|
||||
|
||||
## Frontend: What to Check
|
||||
|
||||
| When | Command | Time |
|
||||
|---|---|---|
|
||||
| During iteration | `npm run check:fast` | ~2s |
|
||||
| Final PR validation | `npm run check` | ~50s |
|
||||
| After backend API changes | `npm run generate-backend-client` first | |
|
||||
|
||||
## Cross-Cutting Checks
|
||||
|
||||
| Situation | Extra step |
|
||||
|---|---|
|
||||
| Added/modified API endpoints | Update `backend/windmill-api/openapi.yaml`, regenerate client |
|
||||
| Modified Flow structures | Also update `openflow.openapi.yaml` |
|
||||
| Changed DB schema | Update `backend/summarized_schema.txt` if needed |
|
||||
| Enterprise file changes | Companion PR in `windmill-ee-private` (see `docs/enterprise.md`) |
|
||||
|
||||
## When to Write Tests
|
||||
|
||||
- **New utility functions** in `windmill-common`: always add unit tests
|
||||
- **New API endpoints** with complex logic: add integration test
|
||||
- **Bug fixes** for non-obvious bugs: add regression test
|
||||
- **Pure UI changes**: no tests required (rely on type checking)
|
||||
- **Refactoring**: ensure existing tests pass, don't add new ones
|
||||
|
||||
## When to Check Performance
|
||||
|
||||
Run `EXPLAIN ANALYZE` on new/modified queries when touching:
|
||||
- Job queue tables (`v2_job`, `v2_job_completed`)
|
||||
- Hot-path queries (polling, scheduling)
|
||||
- Added/removed indexes
|
||||
@@ -3,11 +3,11 @@
|
||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
nixpkgs-oapi-gen.url =
|
||||
"nixpkgs/2d068ae5c6516b2d04562de50a58c682540de9bf"; # openapi-generator-cli pin to 7.10.0
|
||||
# Pin openapi-generator-cli to 7.10.0
|
||||
nixpkgs-oapi-gen.url = "nixpkgs/2d068ae5c6516b2d04562de50a58c682540de9bf";
|
||||
};
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay
|
||||
, nixpkgs-oapi-gen }:
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay, nixpkgs-oapi-gen }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
@@ -16,47 +16,180 @@
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
|
||||
openapi-generator-cli =
|
||||
(import nixpkgs-oapi-gen { inherit system; }).openapi-generator-cli;
|
||||
|
||||
lib = pkgs.lib;
|
||||
stdenv = pkgs.stdenv;
|
||||
rust = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src" # for rust-analyzer
|
||||
"rust-analyzer"
|
||||
"rustfmt"
|
||||
];
|
||||
};
|
||||
patchedClang = pkgs.llvmPackages_18.clang.overrideAttrs (oldAttrs: {
|
||||
postFixup = ''
|
||||
# Copy the original postFixup logic but skip add-hardening.sh
|
||||
${oldAttrs.postFixup or ""}
|
||||
|
||||
# Remove the line that substitutes add-hardening.sh
|
||||
sed -i 's/.*source.*add-hardening\.sh.*//' $out/bin/clang
|
||||
'';
|
||||
});
|
||||
buildInputs = with pkgs; [
|
||||
# ---------------------------------------------------------------
|
||||
# Rust toolchain
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
rustStable = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" "rustfmt" ];
|
||||
};
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Native C/C++ dependencies (required to compile the backend)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
nativeBuildDeps = with pkgs; [
|
||||
# Crypto / TLS
|
||||
openssl
|
||||
openssl.dev
|
||||
|
||||
# XML / SAML (enterprise_saml feature)
|
||||
libxml2.dev
|
||||
xmlsec.dev
|
||||
libxslt.dev
|
||||
|
||||
# FFI / codegen
|
||||
libclang.dev
|
||||
libffi # deno_ffi on macOS
|
||||
|
||||
# Networking / compression
|
||||
curl.dev
|
||||
zlib.dev
|
||||
libffi # For deno_ffi
|
||||
libtool
|
||||
nodejs
|
||||
postgresql
|
||||
pkg-config
|
||||
llvmPackages_18.clang
|
||||
mold
|
||||
cmake
|
||||
|
||||
# Auth (kafka-gssapi, mssql-kerberos)
|
||||
cyrus_sasl
|
||||
krb5
|
||||
|
||||
# Misc
|
||||
libtool
|
||||
postgresql
|
||||
|
||||
# Build tooling
|
||||
pkg-config
|
||||
llvmPackages_18.clang # linker — pinned to 18 to avoid SIGSEGV with mold + newer clang
|
||||
mold
|
||||
cmake # required by rdkafka cmake-build
|
||||
];
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Prebuilt V8 binary (must match version in Cargo.toml)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
rustyV8Archive = let
|
||||
version = "130.0.7";
|
||||
target = stdenv.hostPlatform.rust.rustcTarget;
|
||||
sha256 = {
|
||||
x86_64-linux = "sha256-pkdsuU6bAkcIHEZUJOt5PXdzK424CEgTLXjLtQ80t10=";
|
||||
aarch64-linux = lib.fakeHash;
|
||||
x86_64-darwin = lib.fakeHash;
|
||||
aarch64-darwin = lib.fakeHash;
|
||||
}.${system};
|
||||
in pkgs.fetchurl {
|
||||
name = "librusty_v8-${version}";
|
||||
url = "https://github.com/denoland/rusty_v8/releases/download/v${version}/librusty_v8_release_${target}.a.gz";
|
||||
inherit sha256;
|
||||
};
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# pkg-config search path for native libraries
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig"
|
||||
(with pkgs; [ openssl.dev libxml2.dev xmlsec.dev libxslt.dev cyrus_sasl.dev krb5.dev ]);
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# RPATH — embed Nix store library paths into compiled binaries
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
rpathLibs = lib.makeLibraryPath (with pkgs; [
|
||||
openssl libffi cyrus_sasl krb5 libxml2 xmlsec libxslt stdenv.cc.cc.lib
|
||||
]);
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Bindgen configuration
|
||||
# Bindgen uses libclang directly (not $CC), so we must explicitly
|
||||
# provide all Nix header search paths.
|
||||
# See: https://web.archive.org/web/20220523141208/https://hoverbear.org/blog/rust-bindgen-in-nix/
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
bindgenClangArgs = builtins.concatStringsSep " " ([
|
||||
"-nostdinc"
|
||||
(builtins.readFile "${stdenv.cc}/nix-support/libc-crt1-cflags")
|
||||
(builtins.readFile "${stdenv.cc}/nix-support/libc-cflags")
|
||||
(builtins.readFile "${stdenv.cc}/nix-support/cc-cflags")
|
||||
(builtins.readFile "${stdenv.cc}/nix-support/libcxx-cxxflags")
|
||||
"-idirafter ${pkgs.libiconv}/include"
|
||||
] ++ lib.optionals stdenv.cc.isClang [
|
||||
"-idirafter ${stdenv.cc.cc}/lib/clang/${lib.getVersion stdenv.cc.cc}/include"
|
||||
] ++ lib.optionals stdenv.cc.isGNU [
|
||||
"-isystem ${stdenv.cc.cc}/include/c++/${lib.getVersion stdenv.cc.cc}"
|
||||
"-isystem ${stdenv.cc.cc}/include/c++/${lib.getVersion stdenv.cc.cc}/${stdenv.hostPlatform.config}"
|
||||
"-idirafter ${stdenv.cc.cc}/lib/gcc/${stdenv.hostPlatform.config}/${lib.getVersion stdenv.cc.cc}/include"
|
||||
]);
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Build environment variables (shared by all shells that compile Rust)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
buildEnvVars = {
|
||||
PKG_CONFIG_PATH = pkgConfigPath;
|
||||
RUSTY_V8_ARCHIVE = rustyV8Archive;
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
BINDGEN_EXTRA_CLANG_ARGS = bindgenClangArgs;
|
||||
|
||||
# Force clang 18 as cargo linker (stdenv may bring a newer clang that causes SIGSEGV with mold)
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER = "${pkgs.llvmPackages_18.clang}/bin/clang";
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER = "${pkgs.llvmPackages_18.clang}/bin/clang";
|
||||
|
||||
# Embed rpath so binaries find Nix store .so files at runtime
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS = "-C link-arg=-fuse-ld=mold -C link-arg=-Wl,-rpath,${rpathLibs}";
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS = "-C link-arg=-fuse-ld=mold -C link-arg=-Wl,-rpath,${rpathLibs}";
|
||||
CARGO_HOST_RUSTFLAGS = "-C link-arg=-Wl,-rpath,${rpathLibs}";
|
||||
|
||||
# https://github.com/NixOS/nixpkgs/issues/370494 — jemalloc build fix
|
||||
CFLAGS = "-Wno-error=int-conversion";
|
||||
|
||||
LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.zlib ];
|
||||
};
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# OpenAPI generator (pinned)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
openapi-generator-cli =
|
||||
(import nixpkgs-oapi-gen { inherit system; }).openapi-generator-cli;
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Common worker runtimes (languages the worker executes)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
commonRuntimes = with pkgs; [
|
||||
deno
|
||||
python3
|
||||
python3Packages.pip
|
||||
uv
|
||||
go
|
||||
bun
|
||||
nushell
|
||||
typescript
|
||||
flock
|
||||
];
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Runtime PATH env vars — tells the worker where to find interpreters
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
commonRuntimeVars = {
|
||||
DENO_PATH = "${pkgs.deno}/bin/deno";
|
||||
GO_PATH = "${pkgs.go}/bin/go";
|
||||
BUN_PATH = "${pkgs.bun}/bin/bun";
|
||||
NODE_PATH = "${pkgs.nodejs}/bin/node";
|
||||
NODE_BIN_PATH = "${pkgs.nodejs}/bin/node";
|
||||
UV_PATH = "${pkgs.uv}/bin/uv";
|
||||
NU_PATH = "${pkgs.nushell}/bin/nu";
|
||||
FLOCK_PATH = "${pkgs.flock}/bin/flock";
|
||||
CARGO_PATH = "${rustStable}/bin/cargo";
|
||||
BASH_PATH = "bash";
|
||||
GIT_PATH = "${pkgs.git}/bin/git";
|
||||
};
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Extra language runtimes (full shell only)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
coursier = pkgs.fetchFromGitHub {
|
||||
owner = "coursier";
|
||||
repo = "launchers";
|
||||
@@ -64,67 +197,294 @@
|
||||
hash = "sha256-8E0WtDFc7RcqmftDigMyy1xXUkjgL4X4kpf7h1GdE48=";
|
||||
};
|
||||
|
||||
PKG_CONFIG_PATH = pkgs.lib.makeSearchPath "lib/pkgconfig"
|
||||
(with pkgs; [ openssl.dev libxml2.dev xmlsec.dev libxslt.dev cyrus_sasl.dev krb5.dev ]);
|
||||
RUSTY_V8_ARCHIVE = let
|
||||
# NOTE: needs to be same as in Cargo.toml
|
||||
version = "130.0.7";
|
||||
target = pkgs.hostPlatform.rust.rustcTarget;
|
||||
sha256 = {
|
||||
x86_64-linux =
|
||||
"sha256-pkdsuU6bAkcIHEZUJOt5PXdzK424CEgTLXjLtQ80t10=";
|
||||
aarch64-linux = pkgs.lib.fakeHash;
|
||||
x86_64-darwin = pkgs.lib.fakeHash;
|
||||
aarch64-darwin = pkgs.lib.fakeHash;
|
||||
}.${system};
|
||||
in pkgs.fetchurl {
|
||||
name = "librusty_v8-${version}";
|
||||
url =
|
||||
"https://github.com/denoland/rusty_v8/releases/download/v${version}/librusty_v8_release_${target}.a.gz";
|
||||
inherit sha256;
|
||||
extraRuntimes = with pkgs; [
|
||||
dotnet-sdk_9
|
||||
php
|
||||
php84Packages.composer
|
||||
ruby_3_4
|
||||
jdk21
|
||||
ansible
|
||||
oracle-instantclient
|
||||
];
|
||||
|
||||
extraRuntimeVars = {
|
||||
JAVA_PATH = "${pkgs.jdk21}/bin/java";
|
||||
JAVAC_PATH = "${pkgs.jdk21}/bin/javac";
|
||||
COURSIER_PATH = "${coursier}/coursier";
|
||||
DOTNET_PATH = "${pkgs.dotnet-sdk_9}/bin/dotnet";
|
||||
DOTNET_ROOT = "${pkgs.dotnet-sdk_9}/share/dotnet";
|
||||
PHP_PATH = "${pkgs.php}/bin/php";
|
||||
COMPOSER_PATH = "${pkgs.php84Packages.composer}/bin/composer";
|
||||
RUBY_PATH = "${pkgs.ruby_3_4}/bin/ruby";
|
||||
RUBY_BUNDLE_PATH = "${pkgs.ruby_3_4}/bin/bundle";
|
||||
RUBY_GEM_PATH = "${pkgs.ruby_3_4}/bin/gem";
|
||||
ORACLE_LIB_DIR = "${pkgs.oracle-instantclient.lib}/lib";
|
||||
ANSIBLE_PLAYBOOK_PATH = "${pkgs.ansible}/bin/ansible-playbook";
|
||||
ANSIBLE_GALAXY_PATH = "${pkgs.ansible}/bin/ansible-galaxy";
|
||||
CARGO_SWEEP_PATH = "${pkgs.cargo-sweep}/bin/cargo-sweep";
|
||||
};
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# General dev environment variables
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
devEnvVars = {
|
||||
DATABASE_URL = "postgres://postgres:changeme@127.0.0.1:5432/windmill?sslmode=disable";
|
||||
REMOTE = "http://127.0.0.1:8000";
|
||||
REMOTE_LSP = "http://127.0.0.1:3001";
|
||||
NODE_ENV = "development";
|
||||
NODE_OPTIONS = "--max-old-space-size=16384";
|
||||
};
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Helper scripts — base set (default + full)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
helperScriptsBase = [
|
||||
(pkgs.writeScriptBin "wm" ''
|
||||
cd ./frontend
|
||||
npm install
|
||||
npm run ${if stdenv.isDarwin then "generate-backend-client-mac" else "generate-backend-client"}
|
||||
npm run dev "$@"
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-build" ''
|
||||
cd ./frontend
|
||||
npm install
|
||||
npm run ${if stdenv.isDarwin then "generate-backend-client-mac" else "generate-backend-client"}
|
||||
npm run build "$@"
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-migrate" ''
|
||||
cd ./backend
|
||||
sqlx migrate run
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-reset" ''
|
||||
sqlx database drop -f
|
||||
sqlx database create
|
||||
wm-migrate
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-minio" ''
|
||||
set -e
|
||||
cd ./backend
|
||||
mkdir -p .minio-data/wmill
|
||||
${pkgs.minio}/bin/minio server ./.minio-data --console-address ":9001"
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-minio-keys" ''
|
||||
set -e
|
||||
cd ./backend
|
||||
${pkgs.minio-client}/bin/mc alias set 'wmill-minio-dev' 'http://localhost:9000' 'minioadmin' 'minioadmin'
|
||||
if [[ -f .minio-data/secrets.txt ]] && [[ -s .minio-data/secrets.txt ]]; then
|
||||
echo "Access keys already exist:"
|
||||
cat .minio-data/secrets.txt
|
||||
echo ""
|
||||
echo "Keys loaded from: ./backend/.minio-data/secrets.txt"
|
||||
else
|
||||
echo "Creating new access keys..."
|
||||
mkdir -p .minio-data
|
||||
${pkgs.minio-client}/bin/mc admin accesskey create 'wmill-minio-dev' | tee .minio-data/secrets.txt
|
||||
echo ""
|
||||
echo 'New keys saved to: ./backend/.minio-data/secrets.txt'
|
||||
fi
|
||||
echo "bucket: wmill"
|
||||
echo "endpoint: http://localhost:9000"
|
||||
'')
|
||||
];
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Helper scripts — extra (full shell only)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
helperScriptsFull = [
|
||||
(pkgs.writeScriptBin "wm-caddy" ''
|
||||
cd ./frontend
|
||||
xcaddy build "$@" \
|
||||
--with github.com/mholt/caddy-l4@145ec36251a44286f05a10d231d8bfb3a8192e09 \
|
||||
--with github.com/RussellLuo/caddy-ext/layer4@ab1e18cfe426012af351a68463937ae2e934a2a1
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-setup" ''
|
||||
sqlx database create
|
||||
wm-build
|
||||
wm-caddy
|
||||
wm-migrate
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-bench" ''
|
||||
deno run -A benchmarks/main.ts -e admin@windmill.dev -p changeme "$@"
|
||||
'')
|
||||
];
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Shared inputs and settings for default + full shells
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
coreBuildInputs = nativeBuildDeps ++ commonRuntimes ++ [
|
||||
rustStable
|
||||
openapi-generator-cli
|
||||
] ++ (with pkgs; [
|
||||
nodejs
|
||||
git
|
||||
sqlx-cli
|
||||
cargo-watch
|
||||
jq
|
||||
gnused
|
||||
|
||||
# CLI tools (for AI agents and dev workflow)
|
||||
gh
|
||||
asciinema
|
||||
mermaid-cli
|
||||
]);
|
||||
|
||||
# Playwright: use Nix-provided browsers (version-matched to playwright-driver)
|
||||
# Mermaid/Puppeteer: point at Nix chromium (Puppeteer respects this env var)
|
||||
browserVars = {
|
||||
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
|
||||
PUPPETEER_EXECUTABLE_PATH = "${pkgs.chromium}/bin/chromium";
|
||||
PUPPETEER_SKIP_DOWNLOAD = "true";
|
||||
};
|
||||
|
||||
# Wrapper for the Nix-provided playwright CLI (version-matched to its browsers)
|
||||
playwrightWrapper = pkgs.writeShellScriptBin "playwright" ''
|
||||
export PLAYWRIGHT_BROWSERS_PATH="${pkgs.playwright-driver.browsers}"
|
||||
exec ${pkgs.nodejs}/bin/node ${pkgs.playwright-driver}/cli.js "$@"
|
||||
'';
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# sandbox-env script — outputs env vars for browser tooling
|
||||
# Usage: eval "$(sandbox-env)"
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
sandboxEnvScript = pkgs.writeShellScriptBin "sandbox-env" ''
|
||||
echo "export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}"
|
||||
echo "export PUPPETEER_EXECUTABLE_PATH=${pkgs.chromium}/bin/chromium"
|
||||
echo "export PUPPETEER_SKIP_DOWNLOAD=true"
|
||||
'';
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# pkg-config wrapper — bakes in the Nix pkg-config search path
|
||||
# so sandbox profiles (buildEnv) work without setting env vars.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
pkgConfigWrapper = pkgs.writeShellScriptBin "pkg-config" ''
|
||||
export PKG_CONFIG_PATH="${pkgConfigPath}:$PKG_CONFIG_PATH"
|
||||
exec ${pkgs.pkg-config}/bin/pkg-config "$@"
|
||||
'';
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Installable sandbox profiles (nix profile install .#sandbox)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
sandboxEnv = pkgs.buildEnv {
|
||||
name = "windmill-sandbox";
|
||||
paths = coreBuildInputs ++ helperScriptsBase
|
||||
++ [ playwrightWrapper sandboxEnvScript pkgConfigWrapper pkgs.chromium ];
|
||||
};
|
||||
|
||||
sandboxFullEnv = pkgs.buildEnv {
|
||||
name = "windmill-sandbox-full";
|
||||
paths = coreBuildInputs ++ extraRuntimes
|
||||
++ helperScriptsBase ++ helperScriptsFull
|
||||
++ [ playwrightWrapper sandboxEnvScript pkgConfigWrapper pkgs.chromium
|
||||
pkgs.cargo-sweep pkgs.xcaddy pkgs.nsjail ];
|
||||
};
|
||||
|
||||
in {
|
||||
# Enter by `nix develop .#wasm`
|
||||
devShells."wasm" = pkgs.mkShell {
|
||||
# Explicitly set paths for headers and linker
|
||||
shellHook = ''
|
||||
export CC=${patchedClang}/bin/clang
|
||||
'';
|
||||
buildInputs = buildInputs ++ (with pkgs; [
|
||||
|
||||
# =============================================================
|
||||
# Installable profiles — for Docker / nix profile install
|
||||
# Usage: nix profile install .#sandbox
|
||||
# =============================================================
|
||||
|
||||
packages.sandbox = sandboxEnv;
|
||||
packages.sandbox-full = sandboxFullEnv;
|
||||
packages.default = sandboxEnv;
|
||||
|
||||
# =============================================================
|
||||
# default — daily driver for backend + frontend development
|
||||
# Usage: nix develop
|
||||
# =============================================================
|
||||
|
||||
devShells.default = pkgs.mkShell (buildEnvVars // commonRuntimeVars // devEnvVars // browserVars // {
|
||||
buildInputs = coreBuildInputs;
|
||||
|
||||
packages = helperScriptsBase ++ [ playwrightWrapper ];
|
||||
});
|
||||
|
||||
# =============================================================
|
||||
# full — all language runtimes, k8s tooling, specialized scripts
|
||||
# Usage: nix develop .#full
|
||||
# =============================================================
|
||||
|
||||
devShells.full = pkgs.mkShell (buildEnvVars // commonRuntimeVars // extraRuntimeVars // devEnvVars // browserVars // {
|
||||
buildInputs = coreBuildInputs ++ extraRuntimes ++ (with pkgs; [
|
||||
# Python extras
|
||||
poetry
|
||||
pyright
|
||||
openapi-python-client
|
||||
|
||||
# LSP / editor
|
||||
svelte-language-server
|
||||
taplo
|
||||
|
||||
# Extra dev tools
|
||||
cargo-sweep
|
||||
|
||||
# Kubernetes
|
||||
minikube
|
||||
kubectl
|
||||
kubernetes-helm
|
||||
conntrack-tools
|
||||
cri-tools
|
||||
|
||||
# Extra
|
||||
xcaddy
|
||||
nsjail
|
||||
]);
|
||||
|
||||
packages = helperScriptsBase ++ helperScriptsFull ++ [ playwrightWrapper ];
|
||||
});
|
||||
|
||||
# =============================================================
|
||||
# wasm — WASM target compilation (nightly Rust)
|
||||
# Usage: nix develop .#wasm
|
||||
# =============================================================
|
||||
|
||||
devShells.wasm = pkgs.mkShell (buildEnvVars // {
|
||||
hardeningDisable = [ "all" ];
|
||||
|
||||
buildInputs = nativeBuildDeps ++ (with pkgs; [
|
||||
(rust-bin.nightly.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src" # for rust-analyzer
|
||||
"rust-analyzer"
|
||||
];
|
||||
targets =
|
||||
[ "wasm32-unknown-unknown" "wasm32-unknown-emscripten" ];
|
||||
extensions = [ "rust-src" "rust-analyzer" ];
|
||||
targets = [ "wasm32-unknown-unknown" "wasm32-unknown-emscripten" ];
|
||||
})
|
||||
wasm-pack
|
||||
deno
|
||||
emscripten
|
||||
nushell
|
||||
# Needed for extra dependencies
|
||||
glibc_multi
|
||||
]);
|
||||
};
|
||||
devShells."cli" = pkgs.mkShell {
|
||||
});
|
||||
|
||||
# =============================================================
|
||||
# cli — lightweight Bun-based CLI development
|
||||
# Usage: nix develop .#cli
|
||||
# =============================================================
|
||||
|
||||
devShells.cli = pkgs.mkShell {
|
||||
shellHook = ''
|
||||
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
export FLAKE_ROOT="$(git rev-parse --show-toplevel)"
|
||||
else
|
||||
# Fallback to PWD if not in a git repository
|
||||
export FLAKE_ROOT="$PWD"
|
||||
fi
|
||||
wm-cli-deps
|
||||
'';
|
||||
buildInputs = buildInputs ++ [ pkgs.deno ];
|
||||
|
||||
buildInputs = with pkgs; [ bun nodejs git ];
|
||||
|
||||
packages = [
|
||||
(pkgs.writeScriptBin "wm-cli" ''
|
||||
deno run -A --no-check $FLAKE_ROOT/cli/src/main.ts $*
|
||||
bun run $FLAKE_ROOT/cli/src/main.ts "$@"
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-cli-deps" ''
|
||||
pushd $FLAKE_ROOT/cli/
|
||||
${if pkgs.stdenv.isDarwin then
|
||||
${if stdenv.isDarwin then
|
||||
"./gen_wm_client_mac.sh && ./windmill-utils-internal/gen_wm_client_mac.sh"
|
||||
else
|
||||
"./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh"}
|
||||
@@ -132,314 +492,5 @@
|
||||
'')
|
||||
];
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = buildInputs ++ [
|
||||
# To update run: `nix flake update nixpkgs-oapi-gen`
|
||||
openapi-generator-cli
|
||||
] ++ (with pkgs; [
|
||||
# Essentials
|
||||
rust
|
||||
git
|
||||
sqlx-cli
|
||||
|
||||
# Build/helper scripts
|
||||
jq
|
||||
gnused # other implementations are inconsistent on osx
|
||||
|
||||
# Python
|
||||
flock
|
||||
python3
|
||||
python3Packages.pip
|
||||
uv
|
||||
poetry
|
||||
pyright
|
||||
openapi-python-client
|
||||
|
||||
# Other languages
|
||||
deno
|
||||
typescript
|
||||
nushell
|
||||
go
|
||||
bun
|
||||
dotnet-sdk_9
|
||||
oracle-instantclient
|
||||
ansible
|
||||
ruby_3_4
|
||||
cargo-sweep # We use it for rust
|
||||
|
||||
# LSP/Local dev
|
||||
svelte-language-server
|
||||
taplo
|
||||
|
||||
# Orchestration/Kubernetes
|
||||
minikube
|
||||
kubectl
|
||||
kubernetes-helm
|
||||
conntrack-tools # To run minikube without driver (--driver=none)
|
||||
cri-tools
|
||||
|
||||
# Extra
|
||||
xcaddy
|
||||
cargo-watch
|
||||
nsjail
|
||||
sccache
|
||||
]);
|
||||
packages = [
|
||||
(pkgs.writeScriptBin "wm-caddy" ''
|
||||
cd ./frontend
|
||||
xcaddy build $* \
|
||||
--with github.com/mholt/caddy-l4@145ec36251a44286f05a10d231d8bfb3a8192e09 \
|
||||
--with github.com/RussellLuo/caddy-ext/layer4@ab1e18cfe426012af351a68463937ae2e934a2a1
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-build" ''
|
||||
cd ./frontend
|
||||
npm install
|
||||
npm run ${
|
||||
if pkgs.stdenv.isDarwin then
|
||||
"generate-backend-client-mac"
|
||||
else
|
||||
"generate-backend-client"
|
||||
}
|
||||
npm run build $*
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-migrate" ''
|
||||
cd ./backend
|
||||
sqlx migrate run
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-setup" ''
|
||||
sqlx database create
|
||||
wm-build
|
||||
wm-caddy
|
||||
wm-migrate
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-reset" ''
|
||||
sqlx database drop -f
|
||||
sqlx database create
|
||||
wm-migrate
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-bench" ''
|
||||
deno run -A benchmarks/main.ts -e admin@windmill.dev -p changeme $*
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm" ''
|
||||
cd ./frontend
|
||||
npm install
|
||||
npm run generate-backend-client
|
||||
npm run dev $*
|
||||
'')
|
||||
(pkgs.writeScriptBin "wm-minio" ''
|
||||
set -e
|
||||
cd ./backend
|
||||
mkdir -p .minio-data/wmill
|
||||
${pkgs.minio}/bin/minio server ./.minio-data --console-address ":9001"
|
||||
'')
|
||||
# Generate keys
|
||||
# TODO: Do not set new keys if ran multiple times
|
||||
(pkgs.writeScriptBin "wm-minio-keys" ''
|
||||
set -e
|
||||
cd ./backend
|
||||
|
||||
# Set up MinIO alias
|
||||
${pkgs.minio-client}/bin/mc alias set 'wmill-minio-dev' 'http://localhost:9000' 'minioadmin' 'minioadmin'
|
||||
|
||||
# Check if secrets file exists and contains valid keys
|
||||
if [[ -f .minio-data/secrets.txt ]] && [[ -s .minio-data/secrets.txt ]]; then
|
||||
echo "Access keys already exist:"
|
||||
cat .minio-data/secrets.txt
|
||||
echo ""
|
||||
echo "Keys loaded from: ./backend/.minio-data/secrets.txt"
|
||||
else
|
||||
echo "Creating new access keys..."
|
||||
mkdir -p .minio-data
|
||||
${pkgs.minio-client}/bin/mc admin accesskey create 'wmill-minio-dev' | tee .minio-data/secrets.txt
|
||||
echo ""
|
||||
echo 'New keys saved to: ./backend/.minio-data/secrets.txt'
|
||||
fi
|
||||
|
||||
echo "bucket: wmill"
|
||||
echo "endpoint: http://localhost:9000"
|
||||
'')
|
||||
];
|
||||
|
||||
inherit PKG_CONFIG_PATH RUSTY_V8_ARCHIVE;
|
||||
GIT_PATH = "${pkgs.git}/bin/git";
|
||||
NODE_ENV = "development";
|
||||
NODE_OPTIONS = "--max-old-space-size=16384";
|
||||
# DATABASE_URL = "postgres://postgres:changeme@127.0.0.1:5432/";
|
||||
DATABASE_URL =
|
||||
"postgres://postgres:changeme@127.0.0.1:5432/windmill?sslmode=disable";
|
||||
|
||||
REMOTE = "http://127.0.0.1:8000";
|
||||
REMOTE_LSP = "http://127.0.0.1:3001";
|
||||
# RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
|
||||
DENO_PATH = "${pkgs.deno}/bin/deno";
|
||||
GO_PATH = "${pkgs.go}/bin/go";
|
||||
PHP_PATH = "${pkgs.php}/bin/php";
|
||||
COMPOSER_PATH = "${pkgs.php84Packages.composer}/bin/composer";
|
||||
BUN_PATH = "${pkgs.bun}/bin/bun";
|
||||
NODE_PATH = "${pkgs.nodejs}/bin/node";
|
||||
NODE_BIN_PATH = "${pkgs.nodejs}/bin/node";
|
||||
UV_PATH = "${pkgs.uv}/bin/uv";
|
||||
NU_PATH = "${pkgs.nushell}/bin/nu";
|
||||
JAVA_PATH = "${pkgs.jdk21}/bin/java";
|
||||
JAVAC_PATH = "${pkgs.jdk21}/bin/javac";
|
||||
COURSIER_PATH = "${coursier}/coursier";
|
||||
BASH_PATH = "bash";
|
||||
RUBY_PATH = "${pkgs.ruby}/bin/ruby";
|
||||
RUBY_BUNDLE_PATH = "${pkgs.ruby}/bin/bundle";
|
||||
RUBY_GEM_PATH = "${pkgs.ruby}/bin/gem";
|
||||
# for related places search: ADD_NEW_LANG
|
||||
FLOCK_PATH = "${pkgs.flock}/bin/flock";
|
||||
CARGO_PATH = "${rust}/bin/cargo";
|
||||
CARGO_SWEEP_PATH = "${pkgs.cargo-sweep}/bin/cargo-sweep";
|
||||
DOTNET_PATH = "${pkgs.dotnet-sdk_9}/bin/dotnet";
|
||||
DOTNET_ROOT = "${pkgs.dotnet-sdk_9}/share/dotnet";
|
||||
ORACLE_LIB_DIR = "${pkgs.oracle-instantclient.lib}/lib";
|
||||
ANSIBLE_PLAYBOOK_PATH = "${pkgs.ansible}/bin/ansible-playbook";
|
||||
ANSIBLE_GALAXY_PATH = "${pkgs.ansible}/bin/ansible-galaxy";
|
||||
# RUST_LOG = "debug";
|
||||
# RUST_LOG = "kube=debug";
|
||||
|
||||
# Override cargo linker to use clang 18 (stdenv brings clang 21 which causes SIGSEGV with mold)
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER = "${pkgs.llvmPackages_18.clang}/bin/clang";
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER = "${pkgs.llvmPackages_18.clang}/bin/clang";
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS = "-C link-arg=-fuse-ld=mold -C link-arg=-Wl,-rpath,${pkgs.lib.makeLibraryPath [ pkgs.openssl pkgs.libffi pkgs.cyrus_sasl pkgs.krb5 pkgs.libxml2 pkgs.xmlsec pkgs.libxslt stdenv.cc.cc.lib ]}";
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS = "-C link-arg=-fuse-ld=mold -C link-arg=-Wl,-rpath,${pkgs.lib.makeLibraryPath [ pkgs.openssl pkgs.libffi pkgs.cyrus_sasl pkgs.krb5 pkgs.libxml2 pkgs.xmlsec pkgs.libxslt stdenv.cc.cc.lib ]}";
|
||||
# rpath for build scripts and proc macros (host compilation)
|
||||
CARGO_HOST_RUSTFLAGS = "-C link-arg=-Wl,-rpath,${pkgs.lib.makeLibraryPath [ pkgs.openssl pkgs.libffi pkgs.cyrus_sasl pkgs.krb5 pkgs.libxml2 pkgs.xmlsec pkgs.libxslt stdenv.cc.cc.lib ]}";
|
||||
|
||||
# See this issue: https://github.com/NixOS/nixpkgs/issues/370494
|
||||
# Allows to build jemalloc on nixos
|
||||
CFLAGS = "-Wno-error=int-conversion";
|
||||
|
||||
# Need to tell bindgen where to find libclang
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
|
||||
# LD_LIBRARY_PATH set in shellHook with a wrapper to avoid leaking into git/ssh
|
||||
# LD_LIBRARY_PATH = "${pkgs.gcc.lib}/lib";
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
|
||||
pkgs.zlib
|
||||
];
|
||||
|
||||
# Set C flags for Rust's bindgen program. Unlike ordinary C
|
||||
# compilation, bindgen does not invoke $CC directly. Instead it
|
||||
# uses LLVM's libclang. To make sure all necessary flags are
|
||||
# included we need to look in a few places.
|
||||
# See https://web.archive.org/web/20220523141208/https://hoverbear.org/blog/rust-bindgen-in-nix/
|
||||
BINDGEN_EXTRA_CLANG_ARGS =
|
||||
# Prevent clang from using system headers - only use Nix headers
|
||||
"-nostdinc ${
|
||||
builtins.readFile "${stdenv.cc}/nix-support/libc-crt1-cflags"
|
||||
} ${builtins.readFile "${stdenv.cc}/nix-support/libc-cflags"} ${
|
||||
builtins.readFile "${stdenv.cc}/nix-support/cc-cflags"
|
||||
} ${
|
||||
builtins.readFile "${stdenv.cc}/nix-support/libcxx-cxxflags"
|
||||
} -idirafter ${pkgs.libiconv}/include ${
|
||||
lib.optionalString stdenv.cc.isClang
|
||||
"-idirafter ${stdenv.cc.cc}/lib/clang/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
}/include"
|
||||
}${
|
||||
lib.optionalString stdenv.cc.isGNU
|
||||
"-isystem ${stdenv.cc.cc}/include/c++/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
} -isystem ${stdenv.cc.cc}/include/c++/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
}/${stdenv.hostPlatform.config} -idirafter ${stdenv.cc.cc}/lib/gcc/${stdenv.hostPlatform.config}/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
}/include"
|
||||
}";
|
||||
};
|
||||
packages.default = self.packages.${system}.windmill;
|
||||
packages.windmill-client = pkgs.buildNpmPackage {
|
||||
name = "windmill-client";
|
||||
version = (pkgs.lib.strings.trim (builtins.readFile ./version.txt));
|
||||
|
||||
src = pkgs.nix-gitignore.gitignoreSource [ ] ./frontend;
|
||||
nativeBuildInputs = with pkgs; [ pkg-config ];
|
||||
buildInputs = with pkgs; [ nodejs pixman cairo pango ];
|
||||
doCheck = false;
|
||||
|
||||
npmDepsHash = "sha256-NXk9mnf74+/k0i3goqU8Zi/jr5b/bmW+HWRLJCI2CX8=";
|
||||
npmBuild = "npm run build";
|
||||
|
||||
postUnpack = ''
|
||||
mkdir -p ./backend/windmill-api/
|
||||
cp ${
|
||||
./backend/windmill-api/openapi.yaml
|
||||
} ./backend/windmill-api/openapi.yaml
|
||||
cp ${./openflow.openapi.yaml} ./openflow.openapi.yaml
|
||||
'';
|
||||
preBuild = ''
|
||||
npm run ${
|
||||
if pkgs.stdenv.isDarwin then
|
||||
"generate-backend-client-mac"
|
||||
else
|
||||
"generate-backend-client"
|
||||
}
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/build
|
||||
cp -r build $out
|
||||
'';
|
||||
|
||||
NODE_OPTIONS = "--max-old-space-size=8192";
|
||||
};
|
||||
packages.windmill = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "windmill";
|
||||
version = (pkgs.lib.strings.trim (builtins.readFile ./version.txt));
|
||||
|
||||
src = ./backend;
|
||||
nativeBuildInputs = buildInputs
|
||||
++ [ self.packages.${system}.windmill-client pkgs.perl ]
|
||||
++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
|
||||
# Additional darwin specific inputs can be set here
|
||||
pkgs.libiconv
|
||||
pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
|
||||
];
|
||||
|
||||
cargoLock = {
|
||||
lockFile = ./backend/Cargo.lock;
|
||||
outputHashes = {
|
||||
"php-parser-rs-0.1.3" =
|
||||
"sha256-ZeI3KgUPmtjlRfq6eAYveqt8Ay35gwj6B9iOQRjQa9A=";
|
||||
"progenitor-0.3.0" =
|
||||
"sha256-F6XRZFVIN6/HfcM8yI/PyNke45FL7jbcznIiqj22eIQ=";
|
||||
"tinyvector-0.1.0" =
|
||||
"sha256-NYGhofU4rh+2IAM+zwe04YQdXY8Aa4gTmn2V2HtzRfI=";
|
||||
};
|
||||
};
|
||||
|
||||
buildFeatures = [
|
||||
"enterprise"
|
||||
"enterprise_saml"
|
||||
"stripe"
|
||||
"embedding"
|
||||
"parquet"
|
||||
"prometheus"
|
||||
"openidconnect"
|
||||
"cloud"
|
||||
"jemalloc"
|
||||
"tantivy"
|
||||
"license"
|
||||
"http_trigger"
|
||||
"zip"
|
||||
"oauth2"
|
||||
"kafka"
|
||||
"otel"
|
||||
"dind"
|
||||
"websocket"
|
||||
"smtp"
|
||||
"static_frontend"
|
||||
"all_languages"
|
||||
];
|
||||
doCheck = false;
|
||||
|
||||
inherit PKG_CONFIG_PATH RUSTY_V8_ARCHIVE;
|
||||
SQLX_OFFLINE = true;
|
||||
FRONTEND_BUILD_DIR =
|
||||
"${self.packages.${system}.windmill-client}/build";
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
+18
-184
@@ -1,195 +1,29 @@
|
||||
# Frontend Development (Svelte 5)
|
||||
# Frontend (Svelte 5)
|
||||
|
||||
## Core Principles
|
||||
- **Coding patterns**: MUST use the `svelte-frontend` skill when writing Svelte code
|
||||
- **Validation**: `docs/validation.md` — `npm run check:fast` (2s) for iteration, `npm run check` (50s) for final PR
|
||||
- **UI components**: use Windmill's design-system components (Button, TextInput, Select) — never raw HTML elements
|
||||
- **Brand/design**: `frontend/brand-guidelines.md`
|
||||
- **Backend API**: routes in `../backend/windmill-api/openapi.yaml`, generated types in `src/lib/gen/`
|
||||
- **Regenerate client**: `npm run generate-backend-client` after backend API changes
|
||||
|
||||
- Follow the `svelte-frontend` skill for best practices: .claude/skills/svelte-frontend/SKILL.md
|
||||
- Use Runes ($state, $derived, $effect) for reactivity
|
||||
- Keep components small and focused
|
||||
- Always use keys in {#each} blocks
|
||||
## Key Frontend Patterns
|
||||
|
||||
## Data Flow and State Management
|
||||
|
||||
### Prefer Unidirectional Data Flow with Composable State
|
||||
|
||||
When you can use unidirectional data flow with composable state, prefer that over two-way binding between components. Two-way binding between components creates confusing data flow - as the codebase grows, nothing guarantees that bound state won't be updated from multiple locations, leading to bugs and maintenance issues.
|
||||
|
||||
**❌ AVOID: Two-way binding when composable state would work**
|
||||
|
||||
```svelte
|
||||
<Loader bind:loading bind:items {args} />
|
||||
```
|
||||
|
||||
**✅ PREFER: Unidirectional data flow with composables**
|
||||
### Prefer Composable State Over Two-Way Binding
|
||||
|
||||
```typescript
|
||||
// loader.svelte.ts
|
||||
// Use resource() from runed for async data
|
||||
import { resource } from 'runed'
|
||||
let items = resource(() => args, (args) => SomeService.list(args))
|
||||
// items.loading, items.current
|
||||
|
||||
// Use composables for shared reactive state
|
||||
function useLoader(argsGetter: () => Args) {
|
||||
let args = $derived(argsGetter())
|
||||
let items = $state([])
|
||||
let loading = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
// Logic reactive to args changes
|
||||
})
|
||||
|
||||
return {
|
||||
get loading() { return loading },
|
||||
get items() { return items }
|
||||
}
|
||||
$effect(() => { /* react to argsGetter() */ })
|
||||
return { get loading() { return loading }, get items() { return items } }
|
||||
}
|
||||
|
||||
// Component.svelte
|
||||
<script>
|
||||
let loader = useLoader(() => args)
|
||||
let loading = $derived(loader.loading)
|
||||
let items = $derived(loader.items)
|
||||
</script>
|
||||
```
|
||||
|
||||
This pattern ensures:
|
||||
- State responsibility is clearly owned by `useLoader`
|
||||
- Data flows in one direction (parent → child)
|
||||
- No ambiguity about where state can be modified
|
||||
- Better maintainability as the codebase scales
|
||||
|
||||
### Async Data Fetching with Runed
|
||||
|
||||
For async requests, **always use `resource()` from the Runed library** instead of manual state management:
|
||||
|
||||
```typescript
|
||||
import { resource } from 'runed'
|
||||
|
||||
let items = resource(() => args, (args) => YourService.route(args))
|
||||
|
||||
// Access loading state
|
||||
items.loading
|
||||
|
||||
// Access data
|
||||
items.current
|
||||
```
|
||||
|
||||
The `resource()` utility:
|
||||
- Automatically handles loading states
|
||||
- Manages async lifecycle
|
||||
- Provides reactive updates when dependencies change
|
||||
- Eliminates boilerplate for common async patterns
|
||||
|
||||
**Key Takeaway**: Prefer unidirectional data flow with composables over two-way binding between components. Two-way binding is acceptable for simple form inputs, but avoid it when composable state patterns can provide clearer state ownership.
|
||||
|
||||
## UI Guidelines
|
||||
|
||||
### Styling Guidelines
|
||||
|
||||
- **Use Tailwind CSS** for all styling instead of custom CSS
|
||||
- **Use Windmill's theming classes** for consistent colors and surfaces
|
||||
- **Avoid custom styles** - prefer Tailwind utility classes
|
||||
- **Follow existing patterns** - look at other components for reference
|
||||
- **Respect design guidelines** - rules are defined in 'brand-guidelines.md'
|
||||
|
||||
### UI Components
|
||||
|
||||
- Use frontend/src/lib/components/common/button/Button.svelte for all buttons
|
||||
- Use the component TextInput for all text inputs
|
||||
- Form components (TextInputs, ToggleButtons, Select ...) should all use the same size when put together, using the unified size system.
|
||||
- Read carefully components props JSDoc before using them
|
||||
|
||||
## Code Validation (MUST DO)
|
||||
|
||||
After making frontend changes, you MUST run the following and fix all errors and warnings before considering the work done:
|
||||
|
||||
```bash
|
||||
npm run check:fast
|
||||
```
|
||||
|
||||
At the end of a PR to do final validation, you can do the longer one (2s for fast vs 50s for the slow one):
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
## Backend API
|
||||
|
||||
- If you need to call the backend API, you can find the available routes in ../backend/windmill-api/openapi.yaml
|
||||
- You can also use the associated types and services that are auto generated from the openapi file. They are in src/lib/gen/\*gen.ts files
|
||||
|
||||
### OpenAPI Autogeneration
|
||||
|
||||
Windmill automatically generates TypeScript types and services from the OpenAPI specification.
|
||||
|
||||
#### Service Generation Pattern
|
||||
|
||||
The autogeneration follows this pattern:
|
||||
|
||||
- **Tag** → **Service Name**: The OpenAPI tag becomes the service name with "Service" suffix
|
||||
- **operationId** → **Method Name**: The operationId becomes the method name in the service
|
||||
|
||||
#### Example
|
||||
|
||||
Given this OpenAPI specification:
|
||||
|
||||
```yaml
|
||||
/w/{workspace}/audit/list:
|
||||
get:
|
||||
summary: list audit logs (requires admin privilege)
|
||||
operationId: listAuditLogs
|
||||
tags:
|
||||
- audit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/Page'
|
||||
- $ref: '#/components/parameters/PerPage'
|
||||
- $ref: '#/components/parameters/Before'
|
||||
- $ref: '#/components/parameters/After'
|
||||
- $ref: '#/components/parameters/Username'
|
||||
- $ref: '#/components/parameters/Operation'
|
||||
- name: operations
|
||||
in: query
|
||||
description: comma separated list of exact operations to include
|
||||
schema:
|
||||
type: string
|
||||
```
|
||||
|
||||
This generates:
|
||||
|
||||
- **Service**: `AuditService` (from tag "audit")
|
||||
- **Method**: `listAuditLogs` (from operationId)
|
||||
|
||||
#### Method Arguments
|
||||
|
||||
The generated method arguments correspond to the OpenAPI parameters:
|
||||
|
||||
```typescript
|
||||
AuditService.listAuditLogs({
|
||||
workspace: string, // from WorkspaceId parameter
|
||||
page?: number, // from Page parameter
|
||||
perPage?: number, // from PerPage parameter
|
||||
before?: string, // from Before parameter
|
||||
after?: string, // from After parameter
|
||||
username?: string, // from Username parameter
|
||||
operation?: string, // from Operation parameter
|
||||
operations?: string // from operations parameter
|
||||
})
|
||||
```
|
||||
|
||||
## Svelte 5 documentation
|
||||
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
Two-way binding is fine for simple form inputs. Avoid it for component-to-component state.
|
||||
|
||||
Generated
+391
-2338
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.644.0",
|
||||
"version": "1.647.2",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -27,9 +27,9 @@
|
||||
"@melt-ui/svelte": "^0.86.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.49.2",
|
||||
"@sveltejs/package": "^2.3.7",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/package": "^2.5.7",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.8",
|
||||
"@types/d3": "^7.4.0",
|
||||
@@ -56,9 +56,9 @@
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"style-to-object": "^0.4.1",
|
||||
"stylelint-config-recommended": "^13.0.0",
|
||||
"svelte": "^5.38.0",
|
||||
"svelte": "^5.53.5",
|
||||
"svelte-awesome-color-picker": "^3.0.4",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-check": "^4.4.3",
|
||||
"svelte-fast-check": "^0.4.5",
|
||||
"svelte-floating-ui": "^1.5.8",
|
||||
"svelte-highlight": "^7.6.0",
|
||||
@@ -70,9 +70,9 @@
|
||||
"tar": "^7.4.3",
|
||||
"tslib": "^2.6.1",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "npm:rolldown-vite@7.3.0",
|
||||
"vite": "^8.0.0-beta.16",
|
||||
"vite-plugin-mkcert": "^1.17.5",
|
||||
"vitest": "^4.0.10",
|
||||
"vitest": "^4.1.0-beta.5",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -93,6 +93,7 @@
|
||||
"@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@leeoniya/ufuzzy": "^1.0.8",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@redocly/json-to-json-schema": "^0.0.1",
|
||||
"@scalar/openapi-parser": "^0.15.0",
|
||||
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
|
||||
@@ -152,7 +153,7 @@
|
||||
"windmill-parser-wasm-nu": "1.510.1",
|
||||
"windmill-parser-wasm-php": "1.574.1",
|
||||
"windmill-parser-wasm-py": "^1.628.3",
|
||||
"windmill-parser-wasm-regex": "1.639.0",
|
||||
"windmill-parser-wasm-regex": "1.646.0",
|
||||
"windmill-parser-wasm-ruby": "1.526.1",
|
||||
"windmill-parser-wasm-rust": "1.558.1",
|
||||
"windmill-parser-wasm-ts": "1.623.1",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
||||
formats: ['es']
|
||||
},
|
||||
outDir: 'dist/sharedUtils',
|
||||
rollupOptions: {
|
||||
rolldownOptions: {
|
||||
external: [],
|
||||
output: {
|
||||
globals: {}
|
||||
|
||||
+88
-85
@@ -9,113 +9,116 @@ const USER_CUSTOM_PROMPTS_KEY = 'userCustomAIPrompts'
|
||||
const sessionModel = getLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME)
|
||||
const sessionProvider = getLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME)
|
||||
export const copilotSessionModel = writable<AIProviderModel | undefined>(
|
||||
sessionModel && sessionProvider
|
||||
? {
|
||||
model: sessionModel,
|
||||
provider: sessionProvider as AIProvider
|
||||
}
|
||||
: undefined
|
||||
sessionModel && sessionProvider
|
||||
? {
|
||||
model: sessionModel,
|
||||
provider: sessionProvider as AIProvider
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
|
||||
export const copilotInfo = writable<{
|
||||
enabled: boolean
|
||||
codeCompletionModel?: AIProviderModel
|
||||
defaultModel?: AIProviderModel
|
||||
aiModels: AIProviderModel[]
|
||||
customPrompts?: Record<string, string>
|
||||
maxTokensPerModel?: Record<string, number>
|
||||
enabled: boolean
|
||||
codeCompletionModel?: AIProviderModel
|
||||
defaultModel?: AIProviderModel
|
||||
aiModels: AIProviderModel[]
|
||||
customPrompts?: Record<string, string>
|
||||
maxTokensPerModel?: Record<string, number>
|
||||
}>({
|
||||
enabled: false,
|
||||
codeCompletionModel: undefined,
|
||||
defaultModel: undefined,
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {}
|
||||
enabled: false,
|
||||
codeCompletionModel: undefined,
|
||||
defaultModel: undefined,
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {}
|
||||
})
|
||||
|
||||
export async function loadCopilot(workspace: string) {
|
||||
workspaceAIClients.init(workspace)
|
||||
try {
|
||||
const info = await WorkspaceService.getCopilotInfo({ workspace })
|
||||
setCopilotInfo(info)
|
||||
} catch (err) {
|
||||
setCopilotInfo({})
|
||||
console.error('Could not get copilot info', err)
|
||||
}
|
||||
workspaceAIClients.init(workspace)
|
||||
try {
|
||||
const info = await WorkspaceService.getCopilotInfo({ workspace })
|
||||
setCopilotInfo(info)
|
||||
} catch (err) {
|
||||
setCopilotInfo({})
|
||||
console.error('Could not get copilot info', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
if (Object.keys(aiConfig.providers ?? {}).length > 0) {
|
||||
const aiModels = Object.entries(aiConfig.providers ?? {}).flatMap(
|
||||
([provider, providerConfig]) =>
|
||||
providerConfig.models.map((m) => ({ model: m, provider: provider as AIProvider }))
|
||||
)
|
||||
if (Object.keys(aiConfig.providers ?? {}).length > 0) {
|
||||
const aiModels = Object.entries(aiConfig.providers ?? {}).flatMap(
|
||||
([provider, providerConfig]) =>
|
||||
providerConfig.models.map((m) => ({ model: m, provider: provider as AIProvider }))
|
||||
)
|
||||
|
||||
copilotSessionModel.update((model) => {
|
||||
if (
|
||||
model &&
|
||||
!aiModels.some((m) => m.model === model.model && m.provider === model.provider)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return model
|
||||
})
|
||||
copilotSessionModel.update((model) => {
|
||||
if (
|
||||
model &&
|
||||
!aiModels.some((m) => m.model === model.model && m.provider === model.provider)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return model
|
||||
})
|
||||
|
||||
copilotInfo.set({
|
||||
enabled: true,
|
||||
codeCompletionModel: aiConfig.code_completion_model,
|
||||
defaultModel: aiConfig.default_model,
|
||||
aiModels: aiModels,
|
||||
customPrompts: aiConfig.custom_prompts ?? {},
|
||||
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}
|
||||
})
|
||||
} else {
|
||||
copilotSessionModel.set(undefined)
|
||||
copilotInfo.set({
|
||||
enabled: true,
|
||||
codeCompletionModel: aiConfig.code_completion_model,
|
||||
defaultModel: aiConfig.default_model,
|
||||
aiModels: aiModels,
|
||||
customPrompts: aiConfig.custom_prompts ?? {},
|
||||
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}
|
||||
})
|
||||
} else {
|
||||
copilotSessionModel.set(undefined)
|
||||
|
||||
copilotInfo.set({
|
||||
enabled: false,
|
||||
codeCompletionModel: undefined,
|
||||
defaultModel: undefined,
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {}
|
||||
})
|
||||
}
|
||||
copilotInfo.set({
|
||||
enabled: false,
|
||||
codeCompletionModel: undefined,
|
||||
defaultModel: undefined,
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentModel() {
|
||||
const model =
|
||||
get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0]
|
||||
if (!model) {
|
||||
throw new Error('No model selected')
|
||||
}
|
||||
return model
|
||||
export function getCurrentModel(): AIProviderModel {
|
||||
const model =
|
||||
get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0]
|
||||
if (!model) {
|
||||
throw new Error('No model selected')
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
export function tryGetCurrentModel(): AIProviderModel | undefined {
|
||||
return get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0]
|
||||
}
|
||||
|
||||
export function getUserCustomPrompts(): Record<string, string> {
|
||||
const stored = getLocalSetting(USER_CUSTOM_PROMPTS_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user custom prompts', e)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
const stored = getLocalSetting(USER_CUSTOM_PROMPTS_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user custom prompts', e)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function getCombinedCustomPrompt(mode: string): string | undefined {
|
||||
const workspacePrompt = get(copilotInfo).customPrompts?.[mode]
|
||||
const userPrompts = getUserCustomPrompts()
|
||||
const userPrompt = userPrompts[mode]
|
||||
const workspacePrompt = get(copilotInfo).customPrompts?.[mode]
|
||||
const userPrompts = getUserCustomPrompts()
|
||||
const userPrompt = userPrompts[mode]
|
||||
|
||||
const prompts = [workspacePrompt, userPrompt].filter((p) => p?.trim())
|
||||
const prompts = [workspacePrompt, userPrompt].filter((p) => p?.trim())
|
||||
|
||||
if (prompts.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
if (prompts.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return prompts.join('\n\n')
|
||||
return prompts.join('\n\n')
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
import DiffDrawer from './DiffDrawer.svelte'
|
||||
import DeployWorkspaceDrawer from './DeployWorkspaceDrawer.svelte'
|
||||
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
import { deployItem, getItemValue, getOnBehalfOfEmail } from '$lib/utils_workspace_deploy'
|
||||
@@ -119,6 +119,8 @@
|
||||
// Source workspace on_behalf_of emails (keyed by workspace/kind:path)
|
||||
let onBehalfOfInfo = $state<Record<string, string | undefined>>({})
|
||||
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
|
||||
let customOnBehalfOfEmails = $state<Record<string, string>>({})
|
||||
let deployTargetWorkspace = $derived(mergeIntoParent ? parentWorkspaceId : currentWorkspaceId)
|
||||
|
||||
function getItemKey(diff: WorkspaceItemDiff): string {
|
||||
return `${diff.kind}:${diff.path}`
|
||||
@@ -210,14 +212,9 @@
|
||||
return onBehalfOfInfo[getWorkspacedKey(targetWorkspace, itemKey)]
|
||||
}
|
||||
|
||||
// Check if an item needs on_behalf_of selection (more than 1 unique option)
|
||||
// Check if an item needs on_behalf_of selection
|
||||
function itemNeedsOnBehalfOfSelection(itemKey: string, kind: string): boolean {
|
||||
return needsOnBehalfOfSelection(
|
||||
kind,
|
||||
getSourceEmail(itemKey),
|
||||
getTargetEmail(itemKey),
|
||||
$userStore?.email
|
||||
)
|
||||
return needsOnBehalfOfSelection(kind, getSourceEmail(itemKey))
|
||||
}
|
||||
|
||||
// Check if all required on_behalf_of selections are made
|
||||
@@ -234,8 +231,8 @@
|
||||
// Get the email to use for deployment based on user's choice
|
||||
function getOnBehalfOfEmailForDeploy(itemKey: string): string | undefined {
|
||||
const choice = onBehalfOfChoice[itemKey]
|
||||
if (choice === 'source') return getSourceEmail(itemKey)
|
||||
if (choice === 'target') return getTargetEmail(itemKey)
|
||||
if (choice === 'custom') return customOnBehalfOfEmails[itemKey]
|
||||
// 'me' or undefined = don't pass, backend will use deploying user's email
|
||||
return undefined
|
||||
}
|
||||
@@ -868,7 +865,6 @@
|
||||
{#snippet itemActions(item)}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const sourceEmail = getSourceEmail(key)}
|
||||
{@const targetEmail = getTargetEmail(key)}
|
||||
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
|
||||
{@const existsInBothWorkspaces = !(
|
||||
@@ -878,12 +874,16 @@
|
||||
<!-- On-behalf-of selector -->
|
||||
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
|
||||
<OnBehalfOfSelector
|
||||
{sourceEmail}
|
||||
targetWorkspace={deployTargetWorkspace}
|
||||
{targetEmail}
|
||||
selected={onBehalfOfChoice[key]}
|
||||
onSelect={(choice) => (onBehalfOfChoice[key] = choice)}
|
||||
onSelect={(choice, email) => {
|
||||
onBehalfOfChoice[key] = choice
|
||||
if (email) customOnBehalfOfEmails[key] = email
|
||||
}}
|
||||
kind={diff.kind}
|
||||
canPreserve={canPreserveOnBehalfOf}
|
||||
customEmail={customOnBehalfOfEmails[key]}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Status badges -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user