Compare commits

...
Author SHA1 Message Date
Ruben Fiszelandrubenfiszel 9e4cf139b1 chore(main): release 1.738.0 (#9735)
* chore(main): release 1.738.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-23 21:08:15 +00:00
Ruben FiszelandClaude Opus 4.8 cfb9f1dbc2 feat: render mermaid diagrams in chat code blocks (#9738)
* feat: render mermaid diagrams in chat code blocks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: guard mermaid render against out-of-order async and transient streaming failures

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: only show mermaid diagram while it matches current source

Addresses Codex review: keeping the last good SVG through parse failures left a stale, mismatched diagram on screen when the source changed to something invalid. Tie the rendered SVG to the source that produced it and only display it while it still matches the current code, falling back to the raw source otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:02:10 +00:00
hugocasaandClaude Opus 4.8 cbf54d4eb4 fix: preserve fork parent linkage on workspace id change (#9716)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:54:07 +00:00
9793d01575 feat: add resource and infrastructure telemetry (#9737)
* feat(telemetry): disclose resource and infra usage stats

When minimal telemetry is disabled, the stats payload now includes resource
counts (workspaces, scripts per language, flows, workflows as code, low-code
and raw apps) and, on EE only, infrastructure info (container runtime,
database size, max connections, RDS detection).

Update the telemetry disclosure in instance settings accordingly: resource
counts are listed for both CE and EE; infra info is shown only on EE since it
is collected only there. Bump the EE ref and add the sqlx cache for the new
queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(telemetry): expand EE infra disclosure and add sysinfo dep

Disclose the expanded EE infrastructure telemetry (deployment mode, host
OS/arch/CPU/memory, filesystem space, Postgres version and connection counts,
object storage backend, sandboxing and retention settings) in instance
settings. Add sysinfo as a windmill-common dependency for host memory and
filesystem stats, bump the EE ref, and add the sqlx cache for the new queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(telemetry): focus EE infra disclosure on wrapping platform

Drop the single-server host details (OS, arch, CPU, memory, filesystem) and
tuning config from the EE infra disclosure, and revert the sysinfo dependency
they required. Reflect managed-database-provider detection in place of the RDS
flag. Bump the EE ref and update the sqlx cache for the revised queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(telemetry): drop deployment mode and worker count from disclosure

Remove deployment mode and worker count from the EE infra disclosure to match
the backend, and bump the EE ref. They reflect only the node sending telemetry,
not the deployment topology.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 6d3301507db50818f1683dac3941d3e0cf1152a7

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

Previous ee-repo-ref: d30e7d18d14992598a97356d0ed13f7d5d585115

New ee-repo-ref: 6d3301507db50818f1683dac3941d3e0cf1152a7

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-06-23 20:53:12 +00:00
hugocasaandClaude Opus 4.8 24446e8009 fix: allow object storage test for non-super-admins, harden on cloud (#9739)
* fix: allow non-super-admin object storage test, harden SSRF surface on cloud

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: validate effective object storage host to close region/bucket SSRF bypass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: validate gcs_base_url/token_uri in GCS service account key to close SSRF bypass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: match url scheme case-insensitively in object storage host validation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:50:40 +00:00
Ruben FiszelandClaude Opus 4.8 e90b2be8fa perf(monitor): skip protected prefix in retention delete via cross-batch watermark (WIN-2088) (#9744)
The expired-job retention loop re-scanned the same oldest rows on every batch. When
the oldest completed jobs are undeletable (children of a still-active root flow), the
ORDER BY completed_at ASC scan walked that protected prefix on each of the up-to-20
batches, doing a v2_job PK lookup per row — quadratic in prefix size (measured ~9s/batch,
~180s/cleanup-cycle on a 1.5M-row prefix).

Carry a completed_at watermark (max deleted) across batches and re-apply it as
completed_at >= floor so each batch resumes past the already-processed prefix. Also skip
the v2_job join entirely when no old root flow is active (the common case), since nothing
is protected then. Measured: subsequent batches 9000ms -> 159ms; empty-set path 154 -> 36ms.

The watermark only ever skips rows the current run already deleted, was protecting, or
skip-locked — all deferred to the next run, identical to the unbounded scan's row set
(verified: union of batched deletes == single delete, 0 diff). Mirrored in
windmill-api-settings log_cleanup.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:49:55 +00:00
29c67ced97 fix(frontend): debounce external code→Monaco sync in Editor (#9743)
* fix(frontend): debounce external code→Monaco sync in Editor

Make the external `code` prop → Monaco sync always-on and 500ms
debounced, replacing the opt-in `syncExternalCode` prop. Removes the
prop from the two inline rawscript call sites in FlowModuleComponent.

Includes temporary debug scaffolding (A→B executeEdits button and
console logs) for diagnosing successive-edit behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): share alignCodeWithEditor + bump debounce to 800ms

Extract the full-range executeEdits sync into alignCodeWithEditor() and
reuse it from both setCode and the debounced external-code effect. Bump
the external-sync debounce 500ms -> 800ms. ScriptEditor now calls
editor.setCode when syncing external code in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nits

* nits

* Fix AI not seeing latest code

* remvoe debug button

* nit types

* nits

* Nits

* Check timeoutModel is undefined

* fix(frontend): suppress editor echo in external code sync to prevent typing clobber

* fix(frontend): cancel pending keystroke debounce in setCode to prevent clobber

* fix(frontend): preserve pending external code write in updateCode

* Revert "fix(frontend): preserve pending external code write in updateCode"

This reverts commit 731d877730.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Imbert <diego@windmill.dev>
2026-06-23 20:45:02 +00:00
Ruben Fiszel 6dfccd9d88 frontend improvements 2026-06-23 17:52:22 +00:00
Diego Imbert fc797a35fe fix(ai-chat): Fix incorrect editor edits from ai chat #1 (#9741) 2026-06-23 15:23:06 +00:00
Diego ImbertandClaude Opus 4.8 11d0e65f3a fix(frontend): preserve editor content when closing instance settings drawer (#9740)
Closing the Instance settings drawer cleared the underlying script
editor. On unmount, SuperadminSettingsInner.removeHash() stripped the
`#superadmin-settings` hash with a SvelteKit `goto()`, and that
navigation re-fired the script editor page's path-reactive `$effect`,
reloading the script and wiping unsaved editor content.

Use `replaceState` to drop the hash without a navigation (matching the
existing RunForm.svelte pattern), guarded against router-teardown throws.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:31:41 +00:00
Ruben FiszelandClaude Opus 4.8 984ea728d9 fix: pipeline annotation false-positives from body comments (#9736)
* fix: reject pipeline `# tag` annotation false-positives on regular comments

`parse_pipeline_annotations` treats any comment line starting with
`# tag <text>` as a worker-tag annotation. In Python scripts, ordinary
English comments beginning with "# tag ..." were misinterpreted: values
over 50 chars failed the `script.tag` INSERT (varchar(50)), and shorter
ones silently overrode the script's worker tag.

Worker tags are single-word identifiers (e.g. `heavy`, `gpu`), so reject
any candidate that contains whitespace or exceeds 50 characters. Mirror
the same validation in the TS parity parser and add regression tests on
both sides.

Fixes WIN-2090

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: restrict pipeline annotation scan to the leading comment header

The root cause of the `# tag` false-positive is broader than the `tag`
keyword: `parse_pipeline_annotations` scanned every comment line in the
whole file, so any body comment matching an annotation grammar
(`on`, `freshness`, `tag`, `retry`, ...) was misinterpreted. The `tag`
case was the most visible because an over-length value crashed the
`script.tag` INSERT (varchar(50)).

Windmill's other comment-directive parsers (BashAnnotations::sandbox_image,
ssh_target) already scan only the leading comment header and stop at the
first line of real code. Align parse_pipeline_annotations (and its TS
mirror) with that convention: skip blank lines, break on the first
non-comment line. This eliminates body-comment false-positives for every
annotation, not just `tag`.

The `tag` whitespace/length guard from the previous commit is kept as
defense for prose that sits in the header itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:35:37 +00:00
57 changed files with 2143 additions and 388 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## [1.738.0](https://github.com/windmill-labs/windmill/compare/v1.737.0...v1.738.0) (2026-06-23)
### Features
* add resource and infrastructure telemetry ([#9737](https://github.com/windmill-labs/windmill/issues/9737)) ([9793d01](https://github.com/windmill-labs/windmill/commit/9793d01575415963a89609a1baf2cd64f0d050cc))
* render mermaid diagrams in chat code blocks ([#9738](https://github.com/windmill-labs/windmill/issues/9738)) ([cfb9f1d](https://github.com/windmill-labs/windmill/commit/cfb9f1dbc23110ecf8f91bb3c8c81fc6e35dc09b))
### Bug Fixes
* **ai-chat:** Fix incorrect editor edits from ai chat [#1](https://github.com/windmill-labs/windmill/issues/1) ([#9741](https://github.com/windmill-labs/windmill/issues/9741)) ([fc797a3](https://github.com/windmill-labs/windmill/commit/fc797a35fe7885630c81453df0fc94769e73873a))
* allow object storage test for non-super-admins, harden on cloud ([#9739](https://github.com/windmill-labs/windmill/issues/9739)) ([24446e8](https://github.com/windmill-labs/windmill/commit/24446e80093ade349f7fbf65063d2d1cb5551c1e))
* **frontend:** debounce external code→Monaco sync in Editor ([#9743](https://github.com/windmill-labs/windmill/issues/9743)) ([29c67ce](https://github.com/windmill-labs/windmill/commit/29c67ced97bf2919584986f9d9eceb4337c34ad9))
* **frontend:** preserve editor content when closing instance settings drawer ([#9740](https://github.com/windmill-labs/windmill/issues/9740)) ([11d0e65](https://github.com/windmill-labs/windmill/commit/11d0e65f3af9a048bc1921bbdd3d676a07483a57))
* pipeline annotation false-positives from body comments ([#9736](https://github.com/windmill-labs/windmill/issues/9736)) ([984ea72](https://github.com/windmill-labs/windmill/commit/984ea728d98649b66b1cae899bdab9af3176caa7))
* preserve fork parent linkage on workspace id change ([#9716](https://github.com/windmill-labs/windmill/issues/9716)) ([cbf54d4](https://github.com/windmill-labs/windmill/commit/cbf54d4eb432638e27f67c4c8b879cbcc0291da3))
* prevent variable push from corrupting is_secret variables ([#9705](https://github.com/windmill-labs/windmill/issues/9705)) ([ba4b368](https://github.com/windmill-labs/windmill/commit/ba4b368706e95e22f346a10e5fe145b0795ac3f6))
### Performance Improvements
* **monitor:** skip protected prefix in retention delete via cross-batch watermark (WIN-2088) ([#9744](https://github.com/windmill-labs/windmill/issues/9744)) ([e90b2be](https://github.com/windmill-labs/windmill/commit/e90b2be8fade1eb78cd685890291f5a4553a6a10))
## [1.737.0](https://github.com/windmill-labs/windmill/compare/v1.736.0...v1.737.0) (2026-06-23)
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT SUM(pg_database_size(datname))::BIGINT AS \"v!\" FROM pg_database",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "074dd26f6427f4ff97e92c35163ad042144e656adcca56a4936a2eb196d3f48c"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT language AS \"language!: _\", COUNT(*)::BIGINT AS \"count!\"\n FROM script\n WHERE archived = false AND deleted = false AND kind = 'script'\n AND (auto_kind IS NULL OR auto_kind <> 'wac')\n GROUP BY language\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "language!: _",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang"
]
}
}
}
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
null
]
},
"hash": "11813108dbf6b104eba968c3609c74ac5a589542d3e76b1e756a82fb19d49ee8"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::INT AS \"v!\" FROM pg_stat_activity",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "3b439ae7af0fcbb9df8e19faf84abf590e5e94898954711d57a602e6fd8a2f84"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM script WHERE archived = false AND deleted = false AND auto_kind = 'wac'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "3db1c61295c284725eef9e74a8aa2bc7822d263605a445f1f4a76e58e76a3e79"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "40a8cf5e87bb489fd172689e9a6f0f1075b878f9916145929b3cd3b1a53b777e"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT p.id AS \"id!\", p.deleted AS \"deleted!\"\n FROM workspace f\n JOIN workspace p ON p.id = f.parent_workspace_id\n WHERE f.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "deleted!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "42322020ff9cc7dd7ebafc1cb4122ba3d670cc36bdbc6451f29b8f22f8cff688"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_database_size(current_database())::BIGINT AS \"v!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "48a5df355a2bca557a3a541cf66c8e75790b7c3dd7845359019ff645a1f7c8bf"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT current_setting('max_connections')::INT AS \"v!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "49e1f5663eed128ed956c9a50bc73a85256c0a3e5a701cc13c944e66f6402617"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM flow WHERE archived = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "50490ff42fb1f2d78864d7b374d299bf8290c3b969b576cb185c8b5b0abb0265"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT current_setting('server_version_num')::INT AS \"v!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "56a98a07a2f6af4d694db05d57acbe0b55cc39d64f73a3c90c250ea670f9cdee"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n COUNT(*) FILTER (WHERE av.raw_app = false)::BIGINT AS \"low_code!\",\n COUNT(*) FILTER (WHERE av.raw_app = true)::BIGINT AS \"raw!\"\n FROM app a\n JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "low_code!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "raw!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "60dc0f1fa17bd2946ba7ddf0c41fe58b8a53d071cdc83eca3092194b4a9c9174"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM workspace WHERE deleted = false AND id NOT LIKE 'wm-fork%'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "9170a350e1da0b33a421a119d4a5b86575c1be761de158ad664670e981524cbf"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($3::timestamptz IS NULL OR completed_at >= $3)\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "completed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz"
]
},
"nullable": [
false,
false
]
},
"hash": "a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END\n FROM workspace WHERE id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "a54efa4a7466e61fd54d8fe293cb775225dcb430026cebe15ba4994ac636514d"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'aurora_version') AS \"aurora!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'rds_superuser') AS \"rds!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'cloudsqlsuperuser') AS \"cloudsql!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname IN ('azure_pg_admin', 'azuresu')) AS \"azure!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "aurora!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "rds!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "cloudsql!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "azure!",
"type_info": "Bool"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "a90e3a1d7c7c0dfb422f44b0ed599f681f5630c024ae9a437a301f149636b0db"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "completed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"UuidArray",
"Timestamptz"
]
},
"nullable": [
false,
false
]
},
"hash": "c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "fbe3a876efd1253d2ef086b03366b2bd117ceb6bc152d2abcd45850ff6aecff9"
}
+78 -78
View File
@@ -13735,7 +13735,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-nats",
@@ -13817,7 +13817,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"async-stream",
"async-trait",
@@ -13850,7 +13850,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13863,7 +13863,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"argon2",
@@ -14001,7 +14001,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14024,7 +14024,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14037,7 +14037,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14063,7 +14063,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14073,7 +14073,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14090,7 +14090,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14112,7 +14112,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14135,7 +14135,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14151,7 +14151,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14172,7 +14172,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14193,7 +14193,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14207,7 +14207,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14242,7 +14242,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14267,7 +14267,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14285,7 +14285,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14307,7 +14307,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14327,7 +14327,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14364,7 +14364,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14392,7 +14392,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"lazy_static",
"serde",
@@ -14404,7 +14404,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14429,7 +14429,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14443,7 +14443,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14476,7 +14476,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"chrono",
"lazy_static",
@@ -14490,7 +14490,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14509,7 +14509,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14611,7 +14611,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14630,7 +14630,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"regex",
"serde",
@@ -14645,7 +14645,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14669,7 +14669,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"futures",
@@ -14686,7 +14686,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14702,7 +14702,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14723,7 +14723,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14754,7 +14754,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -14779,7 +14779,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14813,7 +14813,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"futures",
@@ -14831,7 +14831,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14840,7 +14840,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14852,7 +14852,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14864,7 +14864,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14876,7 +14876,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14888,7 +14888,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14900,7 +14900,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14911,7 +14911,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14922,7 +14922,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14934,7 +14934,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14945,7 +14945,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -14967,7 +14967,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14979,7 +14979,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14993,7 +14993,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15010,7 +15010,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15023,7 +15023,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde",
@@ -15035,7 +15035,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15053,7 +15053,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15069,7 +15069,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15085,7 +15085,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde",
@@ -15096,7 +15096,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"const_format",
@@ -15173,7 +15173,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15184,7 +15184,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15218,7 +15218,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15242,7 +15242,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15275,7 +15275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15308,7 +15308,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15328,7 +15328,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15362,7 +15362,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15398,7 +15398,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15421,7 +15421,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15445,7 +15445,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15469,7 +15469,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15504,7 +15504,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15532,7 +15532,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15557,7 +15557,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"bitflags 2.13.0",
@@ -15576,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15686,7 +15686,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.737.0"
version = "1.738.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.737.0"
version = "1.738.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
ac1f6f666f36141cb6ba6f8eaa614821a90464ad
6d3301507db50818f1683dac3941d3e0cf1152a7
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.737.0"
version = "1.738.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.737.0"
version = "1.738.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
@@ -487,9 +487,13 @@ fn parse_kv_opts(s: &str) -> BTreeMap<String, String> {
out
}
// Scan raw source for pipeline annotations. Language-agnostic: any line
// whose first non-whitespace tokens are a comment prefix (`//`, `#`, or
// `--`) followed by one of the recognized keywords:
// Scan the leading comment header for pipeline annotations. Only the
// contiguous block of comment lines at the top of the file is considered
// (blank lines tolerated, scan stops at the first line of actual code) so
// that ordinary comments in the body can't false-positive as annotations.
// Language-agnostic: any header line whose first non-whitespace tokens are
// a comment prefix (`//`, `#`, or `--`) followed by one of the recognized
// keywords:
// - `pipeline` → opt-in marker (must be alone on the line)
// - `on <trigger-spec>` → asset / native trigger edge (including
// the marker-only `on schedule` form)
@@ -527,6 +531,9 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
for raw_line in code.lines() {
let line = raw_line.trim_start();
if line.is_empty() {
continue;
}
let rest = if let Some(r) = line.strip_prefix("//") {
r
} else if let Some(r) = line.strip_prefix("--") {
@@ -534,7 +541,11 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
} else if let Some(r) = line.strip_prefix('#') {
r
} else {
continue;
// Annotations live in the leading comment header. Stop at the first
// line of actual code so comments inside the body (e.g. a regular
// `# tag ...` prose comment) can't false-positive as annotations.
// Mirrors BashAnnotations::sandbox_image / ssh_target.
break;
};
let rest = rest.trim_start();
@@ -584,7 +595,14 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
if let Some(after_kw) = consume_keyword(rest, "tag") {
let name = after_kw.trim();
if !name.is_empty() && out.tag.is_none() {
// Worker tags are single-word identifiers (e.g. `heavy`, `gpu`).
// A value with whitespace or beyond the `script.tag` column width
// is almost certainly a regular comment starting with "# tag ...".
if !name.is_empty()
&& !name.contains(char::is_whitespace)
&& name.len() <= 50
&& out.tag.is_none()
{
out.tag = Some(name.to_string());
}
continue;
@@ -1074,6 +1092,56 @@ mod pipeline_annotation_tests {
assert!(out.tag.is_none());
}
#[test]
fn tag_with_whitespace_is_skipped() {
// A regular English comment starting with "# tag " must not be
// mistaken for a worker-tag annotation (worker tags are single words).
let out =
parse_pipeline_annotations("# tag this function so we remember to refactor it later");
assert!(out.tag.is_none());
}
#[test]
fn tag_too_long_is_skipped() {
let long = "x".repeat(51);
let out = parse_pipeline_annotations(&format!("// tag {long}"));
assert!(out.tag.is_none());
}
#[test]
fn annotations_in_body_are_ignored() {
// Only the leading comment header is scanned. A regular `# tag ...`
// prose comment buried in the body — the WIN-2090 false-positive that
// crashed the `script.tag` INSERT — must not be treated as an
// annotation once real code has started.
let code = concat!(
"import pandas as pd\n",
"\n",
"def main():\n",
" # tag each row with its source so downstream steps can filter\n",
" # on s3://should/not/parse\n",
" return pd.DataFrame()\n",
);
let out = parse_pipeline_annotations(code);
assert!(out.tag.is_none());
assert!(out.triggers.is_empty());
}
#[test]
fn header_allows_blank_lines_before_code() {
// Blank lines (e.g. after a shebang) don't end the header; the first
// line of real code does.
let code = concat!(
"#!/usr/bin/env python\n",
"\n",
"# tag heavy\n",
"import os\n",
"# tag light\n",
);
let out = parse_pipeline_annotations(code);
assert_eq!(out.tag.as_deref(), Some("heavy"));
}
#[test]
fn retry_count_only() {
let out = parse_pipeline_annotations("// retry 3");
+83 -33
View File
@@ -1324,6 +1324,9 @@ pub async fn delete_expired_items(db: &DB) -> () {
let cleanup_start = Instant::now();
let mut total_deleted = 0u64;
let mut batch_num = 0i32;
// Watermark carried across batches so each one resumes after the rows the previous batch
// already processed instead of re-scanning the (potentially undeletable) oldest prefix.
let mut completed_at_floor: Option<DateTime<Utc>> = None;
// Process batches until no more expired jobs or max batches reached
loop {
@@ -1336,14 +1339,17 @@ pub async fn delete_expired_items(db: &DB) -> () {
}
// Each batch runs in its own transaction to avoid long-running locks
let batch_result = delete_expired_jobs_batch(db, job_retention_secs, batch_size).await;
let batch_result =
delete_expired_jobs_batch(db, job_retention_secs, batch_size, completed_at_floor)
.await;
match batch_result {
Ok(deleted_count) => {
Ok((deleted_count, max_completed_at)) => {
if deleted_count == 0 {
// No more expired jobs to delete
break;
}
completed_at_floor = max_completed_at.or(completed_at_floor);
total_deleted += deleted_count as u64;
batch_num += 1;
}
@@ -1510,12 +1516,20 @@ pub async fn check_expiring_tokens(db: &DB) {
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
/// Uses a single transaction per batch to minimize lock duration.
/// Returns the number of jobs deleted in this batch.
///
/// `completed_at_floor` is the watermark from the previous batch in the same cleanup run (the
/// max `completed_at` it deleted); pass `None` for the first batch. It is re-applied as
/// `completed_at >= floor` so the scan resumes past the rows already processed instead of
/// re-walking them (see the inline comment on the DELETE for why this matters).
///
/// Returns `(jobs deleted in this batch, max completed_at deleted)`. The caller feeds the
/// returned watermark back in as `completed_at_floor` for the next batch.
async fn delete_expired_jobs_batch(
db: &DB,
job_retention_secs: i64,
batch_size: i64,
) -> error::Result<usize> {
completed_at_floor: Option<DateTime<Utc>>,
) -> error::Result<(usize, Option<DateTime<Utc>>)> {
let mut tx = db.begin().await?;
// Fetch active ROOT job IDs that started before the retention period. We only care about
@@ -1531,34 +1545,70 @@ async fn delete_expired_jobs_batch(
.fetch_all(&mut *tx)
.await?;
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas
// ORDER BY completed_at ensures we delete oldest jobs first.
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than
// `!= ALL($3)`: the subquery form lets the planner build a one-time hashed
// SubPlan and apply it as a filter on the ordered index scan, giving O(1)
// membership per candidate instead of a per-row linear array scan (which
// degrades sharply when many root jobs are active). The `u IS NOT NULL` guard
// sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
)
RETURNING id",
job_retention_secs,
batch_size,
&active_root_job_ids
)
.fetch_all(&mut *tx)
.await?;
// `completed_at_floor` is a watermark carried across batches within a cleanup run: it is the
// max(completed_at) deleted by the previous batch. Re-applying it as `completed_at >= floor`
// lets each batch resume after the rows the previous batch already processed instead of
// re-scanning them. This matters when the oldest rows are undeletable (children of a
// still-active root flow): without the floor the `ORDER BY completed_at ASC` scan walks that
// same protected prefix on every batch, turning a cleanup run quadratic in prefix size.
// Floor only ever skips rows the current run already deleted, was protecting, or skip-locked —
// all correctly deferred to the next run, identical to the unbounded scan's semantics.
//
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas; ORDER BY completed_at
// deletes oldest jobs first.
let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() {
// Common case: no old root flow is still running, so nothing is protected and the
// v2_job join (a PK lookup per candidate) is pure overhead — skip it entirely.
let rows = sqlx::query!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT id FROM v2_job_completed
WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval
AND ($3::timestamptz IS NULL OR completed_at >= $3)
ORDER BY completed_at ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
)
RETURNING id, completed_at",
job_retention_secs,
batch_size,
completed_at_floor,
)
.fetch_all(&mut *tx)
.await?;
let max = rows.iter().map(|r| r.completed_at).max();
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
} else {
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than `!= ALL($3)`:
// the subquery form lets the planner build a one-time hashed SubPlan and apply it as a
// filter on the ordered index scan, giving O(1) membership per candidate instead of a
// per-row linear array scan (which degrades sharply when many root jobs are active). The
// `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
let rows = sqlx::query!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
)
RETURNING id, completed_at",
job_retention_secs,
batch_size,
&active_root_job_ids,
completed_at_floor,
)
.fetch_all(&mut *tx)
.await?;
let max = rows.iter().map(|r| r.completed_at).max();
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
};
let deleted_count = deleted_jobs.len();
@@ -1618,7 +1668,7 @@ async fn delete_expired_jobs_batch(
tx.commit().await?;
Ok(deleted_count)
Ok((deleted_count, max_completed_at))
}
async fn delete_log_files_from_disk_and_store(
@@ -646,6 +646,20 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// Regression: changing a fork's workspace id must preserve its parent
// linkage. Dropping it leaves a wm-fork- workspace with no parent — a
// "fork of nothing" that can no longer be compared or merged.
let parent: Option<String> =
sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1")
.bind("wm-fork-renamed")
.fetch_one(&db)
.await?;
assert_eq!(
parent.as_deref(),
Some("new-test-ws"),
"renamed fork must keep its parent_workspace_id"
);
// --- create_fork over an existing (active) workspace id: clear 400, not a raw SQL 500 ---
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
.json(&json!({
+391 -35
View File
@@ -256,52 +256,300 @@ pub async fn test_s3_bucket(
use bytes::Bytes;
use futures::StreamExt;
require_super_admin(&db, &authed.email).await?;
// The probe executes on the API server itself. On multi-tenant Cloud that is a shared control
// plane, so we constrain untrusted callers to remove the SSRF / credential-exfiltration /
// local-filesystem surface (see validate_object_storage_test). On self-hosted instances the
// object store usually lives on the local/private network and all authenticated users are
// trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too.
let is_super_admin = is_super_admin_email(&db, &authed.email).await?;
let restrict = !is_super_admin && *CLOUD_HOSTED;
if restrict {
validate_object_storage_test(&test_s3_bucket).await?;
}
let client = build_object_store_from_settings(test_s3_bucket, Some(&db))
.await?
.store;
let mut list = client.list(Some(
&windmill_object_store::object_store_reexports::Path::from("".to_string()),
));
let first_file = list.next().await;
if first_file.is_some() {
if let Err(e) = first_file.as_ref().unwrap() {
tracing::error!("error listing bucket: {e:#}");
error::Error::internal_err(format!("Failed to list files in blob storage: {e:#}"));
let run = async {
let mut list = client.list(Some(
&windmill_object_store::object_store_reexports::Path::from("".to_string()),
));
let first_file = list.next().await;
if first_file.is_some() {
if let Err(e) = first_file.as_ref().unwrap() {
tracing::error!("error listing bucket: {e:#}");
error::Error::internal_err(format!("Failed to list files in blob storage: {e:#}"));
}
tracing::info!("Listed files: {:?}", first_file.unwrap());
} else {
tracing::info!("No files in blob storage");
}
tracing::info!("Listed files: {:?}", first_file.unwrap());
let path = windmill_object_store::object_store_reexports::Path::from(format!(
"/test-s3-bucket-{uuid}",
uuid = uuid::Uuid::new_v4()
));
tracing::info!("Testing blob storage at path: {path}");
client
.put(
&path,
windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"),
)
.await
.map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?;
let content = client
.get(&path)
.await
.map_err(to_anyhow)?
.bytes()
.await
.map_err(to_anyhow)?;
if content != Bytes::from_static(b"hello") {
return Err(error::Error::internal_err(
"Failed to read back from blob storage".to_string(),
));
}
client.delete(&path).await.map_err(to_anyhow)?;
Ok::<String, error::Error>("Tested blob storage successfully".to_string())
};
if restrict {
// The object-store client is built with timeouts disabled, so a malicious endpoint could
// otherwise hold the API server connection open indefinitely.
tokio::time::timeout(Duration::from_secs(15), run)
.await
.map_err(|_| {
error::Error::internal_err("Object storage connectivity test timed out".to_string())
})?
} else {
tracing::info!("No files in blob storage");
run.await
}
}
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller on
// Cloud. The probe runs on the shared API server, so without these constraints an authenticated
// user could coerce the server into connecting to arbitrary internal endpoints (SSRF), signing
// requests with the instance role (credential exfiltration), or reading/writing the server's local
// disk (filesystem object store).
#[cfg(feature = "parquet")]
async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Result<()> {
fn non_empty(opt: &Option<String>) -> bool {
opt.as_ref().is_some_and(|s| !s.is_empty())
}
let path = windmill_object_store::object_store_reexports::Path::from(format!(
"/test-s3-bucket-{uuid}",
uuid = uuid::Uuid::new_v4()
));
tracing::info!("Testing blob storage at path: {path}");
client
.put(
&path,
windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"),
)
// Reject backends that rely on the server's identity or local filesystem, require explicit
// credentials for the rest (so the server never falls back to its own ambient credentials), and
// resolve the host the client will actually connect to. We derive the *effective* endpoint here
// — mirroring build_*_from_settings: the region/account-derived default and the virtual-hosted
// bucket prefix — rather than only validating a caller-supplied `endpoint`, so caller-controlled
// `region`/`account_name`/`bucket` cannot smuggle an internal host past the check (e.g. an empty
// endpoint with region = "@169.254.169.254/" otherwise resolves to the cloud metadata service).
let effective_endpoint: Option<String> = match settings {
ObjectSettings::Filesystem(_) => {
return Err(error::Error::NotAuthorized(
"Testing a local filesystem object store requires a super admin".to_string(),
));
}
ObjectSettings::AwsOidc(_) => {
return Err(error::Error::NotAuthorized(
"Testing OIDC-based object storage requires a super admin".to_string(),
));
}
ObjectSettings::S3(s3) => {
if !(non_empty(&s3.access_key) && non_empty(&s3.secret_key)) {
return Err(error::Error::NotAuthorized(
"Testing S3 storage without explicit credentials requires a super admin"
.to_string(),
));
}
let region = s3
.region
.clone()
.filter(|r| !r.is_empty())
.or_else(|| std::env::var("AWS_REGION").ok().filter(|r| !r.is_empty()))
.unwrap_or_else(|| "us-east-1".to_string());
let raw_endpoint = s3
.endpoint
.clone()
.filter(|e| !e.is_empty())
.or_else(|| std::env::var("S3_ENDPOINT").ok().filter(|e| !e.is_empty()))
.unwrap_or_else(|| format!("s3.{region}.amazonaws.com"));
Some(windmill_object_store::render_endpoint(
raw_endpoint,
!s3.allow_http.unwrap_or(true),
s3.port,
s3.path_style,
s3.bucket.clone().unwrap_or_default(),
))
}
ObjectSettings::Azure(azure) => {
if !non_empty(&azure.access_key) {
return Err(error::Error::NotAuthorized(
"Testing Azure storage without an explicit access key requires a super admin"
.to_string(),
));
}
Some(
azure
.endpoint
.clone()
.filter(|e| !e.is_empty())
.unwrap_or_else(|| format!("{}.blob.core.windows.net", azure.account_name)),
)
}
ObjectSettings::Gcs(gcs) => {
if gcs.service_account_key.is_empty() {
return Err(error::Error::NotAuthorized(
"Testing GCS storage without a service account key requires a super admin"
.to_string(),
));
}
// The service-account-key JSON can override the data-plane URL (`gcs_base_url`) and the
// OAuth token endpoint (`token_uri`); the GCS client connects to whatever they point at.
// Validate every http(s) URL embedded in the key. When none override it, the host stays
// the public storage.googleapis.com, so no further check is needed.
if let Ok(serde_json::Value::Object(map)) =
serde_json::from_str::<serde_json::Value>(&gcs.service_account_key)
{
for value in map.values() {
if let Some(url) = value.as_str() {
// Match how the URL parser reads the value: leading whitespace/control is
// ignored and the scheme is case-insensitive.
let url =
url.trim_start_matches(|c: char| c.is_whitespace() || c.is_control());
if strip_http_scheme(url).is_some() {
validate_public_endpoint(url).await?;
}
}
}
}
None
}
};
// Block non-public network targets (internal services, cloud metadata, loopback, ...).
if let Some(endpoint) = effective_endpoint {
validate_public_endpoint(&endpoint).await?;
}
Ok(())
}
#[cfg(feature = "parquet")]
async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> {
let host = extract_host(endpoint).ok_or_else(|| {
error::Error::BadRequest(format!("Invalid object storage endpoint: {endpoint}"))
})?;
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host.as_str(), 443u16))
.await
.map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?;
let content = client
.get(&path)
.await
.map_err(to_anyhow)?
.bytes()
.await
.map_err(to_anyhow)?;
if content != Bytes::from_static(b"hello") {
return Err(error::Error::internal_err(
"Failed to read back from blob storage".to_string(),
));
.map_err(|e| {
error::Error::BadRequest(format!(
"Could not resolve object storage endpoint '{host}': {e}"
))
})?
.collect();
if addrs.is_empty() {
return Err(error::Error::BadRequest(format!(
"Could not resolve object storage endpoint '{host}'"
)));
}
// Reject if any resolved address is non-public, which also defeats the simplest DNS-rebinding
// attempts (a name resolving to both a public and a private address).
for addr in addrs {
if is_forbidden_ip(addr.ip()) {
return Err(error::Error::NotAuthorized(
"Testing object storage at a private, loopback, or link-local endpoint requires a super admin"
.to_string(),
));
}
}
Ok(())
}
// Strip a leading `http://`/`https://` scheme case-insensitively (URL schemes are
// case-insensitive), returning the remainder when one was present.
#[cfg(feature = "parquet")]
fn strip_http_scheme(s: &str) -> Option<&str> {
for scheme in ["https://", "http://"] {
let b = scheme.as_bytes();
if s.len() >= b.len() && s.as_bytes()[..b.len()].eq_ignore_ascii_case(b) {
return Some(&s[b.len()..]);
}
}
None
}
#[cfg(feature = "parquet")]
fn extract_host(endpoint: &str) -> Option<String> {
let mut s = endpoint.trim();
if let Some(rest) = strip_http_scheme(s) {
s = rest;
}
s = s.split(['/', '?', '#', '\\']).next().unwrap_or(s);
if let Some((_, rest)) = s.rsplit_once('@') {
s = rest;
}
let host = if let Some(rest) = s.strip_prefix('[') {
// IPv6 literal, e.g. [::1]:9000
rest.split(']').next().unwrap_or(rest)
} else {
// host or host:port
s.split(':').next().unwrap_or(s)
}
.trim();
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
#[cfg(feature = "parquet")]
fn is_forbidden_ip(ip: std::net::IpAddr) -> bool {
use std::net::{IpAddr, Ipv4Addr};
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local() // 169.254.0.0/16, incl. the cloud metadata endpoint
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_multicast()
|| v4.octets()[0] == 0 // 0.0.0.0/8
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT
}
IpAddr::V6(v6) => {
// Any IPv4 embedded in an IPv6 address (IPv4-mapped ::ffff:0:0/96, IPv4-compatible
// ::/96, or NAT64 64:ff9b::/96) is re-checked against the IPv4 rules, so e.g.
// 64:ff9b::169.254.169.254 cannot route to the metadata endpoint in a NAT64 network.
let seg = v6.segments();
let is_v4_compatible = seg[0..6] == [0, 0, 0, 0, 0, 0];
let is_nat64 = seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0];
if let Some(v4) = v6.to_ipv4_mapped() {
return is_forbidden_ip(IpAddr::V4(v4));
}
if is_v4_compatible || is_nat64 {
let embedded = Ipv4Addr::new(
(seg[6] >> 8) as u8,
(seg[6] & 0xff) as u8,
(seg[7] >> 8) as u8,
(seg[7] & 0xff) as u8,
);
if is_forbidden_ip(IpAddr::V4(embedded)) {
return true;
}
}
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local
|| (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
}
}
client.delete(&path).await.map_err(to_anyhow)?;
Ok("Tested blob storage successfully".to_string())
}
#[cfg(feature = "parquet")]
@@ -1861,3 +2109,111 @@ mod tests {
);
}
}
#[cfg(all(test, feature = "parquet"))]
mod object_storage_test_hardening {
use super::{extract_host, is_forbidden_ip, validate_object_storage_test};
use std::net::IpAddr;
use windmill_object_store::ObjectSettings;
// IP literals (not hostnames) keep validate_public_endpoint deterministic — `lookup_host`
// parses them without any network round-trip.
fn gcs_settings(gcs_base_url: &str) -> ObjectSettings {
serde_json::from_value(serde_json::json!({
"type": "Gcs",
"bucket": "b",
"serviceAccountKey": { "gcs_base_url": gcs_base_url, "client_email": "x@y.z" }
}))
.unwrap()
}
#[tokio::test]
async fn rejects_gcs_internal_base_url() {
// gcs_base_url in the service-account key must not smuggle an internal host past the check,
// including via a mixed-case scheme (URL schemes are case-insensitive).
for url in [
"http://169.254.169.254",
"HTTP://169.254.169.254",
"Https://10.0.0.5",
] {
assert!(
validate_object_storage_test(&gcs_settings(url))
.await
.is_err(),
"{url} should be rejected"
);
}
}
#[tokio::test]
async fn allows_gcs_public_base_url() {
assert!(
validate_object_storage_test(&gcs_settings("https://8.8.8.8"))
.await
.is_ok()
);
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn forbids_internal_ips() {
for s in [
"127.0.0.1", // loopback
"169.254.169.254", // cloud metadata (link-local)
"10.0.0.5", // private
"172.16.3.4", // private
"192.168.1.10", // private
"0.0.0.0", // unspecified
"100.64.0.1", // CGNAT
"::1", // IPv6 loopback
"fe80::1", // IPv6 link-local
"fc00::1", // IPv6 unique local
"::ffff:127.0.0.1", // IPv4-mapped loopback
"::ffff:169.254.169.254", // IPv4-mapped metadata
"::169.254.169.254", // IPv4-compatible metadata
"64:ff9b::169.254.169.254", // NAT64-embedded metadata
"64:ff9b::a9fe:a9fe", // NAT64-embedded metadata (hex form)
] {
assert!(is_forbidden_ip(ip(s)), "{s} should be forbidden");
}
}
#[test]
fn allows_public_ips() {
for s in ["8.8.8.8", "1.1.1.1", "52.95.110.1", "2606:4700:4700::1111"] {
assert!(!is_forbidden_ip(ip(s)), "{s} should be allowed");
}
}
#[test]
fn extracts_host_from_endpoint() {
let cases = [
("s3.amazonaws.com", Some("s3.amazonaws.com")),
("https://minio.internal:9000", Some("minio.internal")),
("http://10.0.0.5:9000/bucket", Some("10.0.0.5")),
("user:pass@host.example:443", Some("host.example")),
("[::1]:9000", Some("::1")),
("https://[fe80::1]/x", Some("fe80::1")),
("", None),
// Injection via region/bucket interpolation into the default endpoint string: the
// userinfo `@` and the path `/` must not hide the real authority from the host check.
(
"https://s3.@169.254.169.254/.amazonaws.com",
Some("169.254.169.254"),
),
(
"https://@169.254.169.254/mybucket.s3.amazonaws.com",
Some("169.254.169.254"),
),
("s3.#@169.254.169.254/x.amazonaws.com", Some("s3.")),
// Scheme is case-insensitive.
("HTTP://169.254.169.254", Some("169.254.169.254")),
];
for (input, expected) in cases {
assert_eq!(extract_host(input).as_deref(), expected, "input: {input}");
}
}
}
@@ -339,13 +339,15 @@ async fn cleanup_job_logs(
return Ok(());
}
let mut completed_at_floor: Option<DateTime<Utc>> = None;
loop {
let (deleted_count, rel_paths) =
delete_expired_jobs_batch(db, retention_secs, JOB_BATCH).await?;
let (deleted_count, rel_paths, max_completed_at) =
delete_expired_jobs_batch(db, retention_secs, JOB_BATCH, completed_at_floor).await?;
if deleted_count == 0 {
break;
}
completed_at_floor = max_completed_at.or(completed_at_floor);
let s3_paths: Vec<ObjectPath> = rel_paths
.iter()
@@ -382,7 +384,8 @@ async fn delete_expired_jobs_batch(
db: &DB,
job_retention_secs: i64,
batch_size: i64,
) -> error::Result<(usize, Vec<String>)> {
completed_at_floor: Option<DateTime<Utc>>,
) -> error::Result<(usize, Vec<String>, Option<DateTime<Utc>>)> {
let mut tx = db.begin().await?;
let active_root_job_ids: Vec<Uuid> = sqlx::query_scalar!(
@@ -395,33 +398,61 @@ async fn delete_expired_jobs_batch(
.fetch_all(&mut *tx)
.await?;
// Active-root exclusion via NOT IN (hashed SubPlan) instead of `!= ALL($3)`;
// see backend/src/monitor.rs::delete_expired_jobs_batch for the rationale.
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
)
RETURNING id",
job_retention_secs,
batch_size,
&active_root_job_ids
)
.fetch_all(&mut *tx)
.await?;
// `completed_at_floor` carries a watermark across batches so each one resumes after the rows
// the previous batch processed instead of re-scanning the (potentially undeletable) oldest
// prefix; the empty-active-roots branch skips the v2_job join entirely. See
// backend/src/monitor.rs::delete_expired_jobs_batch for the full rationale.
let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() {
let rows = sqlx::query!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT id FROM v2_job_completed
WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval
AND ($3::timestamptz IS NULL OR completed_at >= $3)
ORDER BY completed_at ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
)
RETURNING id, completed_at",
job_retention_secs,
batch_size,
completed_at_floor,
)
.fetch_all(&mut *tx)
.await?;
let max = rows.iter().map(|r| r.completed_at).max();
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
} else {
let rows = sqlx::query!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
)
RETURNING id, completed_at",
job_retention_secs,
batch_size,
&active_root_job_ids,
completed_at_floor,
)
.fetch_all(&mut *tx)
.await?;
let max = rows.iter().map(|r| r.completed_at).max();
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
};
let deleted_count = deleted_jobs.len();
if deleted_count == 0 {
tx.commit().await?;
return Ok((0, Vec::new()));
return Ok((0, Vec::new(), max_completed_at));
}
if let Err(e) = sqlx::query!(
@@ -471,7 +502,7 @@ async fn delete_expired_jobs_batch(
tx.commit().await?;
Ok((deleted_count, log_paths))
Ok((deleted_count, log_paths, max_completed_at))
}
/// Scan S3 under the `logs/` prefix for orphan log files and delete them.
@@ -717,16 +717,27 @@ async fn create_deployment_request_comment(
// ---- helpers ------------------------------------------------------------
async fn parent_of_fork(db: &DB, w_id: &str) -> Result<String> {
sqlx::query_scalar!(
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
// Resolve the fork's parent and require it to still exist and be active. A
// parent that is archived (soft-deleted) can no longer be accessed, so a
// diff or deployment request against it targets an unreachable workspace.
let parent = sqlx::query!(
"SELECT p.id AS \"id!\", p.deleted AS \"deleted!\"
FROM workspace f
JOIN workspace p ON p.id = f.parent_workspace_id
WHERE f.id = $1",
w_id,
)
.fetch_optional(db)
.await?
.flatten()
.ok_or_else(|| {
Error::BadRequest(format!(
.await?;
match parent {
None => Err(Error::BadRequest(format!(
"workspace {w_id} is not a fork (no parent_workspace_id)"
))
})
))),
Some(p) if p.deleted => Err(Error::BadRequest(format!(
"parent workspace {} of fork {w_id} is archived",
p.id
))),
Some(p) => Ok(p.id),
}
}
@@ -65,13 +65,22 @@ pub(crate) async fn change_workspace_id(
old_id, rw.new_id
);
// Create new workspace with new id and name
// Create new workspace with new id and name. A fork that keeps a wm-fork-
// id must carry its parent_workspace_id over, otherwise it becomes a
// parentless "fork of nothing" with no source to compare or merge against.
// A non-fork target id means the workspace is being promoted out of a fork,
// so the parent pointer is intentionally cleared.
info!("Creating new workspace row");
let new_is_fork = rw.new_id.starts_with(WM_FORK_PREFIX);
sqlx::query!(
"INSERT INTO workspace SELECT $1, $2, owner, false, premium FROM workspace WHERE id = $3",
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)
SELECT $1, $2, owner, false, premium,
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END
FROM workspace WHERE id = $3",
&rw.new_id,
&rw.new_name,
&old_id
&old_id,
new_is_fork
)
.execute(&mut *tx)
.await?;
@@ -347,6 +356,18 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
// Re-parent child forks: any fork whose parent_workspace_id was the old id
// must follow the renamed parent to the new id, otherwise it is left
// pointing at the soft-deleted old shell (whose data has moved here).
info!("Re-parenting child forks to the new workspace id");
sqlx::query!(
"UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
info!("Updating workspace_protection_rule table");
sqlx::query!(
"UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.737.0
version: 1.738.0
title: Windmill API
contact:
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.737.0";
export const VERSION = "v1.738.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.737.0";
export const VERSION = "1.738.0";
+689 -30
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.737.0",
"version": "1.738.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -127,6 +127,7 @@
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"mdast-util-find-and-replace": "^3.0.2",
"mermaid": "^11.15.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
+53 -60
View File
@@ -158,11 +158,6 @@
preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined
// To execute preview scripts with the right worker group
customTag?: string
// Opt-in: reflect external `code` prop mutations back into Monaco (see
// the effect below). One-way `code={...}` callers that need live
// external updates — e.g. the inline flow rawscript — set this. Off by
// default so every other caller's behavior is unchanged.
syncExternalCode?: boolean
}
let {
@@ -195,8 +190,7 @@
enablePreprocessorSnippet = false,
rawAppRunnableKey = undefined,
preparedAssetsSqlQueries,
customTag,
syncExternalCode = false
customTag
}: Props = $props()
$effect.pre(() => {
@@ -375,23 +369,12 @@
code = ncode
}
if (noHistory) {
editor?.setValue(ncode)
} else {
if (editor?.getModel()) {
// editor.setValue(ncode)
editor.pushUndoStop()
editor.executeEdits('set', [
{
range: editor.getModel()!.getFullModelRange(), // full range
text: ncode
}
])
editor.pushUndoStop()
}
}
// setCode is an authoritative overwrite (reset, AI apply, module switch).
// Cancel any in-flight keystroke debounce first: otherwise alignCodeWithEditor
// skips on the `timeoutModel` guard (leaving Monaco stale), and the pending
// updateCode later reads the old buffer and writes it back over `ncode`.
cancelPendingChanges()
alignCodeWithEditor(!noHistory)
// Dispatch change immediately when code actually changed. This ensures
// callers like the Reset button and copilot trigger on:change handlers.
// The debounced onDidChangeModelContent handler will no-op since code
@@ -425,6 +408,7 @@
return
}
code = ncode
lastEditorCode = ncode
dispatch('change', ncode)
}
@@ -436,12 +420,19 @@
* see it. Clears the chain state so the next keystroke after this
* flush is a fresh leading fire. */
export function flushPendingChanges(): void {
cancelPendingChanges()
updateCode()
}
/** Discard any in-flight keystroke debounce without materializing it, so a
* deferred updateCode can't fire later. Resets chain state to a fresh leading
* fire on the next keystroke. */
function cancelPendingChanges(): void {
if (timeoutModel !== undefined) {
clearTimeout(timeoutModel)
timeoutModel = undefined
}
changeChainStart = undefined
updateCode()
}
export function append(code: string): void {
@@ -1901,29 +1892,6 @@
lang = scriptLangToEditorLang(scriptLang)
})
// Opt-in (syncExternalCode): reflect external `code` prop mutations into
// Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g.
// the inline rawscript in the flow editor — otherwise mutate the prop
// without Monaco ever showing the change (the AI chat editing a flow
// module's content in a session is the motivating case). Gated off by
// default: Editor is sensitive and most callers either bind:code (and
// carry their own external-sync) or treat code as init-only, so a blanket
// setValue would risk clobbering them. The `getValue() !== code` guard
// keeps the caret intact when the change originated from typing inside
// Monaco (which round-trips code back via `$bindable`, re-firing this
// effect with `code === getValue()`).
let lastExternalCodeSync = code
$effect(() => {
if (!syncExternalCode) return
if (code === lastExternalCodeSync) return
lastExternalCodeSync = code
if (!editor) return
untrack(() => {
if (editor!.getValue() !== code) {
editor!.setValue(code ?? '')
}
})
})
$effect(() => {
filePath = computePath(path)
})
@@ -2011,25 +1979,50 @@
})
})
// External `code` prop changes should flow into the Monaco editor. The
// `untrack` block reads/writes Monaco without subscribing — only the
// prop read above is tracked — so the editor's own change handler
// (`updateCode`) re-running with the same value short-circuits and we
// don't loop.
$effect(() => {
const next = code ?? ''
let applyExternalCode = useDebounce(() => alignCodeWithEditor(true), 800)
// Last `code` value the editor itself produced or aligned to. Used to tell an
// echo (the bindable changed because the user typed — Monaco is already
// ahead) from a genuine external write. Without this, a typing burst longer
// than the debounce window would sync the lagging `code` back over newer
// keystrokes. Must be kept in step with every editor↔`code` sync point.
let lastEditorCode = code
function alignCodeWithEditor(history: boolean) {
const ed = editor
if (!ed) return
untrack(() => {
if (ed.getValue() === next) return
const model = ed.getModel()
if (!model) return
const next = code ?? ''
const value = ed.getValue()
const model = ed.getModel()
// Some keystrokes are still being debounced, don't overwrite them.
// When the debounce is done, updateCode will be called and the code will be aligned with the editor.
if (timeoutModel !== undefined) return
if (!model) return
lastEditorCode = next
if (value === next) return
if (history) {
ed.pushUndoStop()
ed.executeEdits('external', [{ range: model.getFullModelRange(), text: next }])
ed.pushUndoStop()
} else {
ed.setValue(next)
}
}
// External `code` prop changes should flow into the Monaco editor. Skip
// echoes: when `code` matches what the editor last produced (`updateCode`)
// or aligned to, the change came from the editor itself, so syncing back
// would clobber input typed since. Only genuine external writes — where
// `code` diverges from `lastEditorCode` — schedule a sync. The `untrack`
// block reads/writes Monaco without subscribing, so we don't loop.
$effect(() => {
;[code, editor]
if (!editor) return
untrack(() => {
if (code === lastEditorCode) return
applyExternalCode()
})
})
let isTsWorkerInitialized = resource([() => lang, () => initialized], async () => {
if (lang !== 'typescript' || !initialized) return false
// Use the stable model URI (computed once at mount), not filePath which changes on rename
@@ -12,10 +12,14 @@
let comparison: WorkspaceComparison | undefined = $state(undefined)
let error: string | undefined = $state(undefined)
let isFork = $derived($workspaceStore?.startsWith('wm-fork-') ?? false)
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
// A fork must have a parent to compare/merge against. Treating the wm-fork-
// prefix alone as "is a fork" renders a parentless "Fork of ()" banner when
// the parent linkage was dropped (e.g. by a workspace id change), so require
// both, matching the forks/compare page.
let isFork = $derived(($workspaceStore?.startsWith('wm-fork-') ?? false) && !!parentWorkspaceId)
// Drafts in this fork. When the fork is otherwise in sync with its parent, a
// user with only pending drafts should still get the draft CTA (mirrors the
@@ -1063,6 +1063,14 @@
<li
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
apps, raw apps)</li
>
<li
>infrastructure info (container runtime, managed database provider, database version,
size and cluster size, max and active connections, object storage backend)</li
>
</ul>
<br />For air-gapped instances, you can download the telemetry data and send it manually.
</div>
@@ -1101,6 +1109,10 @@
<li
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
apps, raw apps)</li
>
</ul>
</div>
{/if}
@@ -952,9 +952,7 @@
}
})
})
$effect(() => {
readFieldsRecursively(script)
})
// Mirror the draft triggers (held in a separate `triggersState` $state)
// back into `script.draft_triggers` so the UserDraft autosave — which
// deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own
@@ -268,6 +268,7 @@
if (activeModuleTab === null && code !== lastSyncedCode) {
editorCode = code
lastSyncedCode = code
editor?.setCode(editorCode) // immediate sync, don't wait for the 800ms debounce
untrack(() => inferSchema(code))
}
})
@@ -1591,18 +1592,29 @@
let error = $derived(getError(testJob))
$effect(() => {
const options: ScriptOptions = {
code,
lang: lang as ScriptLang,
error,
args: args ?? {},
path,
;[
editor,
lastSavedCode,
lastDeployedCode,
diffMode,
workflowAsCode: workflowAsCodeAiContext
}
workflowAsCodeAiContext,
args,
error,
lang,
path
]
untrack(() => {
const options: ScriptOptions = {
getCode: () => code,
lang: lang as ScriptLang,
error,
args: args ?? {},
path,
lastSavedCode,
lastDeployedCode,
diffMode,
workflowAsCode: workflowAsCodeAiContext
}
aiChatManager.scriptEditorOptions = options
aiChatManager.scriptEditorApplyCode = async (code: string, opts?: ReviewChangesOpts) => {
hideDiffMode()
@@ -10,7 +10,7 @@
import { base } from '$lib/base'
import SearchItems from './SearchItems.svelte'
import { page } from '$app/state'
import { goto as gotoUrl } from '$app/navigation'
import { replaceState } from '$app/navigation'
import Version from './Version.svelte'
import Uptodate from './Uptodate.svelte'
import InstanceSettings from './InstanceSettings.svelte'
@@ -70,7 +70,16 @@
const index = page.url.href.lastIndexOf('#')
if (index === -1) return
const hashRemoved = page.url.href.slice(0, index)
gotoUrl(hashRemoved)
// Strip the drawer's URL hash without a SvelteKit navigation: a `goto`
// here re-fires path-reactive effects on the underlying page (e.g. the
// script editor's load effect), wiping unsaved editor content.
try {
replaceState(hashRemoved, page.state)
} catch (e) {
// replaceState throws if the router isn't initialized yet — possible
// when onDestroy runs during router teardown.
console.error(e)
}
}
onDestroy(() => {
@@ -25,6 +25,38 @@ describe('parsePipelineAnnotations: tag', () => {
const out = parsePipelineAnnotations('// tagged heavy')
expect(out.tag).toBeUndefined()
})
it('skips a tag value containing whitespace (regular comment false-positive)', () => {
const out = parsePipelineAnnotations('# tag this function so we remember to refactor it later')
expect(out.tag).toBeUndefined()
})
it('skips a tag value longer than 50 chars', () => {
const out = parsePipelineAnnotations('// tag ' + 'x'.repeat(51))
expect(out.tag).toBeUndefined()
})
})
describe('parsePipelineAnnotations: header scan', () => {
it('ignores annotations in the body once code has started', () => {
const code = [
'import pandas as pd',
'',
'def main():',
' # tag each row with its source so downstream steps can filter',
' # on s3://should/not/parse',
' return pd.DataFrame()'
].join('\n')
const out = parsePipelineAnnotations(code)
expect(out.tag).toBeUndefined()
expect(out.triggerAssets).toHaveLength(0)
})
it('tolerates blank lines before code but stops at the first code line', () => {
const code = ['#!/usr/bin/env python', '', '# tag heavy', 'import os', '# tag light'].join('\n')
const out = parsePipelineAnnotations(code)
expect(out.tag).toBe('heavy')
})
})
describe('parsePipelineAnnotations: retry', () => {
@@ -291,8 +291,13 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
}
for (const rawLine of code.split('\n')) {
// Annotations live in the leading comment header: skip blank lines but
// stop at the first line of actual code, so comments inside the body
// (e.g. a regular `# tag ...` prose comment) can't false-positive.
// Mirrors the Rust parse_pipeline_annotations header scan.
if (rawLine.trim() === '') continue
const rest = stripCommentPrefix(rawLine)
if (rest === undefined) continue
if (rest === undefined) break
const inner = rest.trimStart()
const afterPipeline = consumeKeyword(inner, 'pipeline')
@@ -323,7 +328,10 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
const afterTag = consumeKeyword(inner, 'tag')
if (afterTag !== undefined) {
const name = afterTag.trim()
if (name && !out.tag) {
// Worker tags are single-word identifiers; a value with whitespace
// or beyond the script.tag column width is almost certainly a
// regular comment starting with "# tag ...".
if (name && !out.tag && !/\s/.test(name) && name.length <= 50) {
out.tag = name
}
continue
@@ -163,7 +163,8 @@
{headerLeft}
hasDiff={aiChatManager.scriptEditorOptions &&
!!aiChatManager.scriptEditorOptions.lastDeployedCode &&
aiChatManager.scriptEditorOptions.lastDeployedCode !== aiChatManager.scriptEditorOptions.code}
aiChatManager.scriptEditorOptions.lastDeployedCode !==
aiChatManager.scriptEditorOptions.getCode()}
diffMode={aiChatManager.scriptEditorOptions?.diffMode ?? false}
{disabled}
{disabledMessage}
@@ -772,7 +772,7 @@ export class AIChatManager {
this.helpers = {
getScriptOptions: () => {
return {
code: this.scriptEditorOptions?.code ?? '',
code: this.scriptEditorOptions?.getCode() ?? '',
lang: lang,
path: this.scriptEditorOptions?.path ?? '',
args: this.scriptEditorOptions?.args ?? {}
@@ -1922,14 +1922,13 @@ export class AIChatManager {
lastDeployedCode: undefined,
lastSavedCode: undefined
}
return {
args: moduleState?.previewArgs ?? {},
error:
moduleState && !moduleState.previewSuccess
? getStringError(moduleState.previewResult)
: undefined,
code: module.value.content,
getCode: () => module.value.type === 'rawscript' ? module.value.content : '',
lang: module.value.language,
path: module.id,
...editorRelated
@@ -11,7 +11,7 @@ import type { ExtendedOpenFlow } from '$lib/components/flows/types'
export interface ScriptOptions {
lang: ScriptLang | 'bunnative'
code: string
getCode: () => string
error: string | undefined
args: Record<string, any>
path: string | undefined
@@ -192,7 +192,7 @@ export default class ContextManager {
{
type: 'code',
title: this.getContextCodePath(scriptOptions) ?? '',
content: scriptOptions.code,
content: scriptOptions.getCode(),
lang: scriptOptions.lang
}
]
@@ -209,22 +209,22 @@ export default class ContextManager {
}
}
if (scriptOptions.lastSavedCode && scriptOptions.lastSavedCode !== scriptOptions.code) {
if (scriptOptions.lastSavedCode && scriptOptions.lastSavedCode !== scriptOptions.getCode()) {
newAvailableContext.push({
type: 'diff',
title: 'diff_with_last_saved_draft', // can't use spaces in the title, because it will break the word match in the context text area hightlighting logic
content: scriptOptions.lastSavedCode ?? '',
diff: diffLines(scriptOptions.lastSavedCode ?? '', scriptOptions.code),
diff: diffLines(scriptOptions.lastSavedCode ?? '', scriptOptions.getCode()),
lang: scriptOptions.lang
})
}
if (scriptOptions.lastDeployedCode && scriptOptions.lastDeployedCode !== scriptOptions.code) {
if (scriptOptions.lastDeployedCode && scriptOptions.lastDeployedCode !== scriptOptions.getCode()) {
newAvailableContext.push({
type: 'diff',
title: 'diff_with_last_deployed_version',
content: scriptOptions.lastDeployedCode ?? '',
diff: diffLines(scriptOptions.lastDeployedCode ?? '', scriptOptions.code),
diff: diffLines(scriptOptions.lastDeployedCode ?? '', scriptOptions.getCode()),
lang: scriptOptions.lang
})
}
@@ -251,7 +251,7 @@ export default class ContextManager {
{
type: 'code',
title: this.getContextCodePath(scriptOptions) ?? '',
content: scriptOptions.code,
content: scriptOptions.getCode(),
lang: scriptOptions.lang,
deletable: false
},
@@ -277,7 +277,7 @@ export default class ContextManager {
newSelectedContext = newSelectedContext
.filter(
(c) =>
(c.type === 'code_piece' && scriptOptions.code.includes(c.content)) ||
(c.type === 'code_piece' && scriptOptions.getCode().includes(c.content)) ||
c.type === 'code' ||
// Workspace references are user-picked via @-mention and not in
// availableContext; preserve so badges survive editor refreshes.
@@ -289,7 +289,7 @@ export default class ContextManager {
if (c.type === 'code') {
return {
...c,
content: scriptOptions.code,
content: scriptOptions.getCode(),
title: this.getContextCodePath(scriptOptions)
}
}
@@ -403,7 +403,7 @@ export default class ContextManager {
type: 'diff' as const,
title: 'diff_with_last_deployed_version',
content: this.scriptOptions.lastDeployedCode ?? '',
diff: diffLines(this.scriptOptions.lastDeployedCode ?? '', this.scriptOptions.code),
diff: diffLines(this.scriptOptions.lastDeployedCode ?? '', this.scriptOptions.getCode()),
lang: this.scriptOptions.lang
}
]
@@ -129,9 +129,17 @@ export class AIChatEditorHandler {
const deletedChange = group.changes[0]
const addedChange = group.changes[1]
if (deletedChange.type === 'deleted' && addedChange.type === 'added_block') {
applyChange(this.editor, deletedChange)
addedChange.position.afterLineNumber = deletedChange.range.startLine - 1
applyChange(this.editor, addedChange)
this.editor.executeEdits('chat', [
{
range: {
startLineNumber: deletedChange.range.startLine,
startColumn: 1,
endLineNumber: deletedChange.range.endLine + 1,
endColumn: 0
},
text: addedChange.value + '\n'
}
])
} else {
throw new Error('Invalid group')
}
@@ -284,7 +292,7 @@ export class AIChatEditorHandler {
})
if (!opts?.applyAll) {
;({ collection, ids } = await displayVisualChanges(
; ({ collection, ids } = await displayVisualChanges(
'editor-windmill-chat-style',
this.editor,
changes,
@@ -17,6 +17,7 @@
import { AIMode } from '../AIChatManager.svelte'
import { getAiChatManager } from '../aiChatManagerContext'
import { Check, Play } from 'lucide-svelte'
import MermaidDisplay from './MermaidDisplay.svelte'
const aiChatManager = getAiChatManager()
@@ -90,7 +91,7 @@
if (
aiChatManager.mode !== AIMode.SCRIPT ||
!aiChatManager.scriptEditorApplyCode ||
code === aiChatManager.scriptEditorOptions?.code
code === aiChatManager.scriptEditorOptions?.getCode()
) {
return false
}
@@ -108,14 +109,18 @@
<div
class="relative w-full border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"
>
<HighlightCode
className="p-1"
code={code ?? ''}
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
language={undefined}
onApplyCode={handleApplyCode}
{showApplyButton}
applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play}
/>
{#if language === 'mermaid'}
<MermaidDisplay code={code ?? ''} />
{:else}
<HighlightCode
className="p-1"
code={code ?? ''}
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
language={undefined}
onApplyCode={handleApplyCode}
{showApplyButton}
applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play}
/>
{/if}
</div>
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import { randomUUID } from '$lib/utils/uuid'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
let { code }: { code: string } = $props()
const isDarkMode = useIsDarkMode()
let svg = $state<string | undefined>(undefined)
// The exact source that produced `svg`. The diagram is only shown while this
// still matches the current `code`, so a later edit that fails to parse falls
// back to the raw source instead of leaving a stale, mismatched diagram.
let renderedCode = $state<string | undefined>(undefined)
// Monotonic token so an earlier-started render that resolves late can't
// overwrite the result of a newer one (out-of-order async on rapid code/theme changes).
let renderSeq = 0
async function render(source: string, dark: boolean) {
const seq = ++renderSeq
if (!source?.trim()) {
svg = undefined
renderedCode = undefined
return
}
try {
const mermaid = (await import('mermaid')).default
mermaid.initialize({
startOnLoad: false,
theme: dark ? 'dark' : 'default',
securityLevel: 'strict',
// Throw on parse errors instead of injecting an orphan error diagram into the DOM.
suppressErrorRendering: true
})
// mermaid.render needs a fresh element id per attempt to avoid id collisions.
const result = await mermaid.render(`mermaid-${randomUUID()}`, source)
if (seq !== renderSeq) return
svg = result.svg
renderedCode = source
} catch {
// Parse failure (often a partial block still streaming in): fall back to the
// raw source. `showSvg` already hides any previous diagram since `renderedCode`
// no longer matches the current `code`.
}
}
$effect(() => {
void render(code, isDarkMode.val)
})
// Only show the diagram while it corresponds to the current source.
let showSvg = $derived(svg !== undefined && renderedCode === code)
</script>
{#if showSvg}
<div class="p-2 flex justify-center overflow-x-auto">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html svg}
</div>
{:else}
<!-- Fallback while loading or when rendering fails: show the raw source -->
<pre class="overflow-auto max-h-screen text-xs p-2">{code}</pre>
{/if}
@@ -867,7 +867,6 @@
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
syncExternalCode
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
@@ -931,7 +930,6 @@
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
syncExternalCode
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.737.0"
wmill = ">=1.738.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.737.0
version: 1.738.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.737.0'
ModuleVersion = '1.738.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.737.0"
version = "1.738.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.737.0",
"version": "1.738.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.737.0",
"version": "1.738.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.737.0
1.738.0