Compare commits

...

16 Commits

Author SHA1 Message Date
Diego Imbert
6bafef661c Merge branch 'main' into di/db-manager-backend-scripts 2026-03-09 14:00:11 +01:00
centdix
2828616a79 chore: webmux config#8279 2026-03-09 12:58:58 +00:00
Ruben Fiszel
73d27e92dd feat: add secretKeyRef support for package registry and storage credentials (#8275)
* feat: add secretKeyRef support for package registry and storage credentials

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

* chore: update ee-repo-ref for test coverage commit

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

* chore: update ee-repo-ref to 716b350bce1730b302c66ea69df618fa40f2f16b

This commit updates the EE repository reference after PR #443 was merged in windmill-ee-private.

Previous ee-repo-ref: d8498f003af407853eb1e98673d86d1816dbfeae

New ee-repo-ref: 716b350bce1730b302c66ea69df618fa40f2f16b

Automated by sync-ee-ref workflow.

* fix: box::pin database executor futures to prevent stack overflow

The if-else chain for database languages (postgresql, mysql, bigquery,
snowflake, mssql, oracledb, duckdb, graphql, nativets) was awaiting
futures directly on the stack. With all features enabled, the combined
async state machine became too large for the default thread stack size,
causing stack overflow in test_workflow_as_code.

The match block for main languages already used Box::pin; this applies
the same pattern to the database language branches.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-09 10:35:16 +00:00
hugocasa
41e523f827 fix: parallel branchall hang on bad stop_after_all_iters_if + results.x.length null (#8276)
Two fixes:

1. When a parallel branchall/forloop has a `stop_after_all_iters_if` expression
   that fails (e.g. bad JS syntax), the error was propagated with `?`, causing
   the transaction to roll back the parallel index increment. Since all parallel
   jobs were already completed, nothing could ever increment the index again and
   the flow hung forever. Now the error is caught and converted to a stop-early
   failure so the transaction commits and the flow fails gracefully.

2. Expressions like `results.a.length` in step input transforms resolved to null
   because the `handle_full_regex` fast path intercepted them and used
   PostgreSQL's `#>` JSON path operator, which can't resolve JS runtime
   properties like `.length` on arrays. Now the fast path skips expressions
   ending with JS-only properties (like `length`), falling through to full
   QuickJS evaluation where they work correctly.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:45:35 +00:00
Ruben Fiszel
8b1fe8f9de fix: gracefully handle uninitialized OTEL tracing proxy port (#8274)
* fix: gracefully handle uninitialized OTEL tracing proxy port

When OTEL tracing proxy is enabled but the MITM proxy port hasn't been
assigned yet (race condition at startup, or NUM_WORKERS > 1), fall back
to standard proxy envs instead of failing the job with
"OTEL tracing proxy port not initialized".

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

* fix: log to job logs when OTEL tracing proxy is unavailable

When the OTEL tracing proxy is enabled but the port isn't initialized
(race at startup or NUM_WORKERS > 1), append a warning to the job logs
explaining why HTTP request tracing is unavailable for that job.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:34:21 +00:00
claude[bot]
c97cf604ab fix: guard iteration picker VirtualList against empty items array (#8273)
When a flow loops over an empty array, the VirtualList component crashes
trying to access index 0 in an empty range. Add a guard to only render
VirtualList when items.length > 0, showing a "No iterations" message
otherwise.

Fixes #8272

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:19:33 +00:00
Ruben Fiszel
5ba4029d86 fix: skip down migrations in potentially_stale checksum comparison (#8271)
The potentially_stale block iterated over all migrations including
.down.sql reversible migrations. Down migrations share the same version
as their up counterpart but have a different checksum, causing the
DELETE to remove the up migration row on every startup and triggering
re-application of the concurrent index migrations.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:18:22 +00:00
Ruben Fiszel
e75763dbe5 fix: mask secrets in OAuth config debug/log output (#8269)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:44:47 +00:00
hugocasa
ce8ac9cf52 fix: sql input horizontal scroll missing after switching flow steps (#8249)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:41:14 +00:00
claude[bot]
7e7d7645e2 docs: ban $bindable(default_value) on optional props in CLAUDE.md (#8267)
Add a "Banned Patterns" section documenting that $bindable(default_value)
on props that can be undefined is banned. The correct alternatives are
using $derived(my_prop ?? default_value) or creating a useMyPropState()
helper higher in the component tree.

Closes #8266

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-07 18:55:40 +00:00
Ruben Fiszel
037035e094 fix: remove $bindable() fallback values causing props_invalid_value error in oauth settings (#8265)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com>
2026-03-07 19:51:38 +01:00
Ruben Fiszel
24078d736c same darkMode props_invalid_value fix in flows/dev/+page.svelte (#8262)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:06:45 +00:00
Ruben Fiszel
3a2258745d initialize darkMode in Dev.svelte to avoid props_invalid_value error (#8260)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:54:42 +00:00
Colin Lienard
0330993cb6 fix(frontend): unsaved changes dialog when flow already saved (#8259) 2026-03-07 15:45:53 +00:00
Diego Imbert
1d78589940 fix: Database studio fixes (#8251)
* disable dynamic fields for db studio config

* Fix SQL safe interpolated arg

* Fix db studio not passing AppEditorContext to modal

* Fix db studio modal grid not being able to move/resize components
2026-03-06 16:32:50 +00:00
Diego Imbert
822723ae69 DB Manager backend scripts 2026-03-06 15:32:37 +01:00
49 changed files with 2128 additions and 496 deletions

View File

@@ -1,113 +1,52 @@
name: Windmill
# Project display name in the dashboard
name: windmill
workspace:
mainBranch: main
worktreeRoot: ../__worktrees
defaultAgent: claude
startupEnvs:
CARGO_FEATURES: "quickjs"
WM_CLONE_DB: false
USE_RUST_PLUGIN: false
lifecycleHooks:
postCreate: bash ./scripts/post-create.sh
preRemove: bash ./scripts/pre-remove.sh
# Each service defines a port env var that webmux injects into pane and agent
# process environments when creating a worktree. Ports are auto-assigned:
# base + (slot x step).
services:
- name: BE
- name: backend
portEnv: BACKEND_PORT
- name: FE
portStart: 8000
portStep: 10
- name: frontend
portEnv: FRONTEND_PORT
portStart: 3000
portStep: 10
profiles:
default:
name: default
runtime: host
envPassthrough: []
panes:
- id: agent
kind: agent
focus: true
- id: backend
kind: command
split: right
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}"
- id: frontend
kind: command
split: bottom
command: ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
sandbox:
name: sandbox
image: windmill-sandbox
envPassthrough:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- R2_ENDPOINT
- R2_BUCKET
- R2_PUBLIC_URL
extraMounts:
- hostPath: ~/.ssh
guestPath: /root/.ssh
writable: true
- hostPath: ~/.codex
guestPath: /root/.codex
writable: true
- hostPath: ~/windmill-ee-private
writable: true
- hostPath: ~/windmill-ee-private__worktrees
writable: true
systemPrompt: >
You are running inside a sandboxed container with full permissions.
This worktree is configured with the following ports:
- Backend: port ${BACKEND_PORT}.
Start with: cd backend && PORT=${BACKEND_PORT}
DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill
cargo watch -x run
- Frontend: port ${FRONTEND_PORT}.
Start with: cd frontend && REMOTE=http://localhost:${BACKEND_PORT}
npm run dev -- --port ${FRONTEND_PORT} --host 0.0.0.0
--- Screenshots ---
You can take screenshots of the frontend UI and upload them to R2
for use in PR descriptions.
1) Take a screenshot:
bunx playwright screenshot --browser chromium
http://localhost:${FRONTEND_PORT}/path/to/page /tmp/screenshot.png
2) Upload to R2:
aws s3 cp /tmp/screenshot.png
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/screenshot.png"
--endpoint-url "$(printenv R2_ENDPOINT)"
3) The public URL will be:
$(printenv R2_PUBLIC_URL)/<branch>/screenshot.png
4) Include in PR descriptions using markdown image syntax.
--- Terminal Recordings (asciinema) ---
You can record terminal sessions and upload them for sharing.
asciinema is available on PATH.
1) Write a shell script with the commands to demo. Add sleep
delays for readable pacing:
- 0.5s after printing a "$ command" line (lets viewer read it)
- 1.5-2s after command output (lets viewer absorb the result)
- Set GIT_PAGER=cat and PAGER=cat to prevent pager hangs
2) Record headlessly:
asciinema rec --headless --overwrite \
-c "bash /tmp/demo.sh" \
--window-size 120x50 \
--title "Description of demo" \
/tmp/demo.cast
3) Upload to asciinema.org:
XDG_DATA_HOME=/tmp/.local/share \
asciinema upload --server-url https://asciinema.org /tmp/demo.cast
--- Mermaid Diagrams ---
You can render Mermaid diagrams to SVG using the pre-installed mmdc CLI.
The puppeteer config (no-sandbox + Chromium path) is at /root/.puppeteerrc.json.
1) Write a .mmd file with your diagram:
cat > /tmp/diagram.mmd << 'EOF'
graph TD
A[Start] --> B[End]
EOF
2) Render to SVG (the -p flag is required):
mmdc -i /tmp/diagram.mmd -o /tmp/diagram.svg -p /root/.puppeteerrc.json
3) Upload to R2:
aws s3 cp /tmp/diagram.svg
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/diagram.svg"
--endpoint-url "$(printenv R2_ENDPOINT)"
4) The public URL will be:
$(printenv R2_PUBLIC_URL)/<branch>/diagram.svg
5) Include in PR descriptions using markdown image syntax.
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
linkedRepos:
- repo: windmill-labs/windmill-ee-private
alias: ee
integrations:
github:
linkedRepos: []
linear:
enabled: true

View File

@@ -26,6 +26,27 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Login**: `admin@windmill.dev` / `changeme`
- **Instance settings**: navigate to `/#superadmin-settings`
## Banned Patterns
### `$bindable(default_value)` on optional props
Using `$bindable(default_value)` on props that can be `undefined` is **banned**. This pattern causes subtle bugs because the default value masks the `undefined` state.
**Bad:**
```svelte
let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props()
```
**Correct alternatives:**
1. **Use `$derived` with nullish coalescing** — handle the potential `undefined` at the usage site:
```svelte
let { my_prop = $bindable() }: { my_prop?: string } = $props()
let effective_value = $derived(my_prop ?? default_value)
```
2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value.
## Core Principles
- Search for existing code to reuse before writing new code

View File

@@ -1 +1 @@
f9549c813b3dba5324ea9d1edacc8756a6d699bf
716b350bce1730b302c66ea69df618fa40f2f16b

View File

@@ -238,7 +238,7 @@ lazy_static::lazy_static! {
// used for `unsafe` sql interpolation
// -- %%name%% (type) = default
static ref RE_ARG_SQL_INTERPOLATION: Regex = Regex::new(r#"(?m)^--\s*%%([a-z_][a-z0-9_]*)%%\s*([\s\w\/]+)?(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
static ref RE_ARG_SQL_INTERPOLATION: Regex = Regex::new(r#"(?m)^--\s*%%([a-z_][a-z0-9_]*)%%[ \t]*([\w][\w \t\/]*)?(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
}
fn parsed_default(parsed_typ: &Typ, default: String) -> Option<serde_json::Value> {
@@ -1547,4 +1547,36 @@ SELECT $1::integer;
Ok(())
}
#[test]
fn test_parse_pgsql_safe_interpolated_args() -> anyhow::Result<()> {
// There was a bug where enum would be "angrycreative"/"bishop"/"test SELECT x"
let code = r#"
-- %%table_name%% angrycreative/bishop/test
SELECT x
"#;
assert_eq!(
parse_pgsql_sig(code)?,
MainArgSignature {
star_args: false,
star_kwargs: false,
args: vec![Arg {
otyp: Some("__sanitized_enum__".to_string()),
name: "table_name".to_string(),
typ: Typ::Str(Some(vec![
"angrycreative".to_string(),
"bishop".to_string(),
"test".to_string()
])),
default: None,
has_default: false,
oidx: None,
},],
no_main_func: None,
has_preprocessor: None
}
);
Ok(())
}
}

View File

@@ -3548,3 +3548,170 @@ async fn test_flow_substep_tag_availability_check(db: Pool<Postgres>) -> anyhow:
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_stop_after_all_iters_if_bad_expr_parallel_branchall(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let port = 123;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
"modules": [
{
"id": "a",
"value": {
"branches": [
{"modules": [{
"id": "b",
"value": {
"input_transforms": { "n": { "type": "javascript", "expr": "flow_input.n" } },
"type": "rawscript",
"language": "python3",
"content": "def main(n): return n",
},
}]}
],
"type": "branchall",
"parallel": true,
},
"stop_after_all_iters_if": {
"expr": "invalid!!!syntax",
"skip_if_stopped": false,
},
},
],
}))
.unwrap();
let job = JobPayload::RawFlow { value: flow, path: None, restarted_from: None };
let cjob = RunJob::from(job)
.arg("n", json!(42))
.run_until_complete(&db, false, port)
.await;
assert!(
!cjob.success,
"flow should fail when stop_after_all_iters_if has bad expression"
);
let result = cjob.json_result().unwrap();
let error_msg = result["error"]["message"].as_str().unwrap_or("");
assert!(
error_msg.contains("stop_after_all_iters_if"),
"error should mention stop_after_all_iters_if, got: {error_msg}"
);
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_stop_after_all_iters_if_bad_expr_parallel_forloop(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let port = 123;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
"modules": [
{
"id": "a",
"value": {
"type": "forloopflow",
"iterator": { "type": "javascript", "expr": "result.items" },
"skip_failures": false,
"parallel": true,
"modules": [{
"value": {
"input_transforms": {
"n": { "type": "javascript", "expr": "flow_input.iter.value" },
},
"type": "rawscript",
"language": "python3",
"content": "def main(n): return n",
},
}],
},
"stop_after_all_iters_if": {
"expr": "invalid!!!syntax",
"skip_if_stopped": false,
},
},
],
}))
.unwrap();
let job = JobPayload::RawFlow { value: flow, path: None, restarted_from: None };
let cjob = RunJob::from(job)
.arg("items", json!([1, 2, 3]))
.run_until_complete(&db, false, port)
.await;
assert!(
!cjob.success,
"flow should fail when stop_after_all_iters_if has bad expression"
);
let result = cjob.json_result().unwrap();
let error_msg = result["error"]["message"].as_str().unwrap_or("");
assert!(
error_msg.contains("stop_after_all_iters_if"),
"error should mention stop_after_all_iters_if, got: {error_msg}"
);
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_results_length_in_input_transform(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Step a returns a list, step b accesses results.a.length via input transform.
// This tests that the handle_full_regex fast path falls through to QuickJS
// when the SQL JSON path operator can't resolve JS properties like .length.
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"id": "a",
"value": {
"type": "rawscript",
"language": "python3",
"content": "def main(): return [10, 20, 30]",
},
},
{
"id": "b",
"value": {
"input_transforms": {
"v": { "type": "javascript", "expr": "results.a.length" },
},
"type": "rawscript",
"language": "python3",
"content": "def main(v): return v",
},
},
],
}))
.unwrap();
let result =
RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(
result,
json!(3),
"results.a.length should resolve to 3, not null"
);
Ok(())
}

View File

@@ -1877,6 +1877,15 @@ pub struct ExecuteApp {
pub run_query_params: Option<RunJobQuery>,
}
fn maybe_replace_internal_db_script(mut raw_code: RawCode) -> RawCode {
if let Some(replaced) =
crate::db_studio_scripts::maybe_replace_internal_script(&raw_code.content)
{
raw_code.content = replaced;
}
raw_code
}
fn digest(code: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(code);
@@ -2138,13 +2147,30 @@ async fn execute_component(
// flow or script:
(Some(path), None, None) => get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?,
// inline script: in "preview" mode or without entry in the `app_script` table.
(None, Some(raw_code), None) => (JobPayload::Code(raw_code), None, None),
(None, Some(raw_code), None) => {
let raw_code = maybe_replace_internal_db_script(raw_code);
(JobPayload::Code(raw_code), None, None)
}
// inline script: in "run" mode and with an entry in the `app_script` table.
(None, Some(RawCode { language, path, cache_ttl, .. }), Some(id)) => (
JobPayload::AppScript { id: AppScriptId(id), cache_ttl, language, path },
None,
None,
),
(None, Some(raw_code), Some(id)) => {
// Check if this is an internal DB script marker — if so, replace content and
// execute as Code (preview-style) since the content is server-controlled.
if raw_code
.content
.trim_start()
.starts_with(crate::db_studio_scripts::WM_INTERNAL_PREFIX)
{
let raw_code = maybe_replace_internal_db_script(raw_code);
(JobPayload::Code(raw_code), None, None)
} else {
let RawCode { language, path, cache_ttl, .. } = raw_code;
(
JobPayload::AppScript { id: AppScriptId(id), cache_ttl, language, path },
None,
None,
)
}
}
_ => unreachable!(),
};
let tx = PushIsolationLevel::IsolatedRoot(db.clone());

View File

@@ -284,6 +284,9 @@ pub async fn migrate(
20260207000004,
];
for m in migrator.migrations.iter() {
if m.migration_type.is_down_migration() {
continue;
}
if potentially_stale.contains(&m.version) {
if let Err(err) =
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = $1 AND checksum != $2")

File diff suppressed because it is too large Load Diff

View File

@@ -4612,21 +4612,26 @@ async fn run_preview_script(
match preview.kind {
Some(PreviewKind::Identity) => JobPayload::Identity,
Some(PreviewKind::Noop) => JobPayload::Noop,
_ => JobPayload::Code(RawCode {
hash: preview
.script_hash
.as_ref()
.and_then(|s| windmill_common::scripts::to_i64(s).ok()),
content: preview.content.unwrap_or_default(),
path: preview.path,
language: preview.language.unwrap_or(ScriptLang::Deno),
lock: preview.lock,
concurrency_settings: ConcurrencySettingsWithCustom::default(), // TODO(gbouv): once I find out how to store limits in the content of a script, should be easy to plug limits here
debouncing_settings: DebouncingSettings::default(), // TODO(pyra): same as for concurrency limits.
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: preview.dedicated_worker,
}),
_ => {
let content = preview.content.unwrap_or_default();
let content = crate::db_studio_scripts::maybe_replace_internal_script(&content)
.unwrap_or(content);
JobPayload::Code(RawCode {
hash: preview
.script_hash
.as_ref()
.and_then(|s| windmill_common::scripts::to_i64(s).ok()),
content,
path: preview.path,
language: preview.language.unwrap_or(ScriptLang::Deno),
lock: preview.lock,
concurrency_settings: ConcurrencySettingsWithCustom::default(), // TODO(gbouv): once I find out how to store limits in the content of a script, should be easy to plug limits here
debouncing_settings: DebouncingSettings::default(), // TODO(pyra): same as for concurrency limits.
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: preview.dedicated_worker,
})
}
},
push_args,
authed.display_username(),

View File

@@ -76,6 +76,7 @@ mod bedrock;
mod capture;
mod concurrency_groups;
mod db;
mod db_studio_scripts;
mod drafts;
#[cfg(feature = "private")]

View File

@@ -44,7 +44,7 @@ pub struct EnvRefWrapper {
///
/// `Literal` serializes back to a plain JSON string, preserving backwards
/// compatibility with existing consumers.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[derive(Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum StringOrSecretRef {
@@ -53,6 +53,16 @@ pub enum StringOrSecretRef {
EnvRef(EnvRefWrapper),
}
impl fmt::Debug for StringOrSecretRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Literal(_) => f.write_str("Literal(****)"),
Self::SecretRef(w) => f.debug_tuple("SecretRef").field(w).finish(),
Self::EnvRef(w) => f.debug_tuple("EnvRef").field(w).finish(),
}
}
}
impl StringOrSecretRef {
/// Returns the literal string value, or `None` if this is an unresolved ref.
pub fn as_literal(&self) -> Option<&str> {
@@ -255,25 +265,25 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_python_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_index_url: Option<String>,
pub pip_index_url: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_extra_index_url: Option<String>,
pub pip_extra_index_url: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub npm_config_registry: Option<String>,
pub npm_config_registry: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bunfig_install_scopes: Option<String>,
pub bunfig_install_scopes: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub npmrc: Option<String>,
pub npmrc: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nuget_config: Option<String>,
pub nuget_config: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maven_repos: Option<String>,
pub maven_repos: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ruby_repos: Option<String>,
pub ruby_repos: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub powershell_repo_url: Option<String>,
pub powershell_repo_url: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub powershell_repo_pat: Option<String>,
pub powershell_repo_pat: Option<StringOrSecretRef>,
// Array settings
#[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -152,6 +152,21 @@ pub fn try_exact_property_access(
None
}
/// JS runtime properties (not methods) that cannot be resolved by PostgreSQL's
/// #> JSON path operator. Function calls like .map(...) already don't match the
/// RE_FULL regex due to parentheses, so only property accesses need listing here.
const JS_ONLY_PROPERTIES: &[&str] = &["length"];
fn ends_with_js_only_property(rest: Option<&str>) -> bool {
match rest {
None => false,
Some(rest) => {
let last_segment = rest.rsplit('.').next().unwrap_or("");
JS_ONLY_PROPERTIES.contains(&last_segment)
}
}
}
pub async fn handle_full_regex(
expr: &str,
authed_client: &AuthedClient,
@@ -162,6 +177,13 @@ pub async fn handle_full_regex(
let obj_key = captures.get(2).unwrap().as_str();
let idx_o = captures.get(3).map(|y| y.as_str());
let rest = captures.get(4).map(|y| y.as_str());
// Skip the SQL fast path when the expression accesses a JS runtime
// property (e.g. .length) that the PostgreSQL #> operator can't resolve.
if ends_with_js_only_property(rest) {
return None;
}
let query = if let Some(idx) = idx_o {
match rest {
Some(rest) => Some(format!("{}{}", idx, rest)),

View File

@@ -94,7 +94,7 @@ pub struct OAuthConfig {
}
/// OAuth client credentials
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct OAuthClient {
#[serde(default = "empty_string")]
pub id: String,
@@ -110,6 +110,21 @@ pub struct OAuthClient {
pub grant_types: Vec<String>,
}
impl std::fmt::Debug for OAuthClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthClient")
.field("id", &self.id)
.field("secret", &"***")
.field("display_name", &self.display_name)
.field("allowed_domains", &self.allowed_domains)
.field("connect_config", &self.connect_config)
.field("login_config", &self.login_config)
.field("tenant", &self.tenant)
.field("grant_types", &self.grant_types)
.finish()
}
}
fn empty_string() -> String {
"".to_string()
}
@@ -608,7 +623,18 @@ pub async fn refresh_token<'c>(
.await?;
let account = windmill_common::utils::not_found_if_none(account, "Account", &id.to_string())?;
refresh_token_for_account(tx, path, w_id, id, db, account, oauth_clients, http_client, connect_configs_json).await
refresh_token_for_account(
tx,
path,
w_id,
id,
db,
account,
oauth_clients,
http_client,
connect_configs_json,
)
.await
}
/// Refresh an OAuth token given pre-fetched account info (no additional SELECT).

View File

@@ -215,7 +215,10 @@ exit $exit_status
.current_dir(job_dir)
.env_clear()
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Bash).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Bash, &job.id, &job.workspace_id, conn)
.await?,
)
.env("PATH", PATH_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
.args(cmd_args)
@@ -241,7 +244,10 @@ exit $exit_status
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Bash).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Bash, &job.id, &job.workspace_id, conn)
.await?,
)
.env("PATH", PATH_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
.env("HOME", HOME_ENV.as_str())

View File

@@ -1564,7 +1564,9 @@ try {{
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Bun).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Bun, &job.id, &job.workspace_id, conn).await?,
)
.envs(common_bun_proc_envs)
.env("PATH", PATH_ENV.as_str())
.args(args)
@@ -1582,7 +1584,10 @@ try {{
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Bun).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Bun, &job.id, &job.workspace_id, conn)
.await?,
)
.envs(common_bun_proc_envs)
.stdin(Stdio::null())
.stdout(Stdio::piped())
@@ -1613,7 +1618,10 @@ try {{
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Bun).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Bun, &job.id, &job.workspace_id, conn)
.await?,
)
.envs(common_bun_proc_envs)
.stdin(Stdio::null())
.stdout(Stdio::piped())

View File

@@ -600,7 +600,10 @@ pub async fn handle_csharp_job(
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::CSharp).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::CSharp, &job.id, &job.workspace_id, conn)
.await?,
)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
@@ -633,7 +636,10 @@ pub async fn handle_csharp_job(
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::CSharp).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::CSharp, &job.id, &job.workspace_id, conn)
.await?,
)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("DOTNET_CLI_HOME", &*CSHARP_CACHE_DIR)

View File

@@ -121,11 +121,13 @@ async fn get_common_deno_proc_envs(
}
// Add proxy envs (including OTEL tracing proxy if enabled for deno)
for (k, v) in get_proxy_envs_for_lang(&ScriptLang::Deno)
.await
.unwrap_or_default()
{
deno_envs.insert(k.to_string(), v);
if let Some(conn) = conn {
for (k, v) in get_proxy_envs_for_lang(&ScriptLang::Deno, job_id, w_id, conn)
.await
.unwrap_or_default()
{
deno_envs.insert(k.to_string(), v);
}
}
return deno_envs;

View File

@@ -354,7 +354,7 @@ func Run(req Req) (interface{{}}, error){{
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Go).await?)
.envs(get_proxy_envs_for_lang(&ScriptLang::Go, &job.id, &job.workspace_id, conn).await?)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
@@ -375,7 +375,7 @@ func Run(req Req) (interface{{}}, error){{
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Go).await?)
.envs(get_proxy_envs_for_lang(&ScriptLang::Go, &job.id, &job.workspace_id, conn).await?)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)

View File

@@ -264,7 +264,7 @@ async fn run<'a>(
.env("BASE_INTERNAL_URL", base_internal_url)
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Nu).await?)
.envs(get_proxy_envs_for_lang(&ScriptLang::Nu, &job.id, &job.workspace_id, conn).await?)
.args(vec![
"--config",
"run.config.proto",
@@ -303,7 +303,7 @@ async fn run<'a>(
.env("BASE_INTERNAL_URL", base_internal_url)
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Nu).await?)
.envs(get_proxy_envs_for_lang(&ScriptLang::Nu, &job.id, &job.workspace_id, conn).await?)
// TODO(v1):
// "--plugins",
// &format!(

View File

@@ -841,7 +841,10 @@ mount {{
.env_clear()
// inject PYTHONPATH here - for some reason I had to do it in nsjail conf
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Python3).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Python3, &job.id, &job.workspace_id, conn)
.await?,
)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
@@ -867,7 +870,10 @@ mount {{
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Python3).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Python3, &job.id, &job.workspace_id, conn)
.await?,
)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)

View File

@@ -812,7 +812,10 @@ mount {{
.envs(envs)
.envs(reserved_variables)
.envs(RUBY_PROXY_ENVS.clone())
.envs(get_proxy_envs_for_lang(&ScriptLang::Ruby).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Ruby, &job.id, &job.workspace_id, conn)
.await?,
)
.args(vec![
"--config",
"run.config.proto",
@@ -851,7 +854,10 @@ mount {{
.env("BASE_INTERNAL_URL", base_internal_url)
.envs(reserved_variables)
.envs(RUBY_PROXY_ENVS.clone())
.envs(get_proxy_envs_for_lang(&ScriptLang::Ruby).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Ruby, &job.id, &job.workspace_id, conn)
.await?,
)
.envs(envs);
cmd.stdin(Stdio::null())

View File

@@ -700,7 +700,10 @@ pub async fn handle_rust_job(
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Rust).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Rust, &job.id, &job.workspace_id, conn)
.await?,
)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
@@ -716,7 +719,10 @@ pub async fn handle_rust_job(
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(get_proxy_envs_for_lang(&ScriptLang::Rust).await?)
.envs(
get_proxy_envs_for_lang(&ScriptLang::Rust, &job.id, &job.workspace_id, conn)
.await?,
)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)

View File

@@ -745,21 +745,37 @@ pub async fn is_otel_tracing_proxy_enabled_for_lang(lang: &ScriptLang) -> bool {
/// Otherwise, uses the standard HTTP_PROXY/HTTPS_PROXY from environment.
pub async fn get_proxy_envs_for_lang(
lang: &ScriptLang,
job_id: &uuid::Uuid,
w_id: &str,
conn: &Connection,
) -> anyhow::Result<Vec<(&'static str, String)>> {
#[cfg(all(feature = "private", feature = "enterprise"))]
if is_otel_tracing_proxy_enabled_for_lang(lang).await {
return get_otel_tracing_proxy_envs().await;
return get_otel_tracing_proxy_envs(job_id, w_id, conn).await;
}
let _ = lang;
let _ = (lang, job_id, w_id, conn);
Ok(PROXY_ENVS.clone())
}
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_otel_tracing_proxy_envs() -> anyhow::Result<Vec<(&'static str, String)>> {
let port = crate::otel_tracing_proxy_ee::TRACING_PROXY_PORT
async fn get_otel_tracing_proxy_envs(
job_id: &uuid::Uuid,
w_id: &str,
conn: &Connection,
) -> anyhow::Result<Vec<(&'static str, String)>> {
let port = match *crate::otel_tracing_proxy_ee::TRACING_PROXY_PORT
.read()
.await
.ok_or_else(|| anyhow::anyhow!("OTEL tracing proxy port not initialized"))?;
{
Some(p) => p,
None => {
let reason = "OTEL tracing proxy is enabled but not available (not initialized yet, or NUM_WORKERS > 1). \
This job's HTTP requests will not be traced.";
tracing::warn!("{}", reason);
append_logs(job_id, w_id, format!("\n[warning] {reason}\n"), conn).await;
return Ok(PROXY_ENVS.clone());
}
};
let proxy_url = format!("http://127.0.0.1:{}", port);
Ok(vec![
("HTTP_PROXY", proxy_url.clone()),
@@ -3883,7 +3899,7 @@ pub async fn run_language_executor(
run_inline: bool,
) -> error::Result<Box<RawValue>> {
if language == Some(ScriptLang::Postgresql) {
return do_postgresql(
return Box::pin(do_postgresql(
job,
&client,
&code,
@@ -3895,7 +3911,7 @@ pub async fn run_language_executor(
occupancy_metrics,
parent_runnable_path,
run_inline,
)
))
.await;
} else if language == Some(ScriptLang::Mysql) {
#[cfg(not(feature = "mysql"))]
@@ -3910,7 +3926,7 @@ pub async fn run_language_executor(
"Inline execution is not yet supported for this language".to_string(),
));
}
return do_mysql(
return Box::pin(do_mysql(
job,
&client,
&code,
@@ -3921,7 +3937,7 @@ pub async fn run_language_executor(
column_order,
occupancy_metrics,
parent_runnable_path,
)
))
.await;
}
} else if language == Some(ScriptLang::Bigquery) {
@@ -3947,7 +3963,7 @@ pub async fn run_language_executor(
"Inline execution is not yet supported for this language".to_string(),
));
}
return do_bigquery(
return Box::pin(do_bigquery(
job,
&client,
&code,
@@ -3958,7 +3974,7 @@ pub async fn run_language_executor(
column_order,
occupancy_metrics,
parent_runnable_path,
)
))
.await;
}
} else if language == Some(ScriptLang::Snowflake) {
@@ -3976,7 +3992,7 @@ pub async fn run_language_executor(
"Inline execution is not yet supported for this language".to_string(),
));
}
return do_snowflake(
return Box::pin(do_snowflake(
job,
&client,
&code,
@@ -3987,7 +4003,7 @@ pub async fn run_language_executor(
column_order,
occupancy_metrics,
parent_runnable_path,
)
))
.await;
}
} else if language == Some(ScriptLang::Mssql) {
@@ -4013,7 +4029,7 @@ pub async fn run_language_executor(
"Inline execution is not yet supported for this language".to_string(),
));
}
return do_mssql(
return Box::pin(do_mssql(
job,
&client,
&code,
@@ -4024,7 +4040,7 @@ pub async fn run_language_executor(
occupancy_metrics,
job_dir,
parent_runnable_path,
)
))
.await;
}
} else if language == Some(ScriptLang::OracleDB) {
@@ -4050,7 +4066,7 @@ pub async fn run_language_executor(
"Inline execution is not yet supported for this language".to_string(),
));
}
return do_oracledb(
return Box::pin(do_oracledb(
job,
&client,
&code,
@@ -4061,7 +4077,7 @@ pub async fn run_language_executor(
column_order,
occupancy_metrics,
parent_runnable_path,
)
))
.await;
}
} else if language == Some(ScriptLang::DuckDb) {
@@ -4075,7 +4091,7 @@ pub async fn run_language_executor(
#[cfg(feature = "duckdb")]
{
return do_duckdb(
return Box::pin(do_duckdb(
job,
&client,
&code,
@@ -4087,7 +4103,7 @@ pub async fn run_language_executor(
occupancy_metrics,
parent_runnable_path,
run_inline,
)
))
.await;
}
} else if language == Some(ScriptLang::Graphql) {
@@ -4096,7 +4112,7 @@ pub async fn run_language_executor(
"Inline execution is not yet supported for this language".to_string(),
));
}
return do_graphql(
return Box::pin(do_graphql(
job,
&client,
&code,
@@ -4105,7 +4121,7 @@ pub async fn run_language_executor(
canceled_by,
worker_name,
occupancy_metrics,
)
))
.await;
} else if language == Some(ScriptLang::Nativets) {
if run_inline {
@@ -4132,7 +4148,7 @@ pub async fn run_language_executor(
.collect::<Vec<String>>()
.join("\n"));
let result = do_nativets(
let result = Box::pin(do_nativets(
job,
&client,
env_code,
@@ -4143,7 +4159,7 @@ pub async fn run_language_executor(
worker_name,
occupancy_metrics,
has_stream,
)
))
.await?;
return Ok(result);
}

View File

@@ -865,7 +865,7 @@ pub async fn update_flow_status_after_job_completion_internal(
.and_then(|x| x.stop_after_all_iters_if.as_ref())
{
let args = from_result_to_args(args.as_ref().await.get_ref())?;
evaluate_stop_after_all_iters_if(
if let Err(e) = evaluate_stop_after_all_iters_if(
db,
stop_after_all_iters_if,
module_status,
@@ -879,7 +879,16 @@ pub async fn update_flow_status_after_job_completion_internal(
flow,
&old_status,
)
.await?;
.await
{
tracing::error!("error evaluating stop_after_all_iters_if: {e:#}");
stop_early = true;
skip_if_stop_early = false;
stop_early_err_msg = Some(format!(
"Error evaluating stop_after_all_iters_if expression `{}`: {e:#}",
stop_after_all_iters_if.expr
));
}
}
let new_status = if
@@ -1074,7 +1083,7 @@ pub async fn update_flow_status_after_job_completion_internal(
{
let args = from_result_to_args(args.as_ref().await.get_ref())?;
evaluate_stop_after_all_iters_if(
if let Err(e) = evaluate_stop_after_all_iters_if(
db,
stop_after_all_iters_if,
module_status,
@@ -1088,7 +1097,15 @@ pub async fn update_flow_status_after_job_completion_internal(
flow,
&old_status,
)
.await?;
.await
{
stop_early = true;
skip_if_stop_early = false;
stop_early_err_msg = Some(format!(
"Error evaluating stop_after_all_iters_if expression `{}`: {e:#}",
stop_after_all_iters_if.expr
));
}
}
}

View File

@@ -15,14 +15,7 @@
};
}
let { connect_config = $bindable({
scopes: ['offline_access'],
auth_url: '',
token_url: '',
req_body_auth: true,
extra_params: { tenant_id: '' },
extra_params_callback: {}
}) }: Props = $props();
let { connect_config = $bindable() }: Props = $props();
run(() => {
if (!connect_config) {
@@ -38,13 +31,14 @@
});
run(() => {
if (connect_config.extra_params.tenant_id) {
if (connect_config?.extra_params?.tenant_id) {
connect_config.auth_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/authorize`
connect_config.token_url = `https://login.microsoftonline.com/${connect_config.extra_params.tenant_id}/oauth2/v2.0/token`
}
});
</script>
{#if connect_config}
<label class="flex flex-col gap-1" for="tenant-id">
<span class="text-primary font-semibold text-xs flex gap-2 items-center"> Azure tenant id </span>
<span class="text-secondary font-normal text-xs">
@@ -73,3 +67,4 @@
<OauthScopes bind:scopes={connect_config.scopes} />
</div>
</label>
{/if}

View File

@@ -6,14 +6,7 @@
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
let { connect_config = $bindable({
scopes: [],
auth_url: '',
token_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
}) } = $props();
let { connect_config = $bindable() } = $props();
run(() => {
if (!connect_config) {

View File

@@ -6,15 +6,7 @@
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
let { login_config = $bindable({
scopes: [],
auth_url: '',
token_url: '',
userinfo_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
}) } = $props();
let { login_config = $bindable() } = $props();
run(() => {
if (!login_config) {

View File

@@ -109,7 +109,7 @@
}
let darkModeToggle: DarkModeToggle | undefined = $state()
let darkMode: boolean | undefined = $state(undefined)
let darkMode: boolean = $state(document.documentElement.classList.contains('dark'))
let modeInitialized = $state(false)
function initializeMode() {
modeInitialized = true

View File

@@ -6,9 +6,15 @@
extra_params?: Record<string, string>;
}
let { extra_params = $bindable({}) }: Props = $props();
let { extra_params = $bindable() }: Props = $props();
let extra_params_vec: [string, string][] = $state(Object.entries(extra_params))
$effect.pre(() => {
if (!extra_params) {
extra_params = {}
}
})
let extra_params_vec: [string, string][] = $state(Object.entries(extra_params ?? {}))
function sync() {
extra_params = Object.fromEntries(extra_params_vec)

View File

@@ -6,7 +6,13 @@
scopes?: string[]
}
let { scopes = $bindable([]) }: Props = $props()
let { scopes = $bindable() }: Props = $props()
$effect.pre(() => {
if (!scopes) {
scopes = []
}
})
</script>
{#if scopes && Array.isArray(scopes)}
@@ -18,7 +24,7 @@
size="xs"
btnClasses="mx-6"
on:click={() => {
scopes = scopes.filter((el) => el != v)
scopes = scopes?.filter((el) => el != v)
}}
startIcon={{ icon: Minus }}
iconOnly

View File

@@ -438,7 +438,6 @@
let cip
let extraModel
let width = $state(0)
// let widgets: HTMLElement | undefined = document.getElementById('monaco-widgets-root') ?? undefined
let initialized = $state(false)
@@ -545,9 +544,6 @@
if (divEl) {
divEl.style.height = `${contentHeight}px`
}
try {
editor?.layout({ width, height: contentHeight })
} catch {}
}
editor.onDidContentSizeChange(updateHeight)
updateHeight()
@@ -718,7 +714,6 @@
bind:this={divEl}
style="height: 18px;"
class="template nonmain-editor rounded-md overflow-clip {!editor ? 'hidden' : ''}"
bind:clientWidth={width}
></div>
</div>

View File

@@ -1,5 +1,4 @@
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
import { wrapDucklakeQuery } from '../../../../../ducklake'
import type { DbType, DbInput } from '$lib/components/dbTypes'
import { buildParameters } from '../utils'
import { getLanguageByResourceType, type ColumnDef, buildVisibleFieldList } from '../utils'
@@ -142,6 +141,28 @@ export function makeCountQuery(
return query
}
export function buildCountMarker(
table: string,
columnDefs: ColumnDef[],
whereClause: string | undefined,
dbType: DbType,
ducklake?: string
): string {
const params: Record<string, unknown> = {
table,
column_defs: columnDefs.map((c) => ({
field: c.field,
datatype: c.datatype,
isprimarykey: c.isprimarykey,
ignored: c.ignored ?? false
})),
where_clause: whereClause ?? null,
db_type: dbType
}
if (ducklake) params.ducklake = ducklake
return `-- WM_INTERNAL_DB_COUNT_SCRIPT\n${JSON.stringify(params)}`
}
export function getCountInput(
dbInput: DbInput,
table: string,
@@ -157,8 +178,8 @@ export function getCountInput(
return undefined
}
const dbType = dbInput.type === 'ducklake' ? 'duckdb' : dbInput.resourceType
let query = makeCountQuery(dbType, table, whereClause, columnDefs)
if (dbInput.type === 'ducklake') query = wrapDucklakeQuery(query, dbInput.ducklake)
const ducklake = dbInput.type === 'ducklake' ? dbInput.ducklake : undefined
const query = buildCountMarker(table, columnDefs, whereClause, dbType, ducklake)
const updateRunnable: RunnableByName = {
name: 'AppDbExplorer',

View File

@@ -1,6 +1,5 @@
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
import type { DbType, DbInput } from '$lib/components/dbTypes'
import { wrapDucklakeQuery } from '../../../../../ducklake'
import { getLanguageByResourceType, type ColumnDef, buildParameters } from '../utils'
export function makeDeleteQuery(table: string, columns: ColumnDef[], dbType: DbType) {
@@ -66,6 +65,21 @@ export function makeDeleteQuery(table: string, columns: ColumnDef[], dbType: DbT
}
}
export function buildDeleteMarker(
table: string,
columns: ColumnDef[],
dbType: DbType,
ducklake?: string
): string {
const params: Record<string, unknown> = {
table,
columns: columns.map((c) => ({ field: c.field, datatype: c.datatype })),
db_type: dbType
}
if (ducklake) params.ducklake = ducklake
return `-- WM_INTERNAL_DB_DELETE_SCRIPT\n${JSON.stringify(params)}`
}
export function getDeleteInput(
dbInput: DbInput,
table: string,
@@ -79,8 +93,8 @@ export function getDeleteInput(
return undefined
}
const dbType = dbInput.type === 'ducklake' ? 'duckdb' : dbInput.resourceType
let query = makeDeleteQuery(table, columns, dbType)
if (dbInput.type === 'ducklake') query = wrapDucklakeQuery(query, dbInput.ducklake)
const ducklake = dbInput.type === 'ducklake' ? dbInput.ducklake : undefined
const query = buildDeleteMarker(table, columns, dbType, ducklake)
const deleteRunnable: RunnableByName = {
name: 'AppDbExplorer',
type: 'inline',

View File

@@ -1,5 +1,4 @@
import type { AppInput } from '$lib/components/apps/inputType'
import { wrapDucklakeQuery } from '../../../../../ducklake'
import type { DbType, DbInput } from '$lib/components/dbTypes'
import { buildParameters, ColumnIdentity } from '../utils'
import { getLanguageByResourceType, type ColumnDef } from '../utils'
@@ -106,10 +105,37 @@ export function makeInsertQuery(table: string, columns: ColumnDef[], dbType: DbT
return query
}
export function buildInsertMarker(
table: string,
columns: ColumnDef[],
dbType: DbType,
ducklake?: string
): string {
const params: Record<string, unknown> = {
table,
columns: columns.map((c) => ({
field: c.field,
datatype: c.datatype,
isprimarykey: c.isprimarykey,
ignored: c.ignored ?? false,
isnullable: c.isnullable ?? 'YES',
isidentity: c.isidentity ?? 'No',
defaultvalue: c.defaultvalue ?? null,
hideInsert: c.hideInsert ?? false,
overrideDefaultValue: c.overrideDefaultValue ?? false,
defaultUserValue: c.defaultUserValue ?? null,
defaultValueNull: c.defaultValueNull ?? false
})),
db_type: dbType
}
if (ducklake) params.ducklake = ducklake
return `-- WM_INTERNAL_DB_INSERT_SCRIPT\n${JSON.stringify(params)}`
}
export function getInsertInput(dbInput: DbInput, table: string, columns: ColumnDef[]): AppInput {
const dbType = dbInput.type === 'ducklake' ? 'duckdb' : dbInput.resourceType
let query = makeInsertQuery(table, columns, dbType)
if (dbInput.type === 'ducklake') query = wrapDucklakeQuery(query, dbInput.ducklake)
const ducklake = dbInput.type === 'ducklake' ? dbInput.ducklake : undefined
const query = buildInsertMarker(table, columns, dbType, ducklake)
return {
runnable: {
name: 'AppDbExplorer',

View File

@@ -1,5 +1,4 @@
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
import { wrapDucklakeQuery } from '../../../../../ducklake'
import type { DbType, DbInput } from '$lib/components/dbTypes'
import { buildParameters } from '../utils'
import { getLanguageByResourceType, type ColumnDef, buildVisibleFieldList } from '../utils'
@@ -318,6 +317,33 @@ function coerceToNumber(value: any): number {
return 0
}
export function buildSelectMarker(
table: string,
columnDefs: ColumnDef[],
whereClause: string | undefined,
dbType: DbType,
ducklake?: string
): string {
const params: Record<string, unknown> = {
table,
column_defs: columnDefs.map((c) => ({
field: c.field,
datatype: c.datatype,
isprimarykey: c.isprimarykey,
ignored: c.ignored ?? false,
editable: c.editable ?? false,
isnullable: c.isnullable ?? 'YES',
isidentity: c.isidentity ?? 'No',
defaultvalue: c.defaultvalue ?? null,
hideInsert: c.hideInsert ?? false
})),
where_clause: whereClause ?? null,
db_type: dbType
}
if (ducklake) params.ducklake = ducklake
return `-- WM_INTERNAL_DB_SELECT_SCRIPT\n${JSON.stringify(params)}`
}
export function getSelectInput(
dbInput: DbInput,
table: string | undefined,
@@ -335,8 +361,8 @@ export function getSelectInput(
}
const dbType = dbInput.type === 'ducklake' ? 'duckdb' : dbInput.resourceType
let content = makeSelectQuery(table, columnDefs, whereClause, dbType, options)
if (dbInput.type === 'ducklake') content = wrapDucklakeQuery(content, dbInput.ducklake)
const ducklake = dbInput.type === 'ducklake' ? dbInput.ducklake : undefined
const content = buildSelectMarker(table, columnDefs, whereClause, dbType, ducklake)
const getRunnable: RunnableByName = {
name: 'AppDbExplorer',
type: 'inline',

View File

@@ -1,5 +1,4 @@
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
import { wrapDucklakeQuery } from '../../../../../ducklake'
import type { DbInput, DbType } from '$lib/components/dbTypes'
import { getLanguageByResourceType, type ColumnDef, buildParameters } from '../utils'
@@ -76,6 +75,23 @@ export function makeUpdateQuery(
}
}
export function buildUpdateMarker(
table: string,
column: { datatype: string; field: string },
columns: { datatype: string; field: string }[],
dbType: DbType,
ducklake?: string
): string {
const params: Record<string, unknown> = {
table,
column: { field: column.field, datatype: column.datatype },
columns: columns.map((c) => ({ field: c.field, datatype: c.datatype })),
db_type: dbType
}
if (ducklake) params.ducklake = ducklake
return `-- WM_INTERNAL_DB_UPDATE_SCRIPT\n${JSON.stringify(params)}`
}
export function getUpdateInput(
dbInput: DbInput,
table: string,
@@ -90,8 +106,8 @@ export function getUpdateInput(
return undefined
}
const dbType = dbInput.type === 'ducklake' ? 'duckdb' : dbInput.resourceType
let query = makeUpdateQuery(table, column, columns, dbType)
if (dbInput.type === 'ducklake') query = wrapDucklakeQuery(query, dbInput.ducklake)
const ducklake = dbInput.type === 'ducklake' ? dbInput.ducklake : undefined
const query = buildUpdateMarker(table, column, columns, dbType, ducklake)
const updateRunnable: RunnableByName = {
name: 'AppDbExplorer',

View File

@@ -4,7 +4,12 @@
import { type GridApi, createGrid, type IDatasource } from 'ag-grid-community'
import { sendUserToast } from '$lib/utils'
import { createEventDispatcher, getContext, mount, unmount, untrack } from 'svelte'
import type { AppViewerContext, ComponentCustomCSS, ContextPanelContext } from '../../../types'
import {
type AppEditorContext,
type AppViewerContext,
type ComponentCustomCSS,
type ContextPanelContext
} from '../../../types'
import type { TableAction, components } from '$lib/components/apps/editor/component'
import { deepEqual } from 'fast-equals'
@@ -62,9 +67,15 @@
const context = getContext<AppViewerContext>('AppViewerContext')
const contextPanel = getContext<ContextPanelContext>('ContextPanel')
const editorContext = getContext<AppEditorContext>('AppEditorContext')
const { app, selectedComponent, componentControl, darkMode, mode } = context
let css = $state(initCss($app.css?.aggridcomponent, untrack(() => customCss)))
let css = $state(
initCss(
$app.css?.aggridcomponent,
untrack(() => customCss)
)
)
let selectedRowIndex = -1
@@ -151,7 +162,8 @@
const componentContext = new Map<string, any>([
['AppViewerContext', context],
['ContextPanel', contextPanel]
['ContextPanel', contextPanel],
['AppEditorContext', editorContext]
])
const taComponent = withProps(AppAggridTableActions, {

View File

@@ -280,10 +280,7 @@
}
} catch {}
} else {
const drawerAlreadyHandledFocusedGrid =
item?.data.type === 'drawercomponent' &&
$focusedGrid?.parentComponentId === befSelected
if (!drawerAlreadyHandledFocusedGrid) {
if ($focusedGrid?.parentComponentId !== befSelected) {
$focusedGrid = undefined
}
}

View File

@@ -4159,14 +4159,16 @@ This is a paragraph.
type: 'static',
fieldType: 'resource',
subFieldType: 'mysql',
value: ''
value: '',
allowTypeChange: false
} as StaticAppInput,
table: {
fieldType: 'select',
subFieldType: 'db-table',
type: 'static',
selectOptions: [],
value: undefined
value: undefined,
allowTypeChange: false
}
},
ms_sql_server: {
@@ -4174,14 +4176,16 @@ This is a paragraph.
type: 'static',
fieldType: 'resource',
subFieldType: 'ms_sql_server',
value: ''
value: '',
allowTypeChange: false
} as StaticAppInput,
table: {
fieldType: 'select',
subFieldType: 'db-table',
type: 'static',
selectOptions: [],
value: undefined
value: undefined,
allowTypeChange: false
}
},
snowflake: {
@@ -4189,14 +4193,16 @@ This is a paragraph.
type: 'static',
fieldType: 'resource',
subFieldType: 'snowflake',
value: ''
value: '',
allowTypeChange: false
} as StaticAppInput,
table: {
fieldType: 'select',
subFieldType: 'db-table',
type: 'static',
selectOptions: [],
value: undefined
value: undefined,
allowTypeChange: false
}
},
bigquery: {
@@ -4204,14 +4210,16 @@ This is a paragraph.
type: 'static',
fieldType: 'resource',
subFieldType: 'bigquery',
value: ''
value: '',
allowTypeChange: false
} as StaticAppInput,
table: {
fieldType: 'select',
subFieldType: 'db-table',
type: 'static',
selectOptions: [],
value: undefined
value: undefined,
allowTypeChange: false
}
},
ducklake: {
@@ -4219,14 +4227,16 @@ This is a paragraph.
type: 'static',
fieldType: 'ducklake',
subFieldType: 'ducklake',
value: ''
value: '',
allowTypeChange: false
} as StaticAppInput,
table: {
fieldType: 'select',
subFieldType: 'db-table',
type: 'static',
selectOptions: [],
value: undefined
value: undefined,
allowTypeChange: false
}
},
datatable: {
@@ -4234,14 +4244,16 @@ This is a paragraph.
type: 'static',
fieldType: 'datatable',
subFieldType: 'datatable',
value: ''
value: '',
allowTypeChange: false
} as StaticAppInput,
table: {
fieldType: 'select',
subFieldType: 'db-table',
type: 'static',
selectOptions: [],
value: undefined
value: undefined,
allowTypeChange: false
}
}
}

View File

@@ -221,7 +221,7 @@
const pointerdown = ({ clientX, clientY }) => {
dragClosure = () => {
dragClosure = undefined
ctx.componentActive.set(true)
ctx?.componentActive.set(true)
initX = (clientX / $scale) * 100
initY = (clientY / $scale) * 100
@@ -401,7 +401,7 @@
}, 50)
const pointerup = (e) => {
ctx.componentActive.set(false)
ctx?.componentActive.set(false)
stopAutoscroll()
window.removeEventListener('pointerdown', pointerdown)

View File

@@ -3,12 +3,12 @@ import {
type ColumnDef,
type TableMetadata
} from './apps/components/display/dbtable/utils'
import { makeSelectQuery } from './apps/components/display/dbtable/queries/select'
import { buildSelectMarker } from './apps/components/display/dbtable/queries/select'
import { runScriptAndPollResult } from './jobs/utils'
import { makeCountQuery } from './apps/components/display/dbtable/queries/count'
import { makeUpdateQuery } from './apps/components/display/dbtable/queries/update'
import { makeDeleteQuery } from './apps/components/display/dbtable/queries/delete'
import { makeInsertQuery } from './apps/components/display/dbtable/queries/insert'
import { buildCountMarker } from './apps/components/display/dbtable/queries/count'
import { buildUpdateMarker } from './apps/components/display/dbtable/queries/update'
import { buildDeleteMarker } from './apps/components/display/dbtable/queries/delete'
import { buildInsertMarker } from './apps/components/display/dbtable/queries/insert'
import { makeDeleteTableQuery } from './apps/components/display/dbtable/queries/deleteTable'
import type { DBSchema, SQLSchema } from '$lib/stores'
import { stringifySchema } from './copilot/lib'
@@ -68,8 +68,8 @@ export function dbTableOpsWithPreviewScripts({
tableKey,
colDefs,
getCount: async ({ quicksearch }) => {
let countQuery = makeCountQuery(dbType, tableKey, undefined, colDefs)
if (input.type === 'ducklake') countQuery = wrapDucklakeQuery(countQuery, input.ducklake)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
const countQuery = buildCountMarker(tableKey, colDefs, undefined, dbType, ducklake)
const result = await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, quicksearch }, language, content: countQuery }
@@ -78,10 +78,8 @@ export function dbTableOpsWithPreviewScripts({
return count
},
getRows: async (params) => {
let query = makeSelectQuery(tableKey, colDefs, undefined, dbType, undefined, {
fixPgIntTypes: true
})
if (input.type === 'ducklake') query = wrapDucklakeQuery(query, input.ducklake)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
const query = buildSelectMarker(tableKey, colDefs, undefined, dbType, ducklake)
let items = (await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, ...params }, language, content: query }
@@ -92,8 +90,8 @@ export function dbTableOpsWithPreviewScripts({
return items
},
onUpdate: async ({ values }, colDef, newValue) => {
let updateQuery = makeUpdateQuery(tableKey, colDef, colDefs, dbType)
if (input.type === 'ducklake') updateQuery = wrapDucklakeQuery(updateQuery, input.ducklake)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
const updateQuery = buildUpdateMarker(tableKey, colDef, colDefs, dbType, ducklake)
await runScriptAndPollResult({
workspace,
requestBody: {
@@ -104,16 +102,16 @@ export function dbTableOpsWithPreviewScripts({
})
},
onDelete: async ({ values }) => {
let deleteQuery = makeDeleteQuery(tableKey, colDefs, dbType)
if (input.type === 'ducklake') deleteQuery = wrapDucklakeQuery(deleteQuery, input.ducklake)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
const deleteQuery = buildDeleteMarker(tableKey, colDefs, dbType, ducklake)
await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, ...values }, language, content: deleteQuery }
})
},
onInsert: async ({ values }) => {
let insertQuery = makeInsertQuery(tableKey, colDefs, dbType)
if (input.type === 'ducklake') insertQuery = wrapDucklakeQuery(insertQuery, input.ducklake)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
const insertQuery = buildInsertMarker(tableKey, colDefs, dbType, ducklake)
await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, ...values }, language, content: insertQuery }

View File

@@ -130,7 +130,8 @@
const old = v.assets?.find((a) => assetEq(a, asset))
if (old?.alt_access_type) asset.alt_access_type = old.alt_access_type
}
if (!deepEqual(v.assets, newAssets)) v.assets = newAssets
const normalizedAssets = newAssets.length > 0 ? newAssets : undefined
if (!deepEqual(v.assets, normalizedAssets)) v.assets = normalizedAssets
}
// Check for raw script modules whose assets were not parsed. Useful for flows created

View File

@@ -142,6 +142,7 @@
<div class="max-h-[300px]">
{#key items}
{#if items.length > 0}
<VirtualList height={300} width="100%" itemCount={items.length} itemSize={24}>
{#snippet header()}{/snippet}
{#snippet footer()}{/snippet}
@@ -170,6 +171,9 @@
</div>
{/snippet}
</VirtualList>
{:else}
<div class="text-xs text-tertiary py-2 px-2">No iterations</div>
{/if}
{/key}
<!-- {#each flowJobs ?? [] as id, idx (id)}

View File

@@ -52,7 +52,7 @@
}
let darkModeToggle: DarkModeToggle | undefined = $state()
let darkMode: boolean | undefined = $state(undefined)
let darkMode: boolean = $state(document.documentElement.classList.contains('dark'))
let modeInitialized = $state(false)
function initializeMode() {
modeInitialized = true

25
scripts/post-create.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/worktree-common.sh"
backend_port="${BACKEND_PORT:-}"
frontend_port="${FRONTEND_PORT:-}"
if [[ -z "$backend_port" || -z "$frontend_port" ]]; then
echo "Missing BACKEND_PORT or FRONTEND_PORT in hook environment" >&2
exit 1
fi
cat > .env.local <<EOF
BACKEND_PORT=$backend_port
FRONTEND_PORT=$frontend_port
REMOTE=http://localhost:$backend_port
EOF
if [[ -n "${CARGO_FEATURES:-}" ]]; then
echo "CARGO_FEATURES=$CARGO_FEATURES" >> .env.local
fi
echo "Created .env.local with ports: backend=$backend_port, frontend=$frontend_port"
wm_shared_post_create "$(pwd)"

7
scripts/pre-remove.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/worktree-common.sh"
wm_kill_processes_from_env_file "$(pwd)/.env.local"
wm_shared_pre_remove "$(pwd)"

View File

@@ -1,73 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
# Use WM_WORKTREE_PATH (set by workmux) so this works regardless of cwd
source "$(dirname "${BASH_SOURCE[0]}")/worktree-common.sh"
wt_dir="${WM_WORKTREE_PATH:-.}"
echo "[cleanup] cwd=$(pwd) WM_WORKTREE_PATH=${WM_WORKTREE_PATH:-<unset>} wt_dir=$wt_dir"
# Kill backend/frontend processes using this worktree's ports
if [ -f "$wt_dir/.env.local" ]; then
source "$wt_dir/.env.local"
echo "[cleanup] .env.local found: BACKEND_PORT=${BACKEND_PORT:-<unset>} FRONTEND_PORT=${FRONTEND_PORT:-<unset>}"
for port in "${BACKEND_PORT:-}" "${FRONTEND_PORT:-}"; do
[ -z "$port" ] && continue
pid=$(lsof -ti "TCP:${port}" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "$pid" ]; then
kill "$pid" 2>/dev/null && echo "[cleanup] Killed process $pid on port $port" \
|| echo "[cleanup] Warning: Could not kill process $pid on port $port"
else
echo "[cleanup] No process listening on port $port"
fi
done
else
echo "[cleanup] No .env.local at $wt_dir/.env.local"
fi
# Drop per-worktree database
if [ -n "${WM_DB_NAME:-}" ]; then
db_conn="postgres://postgres:changeme@127.0.0.1:5432"
if command -v psql &>/dev/null; then
psql "$db_conn/postgres" -c "DROP DATABASE IF EXISTS ${WM_DB_NAME} WITH (FORCE)" 2>/dev/null \
&& echo "[cleanup] Dropped database $WM_DB_NAME" \
|| echo "[cleanup] Warning: Could not drop database $WM_DB_NAME"
else
echo "[cleanup] psql not found, skipping database cleanup for $WM_DB_NAME"
fi
else
echo "[cleanup] No WM_DB_NAME in .env.local, skipping database cleanup"
fi
# Remove the matching windmill-ee-private worktree if one exists
wt_basename=$(basename "$wt_dir")
# Find ee repo using same discovery logic as worktree-env
main_repo_root="$(cd "$(git -C "$wt_dir" rev-parse --git-common-dir 2>/dev/null)/.." && pwd)"
parent_dir="$(cd "$wt_dir/.." && pwd)"
echo "[cleanup] wt_basename=$wt_basename main_repo_root=$main_repo_root parent_dir=$parent_dir"
ee_repo=""
for candidate in \
"${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \
"${parent_dir}/windmill-ee-private" \
"${HOME}/windmill-ee-private" \
"${HOME}/projects/windmill-ee-private"; do
if [ -n "$candidate" ] && [ -d "$candidate" ]; then
ee_repo="$(cd "$candidate" && pwd)"
break
fi
done
if [ -z "$ee_repo" ]; then
echo "[cleanup] Could not find windmill-ee-private repo, skipping EE worktree cleanup"
fi
ee_worktree_dir="${ee_repo:+${ee_repo}__worktrees/${wt_basename}}"
echo "[cleanup] ee_repo=${ee_repo:-<not found>} ee_worktree_dir=${ee_worktree_dir:-<none>} exists=$([ -n "$ee_worktree_dir" ] && [ -d "$ee_worktree_dir" ] && echo yes || echo no)"
if [ -n "$ee_worktree_dir" ] && [ -d "$ee_worktree_dir" ]; then
git -C "$ee_repo" worktree remove "$ee_worktree_dir" --force 2>/dev/null \
&& echo "[cleanup] Removed EE worktree at $ee_worktree_dir" \
|| echo "[cleanup] Warning: Could not remove EE worktree at $ee_worktree_dir"
fi
# Clean up Cursor grouped tmux session
tmux kill-session -t "cursor-${wt_basename}" 2>/dev/null || true
wm_kill_processes_from_env_file "${wt_dir}/.env.local"
wm_shared_pre_remove "$wt_dir"

265
scripts/worktree-common.sh Executable file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env bash
set -euo pipefail
wm_is_true() {
case "${1:-}" in
1|true|TRUE|yes|YES|on|ON) return 0 ;;
*) return 1 ;;
esac
}
wm_main_repo_root() {
local repo_root=${1:-.}
cd "$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null)/.." && pwd
}
wm_setup_database() {
local repo_root=$1
local env_file=$2
local wt_basename db_name db_conn db_url license_key
wt_basename="$(basename "$repo_root")"
db_name="windmill_${wt_basename//-/_}"
db_conn="postgres://postgres:changeme@127.0.0.1:5432"
if ! command -v psql >/dev/null 2>&1; then
echo "WARNING: psql not found, skipping per-worktree database creation" >&2
return
fi
if psql "$db_conn/postgres" -tc "SELECT 1 FROM pg_database WHERE datname = '${db_name}'" 2>/dev/null | grep -q 1; then
echo "Database $db_name already exists"
else
if wm_is_true "${WM_CLONE_DB:-}"; then
psql "$db_conn/postgres" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'windmill' AND pid <> pg_backend_pid();" 2>/dev/null || true
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name} TEMPLATE windmill" 2>/dev/null \
&& echo "Created database $db_name (template: windmill)" \
|| echo "WARNING: Could not create database $db_name from template windmill" >&2
else
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name}" 2>/dev/null \
&& echo "Created database $db_name" \
|| echo "WARNING: Could not create database $db_name (is PostgreSQL running?)" >&2
fi
fi
db_url="${db_conn}/${db_name}?sslmode=disable"
DATABASE_URL="$db_url" sqlx migrate run --source "${repo_root}/backend/migrations" \
&& echo "Migrations applied to $db_name" \
|| echo "WARNING: Could not run migrations on $db_name" >&2
license_key="$(psql "$db_conn/windmill" -t -A -c "SELECT value FROM global_settings WHERE name = 'license_key'" 2>/dev/null || true)"
if [[ -n "$license_key" ]]; then
psql "$db_url" -c "INSERT INTO global_settings (name, value) VALUES ('license_key', '${license_key}'::jsonb) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value" 2>/dev/null \
&& echo "Copied license_key to $db_name" \
|| echo "WARNING: Could not copy license_key to $db_name" >&2
fi
cat >> "$env_file" <<EOF
export DATABASE_URL=$db_url
WM_DB_NAME=$db_name
EOF
echo "Added DATABASE_URL for database $db_name to .env.local"
}
wm_copy_dependencies() {
local repo_root=$1
local main_repo_root=$2
if [[ -d "${main_repo_root}/frontend/node_modules" ]]; then
cp -a "${main_repo_root}/frontend/node_modules" "${repo_root}/frontend/"
echo "Copied frontend/node_modules (with symlinks preserved)"
fi
if [[ -d "${main_repo_root}/cli/node_modules" ]]; then
cp -a "${main_repo_root}/cli/node_modules" "${repo_root}/cli/"
echo "Copied cli/node_modules (with symlinks preserved)"
fi
if [[ -d "${repo_root}/cli" ]]; then
(cd "${repo_root}/cli" && npm install && npm run gen-client) \
&& echo "CLI deps installed and client generated" \
|| echo "WARNING: CLI setup failed" >&2
fi
}
wm_allow_direnv() {
local repo_root=$1
if command -v direnv >/dev/null 2>&1 && [[ -f "${repo_root}/.envrc" ]]; then
(cd "$repo_root" && direnv allow)
echo "direnv allowed"
fi
}
wm_trust_claude() {
local repo_root=$1
local claude_json="${HOME}/.claude.json"
if [[ ! -f "$claude_json" ]] || ! command -v python3 >/dev/null 2>&1; then
return
fi
REPO_ROOT="$repo_root" CLAUDE_JSON="$claude_json" python3 - <<'PY' \
&& echo "Added $repo_root to Claude Code trusted directories" \
|| echo "Warning: Could not update Claude Code trusted directories"
import json
import os
path = os.environ["REPO_ROOT"]
claude_json = os.environ["CLAUDE_JSON"]
with open(claude_json, "r") as f:
data = json.load(f)
projects = data.setdefault("projects", {})
proj = projects.setdefault(path, {})
proj["hasTrustDialogAccepted"] = True
proj["hasCompletedProjectOnboarding"] = True
with open(claude_json, "w") as f:
json.dump(data, f, indent=2)
PY
}
wm_find_ee_repo() {
local repo_root=$1
local main_repo_root=$2
local candidate
for candidate in \
"${main_repo_root}/../windmill-ee-private" \
"${repo_root}/../windmill-ee-private" \
"${HOME}/windmill-ee-private" \
"${HOME}/projects/windmill-ee-private"; do
if [[ -d "$candidate" ]]; then
cd "$candidate" && pwd
return 0
fi
done
return 1
}
wm_setup_ee_worktree() {
local repo_root=$1
local main_repo_root=$2
local ee_repo branch wt_basename ee_worktree_dir ee_rel rust_plugin
if ! ee_repo="$(wm_find_ee_repo "$repo_root" "$main_repo_root")"; then
return
fi
branch="$(git -C "$repo_root" branch --show-current 2>/dev/null || true)"
wt_basename="$(basename "$repo_root")"
ee_worktree_dir="${ee_repo}__worktrees/${wt_basename}"
if [[ -n "$branch" && ! -d "$ee_worktree_dir" ]]; then
mkdir -p "$(dirname "$ee_worktree_dir")"
git -C "$ee_repo" fetch --quiet 2>/dev/null || true
if git -C "$ee_repo" worktree add "$ee_worktree_dir" "$branch" 2>/dev/null; then
echo "Created EE worktree at $ee_worktree_dir (branch: $branch)"
elif git -C "$ee_repo" worktree add -b "$branch" "$ee_worktree_dir" main 2>/dev/null; then
echo "Created EE worktree at $ee_worktree_dir (new branch: $branch from main)"
else
echo "Warning: Could not create EE worktree for branch $branch"
fi
elif [[ -d "$ee_worktree_dir" ]]; then
echo "EE worktree already exists at $ee_worktree_dir"
fi
if [[ ! -d "$ee_worktree_dir" ]]; then
return
fi
ee_rel="$(REPO_ROOT="$repo_root" EE_WORKTREE_DIR="$ee_worktree_dir" python3 - <<'PY' 2>/dev/null || echo "$ee_worktree_dir"
import os
print(os.path.relpath(os.environ["EE_WORKTREE_DIR"], os.environ["REPO_ROOT"]))
PY
)"
mkdir -p "${repo_root}/.claude"
rust_plugin=""
if wm_is_true "${USE_RUST_PLUGIN:-}"; then
rust_plugin=',
"enabledPlugins": {
"rust-analyzer-lsp@claude-plugins-official": true
}'
fi
cat > "${repo_root}/.claude/settings.local.json" <<EOF
{
"permissions": {
"additionalDirectories": [
"$ee_rel"
]
}${rust_plugin}
}
EOF
echo "Created .claude/settings.local.json with EE path: $ee_rel"
if [[ -x "${repo_root}/backend/substitute_ee_code.sh" ]]; then
"${repo_root}/backend/substitute_ee_code.sh" -d "$ee_worktree_dir"
fi
}
wm_shared_post_create() {
local repo_root=$1
local main_repo_root
main_repo_root="$(wm_main_repo_root "$repo_root")"
wm_setup_database "$repo_root" "${repo_root}/.env.local"
wm_copy_dependencies "$repo_root" "$main_repo_root"
wm_allow_direnv "$repo_root"
wm_trust_claude "$repo_root"
wm_setup_ee_worktree "$repo_root" "$main_repo_root"
}
wm_kill_processes_from_env_file() {
local env_file=$1
local pid port
if [[ ! -f "$env_file" ]]; then
return
fi
# shellcheck disable=SC1090
source "$env_file"
for port in "${BACKEND_PORT:-}" "${FRONTEND_PORT:-}"; do
[[ -z "$port" ]] && continue
pid="$(lsof -ti "TCP:${port}" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "$pid" ]]; then
kill "$pid" 2>/dev/null && echo "Killed process $pid on port $port" \
|| echo "Warning: Could not kill process $pid on port $port"
fi
done
}
wm_shared_pre_remove() {
local repo_root=$1
local env_file="${repo_root}/.env.local"
local db_conn wt_basename main_repo_root ee_repo ee_worktree_dir
if [[ -f "$env_file" ]]; then
# shellcheck disable=SC1090
source "$env_file"
fi
if [[ -n "${WM_DB_NAME:-}" ]]; then
db_conn="postgres://postgres:changeme@127.0.0.1:5432"
if command -v psql >/dev/null 2>&1; then
psql "$db_conn/postgres" -c "DROP DATABASE IF EXISTS ${WM_DB_NAME} WITH (FORCE)" 2>/dev/null \
&& echo "Dropped database $WM_DB_NAME" \
|| echo "Warning: Could not drop database $WM_DB_NAME"
else
echo "psql not found, skipping database cleanup for $WM_DB_NAME"
fi
fi
main_repo_root="$(wm_main_repo_root "$repo_root")"
wt_basename="$(basename "$repo_root")"
if ee_repo="$(wm_find_ee_repo "$repo_root" "$main_repo_root")"; then
ee_worktree_dir="${ee_repo}__worktrees/${wt_basename}"
if [[ -d "$ee_worktree_dir" ]]; then
git -C "$ee_repo" worktree remove "$ee_worktree_dir" --force 2>/dev/null \
&& echo "Removed EE worktree at $ee_worktree_dir" \
|| echo "Warning: Could not remove EE worktree at $ee_worktree_dir"
fi
fi
tmux kill-session -t "cursor-${wt_basename}" 2>/dev/null || true
}

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/worktree-common.sh"
port_in_use() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN &>/dev/null
}
@@ -51,154 +53,4 @@ if [[ -n "${CARGO_FEATURES:-}" ]]; then
fi
echo "Created .env.local with ports: backend=$backend_port, frontend=$frontend_port"
# --- Create per-worktree database ---
wt_basename=$(basename "$(pwd)")
db_name="windmill_${wt_basename//-/_}"
db_conn="postgres://postgres:changeme@127.0.0.1:5432"
if command -v psql &>/dev/null; then
if psql "$db_conn/postgres" -tc "SELECT 1 FROM pg_database WHERE datname = '${db_name}'" 2>/dev/null | grep -q 1; then
echo "Database $db_name already exists"
else
if [[ "${WM_CLONE_DB:-}" == "1" || "${WM_CLONE_DB:-}" == "true" ]]; then
# Terminate active connections so CREATE DATABASE ... TEMPLATE works
psql "$db_conn/postgres" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'windmill' AND pid <> pg_backend_pid();" 2>/dev/null || true
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name} TEMPLATE windmill" 2>/dev/null \
&& echo "Created database $db_name (template: windmill)" \
|| echo "WARNING: Could not create database $db_name from template windmill" >&2
else
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name}" 2>/dev/null \
&& echo "Created database $db_name" \
|| echo "WARNING: Could not create database $db_name (is PostgreSQL running?)" >&2
fi
fi
db_url="${db_conn}/${db_name}?sslmode=disable"
# Run migrations against the new database
DATABASE_URL="$db_url" sqlx migrate run --source backend/migrations \
&& echo "Migrations applied to $db_name" \
|| echo "WARNING: Could not run migrations on $db_name" >&2
# Copy license_key from the main windmill database to the new database
license_key=$(psql "$db_conn/windmill" -t -A -c "SELECT value FROM global_settings WHERE name = 'license_key'" 2>/dev/null || true)
if [[ -n "$license_key" ]]; then
psql "$db_url" -c "INSERT INTO global_settings (name, value) VALUES ('license_key', '${license_key}'::jsonb) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value" 2>/dev/null \
&& echo "Copied license_key to $db_name" \
|| echo "WARNING: Could not copy license_key to $db_name" >&2
fi
# Use export so DATABASE_URL overrides the nix devshell value for child processes
cat >> .env.local <<EOF
export DATABASE_URL=$db_url
WM_DB_NAME=$db_name
EOF
echo "Added DATABASE_URL for database $db_name to .env.local"
else
echo "WARNING: psql not found, skipping per-worktree database creation" >&2
fi
# --- Copy frontend/node_modules preserving symlinks ---
# cp -a preserves .bin/ symlinks that cp -r would dereference, breaking require() paths
main_repo_root="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." && pwd)"
if [[ -n "$main_repo_root" && -d "$main_repo_root/frontend/node_modules" ]]; then
cp -a "$main_repo_root/frontend/node_modules" frontend/
echo "Copied frontend/node_modules (with symlinks preserved)"
fi
# --- Install cli deps and generate client ---
if [[ -n "$main_repo_root" && -d "$main_repo_root/cli/node_modules" ]]; then
cp -a "$main_repo_root/cli/node_modules" cli/
echo "Copied cli/node_modules (with symlinks preserved)"
fi
(cd cli && npm install && npm run gen-client) \
&& echo "CLI deps installed and client generated" \
|| echo "WARNING: CLI setup failed" >&2
# --- Allow direnv so the nix devshell activates in pane commands ---
if command -v direnv &>/dev/null && [ -f .envrc ]; then
direnv allow
echo "direnv allowed"
fi
# --- Trust worktree directory in Claude Code ---
claude_json="$HOME/.claude.json"
if [ -f "$claude_json" ]; then
wt_path="$(pwd)"
python3 -c "
import json, sys
path = '$wt_path'
with open('$claude_json', 'r') as f:
data = json.load(f)
projects = data.setdefault('projects', {})
proj = projects.setdefault(path, {})
proj['hasTrustDialogAccepted'] = True
proj['hasCompletedProjectOnboarding'] = True
with open('$claude_json', 'w') as f:
json.dump(data, f, indent=2)
" && echo "Added $wt_path to Claude Code trusted directories" \
|| echo "Warning: Could not update Claude Code trusted directories"
fi
# --- Create matching windmill-ee-private worktree ---
# Find ee repo: sibling to the main worktree (git toplevel of the main checkout),
# then try parent of cwd, then fall back to home
ee_repo=""
for candidate in \
"${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \
"$(pwd)/../windmill-ee-private" \
"${HOME}/windmill-ee-private" \
"${HOME}/projects/windmill-ee-private"; do
if [ -n "$candidate" ] && [ -d "$candidate" ]; then
ee_repo="$(cd "$candidate" && pwd)"
break
fi
done
if [ -n "$ee_repo" ]; then
branch=$(git branch --show-current 2>/dev/null || true)
wt_basename=$(basename "$(pwd)")
ee_worktree_dir="${ee_repo}__worktrees/${wt_basename}"
if [ -n "$branch" ] && [ ! -d "$ee_worktree_dir" ]; then
mkdir -p "$(dirname "$ee_worktree_dir")"
# Fetch latest so we can check out remote branches
git -C "$ee_repo" fetch --quiet 2>/dev/null || true
# Try: existing branch, then new branch from main
if git -C "$ee_repo" worktree add "$ee_worktree_dir" "$branch" 2>/dev/null; then
echo "Created EE worktree at $ee_worktree_dir (branch: $branch)"
elif git -C "$ee_repo" worktree add -b "$branch" "$ee_worktree_dir" main 2>/dev/null; then
echo "Created EE worktree at $ee_worktree_dir (new branch: $branch from main)"
else
echo "Warning: Could not create EE worktree for branch $branch"
fi
elif [ -d "$ee_worktree_dir" ]; then
echo "EE worktree already exists at $ee_worktree_dir"
fi
# Point Claude Code additionalDirectories at the EE worktree
if [ -d "$ee_worktree_dir" ]; then
ee_rel=$(python3 -c "import os; print(os.path.relpath('$ee_worktree_dir', '$(pwd)'))" 2>/dev/null || echo "$ee_worktree_dir")
mkdir -p .claude
rust_plugin=""
if [[ "${USE_RUST_PLUGIN:-}" == "1" || "${USE_RUST_PLUGIN:-}" == "true" ]]; then
rust_plugin=',
"enabledPlugins": {
"rust-analyzer-lsp@claude-plugins-official": true
}'
fi
cat > .claude/settings.local.json <<EOFCLAUDE
{
"permissions": {
"additionalDirectories": [
"$ee_rel"
]
}${rust_plugin}
}
EOFCLAUDE
echo "Created .claude/settings.local.json with EE path: $ee_rel"
# Create symlinks from backend crates to the EE worktree
if [ -x "./backend/substitute_ee_code.sh" ]; then
./backend/substitute_ee_code.sh -d "$ee_worktree_dir"
fi
fi
fi
wm_shared_post_create "$(pwd)"