Merge remote-tracking branch 'origin/main' into explore-git-sync-improvements

This commit is contained in:
hugocasa
2026-07-06 21:45:27 +02:00
81 changed files with 3211 additions and 513 deletions
+37
View File
@@ -1,5 +1,42 @@
# Changelog
## [1.751.0](https://github.com/windmill-labs/windmill/compare/v1.750.0...v1.751.0) (2026-07-06)
### Features
* add cosmetic dev/staging label for dev workspaces ([#9959](https://github.com/windmill-labs/windmill/issues/9959)) ([fd8e64d](https://github.com/windmill-labs/windmill/commit/fd8e64d11fea3ffdb7858100c67cb2e9ca841ed6))
* **auth:** add runtime NO_AUTH mode for authentication bypass ([#9962](https://github.com/windmill-labs/windmill/issues/9962)) ([91e1b08](https://github.com/windmill-labs/windmill/commit/91e1b087a206efb7189824b4184e1f3f4cda7211))
* **frontend:** custom skills — detail modal, batch manage, shared validation ([#9847](https://github.com/windmill-labs/windmill/issues/9847)) ([2e14302](https://github.com/windmill-labs/windmill/commit/2e14302e4abbad595584806bff12548d520fcb58))
* **pipelines:** auto-derive cascade edges from ducklake/s3 reads (+ muted-read badge) ([#9963](https://github.com/windmill-labs/windmill/issues/9963)) ([3dcd394](https://github.com/windmill-labs/windmill/commit/3dcd3949a14199b106506994ea31ca3de7e636b3))
### Bug Fixes
* **ai:** centralize Anthropic Messages API routing across completion paths ([#9960](https://github.com/windmill-labs/windmill/issues/9960)) ([cc2f638](https://github.com/windmill-labs/windmill/commit/cc2f638de6cebeffb9fee1d4835a0cfd565af86c))
* **assets:** responsive layout for small screens ([#9961](https://github.com/windmill-labs/windmill/issues/9961)) ([45946d1](https://github.com/windmill-labs/windmill/commit/45946d1185c0bd07948d4d8454880c2801571f9d))
* **cli:** quote non-identifier property names in resource-type namespace ([#9964](https://github.com/windmill-labs/windmill/issues/9964)) ([dc6b997](https://github.com/windmill-labs/windmill/commit/dc6b99775b550e7433fee8a159c30eaf296500c5))
* critical alerts modal mute toggles no longer close popover or fail to save ([#9969](https://github.com/windmill-labs/windmill/issues/9969)) ([6587019](https://github.com/windmill-labs/windmill/commit/6587019d263374ee5707d258f5d8eec7e73c690d))
* **frontend:** theme-aware code block background in prose markdown ([#9968](https://github.com/windmill-labs/windmill/issues/9968)) ([9821596](https://github.com/windmill-labs/windmill/commit/9821596251cff698958ffbfbd11fffa6a7988c6c))
## [1.750.0](https://github.com/windmill-labs/windmill/compare/v1.749.0...v1.750.0) (2026-07-06)
### Features
* chat-scoped session changes bar + unified diff drawer ([#9762](https://github.com/windmill-labs/windmill/issues/9762)) ([a6c0b37](https://github.com/windmill-labs/windmill/commit/a6c0b3756be78ca3fadc7bad6bae98c0887fd538))
* **pipelines:** require data uploads before running a pipeline ([#9953](https://github.com/windmill-labs/windmill/issues/9953)) ([a1c5b7a](https://github.com/windmill-labs/windmill/commit/a1c5b7aa3ed2841f09f4f148ded5c5b5ef10fd3d))
* **pipelines:** wm_partition macro for grain-agnostic partition filters ([#9950](https://github.com/windmill-labs/windmill/issues/9950)) ([43044c2](https://github.com/windmill-labs/windmill/commit/43044c2e28139b1dbde6844c781a821f8de68f58))
### Bug Fixes
* **ai:** test key routes Azure Foundry Claude models via Anthropic Messages API ([#9956](https://github.com/windmill-labs/windmill/issues/9956)) ([ea19cc9](https://github.com/windmill-labs/windmill/commit/ea19cc9dc459bd259e27f7fcc29601a010c5f8f0))
* **cli:** HD-1 test_edges + HD-2 scd2 _current write in --local pipeline graph ([#9947](https://github.com/windmill-labs/windmill/issues/9947)) ([ad6f23d](https://github.com/windmill-labs/windmill/commit/ad6f23d6bfcf1056bcb6d8c6b552114e88177328))
* **pipelines:** make node & pipeline-level run affordances always visible ([#9948](https://github.com/windmill-labs/windmill/issues/9948)) ([6eabb96](https://github.com/windmill-labs/windmill/commit/6eabb96ae78fb966f9916f907bb693d569b04c0b))
* read chat drafts via own-draft route so drawer-kind drafts deploy ([#9913](https://github.com/windmill-labs/windmill/issues/9913)) ([056ebdb](https://github.com/windmill-labs/windmill/commit/056ebdb03543a93094c80ca354c117236cd8d6c8))
* resolve extensionless bun relative imports on windows loader ([#9949](https://github.com/windmill-labs/windmill/issues/9949)) ([bf96621](https://github.com/windmill-labs/windmill/commit/bf9662172ad7e0ff53d39adc338fd7886672c8f9))
## [1.749.0](https://github.com/windmill-labs/windmill/compare/v1.748.0...v1.749.0) (2026-07-05)
+17 -2
View File
@@ -11,6 +11,7 @@ import type {
DataTableTables,
DataTableTableSchema,
GetDraftForUserResponse,
GetOwnDraftResponse,
ListDraftsResponse,
ScriptLang,
UpdateDraftResponse,
@@ -294,8 +295,8 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
/**
* In-memory stand-in for the per-user draft backend (`DraftService`). The global
* AI chat now persists and reads drafts through the backend DB instead of an
* in-tab `UserDraft` cell, so the eval mocks the three draft endpoints it
* exercises (`updateDraft` / `getDraftForUser` / `listDrafts`) and keeps the
* in-tab `UserDraft` cell, so the eval mocks the draft endpoints it exercises
* (`updateDraft` / `getOwnDraft` / `getDraftForUser` / `listDrafts`) and keeps the
* saved values here, keyed by workspace + draft kind + storage path. Mirrors the
* semantics of the production unit test's mock in
* `frontend/src/lib/components/copilot/chat/global/core.test.ts`.
@@ -379,6 +380,20 @@ export function getBenchmarkDraftForUser(input: {
return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP }
}
/** Mirror `DraftService.getOwnDraft`: `null` (200) when absent — unlike
* `getDraftForUser`, absence is not an error on this route. */
export function getBenchmarkOwnDraft(input: {
workspace: string
kind: UserDraftItemKind
path: string
}): GetOwnDraftResponse {
const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path))
if (!entry) {
return null
}
return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP }
}
/** Mirror `DraftService.listDrafts`: metadata rows (no value) for a workspace. */
export function listBenchmarkDrafts(workspace: string): ListDraftsResponse {
return [...benchmarkDrafts.values()]
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
clearBenchmarkDrafts,
getBenchmarkDraftForUser,
getBenchmarkOwnDraft,
listBenchmarkDrafts,
resetBenchmarkMockBackend,
seedBenchmarkDraft,
@@ -55,6 +56,27 @@ describe('mockBackend drafts', () => {
expect(() => getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'variable', path: 'f/evals/token' })).toThrow()
})
it('returns null from getOwnDraft when no draft exists', () => {
expect(
getBenchmarkOwnDraft({ workspace: WORKSPACE, kind: 'trigger_schedule', path: 'u/evals/missing' })
).toBeNull()
})
// The global chat hydrates drawer-kind drafts (schedule/trigger/resource/variable)
// through getOwnDraft — getDraftForUser rejects those kinds as private.
it('hydrates a saved drawer-kind draft through getOwnDraft', () => {
const value = { path: 'u/evals/nightly', schedule: '0 0 9 * * *' }
updateBenchmarkDraft({
workspace: WORKSPACE,
kind: 'trigger_schedule',
path: 'u/evals/nightly',
requestBody: { value }
})
expect(
getBenchmarkOwnDraft({ workspace: WORKSPACE, kind: 'trigger_schedule', path: 'u/evals/nightly' })?.value
).toEqual(value)
})
it('throws a 404-shaped error when no draft exists', () => {
try {
getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/missing' })
@@ -40,6 +40,7 @@ vi.mock('$lib/gen', async () => {
getBenchmarkDraftForUser,
getBenchmarkFlowByPath,
getBenchmarkJobLogs,
getBenchmarkOwnDraft,
getBenchmarkScriptByHash,
getBenchmarkScriptByPath,
hasBenchmarkWorkspace,
@@ -86,6 +87,10 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkDraftForUser(data)
: actual.DraftService.getDraftForUser(data),
getOwnDraft: async (data: { workspace: string; kind: any; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkOwnDraft(data)
: actual.DraftService.getOwnDraft(data),
listDrafts: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? listBenchmarkDrafts(data.workspace)
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": [
false
]
},
"hash": "63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "dev_workspace_label",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,\n CASE WHEN $5 THEN dev_workspace_label ELSE NULL END\n FROM workspace WHERE id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01"
}
@@ -0,0 +1,70 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "parent_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "is_dev_workspace",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "dev_workspace_label",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "operator_settings",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
null,
false
]
},
"hash": "af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322"
}
+95 -95
View File
@@ -2491,18 +2491,18 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
@@ -2510,27 +2510,27 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crossterm_winapi"
@@ -6237,11 +6237,11 @@ dependencies = [
[[package]]
name = "jobserver"
version = "0.1.34"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.3.4",
"getrandom 0.4.3",
"libc",
]
@@ -13746,7 +13746,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-nats",
@@ -13829,7 +13829,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"async-stream",
"async-trait",
@@ -13862,7 +13862,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13875,7 +13875,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"argon2",
@@ -14013,7 +14013,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14036,7 +14036,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14051,7 +14051,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14077,7 +14077,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14087,7 +14087,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14104,7 +14104,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14126,7 +14126,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14149,7 +14149,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14165,7 +14165,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14186,7 +14186,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14207,7 +14207,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14221,7 +14221,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14256,7 +14256,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14281,7 +14281,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14299,7 +14299,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14321,7 +14321,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14341,7 +14341,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14378,7 +14378,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14406,7 +14406,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"lazy_static",
"serde",
@@ -14418,7 +14418,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14443,7 +14443,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14457,7 +14457,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14491,7 +14491,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"chrono",
"lazy_static",
@@ -14505,7 +14505,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14524,7 +14524,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14626,7 +14626,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14645,7 +14645,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"regex",
"serde",
@@ -14660,7 +14660,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14684,7 +14684,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"futures",
@@ -14701,7 +14701,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14717,7 +14717,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14738,7 +14738,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14769,7 +14769,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -14794,7 +14794,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14828,7 +14828,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"futures",
@@ -14846,7 +14846,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14855,7 +14855,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14867,7 +14867,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14879,7 +14879,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14891,7 +14891,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14903,7 +14903,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14915,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14926,7 +14926,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14937,7 +14937,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14949,7 +14949,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14960,7 +14960,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -14982,7 +14982,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14994,7 +14994,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15008,7 +15008,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15025,7 +15025,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15038,7 +15038,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde",
@@ -15050,7 +15050,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15068,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15084,7 +15084,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15100,7 +15100,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde",
@@ -15111,7 +15111,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15150,7 +15150,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"const_format",
@@ -15189,7 +15189,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15200,7 +15200,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15234,7 +15234,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15258,7 +15258,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15291,7 +15291,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15324,7 +15324,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15344,7 +15344,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15378,7 +15378,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15414,7 +15414,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15437,7 +15437,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15461,7 +15461,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15485,7 +15485,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15520,7 +15520,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15548,7 +15548,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15573,7 +15573,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"bitflags 2.13.0",
@@ -15592,7 +15592,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15702,7 +15702,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"bytes",
"futures",
@@ -16428,18 +16428,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.52"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
dependencies = [
"proc-macro2",
"quote",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.749.0"
version = "1.751.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.749.0"
version = "1.751.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -0,0 +1 @@
ALTER TABLE workspace DROP COLUMN dev_workspace_label;
@@ -0,0 +1,4 @@
-- Cosmetic display label for a dev workspace: NULL/'dev' render as "dev", 'staging' renders as "stg".
-- Only meaningful when is_dev_workspace = true; changes nothing about behavior (locking, promote and
-- compare all key off is_dev_workspace / parent_workspace_id). The value is validated in the handler.
ALTER TABLE workspace ADD COLUMN dev_workspace_label VARCHAR;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.749.0"
version = "1.751.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.749.0"
version = "1.751.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
@@ -528,6 +528,16 @@ pub struct PipelineAnnotations {
pub column_lineage: Vec<ColumnLineage>,
pub macros: bool,
pub use_libs: Vec<String>,
// `// mute <asset>` — suppress the auto-derived cascade edge for a read
// that would otherwise trigger this script (a lookup / slowly-changing
// dimension you read every run but don't want to re-run on). Only Asset
// specs are stored; native trigger kinds are never auto-derived, so
// muting them is meaningless.
pub mute: Vec<TriggerSpec>,
// `// mute all` — opt out of auto-derivation entirely for this script.
// Falls back to explicit-`// on`-only semantics. Explicit `// on` edges
// are unaffected.
pub mute_all: bool,
}
impl ParseAssetsOutput {
@@ -956,6 +966,23 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
continue;
}
// `// mute all` opts out of auto-derived cascade edges entirely;
// `// mute <asset>` suppresses the one edge. Only asset refs are
// muteable — native trigger kinds are never auto-derived. Checked
// before the generic `on`/asset shorthand (a complete word, so
// prose like `// muted for now` never matches).
if let Some(after_kw) = consume_keyword(rest, "mute") {
let arg = after_kw.trim();
if arg == "all" {
out.mute_all = true;
} else if let Some(spec @ TriggerSpec::Asset { .. }) = parse_trigger_spec(arg) {
if !out.mute.contains(&spec) {
out.mute.push(spec);
}
}
continue;
}
// `data_test` is checked before `on`/asset shorthands and is a complete
// word (so it never collides with the `// test:` CI annotation, which
// has no whitespace after `test`). Accumulates — every well-formed line
@@ -769,5 +769,109 @@
"tag": null,
"retry": null
}
},
{
"name": "mute suppresses a single ducklake read edge",
"code": "// pipeline\n// mute ducklake://main.orders\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null,
"mute": [
"ducklake:main.orders"
]
}
},
{
"name": "mute all opts out of all auto-derivation",
"code": "// pipeline\n// mute all\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null,
"mute_all": true
}
},
{
"name": "mute accumulates in order and dedups",
"code": "// pipeline\n// mute ducklake://main.a\n// mute s3://raw/b\n// mute ducklake://main.a\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null,
"mute": [
"ducklake:main.a",
"s3object:raw/b"
]
}
},
{
"name": "mute of a native trigger kind is dropped (only assets are muteable)",
"code": "// pipeline\n// mute kafka\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null
}
},
{
"name": "mute prose without an asset ref never false-positives",
"code": "// pipeline\n// muted for now\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null
}
},
{
"name": "mute all coexists with explicit on edges",
"code": "// pipeline\n// mute all\n// on ducklake://main.orders\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [
"ducklake:main.orders"
],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null,
"mute_all": true
}
},
{
"name": "on asset ref strips trailing key=value opts",
"code": "// pipeline\n// on ducklake://main.orders debounce=60s\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [
"ducklake:main.orders"
],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null
}
}
]
@@ -53,6 +53,13 @@ struct Expected {
// `// use <lib_path>` accumulation, declaration order, deduped. Absent === [].
#[serde(default)]
use_libs: Vec<String>,
// `// mute <asset>` accumulation as `kind:path`, declaration order, deduped.
// Absent === [].
#[serde(default)]
mute: Vec<String>,
// `// mute all` marker. Absent === false.
#[serde(default)]
mute_all: bool,
}
#[derive(Deserialize)]
@@ -251,5 +258,18 @@ fn pipeline_annotation_fixtures_match() {
assert_eq!(got.macros, f.expected.macros, "{ctx}: macros");
assert_eq!(got.use_libs, f.expected.use_libs, "{ctx}: use_libs");
let mute: Vec<String> = got
.mute
.iter()
.filter_map(|t| match t {
TriggerSpec::Asset { asset_kind, path, .. } => {
Some(format!("{}:{}", kind_str(*asset_kind), path))
}
_ => None,
})
.collect();
assert_eq!(mute, f.expected.mute, "{ctx}: mute");
assert_eq!(got.mute_all, f.expected.mute_all, "{ctx}: mute_all");
}
}
+10
View File
@@ -578,6 +578,7 @@ fn print_help() {
println!(" JSON_FMT = false Output logs in JSON instead of logfmt");
println!(" METRICS_ADDR = None (EE only) Prometheus metrics addr at /metrics; set \"true\" to use :8001");
println!(" SUPERADMIN_SECRET = None Virtual superadmin token (server)");
println!(" NO_AUTH = false Bypass all auth; every request acts as the admin@windmill.dev superadmin (only behind a trusted gateway; ignored when CLOUD_HOSTED)");
println!(" LICENSE_KEY = None (EE only) Enterprise license key (workers require valid key)");
println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup");
println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool");
@@ -641,6 +642,15 @@ async fn windmill_main() -> anyhow::Result<()> {
println!("Running in MCP mode");
}
if *windmill_common::worker::NO_AUTH {
println!("############################################################");
println!("# NO_AUTH mode is ENABLED: authentication is fully #");
println!("# bypassed and every request is treated as the #");
println!("# admin@windmill.dev superadmin. Only run this behind a #");
println!("# trusted authenticating gateway on a private network. #");
println!("############################################################");
}
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
println!("jemalloc enabled");
+42 -15
View File
@@ -131,6 +131,13 @@ impl AuthCache {
w_id: Option<String>,
token: &str,
) -> Option<OptJobAuthed> {
// In no-auth mode there are no real tokens: resolve directly as the
// admin superadmin so direct cache callers (e.g. get_all_runnables,
// which re-validates the request token per workspace) don't reject the
// fabricated token.
if is_no_auth() {
return Some(OptJobAuthed { authed: no_auth_admin_authed(), job_id: None });
}
let key = (
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
token.to_string(),
@@ -598,6 +605,12 @@ where
let tokened = Self { token };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else if is_no_auth() {
// In `--no-auth` mode requests carry no token, but handlers that
// also require Tokened (e.g. global_whoami) must still resolve.
let tokened = Self { token: "no_auth".to_string() };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else {
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
@@ -677,6 +690,30 @@ fn maybe_get_workspace_id_from_path(path_vec: &[&str]) -> Option<String> {
workspace_id
}
/// `--no-auth` mode: compiled-in `oss` builds, or the `NO_AUTH` runtime flag on
/// any build (the runtime flag is force-disabled on CLOUD_HOSTED). When on,
/// every request resolves as the admin superadmin so a fronting gateway can
/// handle authentication instead.
pub fn is_no_auth() -> bool {
cfg!(feature = "no_auth") || *windmill_common::worker::NO_AUTH
}
/// The synthetic superadmin identity returned for every request in no-auth mode.
fn no_auth_admin_authed() -> ApiAuthed {
ApiAuthed {
email: "admin@windmill.dev".to_string(),
username: "admin".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
}
}
/// Resolves OptJobAuthed from request parts.
/// Takes ownership of Parts and returns them back.
#[allow(unreachable_code, unused_mut)]
@@ -687,21 +724,11 @@ pub async fn resolve_opt_job_authed(
return Ok((OptJobAuthed::default(), parts));
};
#[cfg(feature = "no_auth")]
{
let authed = ApiAuthed {
email: "admin@windmill.dev".to_string(),
username: "admin".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
};
return Ok((OptJobAuthed { authed, job_id: None }, parts));
if is_no_auth() {
return Ok((
OptJobAuthed { authed: no_auth_admin_authed(), job_id: None },
parts,
));
}
let already_authed = parts.extensions.get::<OptJobAuthed>().cloned();
+2 -2
View File
@@ -31,8 +31,8 @@ use scopes::ScopeDefinition;
// Re-export key auth types and functions
pub use auth::{
get_end_user_email, invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened,
Tokened, TruncatedTokenWithEmail, AUTH_CACHE,
get_end_user_email, invalidate_token_from_cache, is_no_auth, AuthCache, ExpiringAuthCache,
OptTokened, Tokened, TruncatedTokenWithEmail, AUTH_CACHE,
};
// ------------ ApiAuthed & OptJobAuthed types ------------
+73 -4
View File
@@ -45,8 +45,9 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_common::{
assets::{
clear_script_triggers, clear_static_asset_usage, clear_static_asset_usage_by_script_hash,
insert_script_trigger, parse_duration_secs, parse_pipeline_annotations,
replace_static_asset_usage, trigger_spec_to_row, AssetUsageKind, TriggerSpec,
derive_pipeline_asset_trigger_refs, insert_script_trigger, parse_duration_secs,
parse_pipeline_annotations, replace_static_asset_usage, trigger_spec_to_row,
AssetUsageKind, ScriptTriggerKind, TriggerSpec,
},
error::{self, to_anyhow},
min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2},
@@ -1457,11 +1458,22 @@ async fn create_script_internal<'c>(
// fire and the view would be an orphan node in the lineage graph.
for (target_kind, path) in m.write_targets() {
let kind = windmill_common::assets::asset_kind_from_parser(target_kind);
if !a.iter().any(|x| x.kind == kind && x.path == path) {
use windmill_common::assets::AssetUsageAccessType;
if let Some(existing) = a.iter_mut().find(|x| x.kind == kind && x.path == path) {
// The body reads its own managed target (an incremental/merge
// model `SELECT`ing from the table it materializes). The runtime
// still generates the write, so the effective access is RW — mark
// it so, otherwise it stays a plain read and (a) auto-derives a
// self-cascade edge back to this producer and (b) shows as a muted
// read on the canvas. Both are wrong: it's the script's own output.
if existing.access_type != Some(AssetUsageAccessType::W) {
existing.access_type = Some(AssetUsageAccessType::RW);
}
} else {
a.push(windmill_common::assets::AssetWithAltAccessType {
path,
kind,
access_type: Some(windmill_common::assets::AssetUsageAccessType::W),
access_type: Some(AssetUsageAccessType::W),
alt_access_type: None,
columns: None,
});
@@ -2035,6 +2047,63 @@ async fn create_script_internal<'c>(
.await?;
}
// Auto-derived cascade edges: within a `// pipeline`, a read of a ducklake
// table or s3 object wires the cascade edge straight from the FROM clause,
// so `// on <asset>` is only needed for edges inference can't see (dynamic
// SQL) or to carry per-edge opts. `// mute <asset>` / `// mute all` opt out.
// Explicit `// on` asset edges (inserted just above) win the dedup — they
// carry the per-edge debounce, so a derived row must not shadow them.
if in_pipeline && !pipeline_annotations.mute_all {
let asset_ref = |spec: &TriggerSpec| {
trigger_spec_to_row(spec)
.filter(|(k, _)| *k == ScriptTriggerKind::Asset)
.map(|(_, r)| r)
};
let explicit_refs: std::collections::HashSet<String> =
pipeline_triggers.iter().filter_map(asset_ref).collect();
let muted_refs: std::collections::HashSet<String> = pipeline_annotations
.mute
.iter()
.filter_map(asset_ref)
.collect();
let derived = derive_pipeline_asset_trigger_refs(
effective_assets.as_deref().unwrap_or(&[]),
&explicit_refs,
&muted_refs,
pipeline_annotations.mute_all,
);
// Derived edges have no per-`// on` opts, so they take the script-level
// `// debounce` default and `// retry` policy — same as writing a bare
// `// on <asset>` would.
let derived_debounce_s = pipeline_debounce_default
.as_deref()
.and_then(parse_duration_secs);
let derived_retry_count = pipeline_annotations
.retry
.as_ref()
.map(|r| r.count.min(i16::MAX as u32) as i16);
let derived_retry_delay_s = pipeline_annotations
.retry
.as_ref()
.and_then(|r| r.delay.as_deref())
.and_then(parse_duration_secs);
for trigger_ref in derived {
insert_script_trigger(
&mut *tx,
&w_id,
AssetUsageKind::Script,
&ns.path,
ScriptTriggerKind::Asset,
&trigger_ref,
pipeline_join_all,
derived_debounce_s,
derived_retry_count,
derived_retry_delay_s,
)
.await?;
}
}
// Schedule annotations (`// on schedule`) are marker-only — the binding
// lives on the schedule row's own `script_path` field, which the user
// creates separately via the schedule editor. No script-create-time
+4 -2
View File
@@ -1942,8 +1942,10 @@ async fn login(
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Json(Login { email, password }): Json<Login>,
) -> Result<String> {
#[cfg(feature = "no_auth")]
{
// In `--no-auth` mode there is no real login; the frontend never needs a
// session cookie because every request already resolves as the admin
// superadmin (see resolve_opt_job_authed).
if windmill_api_auth::is_no_auth() {
return Ok("no_auth".to_string());
}
@@ -156,6 +156,7 @@ pub fn workspaced_service() -> Router {
.route("/create_fork", post(create_workspace_fork))
.route("/attach_dev_workspace", post(attach_dev_workspace))
.route("/detach_dev_workspace", post(detach_dev_workspace))
.route("/set_dev_workspace_label", post(set_dev_workspace_label))
.route("/get_dev_workspace", get(get_dev_workspace))
.route("/change_workspace_name", post(change_workspace_name))
.route("/change_workspace_color", post(change_workspace_color))
@@ -472,6 +473,10 @@ struct CreateWorkspaceFork {
/// the team can work in it. Defaults off; the dev-workspace UI defaults it on.
#[serde(default)]
copy_members: bool,
/// Cosmetic display label for the dev workspace: 'dev' | 'staging'. Purely visual (badge text +
/// wording); ignored for non-dev forks. None defaults to 'dev'.
#[serde(default)]
dev_workspace_label: Option<String>,
}
#[derive(Deserialize)]
@@ -501,6 +506,7 @@ struct UserWorkspace {
pub operator_settings: Option<Option<serde_json::Value>>,
pub parent_workspace_id: Option<String>,
pub is_dev_workspace: bool,
pub dev_workspace_label: Option<String>,
pub disabled: bool,
}
@@ -678,6 +684,20 @@ async fn exists_workspace(
struct DevWorkspaceInfo {
id: String,
name: String,
dev_workspace_label: Option<String>,
}
/// Normalize/validate the cosmetic dev-workspace display label. None or 'dev' both render as "dev";
/// 'staging' renders as "stg". Anything else is rejected. Stored explicitly ('dev'/'staging') so it
/// round-trips, but a NULL column is treated as 'dev' on the read side too.
fn normalize_dev_workspace_label(label: Option<String>) -> Result<Option<String>> {
match label.as_deref() {
None | Some("dev") => Ok(Some("dev".to_string())),
Some("staging") => Ok(Some("staging".to_string())),
Some(other) => Err(Error::BadRequest(format!(
"invalid dev workspace label '{other}' (expected 'dev' or 'staging')"
))),
}
}
/// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace
@@ -691,7 +711,7 @@ async fn get_dev_workspace(
) -> JsonResult<Option<DevWorkspaceInfo>> {
let dev = sqlx::query_as!(
DevWorkspaceInfo,
"SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
"SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
&w_id
)
.fetch_optional(&db)
@@ -3824,7 +3844,7 @@ async fn user_workspaces(
let workspaces = sqlx::query_as!(
UserWorkspace,
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,
workspace.is_dev_workspace,
workspace.is_dev_workspace, workspace.dev_workspace_label,
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,
usr.disabled
FROM workspace
@@ -5324,6 +5344,8 @@ async fn create_workspace_fork_branch(
// that second call. Validating early lets a bad request fail before any branch is created.
if nw.is_dev_workspace {
validate_dev_workspace_id(&nw.id)?;
// Reject a bad cosmetic label before any git branch is created (acted on in create_workspace_fork).
normalize_dev_workspace_label(nw.dev_workspace_label.clone())?;
ensure_dev_parent_is_root(&db, &w_id).await?;
// Reject before creating any git branch if the parent already has a dev workspace,
// otherwise the deferred branch-creation job leaves a dangling branch on the synced repos.
@@ -5555,6 +5577,12 @@ async fn create_workspace_fork(
validate_fork_workspace_id(&nw.id)?;
}
validate_workspace_name(&nw.name)?;
// Cosmetic label only applies to dev workspaces; a non-dev fork stores NULL.
let dev_workspace_label = if nw.is_dev_workspace {
normalize_dev_workspace_label(nw.dev_workspace_label.clone())?
} else {
None
};
// Check the id conflict before the CE workspace-count limit so that
// re-using a taken (possibly archived) fork id reports the actual
// conflict instead of a misleading "maximum number of workspaces" error.
@@ -5632,13 +5660,14 @@ async fn create_workspace_fork(
sqlx::query!(
"INSERT INTO workspace
(id, name, owner, parent_workspace_id, is_dev_workspace)
VALUES ($1, $2, $3, $4, $5)",
(id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)
VALUES ($1, $2, $3, $4, $5, $6)",
forked_id,
nw.name,
authed.email,
parent_workspace_id,
nw.is_dev_workspace,
dev_workspace_label,
)
.execute(&mut *tx)
.await?;
@@ -5771,6 +5800,9 @@ struct AttachDevWorkspace {
lock_prod_deploy: bool,
#[serde(default)]
lock_prod_forking: bool,
/// Cosmetic display label for the attached dev workspace: 'dev' | 'staging'. None defaults to 'dev'.
#[serde(default)]
dev_workspace_label: Option<String>,
}
#[derive(Deserialize)]
@@ -5824,6 +5856,7 @@ async fn attach_dev_workspace(
// The id is interpolated into a `wm-fork/<branch>/<id>` branch name like any fork.
validate_dev_workspace_id(&dev_w_id)?;
let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?;
let dev = sqlx::query!(
r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#,
@@ -5891,9 +5924,10 @@ async fn attach_dev_workspace(
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2",
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2",
&prod_w_id,
&dev_w_id
&dev_w_id,
dev_workspace_label,
)
.execute(&mut *tx)
.await?;
@@ -5959,6 +5993,51 @@ async fn attach_dev_workspace(
))
}
#[derive(Deserialize)]
struct SetDevWorkspaceLabel {
#[serde(default)]
dev_workspace_label: Option<String>,
}
/// Change the cosmetic display label ('dev' | 'staging') of the current workspace, which must itself
/// be a dev workspace. Purely visual (badge text + wording); requires admin of the dev workspace.
async fn set_dev_workspace_label(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(req): Json<SetDevWorkspaceLabel>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let label = normalize_dev_workspace_label(req.dev_workspace_label)?;
let mut tx = db.begin().await?;
let updated = sqlx::query_scalar!(
"UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id",
label,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
if updated.is_none() {
return Err(Error::BadRequest(format!(
"Workspace '{w_id}' is not a dev workspace"
)));
}
audit_log(
&mut *tx,
&authed,
"workspaces.set_dev_workspace_label",
ActionKind::Update,
&w_id,
label.as_deref(),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Updated dev workspace label for {w_id}"))
}
/// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the
/// prod lock. The workspace keeps its `parent_workspace_id` (it remains an ordinary fork).
async fn detach_dev_workspace(
@@ -91,9 +91,10 @@ pub(crate) async fn change_workspace_id(
.await?;
}
sqlx::query!(
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace)
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)
SELECT $1, $2, owner, false, premium,
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,
CASE WHEN $5 THEN dev_workspace_label ELSE NULL END
FROM workspace WHERE id = $3",
&rw.new_id,
&rw.new_name,
@@ -1095,7 +1096,10 @@ pub(crate) async fn delete_workspace(
// effort: failures are logged — the workspace row is already gone, and broken storage
// credentials must not have made it undeletable.
for e in cleanup_fork_ducklake_namespaces(&db, &w_id, fork_ducklake_cleanups).await {
tracing::warn!("deleted workspace {w_id}: ducklake namespace cleanup: {}", e.msg);
tracing::warn!(
"deleted workspace {w_id}: ducklake namespace cleanup: {}",
e.msg
);
}
if let Some(parent) = dev_lock_parent {
+42 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.749.0
version: 1.751.0
title: Windmill API
contact:
@@ -1205,6 +1205,9 @@ paths:
type: boolean
lock_prod_forking:
type: boolean
dev_workspace_label:
type: string
enum: [dev, staging]
required:
- dev_workspace_id
responses:
@@ -1263,10 +1266,40 @@ paths:
type: string
name:
type: string
dev_workspace_label:
type: string
nullable: true
description: "Cosmetic display label ('dev' | 'staging'); null defaults to 'dev'"
required:
- id
- name
/w/{workspace}/workspaces/set_dev_workspace_label:
post:
summary: set the cosmetic display label (dev/staging) of this dev workspace
operationId: setDevWorkspaceLabel
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
dev_workspace_label:
type: string
enum: [dev, staging]
responses:
"200":
description: dev workspace label updated
content:
text/plain:
schema:
type: string
/workspaces/exists:
post:
summary: exists workspace
@@ -28180,6 +28213,10 @@ components:
nullable: true
is_dev_workspace:
type: boolean
dev_workspace_label:
type: string
nullable: true
description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'"
created_by:
type: string
nullable: true
@@ -28249,6 +28286,10 @@ components:
copy_members:
type: boolean
description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it"
dev_workspace_label:
type: string
enum: [dev, staging]
description: "Cosmetic display label for the dev workspace (badge text + wording only); ignored for non-dev forks"
required:
- id
- name
+11 -3
View File
@@ -96,6 +96,14 @@ lazy_static::lazy_static! {
pub struct CustomMigrator {
inner: PoolConnection<Postgres>,
}
impl CustomMigrator {
/// The connection the migrator already holds (with the migration advisory lock).
/// Migration housekeeping runs on it instead of re-acquiring: a second connection
/// while this one is held deadlocks a single-connection backend (e.g. embedded pglite).
pub fn connection(&mut self) -> &mut PgConnection {
&mut *self.inner
}
}
impl Migrate for CustomMigrator {
fn ensure_migrations_table(
&mut self,
@@ -272,7 +280,7 @@ pub async fn migrate(
version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR
version=20250201145631 OR version=20250201145632 OR version=20251006143821"
)
.execute(db)
.execute(custom_migrator.connection())
.await
{
tracing::info!("Could not remove sqlx migrations: {err:#}");
@@ -310,7 +318,7 @@ pub async fn migrate(
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = $1 AND checksum != $2")
.bind(m.version)
.bind(&*m.checksum)
.execute(db)
.execute(custom_migrator.connection())
.await
{
tracing::info!("Could not clean up stale migration {}: {err:#}", m.version);
@@ -340,7 +348,7 @@ pub async fn migrate(
}
}
crate::live_migrations::custom_migrations(&mut custom_migrator, db).await?;
crate::live_migrations::custom_migrations(&mut custom_migrator).await?;
Ok(None)
}
+10 -10
View File
@@ -9,26 +9,26 @@
use sqlx::Postgres;
use windmill_common::error::Error;
use crate::db::{CustomMigrator, DB};
use crate::db::CustomMigrator;
use sqlx::migrate::Migrate;
use sqlx::Acquire;
use sqlx::Executor;
pub async fn custom_migrations(migrator: &mut CustomMigrator, db: &DB) -> Result<(), Error> {
if let Err(err) = fix_flow_versioning_migration(migrator, db).await {
pub async fn custom_migrations(migrator: &mut CustomMigrator) -> Result<(), Error> {
if let Err(err) = fix_flow_versioning_migration(migrator).await {
tracing::error!("Could not apply flow versioning fix migration: {err:#}");
}
Ok(())
}
async fn fix_flow_versioning_migration(
migrator: &mut CustomMigrator,
db: &DB,
) -> Result<(), Error> {
// Runs on the migrator's held connection (see CustomMigrator::connection): re-acquiring
// from the pool here would deadlock a single-connection backend.
async fn fix_flow_versioning_migration(migrator: &mut CustomMigrator) -> Result<(), Error> {
let has_done_migration = sqlx::query_scalar!(
"SELECT EXISTS(SELECT name FROM windmill_migrations WHERE name = 'fix_flow_versioning_2')",
)
.fetch_one(db)
.fetch_one(migrator.connection())
.await?
.unwrap_or(false);
@@ -44,14 +44,14 @@ async fn fix_flow_versioning_migration(
let has_done_migration = sqlx::query_scalar!(
"SELECT EXISTS(SELECT name FROM windmill_migrations WHERE name = 'fix_flow_versioning_2')",
)
.fetch_one(db)
.fetch_one(migrator.connection())
.await?
.unwrap_or(false);
if !has_done_migration {
let query = include_str!("../../custom_migrations/fix_flow_versioning_2.sql");
tracing::info!("Applying fix_flow_versioning_2.sql");
let mut tx: sqlx::Transaction<'_, Postgres> = db.begin().await?;
let mut tx: sqlx::Transaction<'_, Postgres> = migrator.connection().begin().await?;
tx.execute(query).await?;
tracing::info!("Applied fix_flow_versioning_2.sql");
sqlx::query!(
+190
View File
@@ -169,6 +169,68 @@ fn is_write_access(access: Option<AssetUsageAccessType>) -> bool {
)
}
/// Kinds whose *read* usage auto-derives a cascade trigger edge inside a
/// `// pipeline`. Scoped to the two intra-pipeline data kinds — a ducklake
/// table read (the core case) and an s3 object read (file-ingestion
/// producers). Resource / datatable / volume reads stay explicit-`// on`:
/// a config/lookup read cascading is more often surprising than wanted.
fn is_auto_trigger_kind(kind: AssetKind) -> bool {
matches!(kind, AssetKind::Ducklake | AssetKind::S3Object)
}
/// Trigger refs auto-derived from a pipeline script's inferred reads, so the
/// FROM clause alone wires the cascade edge (no redundant `// on <asset>`).
///
/// Included: an input read read-*only* (`R`) of a supported kind
/// ([`is_auto_trigger_kind`]). The effective access type is
/// `access_type.or(alt_access_type)` — same precedence as the persisted
/// `asset.usage_access_type` and the frontend mirror's `access_type ??
/// alt_access_type`, so a manual read override on an ambiguous parse still
/// derives an edge (and the live canvas and the deployed graph agree).
/// Excluded, each for a reason:
/// - `RW` / `W` — the script also writes the asset; an edge would be a
/// self-triggering loop.
/// - `None` access — usage is ambiguous (poisoned merge) with no override;
/// can't confirm a read, so fail safe and don't cascade.
/// - already in `explicit_refs` — the author wrote `// on <asset>`, which
/// wins (it carries the per-edge debounce/opts).
/// - in `muted_refs` — a `// mute <asset>` opt-out (lookup / SCD input).
///
/// `mute_all` (from `// mute all`) short-circuits to no derivation, leaving
/// only the explicit `// on` edges. Returns canonical refs (e.g.
/// `ducklake://main.orders`), deduped, in input order.
pub fn derive_pipeline_asset_trigger_refs(
assets: &[AssetWithAltAccessType],
explicit_refs: &HashSet<String>,
muted_refs: &HashSet<String>,
mute_all: bool,
) -> Vec<String> {
if mute_all {
return vec![];
}
let mut out = vec![];
let mut seen = HashSet::new();
for a in assets {
// Effective access mirrors the persisted `usage_access_type` and the
// frontend derivation: an explicit parse wins, else the manual override.
let access = a.access_type.or(a.alt_access_type);
if access != Some(AssetUsageAccessType::R) || !is_auto_trigger_kind(a.kind) {
continue;
}
let Some(prefix) = a.kind.canonical_prefix() else {
continue;
};
let r = format!("{}{}", prefix, a.path);
if explicit_refs.contains(&r) || muted_refs.contains(&r) {
continue;
}
if seen.insert(r.clone()) {
out.push(r);
}
}
out
}
/// Clear and reinsert the full static-asset usage set of a script in one tx,
/// invalidating the producer-writes cache at most once and only on a real
/// change. The cache (asset_dispatch::ASSET_PRODUCER_WRITES_CACHE) keys a
@@ -369,6 +431,134 @@ mod debounce_duration_tests {
}
}
#[cfg(test)]
mod derive_trigger_tests {
use super::{derive_pipeline_asset_trigger_refs, AssetKind, AssetUsageAccessType};
use std::collections::HashSet;
use windmill_types::assets::AssetWithAltAccessType;
fn asset(
kind: AssetKind,
path: &str,
at: Option<AssetUsageAccessType>,
) -> AssetWithAltAccessType {
AssetWithAltAccessType {
path: path.to_string(),
kind,
access_type: at,
alt_access_type: None,
columns: None,
}
}
fn derive(assets: &[AssetWithAltAccessType]) -> Vec<String> {
derive_pipeline_asset_trigger_refs(assets, &HashSet::new(), &HashSet::new(), false)
}
#[test]
fn read_only_ducklake_and_s3_derive_an_edge() {
use AssetUsageAccessType::R;
let a = [
asset(AssetKind::Ducklake, "main.orders", Some(R)),
asset(AssetKind::S3Object, "raw/events", Some(R)),
];
assert_eq!(
derive(&a),
vec![
"ducklake://main.orders".to_string(),
"s3://raw/events".to_string()
]
);
}
#[test]
fn writes_and_rw_are_skipped_to_avoid_self_edges() {
use AssetUsageAccessType::*;
// W (pure producer) and RW (reads *and* writes the same table — a
// self-cascade if edged) both derive nothing.
let a = [
asset(AssetKind::Ducklake, "main.out", Some(W)),
asset(AssetKind::Ducklake, "main.self", Some(RW)),
];
assert!(derive(&a).is_empty());
}
#[test]
fn ambiguous_access_and_unsupported_kinds_are_skipped() {
use AssetUsageAccessType::R;
let a = [
asset(AssetKind::Ducklake, "main.ambiguous", None), // poisoned merge
asset(AssetKind::Resource, "f/db", Some(R)), // out of scope
asset(AssetKind::DataTable, "main.dt", Some(R)), // out of scope
];
assert!(derive(&a).is_empty());
}
#[test]
fn manual_read_override_on_ambiguous_parse_derives_an_edge() {
use AssetUsageAccessType::{R, W};
// Parser can't confirm access (`access_type: None`) but the user manually
// overrode it. Effective access = `access_type.or(alt_access_type)`, the
// same value persisted to `asset.usage_access_type` and used by the
// frontend canvas — so a read override derives an edge (parity, no
// silently-vanishing edge on deploy) and a write override does not.
let read_override = AssetWithAltAccessType {
path: "main.override_r".to_string(),
kind: AssetKind::Ducklake,
access_type: None,
alt_access_type: Some(R),
columns: None,
};
let write_override = AssetWithAltAccessType {
path: "main.override_w".to_string(),
kind: AssetKind::Ducklake,
access_type: None,
alt_access_type: Some(W),
columns: None,
};
assert_eq!(
derive(&[read_override, write_override]),
vec!["ducklake://main.override_r".to_string()]
);
}
#[test]
fn explicit_and_muted_refs_are_excluded() {
use AssetUsageAccessType::R;
let a = [
asset(AssetKind::Ducklake, "main.explicit", Some(R)),
asset(AssetKind::Ducklake, "main.muted", Some(R)),
asset(AssetKind::Ducklake, "main.keep", Some(R)),
];
let explicit: HashSet<String> = ["ducklake://main.explicit".to_string()].into();
let muted: HashSet<String> = ["ducklake://main.muted".to_string()].into();
assert_eq!(
derive_pipeline_asset_trigger_refs(&a, &explicit, &muted, false),
vec!["ducklake://main.keep".to_string()]
);
}
#[test]
fn mute_all_derives_nothing() {
use AssetUsageAccessType::R;
let a = [asset(AssetKind::Ducklake, "main.orders", Some(R))];
assert!(
derive_pipeline_asset_trigger_refs(&a, &HashSet::new(), &HashSet::new(), true)
.is_empty()
);
}
#[test]
fn duplicate_reads_dedup() {
use AssetUsageAccessType::R;
let a = [
asset(AssetKind::Ducklake, "main.orders", Some(R)),
asset(AssetKind::Ducklake, "main.orders", Some(R)),
];
assert_eq!(derive(&a), vec!["ducklake://main.orders".to_string()]);
}
}
#[cfg(test)]
mod trigger_ref_roundtrip_tests {
use super::{parse_asset_trigger_ref, trigger_spec_to_row, AssetKind, ScriptTriggerKind};
+8
View File
@@ -273,6 +273,14 @@ lazy_static::lazy_static! {
/// production `app.windmill.dev` cluster, not on staging or self-hosted.
pub static ref CLOUD_PRODUCTION_HOST: &'static str = "app.windmill.dev";
/// `--no-auth` mode: when set, every API request is treated as
/// authenticated as the `admin@windmill.dev` superadmin and no login is
/// ever required. Meant for self-hosted deployments that front Windmill
/// with their own authenticating gateway. Never honored on the managed
/// cloud (`CLOUD_HOSTED`), which must always enforce real authentication.
pub static ref NO_AUTH: bool = !*CLOUD_HOSTED
&& std::env::var("NO_AUTH").ok().is_some_and(|x| x == "1" || x == "true");
pub static ref CUSTOM_TAGS: Vec<String> = std::env::var("CUSTOM_TAGS")
.ok()
.map(|x| x.split(',').map(|x| x.to_string()).collect::<Vec<_>>()).unwrap_or_default();
+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.749.0";
export const VERSION = "v1.751.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.749.0";
export const VERSION = "1.751.0";
+7 -3
View File
@@ -1,5 +1,9 @@
import { Schema, SchemaProperty } from "../../bootstrap/common.ts";
function quotePropName(name: string): string {
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
}
export function compileResourceTypeToTsType(schema: Schema) {
function rec(x: { [name: string]: SchemaProperty }, root = false) {
let res = "{\n";
@@ -10,15 +14,15 @@ export function compileResourceTypeToTsType(schema: Schema) {
let i = 0;
for (let [name, prop] of entries) {
if (prop.type == "object") {
res += ` ${name}: ${rec(prop.properties ?? {})}`;
res += ` ${quotePropName(name)}: ${rec(prop.properties ?? {})}`;
} else if (prop.type == "array") {
res += ` ${name}: ${prop?.items?.type ?? "any"}[]`;
res += ` ${quotePropName(name)}: ${prop?.items?.type ?? "any"}[]`;
} else {
let typ = prop?.type ?? "any";
if (typ == "integer") {
typ = "number";
}
res += ` ${name}: ${typ}`;
res += ` ${quotePropName(name)}: ${typ}`;
}
i++;
if (i < entries.length) {
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test";
import { compileResourceTypeToTsType } from "../src/utils/resource_types.ts";
import type { Schema } from "../bootstrap/common.ts";
// =============================================================================
// Resource-type namespace generation (WIN-2132)
//
// `compileResourceTypeToTsType` renders a JSON Schema into the body of a
// TypeScript type used in the generated `rt.d.ts` (RT namespace). JSON Schema
// property names are unconstrained, so a name with a colon, hyphen, or space
// is legal in the schema but not a valid bare TS identifier. Emitting it raw
// produced syntactically invalid output that broke `tsc`. These tests pin that
// such names are quoted while plain identifiers stay bare.
// =============================================================================
function schema(properties: Schema["properties"]): Schema {
return {
$schema: undefined,
type: "object",
properties,
required: [],
};
}
test("plain identifiers are emitted without quotes", () => {
const out = compileResourceTypeToTsType(
schema({
host: { type: "string" },
_port: { type: "integer" },
$ref: { type: "boolean" },
})
);
expect(out).toContain(" host: string");
expect(out).toContain(" _port: number");
expect(out).toContain(" $ref: boolean");
expect(out).not.toContain('"host"');
});
test("non-identifier property names are double-quoted", () => {
const out = compileResourceTypeToTsType(
schema({
"content-type": { type: "string" },
"x:api:key": { type: "string" },
"with space": { type: "integer" },
"3leading": { type: "boolean" },
})
);
expect(out).toContain(' "content-type": string');
expect(out).toContain(' "x:api:key": string');
expect(out).toContain(' "with space": number');
expect(out).toContain(' "3leading": boolean');
});
test("nested object and array property names are quoted too", () => {
const out = compileResourceTypeToTsType(
schema({
"nested-obj": {
type: "object",
properties: { "inner-key": { type: "string" } },
},
"arr-field": { type: "array", items: { type: "string" } },
})
);
expect(out).toContain('"nested-obj": {');
expect(out).toContain('"inner-key": string');
expect(out).toContain('"arr-field": string[]');
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.749.0",
"version": "1.751.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.749.0",
"version": "1.751.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.749.0",
"version": "1.751.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",
@@ -1,7 +1,7 @@
<script lang="ts">
import { workspaceStore, userWorkspaces, usersWorkspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { Button } from '$lib/components/common'
import { Badge, Button } from '$lib/components/common'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { sendUserToast } from '$lib/toast'
@@ -9,12 +9,14 @@
import { goto } from '$app/navigation'
import { base } from '$lib/base'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { GitFork, ExternalLink } from 'lucide-svelte'
import { resource } from 'runed'
let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
let isDev = $derived(currentWs?.is_dev_workspace ?? false)
let currentLabel = $derived(devLabelKey(currentWs?.dev_workspace_label))
let parentId = $derived(currentWs?.parent_workspace_id ?? undefined)
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
@@ -29,12 +31,18 @@
// else the server result (pairing + detach still available to a prod admin).
let pairedDev = $derived(
canonicalDev
? { id: canonicalDev.id, name: canonicalDev.name, isMember: true }
? {
id: canonicalDev.id,
name: canonicalDev.name,
isMember: true,
label: canonicalDev.dev_workspace_label
}
: devWorkspaceResource.current
? {
id: devWorkspaceResource.current.id,
name: devWorkspaceResource.current.name,
isMember: false
isMember: false,
label: devWorkspaceResource.current.dev_workspace_label
}
: undefined
)
@@ -42,7 +50,10 @@
let selectedDevId = $state<string | undefined>(undefined)
let lockProdDeploy = $state(true)
let lockProdForking = $state(true)
// Cosmetic display label chosen when attaching an existing workspace as dev.
let attachLabel = $state<'dev' | 'staging'>('dev')
let busy = $state(false)
let labelBusy = $state(false)
// A standalone root workspace, or an existing fork of this prod (same family), can be attached.
// A fork parented to a different workspace can't (the backend rejects a parent that isn't this
@@ -82,10 +93,11 @@
requestBody: {
dev_workspace_id: selectedDevId,
lock_prod_deploy: lockProdDeploy,
lock_prod_forking: lockProdForking
lock_prod_forking: lockProdForking,
dev_workspace_label: attachLabel
}
})
sendUserToast(`Attached ${selectedDevId} as dev workspace`)
sendUserToast(`Attached ${selectedDevId} as ${attachLabel} workspace`)
selectedDevId = undefined
await refresh()
} catch (e: any) {
@@ -95,6 +107,22 @@
}
}
async function setLabel(label: 'dev' | 'staging') {
if (!$workspaceStore || label === devLabelKey(currentWs?.dev_workspace_label)) return
labelBusy = true
try {
await WorkspaceService.setDevWorkspaceLabel({
workspace: $workspaceStore,
requestBody: { dev_workspace_label: label }
})
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e: any) {
sendUserToast(`Failed to update display label: ${e?.body ?? e}`, true)
} finally {
labelBusy = false
}
}
async function detach(devId: string) {
if (!$workspaceStore) return
busy = true
@@ -116,9 +144,20 @@
{#if isDev && parentId}
<div class="flex flex-col gap-3 max-w-2xl">
<p class="text-sm">
This is a <b>dev workspace</b> paired with root workspace <b>{parentId}</b>. Promote changes
from the home page banner or the Compare &amp; Deploy page.
This is a <b>{devLabelNoun(currentWs?.dev_workspace_label)}</b> paired with root workspace
<b>{parentId}</b>. Promote changes from the home page banner or the Compare &amp; Deploy page.
</p>
<div class="text-2xs text-secondary">
Cosmetic label: <Badge color="indigo" small>{devBadgeText(currentLabel)}</Badge>
<button
type="button"
disabled={labelBusy}
class="text-secondary hover:text-primary hover:underline disabled:opacity-50"
onclick={() => setLabel(currentLabel === 'staging' ? 'dev' : 'staging')}
>
Change to {currentLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<div>
<Button
variant="default"
@@ -132,8 +171,8 @@
{:else if pairedDev}
<div class="flex flex-col gap-3 max-w-2xl">
<p class="text-sm">
This workspace's dev workspace is <b>{pairedDev.name}</b> ({pairedDev.id}). Edits to this
workspace are redirected there.
This workspace's {devLabelNoun(pairedDev.label)} is <b>{pairedDev.name}</b> ({pairedDev.id}).
Edits to this workspace are redirected there.
</p>
<div class="flex gap-2">
{#if pairedDev.isMember}
@@ -142,7 +181,7 @@
startIcon={{ icon: GitFork }}
onclick={() => switchWorkspace(pairedDev.id)}
>
Go to dev workspace
Go to {devLabelNoun(pairedDev.label)}
</Button>
{/if}
<Button color="red" disabled={busy} onclick={() => detach(pairedDev.id)}>Detach</Button>
@@ -168,6 +207,16 @@
clearable
/>
</div>
<div class="text-2xs text-secondary">
Cosmetic label: <Badge color="indigo" small>{devBadgeText(attachLabel)}</Badge>
<button
type="button"
class="text-secondary hover:text-primary hover:underline"
onclick={() => (attachLabel = attachLabel === 'staging' ? 'dev' : 'staging')}
>
Change to {attachLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<Toggle
bind:checked={lockProdDeploy}
options={{
@@ -7,6 +7,7 @@
import { goto } from '$app/navigation'
import { onMount, untrack } from 'svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
let loading = $state(false)
let comparison: WorkspaceComparison | undefined = $state(undefined)
@@ -170,7 +171,9 @@
<GitFork class="w-4 h-4 text-accent" />
<div class="text-sm">
<span class="font-medium text-blue-900 dark:text-blue-100">
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
{isDevWorkspace
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
: 'Fork of'}
<b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
</span>
</div>
@@ -6,6 +6,7 @@
isRuleActive
} from '$lib/workspaceProtectionRules.svelte'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { canCreateFork } from '$lib/utils/editInFork'
import { switchWorkspace } from '$lib/storeUtils'
import { Alert, Button } from './common'
@@ -40,12 +41,14 @@
<div class="my-2">
<Alert
type="info"
title={canonicalDev ? 'Edits happen in the dev workspace' : 'Workspace protection active'}
title={canonicalDev
? `Edits happen in the ${devLabelNoun(canonicalDev.dev_workspace_label)}`
: 'Workspace protection active'}
>
<div class="flex flex-col gap-2">
{#if canonicalDev}
<p>
Edits to this workspace are made in its dev workspace
Edits to this workspace are made in its {devLabelNoun(canonicalDev.dev_workspace_label)}
<b>{canonicalDev.name}</b> ({canonicalDev.id}) and promoted here.
</p>
<div>
@@ -58,7 +61,7 @@
if (canonicalDev) switchWorkspace(canonicalDev.id)
}}
>
Go to dev workspace
Go to {devLabelNoun(canonicalDev.dev_workspace_label)}
</Button>
</div>
{:else}
@@ -18,6 +18,7 @@
import PanToNode from './PanToNode.svelte'
import InitialFitView from './InitialFitView.svelte'
import { layoutAssetGraph } from './assetGraphLayout'
import { computeMutedReadKeys } from './resolveGraph'
import { buildDownstreamMap } from './graphTraversal'
import { buildLineageDownstreamMap } from './boundedCascade'
import type { AssetGraphResponse, AssetGraphSelection, NativeTriggerKind } from './types'
@@ -228,6 +229,11 @@
| 'macro'
| 'test-dependency'
unsaved?: boolean
// Muted read edge: a ducklake/s3 input read every run whose (default)
// auto cascade trigger is suppressed by `// mute` / `// mute all`.
// Rendered with a bell-off badge — auto-wiring is the norm, so we mark
// the read that deliberately does NOT cascade, not every derived edge.
muted?: boolean
// Edge from a missing-trigger placeholder — styled red dashed to
// signal "this script declared `// on kafka` but no trigger row
// targets it; create one or remove the annotation".
@@ -490,6 +496,10 @@
})
}
// Read edges of a ducklake/s3 asset with no cascade trigger = muted
// (`// mute` / `// mute all` opted the default auto trigger out). Gated
// on pipeline scripts inside the helper (non-pipeline reads never derive).
const mutedReadKeys = computeMutedReadKeys(g.edges, g.triggers, g.runnables)
for (const e of g.edges) {
const runnableId = `${e.runnable_kind}:${e.runnable_path}`
const assetId = `asset:${e.asset_kind}:${e.asset_path}`
@@ -542,7 +552,13 @@
source: assetId,
target: runnableId,
kind: 'lineage-read',
unsaved: e.unsaved
unsaved: e.unsaved,
// Only a pure `'r'` read can be muted; `'rw'` is a self-read.
muted:
access === 'r' &&
mutedReadKeys.has(
`${e.asset_kind}:${e.asset_path}->${e.runnable_kind}:${e.runnable_path}`
)
})
}
}
@@ -1079,7 +1095,9 @@
// Macro-edge badge: which of the library's macros the consumer
// calls (all of them when pulled in via `// use`).
macro_names: e.macro_names,
via_use: e.via_use
via_use: e.via_use,
// Muted read edge — bell-off badge on the read link.
muted: e.muted
},
animated,
label,
@@ -636,7 +636,9 @@
dataTests: [],
columnLineage: [],
macros: false,
useLibs: []
useLibs: [],
muteAssets: [],
muteAll: false
}
)
// `// macros` library: the defined signatures for the strip above the
@@ -1,7 +1,7 @@
<script lang="ts">
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { FlaskConical, Columns3, SquareFunction } from 'lucide-svelte'
import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte'
import type { ColumnLineage, DataTest } from './parsePipelineAnnotations'
let {
@@ -76,6 +76,14 @@
// they don't overlap on the link (each badge is 18px tall; +18 clears it).
let columnsBadgeY = $derived(tests && tests.length > 0 ? labelY + 18 : labelY)
// Muted read edge: a ducklake/s3 input the script reads every run but that
// does NOT cascade — `// mute <asset>` or `// mute all` opted it out of the
// (default) auto-derived trigger. Auto-wiring is the norm, so we badge the
// exception (a read with no trigger) rather than every derived edge.
let isMuted = $derived((data as { muted?: boolean } | undefined)?.muted ?? false)
const mutedBadgeTitle =
'Read but not cascaded — `// mute` (or `// mute all`) suppresses the auto trigger, so changes to this asset do not re-run this script.'
// Macro-edge badge: which of the library's macros the consumer calls (all
// of them when the whole lib is pulled in via `// use`).
let macroNames = $derived((data as { macro_names?: string[] } | undefined)?.macro_names)
@@ -222,6 +230,27 @@
</foreignObject>
{/if}
{#if isMuted}
<!-- Muted-read badge, centered on the read link. Bell-off = this input is
read but its auto cascade trigger is suppressed (`// mute`). -->
<foreignObject x={labelX - 30} y={labelY - 9} width="60" height="18" class="overflow-visible">
<div
xmlns="http://www.w3.org/1999/xhtml"
class="w-full h-full flex items-center justify-center"
style="pointer-events: none;"
>
<div
class="flex items-center gap-0.5 px-1 py-0.5 rounded-sm border shadow-sm text-3xs leading-none font-mono cursor-default bg-surface border-amber-300 dark:border-amber-900/60 text-amber-700 dark:text-amber-300"
style="pointer-events: all;"
title={mutedBadgeTitle}
>
<BellOff size={10} />
<span>muted</span>
</div>
</div>
</foreignObject>
{/if}
{#if columnLineage && columnLineage.length > 0}
<!-- Column-lineage badge, stacked below the data-test badge when both exist. -->
<foreignObject
@@ -22,7 +22,9 @@ const ASSERTED_TS_FIELDS: Record<keyof PipelineAnnotations, true> = {
dataTests: true,
columnLineage: true,
macros: true,
useLibs: true
useLibs: true,
muteAssets: true,
muteAll: true
}
// Parser-parity guard: this TS parser (drives the live graph preview) and
@@ -86,6 +88,11 @@ type Fixture = {
macros?: boolean
// `// use <lib_path>` accumulation, declaration order, deduped. Absent === [].
use_libs?: string[]
// `// mute <asset>` accumulation as `kind:path`, declaration order, deduped.
// Absent === [].
mute?: string[]
// `// mute all` marker. Absent === false.
mute_all?: boolean
}
}
@@ -194,6 +201,13 @@ describe('parsePipelineAnnotations matches the shared Rust fixture corpus', () =
expect(got.macros, 'macros').toBe(f.expected.macros ?? false)
expect(got.useLibs, 'use_libs').toEqual(f.expected.use_libs ?? [])
expect(
got.muteAssets.map((a) => `${a.kind}:${a.path}`),
'mute'
).toEqual(f.expected.mute ?? [])
expect(got.muteAll, 'mute_all').toBe(f.expected.mute_all ?? false)
})
}
})
@@ -62,6 +62,13 @@ describe('parsePipelineAnnotations: header scan', () => {
const out = parsePipelineAnnotations(code)
expect(out.tag).toBe('heavy')
})
it('strips trailing key=value opts from a // on asset ref', () => {
// The path must be the bare asset, not `main.orders debounce=60s`, so it
// dedups against body inference and matches the deploy path.
const out = parsePipelineAnnotations('// on ducklake://main.orders debounce=60s')
expect(out.triggerAssets).toEqual([{ kind: 'ducklake', path: 'main.orders' }])
})
})
describe('parsePipelineAnnotations: retry', () => {
@@ -215,6 +215,14 @@ export type PipelineAnnotations = {
// `// use <lib_script_path>` — force-inject the named macro library into
// this script's jobs. Accumulating, declaration order, deduped.
useLibs: string[]
// `// mute <asset>` — suppress the auto-derived cascade edge for a read
// that would otherwise trigger this script (a lookup / SCD input). Only
// asset refs; native trigger kinds are never auto-derived. Accumulating,
// deduped.
muteAssets: PipelineTriggerAsset[]
// `// mute all` — opt out of auto-derivation entirely (explicit `// on`
// edges are unaffected).
muteAll: boolean
}
// Tokenize a `key=value [key="quoted value"] ...` option string. Bare
@@ -254,6 +262,17 @@ function parseKvOpts(s: string): Map<string, string> {
return out
}
// Drop a `// on` right-hand side's trailing `key=value` opts (e.g.
// `debounce=60s`), returning just the trigger ref. The opts start at the first
// whitespace-delimited token shaped like `<ident>=…`; everything before is the
// ref. Mirrors Rust `split_trailing_kv_opts` — asset refs never contain a space
// followed by an `ident=` token, so without this `// on ducklake://t debounce=1s`
// would keep the path as `t debounce=1s` and desync the live canvas from deploy.
function stripTrailingKvOpts(s: string): string {
const m = s.match(/\s([A-Za-z_][A-Za-z0-9_]*)=/)
return (m?.index !== undefined ? s.slice(0, m.index) : s).trimEnd()
}
function parseAssetSyntax(s: string): PipelineTriggerAsset | undefined {
for (const [prefix, kind] of ASSET_PREFIXES) {
if (s.startsWith(prefix)) {
@@ -570,7 +589,9 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
dataTests: [],
columnLineage: [],
macros: false,
useLibs: []
useLibs: [],
muteAssets: [],
muteAll: false
}
for (const rawLine of code.split('\n')) {
@@ -658,6 +679,27 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
continue
}
// `// mute all` opts out of auto-derived cascade edges entirely;
// `// mute <asset>` suppresses the one edge. Only asset refs are
// muteable — native trigger kinds are never auto-derived. A complete
// word, so prose like `// muted for now` never matches.
const afterMute = consumeKeyword(inner, 'mute')
if (afterMute !== undefined) {
const arg = afterMute.trim()
if (arg === 'all') {
out.muteAll = true
} else {
const spec = parseTriggerSpec(arg)
if (
spec?.kind === 'asset' &&
!out.muteAssets.some((a) => a.kind === spec.value.kind && a.path === spec.value.path)
) {
out.muteAssets.push(spec.value)
}
}
continue
}
// `data_test` is a complete word (so it never collides with the `// test:`
// CI annotation, which has no whitespace after `test`). Accumulates.
const afterDataTest = consumeKeyword(inner, 'data_test')
@@ -678,7 +720,10 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
const afterOn = consumeKeyword(inner, 'on')
if (afterOn !== undefined) {
const specText = afterOn.trim()
// Strip trailing `key=value` opts (e.g. `debounce=60s`) so the ref
// matches body inference and the deploy path — otherwise the opts
// leak into the asset path and the explicit edge dedups wrong.
const specText = stripTrailingKvOpts(afterOn.trim())
if (!specText) continue
const spec = parseTriggerSpec(specText)
if (!spec) continue
@@ -91,3 +91,70 @@ describe('pipelineTemplates materialize partition filter', () => {
expect(body).not.toContain('strftime')
})
})
describe('pipelineTemplates: auto-derived reads drop the redundant // on', () => {
const draft = (
input: { kind: 'ducklake' | 's3object' | 'datatable'; path: string } | undefined,
triggers: Parameters<typeof generatePipelineDraft>[0]['triggers'],
language: ScriptLang = 'duckdb',
outputKind: PipelineOutputKind = 'materialize'
) =>
generatePipelineDraft({
language,
outputKind,
output: autoOutputAsset(outputKind, 'demo', language),
input,
triggers
})
it('omits // on for a ducklake input the body reads (auto-derived)', () => {
const body = draft({ kind: 'ducklake', path: 'main/orders' }, [
{ kind: 'asset', ref: 'ducklake://main/orders' }
])
expect(body).not.toContain('on ducklake://main/orders')
// The body still attaches and reads the table (`ducklake://main` →
// `lake.orders`), which is what wires the cascade now that the explicit
// `// on` is gone.
expect(body).toContain(`ATTACH 'ducklake://main'`)
expect(body).toContain('lake.orders')
})
it('omits // on for an s3 input the body reads', () => {
const body = draft({ kind: 's3object', path: 'raw/events.parquet' }, [
{ kind: 'asset', ref: 's3://raw/events.parquet' }
])
expect(body).not.toContain('on s3://raw/events.parquet')
})
it('keeps // on for a datatable input (not auto-derived)', () => {
const body = draft({ kind: 'datatable', path: 'main/dt' }, [
{ kind: 'asset', ref: 'datatable://main/dt' }
])
expect(body).toContain('on datatable://main/dt')
})
it('keeps // on for a native trigger', () => {
const body = draft(undefined, [{ kind: 'schedule', path: undefined }])
expect(body).toContain('on schedule')
})
it('keeps // on when the body does not read the input (postgres, bash)', () => {
// postgres/bash bodies ignore `input`, so dropping `// on` would leave the
// script with neither an explicit trigger nor an inferred read → no cascade.
const pg = draft(
{ kind: 'ducklake', path: 'main/orders' },
[{ kind: 'asset', ref: 'ducklake://main/orders' }],
'postgresql',
'datatable'
)
expect(pg).toContain('on ducklake://main/orders')
const bash = draft(
{ kind: 's3object', path: 'raw/events.parquet' },
[{ kind: 'asset', ref: 's3://raw/events.parquet' }],
'bash',
's3_object'
)
expect(bash).toContain('on s3://raw/events.parquet')
})
})
@@ -332,7 +332,7 @@ export type TemplateContext = {
// Output asset is NOT declared here — it's reconstructed from the body's
// SDK calls / SQL by the asset parser, same as production scripts.
function header(ctx: TemplateContext): string {
const { language, triggers, output, outputKind } = ctx
const { language, triggers, output, outputKind, input } = ctx
const p = commentPrefix(language)
// A custom data test is a standalone script, not a graph node that produces
// an asset — so it gets no `// pipeline` / output annotation. Instead, tell
@@ -345,14 +345,26 @@ function header(ctx: TemplateContext): string {
''
].join('\n')
}
const lines = triggers.map((t) => {
// A ducklake/s3 read the body performs auto-wires its cascade edge from the
// FROM clause, so an explicit `// on` for that same asset would be redundant
// now that auto-derivation is the default. Only drop it when the generated
// body actually READS the input: bun/deno/python/duckdb emit an input load,
// but postgres/bash/generic bodies (and `data_upload`, which reads the picker
// `file` instead) do not — dropping there would leave the script with no
// cascade at all. Also keep `// on` for kinds inference can't derive
// (datatable/resource/…) and native triggers.
const bodyReadsInput = READS_INPUT_LANGS.has(language) && !isDataUpload(triggers)
const inputRef = input ? assetUri(input) : undefined
const inputAutoDerives = input?.kind === 'ducklake' || input?.kind === 's3object'
const lines = triggers.flatMap((t) => {
switch (t.kind) {
case 'asset':
return `${p} on ${t.ref}`
if (bodyReadsInput && inputAutoDerives && t.ref === inputRef) return []
return [`${p} on ${t.ref}`]
default:
// Native triggers (incl. schedule): marker-only — the
// binding lives on the trigger row's own `script_path`.
return `${p} on ${t.kind}`
return [`${p} on ${t.kind}`]
}
})
// Managed materialization is the one kind that declares its output
@@ -384,7 +396,10 @@ function header(ctx: TemplateContext): string {
// link is the canonical reference once they want the details. A blank
// line separates it from the parsed annotations above (`// pipeline`,
// `// on …`) so the editor reads as "real annotations, then a hint".
const more = `${p} More: partitioned daily, freshness 1h, retry 3, tag heavy — https://www.windmill.dev/docs/core_concepts/pipelines`
// Reads of a ducklake/s3 table auto-trigger this script when that table
// changes — `// mute <asset>` opts a read out of the cascade. The rest are
// the annotations users most often miss.
const more = `${p} More: mute <asset> to not cascade on a read, partitioned daily, freshness 1h, retry 3, tag heavy — https://www.windmill.dev/docs/core_concepts/pipelines`
return [`${p} pipeline`, ...lines, ...matLine, ...macrosLine, '', more, ''].join('\n')
}
@@ -402,6 +417,11 @@ function isDataUpload(triggers: DraftTriggerSource[]): boolean {
return triggers.some((t) => t.kind === 'data_upload')
}
// Languages whose generated body reads the `input` asset (an SDK load / SQL
// FROM), so a ducklake/s3 input auto-derives its cascade and the explicit
// `// on` can be dropped. postgres/bash/generic bodies ignore `input`.
const READS_INPUT_LANGS: ReadonlySet<ScriptLang> = new Set(['bun', 'deno', 'python3', 'duckdb'])
function bodyTs(ctx: TemplateContext): string {
const { input, output, outputKind } = ctx
const dataUpload = isDataUpload(ctx.triggers)
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { resolveGraph, type ResolveGraphInput } from './resolveGraph'
import { resolveGraph, computeMutedReadKeys, type ResolveGraphInput } from './resolveGraph'
import type { PipelineAnnotations } from './parsePipelineAnnotations'
import type { AssetGraphResponse } from './types'
import type { AssetWithAltAccessType } from '$lib/components/assets/lib'
@@ -12,6 +12,8 @@ const ann = (over: Partial<PipelineAnnotations> = {}): PipelineAnnotations => ({
columnLineage: [],
macros: false,
useLibs: [],
muteAssets: [],
muteAll: false,
...over
})
@@ -37,6 +39,15 @@ const input = (over: Partial<ResolveGraphInput> = {}): ResolveGraphInput => ({
const s3 = (path: string, access_type: 'r' | 'w' | 'rw'): AssetWithAltAccessType =>
({ kind: 's3object', path, access_type }) as AssetWithAltAccessType
const duck = (path: string, access_type: 'r' | 'w' | 'rw'): AssetWithAltAccessType =>
({ kind: 'ducklake', path, access_type }) as AssetWithAltAccessType
/** kind:path of the unsaved asset-trigger overlays for a runnable. */
const assetTrigKeys = (r: AssetGraphResponse, path: string): string[] =>
r.triggers
.filter((t) => t.trigger_kind === 'asset' && t.runnable_path === path && (t as any).unsaved)
.map((t) => `${(t as any).asset_kind}:${(t as any).asset_path}`)
describe('resolveGraph', () => {
it('passes an empty graph through unchanged', () => {
const r = resolveGraph(input())
@@ -224,6 +235,85 @@ describe('resolveGraph', () => {
expect(r.edges.find((e) => e.asset_path === '/should-not.json')).toBeUndefined()
})
it('open buffer: a ducklake/s3 read auto-derives an unsaved cascade trigger', () => {
const liveBodyAssets = {
scriptPath: 'f/x/open',
assets: [duck('main.orders', 'r'), s3('raw/events', 'r'), duck('main.out', 'w')]
}
const liveAnnotations = { scriptPath: 'f/x/open', annotations: ann({ inPipeline: true }) }
const r = resolveGraph(input({ liveBodyAssets, liveAnnotations }))
// The two reads derive edges; the write (main.out) does not (self-edge).
expect(assetTrigKeys(r, 'f/x/open').sort()).toEqual([
'ducklake:main.orders',
's3object:raw/events'
])
})
it('open buffer: a materialize producer reading its own target does not self-cascade', () => {
// The body `SELECT`s from the table it materializes (incremental model).
// The write is annotation-declared, not in the body, so without excluding
// the materialize target the read would auto-derive a self-cascade edge.
const liveBodyAssets = { scriptPath: 'f/x/p', assets: [duck('main.orders', 'r')] }
const liveAnnotations = {
scriptPath: 'f/x/p',
annotations: ann({
inPipeline: true,
materialize: { targetKind: 'ducklake' as const, targetPath: 'main.orders' }
})
}
const r = resolveGraph(input({ liveBodyAssets, liveAnnotations }))
expect(assetTrigKeys(r, 'f/x/p')).toEqual([])
})
it('open buffer: a read that is also written (rw) does not self-trigger', () => {
const liveBodyAssets = { scriptPath: 'f/x/open', assets: [duck('main.self', 'rw')] }
const liveAnnotations = { scriptPath: 'f/x/open', annotations: ann({ inPipeline: true }) }
const r = resolveGraph(input({ liveBodyAssets, liveAnnotations }))
expect(assetTrigKeys(r, 'f/x/open')).toEqual([])
})
it('open buffer: // mute suppresses one derived edge, // mute all suppresses all', () => {
const liveBodyAssets = {
scriptPath: 'f/x/open',
assets: [duck('main.a', 'r'), duck('main.b', 'r')]
}
const muted = resolveGraph(
input({
liveBodyAssets,
liveAnnotations: {
scriptPath: 'f/x/open',
annotations: ann({ inPipeline: true, muteAssets: [{ kind: 'ducklake', path: 'main.a' }] })
}
})
)
expect(assetTrigKeys(muted, 'f/x/open')).toEqual(['ducklake:main.b'])
const all = resolveGraph(
input({
liveBodyAssets,
liveAnnotations: {
scriptPath: 'f/x/open',
annotations: ann({ inPipeline: true, muteAll: true })
}
})
)
expect(assetTrigKeys(all, 'f/x/open')).toEqual([])
})
it('open buffer: an explicit // on is not double-emitted as a derived edge', () => {
const liveBodyAssets = { scriptPath: 'f/x/open', assets: [duck('main.orders', 'r')] }
const liveAnnotations = {
scriptPath: 'f/x/open',
annotations: ann({
inPipeline: true,
triggerAssets: [{ kind: 'ducklake', path: 'main.orders' }]
})
}
const r = resolveGraph(input({ liveBodyAssets, liveAnnotations }))
// Exactly one overlay for the table, not one explicit + one derived.
expect(assetTrigKeys(r, 'f/x/open')).toEqual(['ducklake:main.orders'])
})
it('open-script live annotations add unsaved triggers, deduped vs persisted', () => {
const base = baseGraph({
triggers: [
@@ -630,3 +720,79 @@ describe('resolveGraph', () => {
expect(lib?.macros).toEqual([{ name: 'dbl', params: 'a', is_table: false }])
})
})
describe('computeMutedReadKeys', () => {
const readEdge = (asset_kind: any, asset_path: string, access: any = 'r') => ({
runnable_path: 'f/x/c',
runnable_kind: 'script' as const,
asset_kind,
asset_path,
access_type: access
})
const assetTrigger = (asset_kind: any, asset_path: string) => ({
trigger_kind: 'asset' as const,
asset_kind,
asset_path,
runnable_kind: 'script' as const,
runnable_path: 'f/x/c'
})
// `f/x/c` (the read consumer) is an in-pipeline script.
const pipelineRunnable = [{ path: 'f/x/c', usage_kind: 'script' as const, in_pipeline: true }]
it('flags a ducklake/s3 read with no cascade trigger as muted', () => {
const muted = computeMutedReadKeys(
[readEdge('ducklake', 'main.orders'), readEdge('s3object', 'raw/events')],
[],
pipelineRunnable
)
expect([...muted].sort()).toEqual([
'ducklake:main.orders->script:f/x/c',
's3object:raw/events->script:f/x/c'
])
})
it('does not flag a read that has a cascade trigger', () => {
const muted = computeMutedReadKeys(
[readEdge('ducklake', 'main.orders')],
[assetTrigger('ducklake', 'main.orders')],
pipelineRunnable
)
expect(muted.size).toBe(0)
})
it('ignores rw self-reads and unsupported kinds', () => {
const muted = computeMutedReadKeys(
[
readEdge('ducklake', 'main.self', 'rw'), // self-read, not muted
readEdge('resource', 'f/db'), // out of scope
readEdge('datatable', 'main.dt') // out of scope
],
[],
pipelineRunnable
)
expect(muted.size).toBe(0)
})
it('does not flag a read whose script also writes the asset', () => {
// Live-overlay shape of a `// materialize` producer reading its own target:
// a separate `'r'` read edge and `'w'` write edge for the same asset. It's
// the script's own output, not a suppressed input, so it is not muted.
const muted = computeMutedReadKeys(
[readEdge('ducklake', 'main.orders', 'r'), readEdge('ducklake', 'main.orders', 'w')],
[],
pipelineRunnable
)
expect(muted.size).toBe(0)
})
it('does not flag a read by a non-pipeline script (auto-derivation never applied)', () => {
// Same read, but the consumer is a plain script (or a flow), not a
// `// pipeline` member — no auto trigger was ever derived to suppress.
const plainScript = [{ path: 'f/x/c', usage_kind: 'script' as const, in_pipeline: false }]
expect(computeMutedReadKeys([readEdge('ducklake', 'main.orders')], [], plainScript).size).toBe(
0
)
const flow = [{ path: 'f/x/c', usage_kind: 'flow' as const, in_pipeline: true }]
expect(computeMutedReadKeys([readEdge('ducklake', 'main.orders')], [], flow).size).toBe(0)
})
})
@@ -70,6 +70,108 @@ function persistedNativeKinds(base: AssetGraphResponse, path: string): Set<strin
)
}
// Read-asset kinds that auto-derive a cascade trigger edge inside a
// `// pipeline`. Mirror of the backend `is_auto_trigger_kind`
// (windmill-common assets.rs) — ducklake tables and s3 objects only.
const AUTO_TRIGGER_KINDS: ReadonlySet<AssetKind> = new Set(['ducklake', 's3object'])
/** `kind:path` refs of a script's `// materialize` write target(s) (base +
* the scd2 `<dim>_current` companion), which the body `SELECT` doesn't express. */
function materializeWriteRefs(parsed: PipelineAnnotations): string[] {
const m = parsed.materialize
if (!m) return []
const refs = [`${m.targetKind}:${m.targetPath}`]
const current = scd2CurrentTargetPath(m)
if (current) refs.push(`${m.targetKind}:${current}`)
return refs
}
/**
* Backend-mirror of `derive_pipeline_asset_trigger_refs` (windmill-common
* assets.rs): a pipeline script's ducklake/s3 read auto-wires a cascade
* trigger edge from the FROM clause, so `// on <asset>` is only needed for
* edges inference can't see. Excluded: assets the script also writes (`writes`
* covers `w`/`rw`, so an `rw` self-read can't loop-trigger), muted assets,
* `// mute all`, and any explicit `// on` (which already emits its own edge).
* Returns the `{kind, path}` refs to overlay as unsaved asset triggers, deduped.
*/
function deriveAutoAssetTriggers(
reads: Array<{ kind: AssetKind; path: string }>,
writes: Array<{ kind: AssetKind; path: string }>,
parsed: PipelineAnnotations
): Array<{ kind: AssetKind; path: string }> {
// Auto-derivation is scoped to `// pipeline` scripts (backend gates on
// `in_pipeline`); `// mute all` opts a pipeline script back out.
if (!parsed.inPipeline || parsed.muteAll) return []
const skip = new Set<string>([
...writes.map((w) => `${w.kind}:${w.path}`),
// The `// materialize` target(s) are this script's own output — the body
// `SELECT` doesn't express the write, so an incremental model that reads
// its own target would otherwise auto-derive a self-cascade edge (backend
// parity: the deploy path upgrades the same read to `rw`).
...materializeWriteRefs(parsed),
...parsed.muteAssets.map((a) => `${a.kind}:${a.path}`),
...parsed.triggerAssets.map((a) => `${a.kind}:${a.path}`)
])
const out: Array<{ kind: AssetKind; path: string }> = []
for (const r of reads) {
const key = `${r.kind}:${r.path}`
if (!AUTO_TRIGGER_KINDS.has(r.kind) || skip.has(key)) continue
skip.add(key) // dedup within reads too
out.push({ kind: r.kind, path: r.path })
}
return out
}
/** Edge key matching a lineage/trigger edge's `(asset, runnable)` pair. */
function edgeKey(a: {
asset_kind: string
asset_path: string
runnable_kind: string
runnable_path: string
}): string {
return `${a.asset_kind}:${a.asset_path}->${a.runnable_kind}:${a.runnable_path}`
}
/**
* Keys of "muted read" edges: a ducklake/s3 asset a script reads read-*only*
* (`access_type === 'r'`) yet has NO cascade trigger for. Auto-derivation is the
* default, so a supported read with no trigger means the author opted it out
* with `// mute <asset>` / `// mute all` (or it's an explicit non-triggering
* read). The canvas badges these the exception instead of every derived
* edge. Self-reads are excluded: a script that also writes the asset carries
* `'rw'` on the deployed graph (one edge) or a separate `'w'` edge in the live
* overlay (a `// materialize` producer reading its own target), and neither
* should badge as muted it's the script's own output, not a suppressed input.
*
* Only `// pipeline` scripts are considered: auto-derivation never applies to a
* plain script or a flow, so a non-pipeline runnable reading a ducklake/s3
* asset has no auto trigger to suppress and must render as ordinary lineage.
*/
export function computeMutedReadKeys(
edges: AssetGraphResponse['edges'],
triggers: AssetGraphResponse['triggers'],
runnables: AssetGraphResponse['runnables']
): Set<string> {
const pipelineScripts = new Set(
runnables.filter((r) => r.usage_kind === 'script' && r.in_pipeline).map((r) => r.path)
)
const cascaded = new Set(
triggers.filter((t) => t.trigger_kind === 'asset').map((t) => edgeKey(t))
)
const written = new Set(
edges.filter((e) => e.access_type === 'w' || e.access_type === 'rw').map((e) => edgeKey(e))
)
const muted = new Set<string>()
for (const e of edges) {
if (e.access_type !== 'r' || !AUTO_TRIGGER_KINDS.has(e.asset_kind)) continue
if (e.runnable_kind !== 'script' || !pipelineScripts.has(e.runnable_path)) continue
const key = edgeKey(e)
if (!cascaded.has(key) && !written.has(key)) muted.add(key)
}
return muted
}
/** `kind:path` keys of persisted asset (`// on <asset>`) triggers for `path`. */
function persistedAssetKeys(base: AssetGraphResponse, path: string): Set<string> {
return new Set(
@@ -313,7 +415,7 @@ function seedAccumulator(input: ResolveGraphInput, ctx: ResolveContext): Accumul
* `drafts` map so multiple concurrent drafts all render at once.
*/
function seedDraftOverlays(acc: Accumulator, input: ResolveGraphInput) {
const { base, drafts, liveBodyAssets } = input
const { base, drafts, liveBodyAssets, inferredReadsByPath, inferredWritesByPath } = input
const { runnables, assets, edges, extraTriggers } = acc
for (const [path, d] of drafts) {
@@ -489,6 +591,31 @@ function seedDraftOverlays(acc: Accumulator, input: ResolveGraphInput) {
const hasTriggerAsset = assets.some((x) => x.kind === a.kind && x.path === a.path)
if (!hasTriggerAsset) assets.push({ kind: a.kind, path: a.path })
}
// Auto-derived cascade edges (backend parity): a ducklake/s3 read wires
// the edge from the body alone. Reads/writes come from the active draft's
// live inference, else the sticky session cache. The open buffer's derived
// edges are re-computed authoritatively in applyLiveBufferOverlay (which
// strips this path's seeded triggers first), same as the explicit `// on`
// triggers above.
const draftReads = liveForThisDraft
? extractReads(liveBodyAssets.assets)
: (inferredReadsByPath.get(path) ?? [])
const draftWrites = liveForThisDraft
? extractWrites(liveBodyAssets.assets)
: (inferredWritesByPath.get(path) ?? [])
for (const a of deriveAutoAssetTriggers(draftReads, draftWrites, parsed)) {
extraTriggers.push({
trigger_kind: 'asset',
asset_kind: a.kind,
asset_path: a.path,
runnable_kind: 'script',
runnable_path: path,
unsaved: true
})
if (!assets.some((x) => x.kind === a.kind && x.path === a.path)) {
assets.push({ kind: a.kind, path: a.path })
}
}
// Native trigger annotations on a draft are "missing" until a
// matching trigger row exists. A brand-new draft never has one (the
// script isn't deployed yet), but a draft promoted from unsaved
@@ -549,6 +676,30 @@ function applyLiveBufferOverlay(acc: Accumulator, input: ResolveGraphInput, ctx:
assets.push({ kind: a.kind, path: a.path })
}
}
// Auto-derived cascade edges (backend parity): a ducklake/s3 read wires
// the edge from the FROM clause alone, keystroke-live. The open buffer's
// live body inference is authoritative for its reads/writes. `// mute` /
// `// mute all` and explicit `// on` (above) suppress a derived edge; a
// derived edge already persisted for a non-draft open script is deduped
// via `assetKeys`.
if (input.liveBodyAssets.scriptPath === livePath) {
const reads = extractReads(input.liveBodyAssets.assets)
const writes = extractWrites(input.liveBodyAssets.assets)
for (const a of deriveAutoAssetTriggers(reads, writes, liveAnnotations.annotations)) {
if (assetKeys.has(`${a.kind}:${a.path}`)) continue
extraTriggers.push({
trigger_kind: 'asset',
asset_kind: a.kind,
asset_path: a.path,
runnable_kind: 'script',
runnable_path: livePath,
unsaved: true
})
if (!assets.some((x) => x.kind === a.kind && x.path === a.path)) {
assets.push({ kind: a.kind, path: a.path })
}
}
}
// Native trigger annotations: kinds for which a matching trigger
// row was found in the backend response. If the live buffer
// declares `// on kafka` and at least one kafka_trigger row points
@@ -3,7 +3,7 @@
import { createEventDispatcher, type Snippet } from 'svelte'
import { fade } from 'svelte/transition'
import Button from '../button/Button.svelte'
import { AlertTriangle, CornerDownLeft, Loader2, RefreshCcw } from 'lucide-svelte'
import { AlertTriangle, CornerDownLeft, Info, Loader2, RefreshCcw } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
type Props = {
@@ -12,7 +12,7 @@
keyListen?: boolean
loading?: boolean
open?: boolean
type?: 'danger' | 'reload'
type?: 'danger' | 'reload' | 'info'
showIcon?: boolean
id?: string
trashbin?: boolean
@@ -83,6 +83,17 @@
icon: 'text-blue-700 dark:text-blue-300',
iconWrapper: 'bg-blue-100 dark:bg-blue-800/50'
}
},
// Neutral, affirmative confirmation (non-destructive) — e.g. proceeding with
// an import. The confirm button stays a plain accent (see `destructive` below).
info: {
Icon: Info,
color: 'blue',
classes: {
icon: 'text-blue-700 dark:text-blue-300',
iconWrapper: 'bg-blue-100 dark:bg-blue-800/50'
}
}
} satisfies { [type in typeof type]: any }
const Icon = $derived(theme[type].Icon ?? AlertTriangle)
@@ -36,7 +36,7 @@ const { backendDrafts, serverTimestamps, failingWrites, failingReads } = vi.hois
// concurrent writer advancing the row; otherwise empty, so the conflict
// branch in `updateDraft` stays inert for every pre-existing test.
serverTimestamps: new Map<string, string>(),
// Keys whose `updateDraft` / `getDraftForUser` throw a non-404 (network/5xx);
// Keys whose `updateDraft` / draft reads throw a non-404 (network/5xx);
// only set by the error-handling tests, empty otherwise.
failingWrites: new Set<string>(),
failingReads: new Set<string>()
@@ -132,13 +132,17 @@ vi.mock('$lib/gen', async () => {
existsSchedule: vi.fn(async () => false),
getSchedule: vi.fn(async () => {
throw new Error('getSchedule mock not configured')
})
}),
createSchedule: vi.fn(async () => 'created'),
updateSchedule: vi.fn(async () => 'updated')
}),
HttpTriggerService: wrapService(actual.HttpTriggerService, {
existsHttpTrigger: vi.fn(async () => false),
getHttpTrigger: vi.fn(async () => {
throw new Error('getHttpTrigger mock not configured')
})
}),
createHttpTrigger: vi.fn(async () => 'created'),
updateHttpTrigger: vi.fn(async () => 'updated')
}),
AppService: wrapService(actual.AppService, {
existsApp: vi.fn(async () => false),
@@ -156,7 +160,9 @@ vi.mock('$lib/gen', async () => {
existsResource: vi.fn(async () => false),
getResource: vi.fn(async () => {
throw new Error('getResource mock not configured')
})
}),
createResource: vi.fn(async () => 'created'),
updateResource: vi.fn(async () => 'updated')
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: vi.fn(async () => false),
@@ -190,14 +196,27 @@ vi.mock('$lib/gen', async () => {
return { status: 'saved', current_timestamp: '2026-06-15T00:00:00Z' }
}),
getDraftForUser: vi.fn(async ({ kind, path }: any) => {
// The real endpoint rejects drawer kinds up front (drafts for
// schedule/trigger/resource/variable are private to their owner) —
// mirror it so a caller regressing to this route for those kinds
// fails in tests the same way it does against the backend.
if (!['script', 'flow', 'app', 'raw_app'].includes(kind))
throw Object.assign(new Error('drafts for this item kind are private to their owner'), {
status: 404
})
const key = `${kind}:${path}`
if (failingReads.has(key)) throw Object.assign(new Error('server error'), { status: 500 })
// 404-shaped (status) like the real ApiError, so the adapter's
// narrowed catch treats it as "no draft" rather than re-throwing.
if (!backendDrafts.has(key))
throw Object.assign(new Error('no draft for that owner at that path'), { status: 404 })
return { value: backendDrafts.get(key), created_at: '2026-06-15T00:00:00Z' }
}),
getOwnDraft: vi.fn(async ({ kind, path }: any) => {
const key = `${kind}:${path}`
if (failingReads.has(key)) throw Object.assign(new Error('server error'), { status: 500 })
// The real endpoint returns 200 with null when the user has no draft.
if (!backendDrafts.has(key)) return null
return { value: backendDrafts.get(key), created_at: '2026-06-15T00:00:00Z' }
}),
listDrafts: vi.fn(async () =>
Array.from(backendDrafts.entries()).map(([key, value]) => {
const idx = key.indexOf(':')
@@ -1163,6 +1182,170 @@ describe('global AI tools', () => {
expect(draft).not.toHaveProperty('override')
})
// Schedule drafts (like all drawer kinds) are private to their owner, so the
// cross-user draft route 404s on them. Reading them back must go through the
// own-draft route, else a freshly written schedule draft is listed but can
// never be read or deployed.
it('reads and deploys a schedule draft written by the chat', async () => {
await callGlobalTool('write_schedule', {
path: 'u/admin/test_schedule_greet',
schedule: '0 0 9 * * *',
timezone: 'UTC',
script_path: 'f/scripts/greet',
is_flow: false,
args: {}
})
const readRaw = await callGlobalTool('read_workspace_item', {
type: 'schedule',
path: 'u/admin/test_schedule_greet'
})
expect(JSON.parse(readRaw)).toMatchObject({
type: 'schedule',
path: 'u/admin/test_schedule_greet',
isDraft: true
})
await callGlobalTool('deploy_workspace_item', {
type: 'schedule',
path: 'u/admin/test_schedule_greet'
})
expect(ScheduleService.createSchedule).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: expect.objectContaining({
path: 'u/admin/test_schedule_greet',
schedule: '0 0 9 * * *',
script_path: 'f/scripts/greet'
})
})
// The draft is consumed by the deploy.
expect(
getBackendDraft('trigger_schedule', 'u/admin/test_schedule_greet', {
workspace: WORKSPACE
})
).toBeUndefined()
})
// Same private-owner read path as schedules, for the trigger drawer kinds.
it('reads and deploys a trigger draft written by the chat', async () => {
await callGlobalTool('write_trigger', {
kind: 'http',
config: {
path: 'u/admin/fresh_route',
script_path: 'f/scripts/handler',
is_flow: false,
route_path: 'api/fresh',
http_method: 'get',
authentication_method: 'none',
is_static_website: false
}
})
const readRaw = await callGlobalTool('read_workspace_item', {
type: 'trigger',
trigger_kind: 'http',
path: 'u/admin/fresh_route'
})
expect(JSON.parse(readRaw)).toMatchObject({
type: 'trigger',
triggerKind: 'http',
path: 'u/admin/fresh_route',
isDraft: true
})
await callGlobalTool('deploy_workspace_item', {
type: 'trigger',
trigger_kind: 'http',
path: 'u/admin/fresh_route'
})
expect(HttpTriggerService.createHttpTrigger).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: expect.objectContaining({
path: 'u/admin/fresh_route',
route_path: 'api/fresh',
script_path: 'f/scripts/handler'
})
})
expect(
getBackendDraft('trigger_http', 'u/admin/fresh_route', { workspace: WORKSPACE })
).toBeUndefined()
})
// Same private-owner read path as schedules, for the resource drawer kind.
it('reads and deploys a resource draft written by the chat', async () => {
await callGlobalTool('write_resource', {
path: 'u/admin/fresh_db',
value: { host: 'db.example.com', port: 5432 },
resource_type: 'postgresql',
description: 'fresh database'
})
const readRaw = await callGlobalTool('read_workspace_item', {
type: 'resource',
path: 'u/admin/fresh_db'
})
expect(JSON.parse(readRaw)).toMatchObject({
type: 'resource',
path: 'u/admin/fresh_db',
isDraft: true
})
await callGlobalTool('deploy_workspace_item', {
type: 'resource',
path: 'u/admin/fresh_db'
})
expect(ResourceService.createResource).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: expect.objectContaining({
path: 'u/admin/fresh_db',
resource_type: 'postgresql',
value: { host: 'db.example.com', port: 5432 }
})
})
expect(
getBackendDraft('resource', 'u/admin/fresh_db', { workspace: WORKSPACE })
).toBeUndefined()
})
// Same private-owner read path as schedules, for the variable drawer kind.
// Secret variables deploy through the ephemeral in-memory value instead
// (see the ephemeral-value tests above); this pins the plain-value cycle.
it('reads and deploys a non-secret variable draft written by the chat', async () => {
await callGlobalTool('write_variable', {
path: 'u/admin/fresh_config',
value: 'plain-value',
is_secret: false,
description: 'fresh config'
})
const readRaw = await callGlobalTool('read_workspace_item', {
type: 'variable',
path: 'u/admin/fresh_config'
})
expect(JSON.parse(readRaw)).toMatchObject({
type: 'variable',
path: 'u/admin/fresh_config',
isDraft: true
})
await callGlobalTool('deploy_workspace_item', {
type: 'variable',
path: 'u/admin/fresh_config'
})
expect(VariableService.createVariable).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: expect.objectContaining({
path: 'u/admin/fresh_config',
value: 'plain-value',
is_secret: false,
description: 'fresh config'
})
})
expect(
getBackendDraft('variable', 'u/admin/fresh_config', { workspace: WORKSPACE })
).toBeUndefined()
})
it('requires trigger_kind when discarding a trigger draft', async () => {
await expect(
callGlobalTool('discard_local_draft', {
@@ -3086,9 +3269,7 @@ describe('folder tools', () => {
})
it('create_folder surfaces a backend error (e.g. name conflict)', async () => {
vi.mocked(FolderService.createFolder).mockRejectedValueOnce(
new Error('Folder already exists')
)
vi.mocked(FolderService.createFolder).mockRejectedValueOnce(new Error('Folder already exists'))
const raw = await callGlobalTool('create_folder', { name: 'taken' })
const parsed = JSON.parse(raw)
expect(parsed.success).toBe(false)
@@ -1,7 +1,5 @@
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
import { DraftService } from '$lib/gen'
import { get } from 'svelte/store'
import { userStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte'
@@ -336,29 +334,26 @@ function getGlobalDraftSlot(
}
// Current user's persisted draft value (+ records the sync baseline so a later
// save detects external conflicts). undefined on 404 (no draft at that path).
// save detects external conflicts). undefined when no draft exists at that path.
// Uses `getOwnDraft` (not `getDraftForUser`): the latter rejects drawer kinds
// (schedule/trigger/resource/variable drafts are private to their owner), which
// would make those drafts write-only here — listed but never readable/deployable.
// Errors (403/500/network) MUST propagate: swallowing one would make the write
// merge fall through to the deployed item instead of the user's in-progress
// draft, silently overwriting their draft-only changes.
async function fetchBackendDraftValue(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string
): Promise<unknown | undefined> {
try {
const resp = await DraftService.getDraftForUser({
workspace,
kind: itemKind as any,
path: storagePath,
username: get(userStore)?.username
})
UserDraftDbSyncer.recordRemoteSync({ workspace, itemKind, path: storagePath }, resp.created_at)
return resp.value ?? undefined
} catch (e) {
// 404 = no draft for this owner at that path (the intended empty case).
// Anything else (403/500/network) MUST propagate: swallowing it would make
// the write merge fall through to the deployed item instead of the user's
// in-progress draft, silently overwriting their draft-only changes.
if ((e as { status?: number } | null | undefined)?.status === 404) return undefined
throw e
}
const resp = await DraftService.getOwnDraft({
workspace,
kind: itemKind,
path: storagePath
})
if (!resp) return undefined
UserDraftDbSyncer.recordRemoteSync({ workspace, itemKind, path: storagePath }, resp.created_at)
return resp.value ?? undefined
}
// Draft VALUE for a write merge: cell-if-present (the user's freshest in-tab
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AIProviderModel } from '$lib/gen'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
// getCurrentModel/getMetadataModel are read per call, so a hoisted holder lets
// each test point the routing at a different provider/model.
const h = vi.hoisted(() => ({ currentModel: undefined as AIProviderModel | undefined }))
vi.mock('monaco-editor', () => ({ editor: {} }))
vi.mock('$lib/stores', () => ({
workspaceStore: { subscribe: () => () => undefined }
}))
vi.mock('$lib/components/flows/flowTree', () => ({
findModuleInModules: () => undefined
}))
vi.mock('$lib/gen', () => ({
OpenAPI: { BASE: '/api', TOKEN: undefined },
ResourceService: {},
ScriptService: {},
FlowService: {},
JobService: {},
ScheduleService: {},
HttpTriggerService: {},
WebsocketTriggerService: {},
KafkaTriggerService: {},
NatsTriggerService: {},
PostgresTriggerService: {},
MqttTriggerService: {},
SqsTriggerService: {},
GcpTriggerService: {},
AzureTriggerService: {}
}))
vi.mock('$lib/utils', () => ({
emptyString: (value: string | undefined | null) => !value,
generateRandomString: () => 'generated_id'
}))
vi.mock('$lib/scripts', () => ({
scriptLangToEditorLang: (language: string) => language
}))
vi.mock('$lib/aiStore', () => ({
getCurrentModel: () => h.currentModel,
getMetadataModel: () => h.currentModel,
copilotInfo: {
subscribe: (run: (value: unknown) => void) => {
run({})
return () => undefined
}
}
}))
vi.mock('@leeoniya/ufuzzy', () => ({
default: class {
search() {
return [[], [], []]
}
}
}))
function streamOf(chunks: unknown[]): any {
return (async function* () {
for (const chunk of chunks) {
yield chunk
}
})()
}
function textDelta(text: string) {
return { type: 'content_block_delta', delta: { type: 'text_delta', text } }
}
const messages: ChatCompletionMessageParam[] = [{ role: 'user', content: 'hi' }]
let anthropicCreate: ReturnType<typeof vi.fn>
let anthropicStream: ReturnType<typeof vi.fn>
let openaiCreate: ReturnType<typeof vi.fn>
async function setupClients() {
const { workspaceAIClients } = await import('./lib')
anthropicCreate = vi.fn().mockResolvedValue({
content: [
{ type: 'text', text: 'Hel' },
{ type: 'thinking', thinking: 'ignored' },
{ type: 'text', text: 'lo' }
]
})
anthropicStream = vi
.fn()
.mockReturnValue(
streamOf([
{ type: 'message_start' },
textDelta('Hel'),
{ type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{' } },
textDelta('lo'),
{ type: 'message_stop' }
])
)
openaiCreate = vi.fn().mockResolvedValue({ choices: [{ message: { content: 'openai text' } }] })
vi.spyOn(workspaceAIClients, 'getAnthropicClient').mockReturnValue({
messages: { create: anthropicCreate, stream: anthropicStream }
} as any)
vi.spyOn(workspaceAIClients, 'getOpenaiClient').mockReturnValue({
chat: { completions: { create: openaiCreate } }
} as any)
}
beforeEach(async () => {
await setupClients()
})
afterEach(() => {
vi.restoreAllMocks()
h.currentModel = undefined
})
describe('Anthropic Messages API routing', () => {
it('getNonStreamingCompletion routes Foundry Claude through the Anthropic client', async () => {
const { getNonStreamingCompletion } = await import('./lib')
h.currentModel = { provider: 'azure_foundry', model: 'claude-sonnet-5' }
const response = await getNonStreamingCompletion(messages, new AbortController())
expect(anthropicCreate).toHaveBeenCalledTimes(1)
expect(openaiCreate).not.toHaveBeenCalled()
// text blocks concatenated, non-text blocks dropped
expect(response).toBe('Hello')
const headers = anthropicCreate.mock.calls[0][1].headers
// X-Provider must carry the real provider so the backend resolves Foundry
// credentials/URL; the SDK header selects the Messages API path.
expect(headers['X-Provider']).toBe('azure_foundry')
expect(headers['X-Anthropic-SDK']).toBe('true')
})
it('getNonStreamingCompletion routes native Anthropic through the Anthropic client', async () => {
const { getNonStreamingCompletion } = await import('./lib')
h.currentModel = { provider: 'anthropic', model: 'claude-opus-4-8' }
await getNonStreamingCompletion(messages, new AbortController())
expect(anthropicCreate).toHaveBeenCalledTimes(1)
expect(anthropicCreate.mock.calls[0][1].headers['X-Provider']).toBe('anthropic')
})
it('getNonStreamingCompletion keeps non-Claude Foundry models on the OpenAI path', async () => {
const { getNonStreamingCompletion } = await import('./lib')
h.currentModel = { provider: 'azure_foundry', model: 'gpt-4o' }
await getNonStreamingCompletion(messages, new AbortController())
expect(anthropicCreate).not.toHaveBeenCalled()
expect(openaiCreate).toHaveBeenCalledTimes(1)
})
it('getCompletion adapts the Anthropic stream into OpenAI text chunks', async () => {
const { getCompletion, getResponseFromEvent } = await import('./lib')
h.currentModel = { provider: 'azure_foundry', model: 'claude-sonnet-5' }
const completion = await getCompletion(messages, new AbortController())
let text = ''
let chunks = 0
for await (const part of completion) {
chunks++
text += getResponseFromEvent(part)
}
expect(anthropicStream).toHaveBeenCalledTimes(1)
// only the two text deltas surface; message_start/stop and input_json are dropped
expect(chunks).toBe(2)
expect(text).toBe('Hello')
})
it('testKey routes Foundry Claude through the Anthropic client', async () => {
const { testKey } = await import('./lib')
await testKey({
resourcePath: 'u/admin/foundry',
model: 'claude-sonnet-5',
abortController: new AbortController(),
messages,
aiProvider: 'azure_foundry'
})
expect(anthropicCreate).toHaveBeenCalledTimes(1)
const headers = anthropicCreate.mock.calls[0][1].headers
expect(headers['X-Provider']).toBe('azure_foundry')
expect(headers['X-Resource-Path']).toBe('u/admin/foundry')
})
it('getFimCompletion no-ops for Anthropic Messages API models', async () => {
const { getFimCompletion } = await import('./lib')
const fetchSpy = vi.spyOn(globalThis, 'fetch')
for (const provider of ['anthropic', 'azure_foundry'] as const) {
const result = await getFimCompletion(
'prefix',
'suffix',
{ provider, model: 'claude-sonnet-5' },
new AbortController()
)
expect(result).toBeUndefined()
}
// no autocomplete request should be issued for these models
expect(fetchSpy).not.toHaveBeenCalled()
})
})
+116 -49
View File
@@ -302,10 +302,10 @@ export function getModelMaxTokens(provider: AIProvider, model: string) {
return 8192
}
function getModelSpecificConfig(
modelProvider: AIProviderModel,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
) {
// Resolves the completion token cap for a model: the workspace's per-model
// override when set, otherwise the built-in default. Shared by the OpenAI and
// Anthropic request paths so both honor the same limit.
function resolveMaxTokens(modelProvider: AIProviderModel): number {
const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model)
const modelKey = `${modelProvider.provider}:${modelProvider.model}`
let customMaxTokensStore: Record<string, number> | undefined
@@ -314,7 +314,14 @@ function getModelSpecificConfig(
} catch {
// copilotInfo store may not be initialized in vitest
}
const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens
return customMaxTokensStore?.[modelKey] ?? defaultMaxTokens
}
function getModelSpecificConfig(
modelProvider: AIProviderModel,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
) {
const maxTokens = resolveMaxTokens(modelProvider)
if (
(modelProvider.provider === 'openai' ||
modelProvider.provider === 'azure_openai' ||
@@ -466,23 +473,10 @@ export async function testKey({
throw new Error('Missing a model to test')
}
// Providers served through the Anthropic Messages API (native Anthropic, and
// Claude deployments on Azure Foundry) must use the Anthropic SDK path rather
// than OpenAI chat completions. Mirrors the chat loop's routing so the test
// key exercises the same request shape the chat actually sends.
if (usesAnthropicMessagesApi(aiProvider, modelToTest)) {
await testAnthropicKey({
apiKey,
workspace,
resourcePath,
model: modelToTest,
abortController,
messages,
aiProvider
})
return
}
// getNonStreamingCompletion routes Anthropic-Messages-API models (native
// Anthropic and Claude on Azure Foundry) through the Anthropic SDK and
// everything else through OpenAI chat completions, so the test exercises the
// same request shape the feature actually sends.
await getNonStreamingCompletion(messages, abortController, {
apiKey,
workspace,
@@ -494,30 +488,35 @@ export async function testKey({
})
}
async function testAnthropicKey({
apiKey,
workspace,
resourcePath,
model,
abortController,
messages,
aiProvider
}: {
// Providers served through the Anthropic Messages API (native Anthropic, and
// Claude deployments on Azure Foundry) require the Anthropic SDK request shape:
// OpenAI chat-completions requests fail against them because the proxy forwards
// the body verbatim and, for Foundry, rewrites the URL to the /anthropic/v1
// surface that only serves /messages. This centralizes the client/header/message
// setup so every completion entry point routes them the same way the chat does.
interface AnthropicCompletionParams {
messages: ChatCompletionMessageParam[]
modelProvider: AIProviderModel
abortController: AbortController
apiKey?: string
workspace?: string
resourcePath?: string
model: string
abortController: AbortController
messages: ChatCompletionMessageParam[]
aiProvider: AIProvider
}) {
}
function buildAnthropicProxyRequest({
messages,
modelProvider,
apiKey,
workspace,
resourcePath
}: Omit<AnthropicCompletionParams, 'abortController'>) {
const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages)
// X-Provider must be the real provider (e.g. azure_foundry) so the backend
// resolves the right credentials and Anthropic URL; the SDK headers tell it to
// route through the Anthropic Messages API.
const headers: Record<string, string> = {
'X-Provider': aiProvider,
'X-Provider': modelProvider.provider,
'anthropic-version': '2023-06-01',
'X-Anthropic-SDK': 'true'
}
@@ -528,24 +527,65 @@ async function testAnthropicKey({
headers['X-API-Key'] = apiKey
}
const anthropicClient = apiKey
const client = apiKey
? createAnthropicProxyClient(getAiProxyBaseURL())
: workspace
? workspaceAIClients.createAnthropicClient(workspace)
: workspaceAIClients.getAnthropicClient()
await anthropicClient.messages.create(
{
model,
max_tokens: 100,
messages: anthropicMessages,
...(system && { system })
},
{
signal: abortController.signal,
headers
const body = {
model: modelProvider.model,
max_tokens: resolveMaxTokens(modelProvider),
messages: anthropicMessages,
...(system && { system })
}
return { client, headers, body }
}
async function getAnthropicNonStreamingCompletion({
abortController,
...params
}: AnthropicCompletionParams): Promise<string> {
const { client, headers, body } = buildAnthropicProxyRequest(params)
const message = await client.messages.create(body, {
signal: abortController.signal,
headers
})
return message.content.map((block) => (block.type === 'text' ? block.text : '')).join('')
}
// Adapts an Anthropic Messages stream into the OpenAI ChatCompletionChunk shape
// the completion consumers already iterate, so they need no Anthropic-specific
// handling. Only text deltas are surfaced (these paths don't use tool calls).
function getAnthropicStreamingCompletion({
abortController,
...params
}: AnthropicCompletionParams): Stream<ChatCompletionChunk> {
const { client, headers, body } = buildAnthropicProxyRequest(params)
const stream = client.messages.stream(body, {
signal: abortController.signal,
headers
})
async function* toOpenAIChunks(): AsyncGenerator<ChatCompletionChunk> {
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield {
id: '',
object: 'chat.completion.chunk',
created: 0,
model: params.modelProvider.model,
choices: [{ index: 0, delta: { content: event.delta.text }, finish_reason: null }]
}
}
}
)
}
return toOpenAIChunks() as unknown as Stream<ChatCompletionChunk>
}
interface BaseOptions {
@@ -773,6 +813,19 @@ export async function getNonStreamingCompletion(
forceModelProvider?: AIProviderModel
}
) {
const modelProvider = options?.forceModelProvider ?? getCurrentModel()
if (usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)) {
return getAnthropicNonStreamingCompletion({
messages,
modelProvider,
abortController,
apiKey: options?.apiKey,
workspace: options?.workspace,
resourcePath: options?.resourcePath
})
}
let response: string | undefined = ''
const { provider, config } = getProviderAndCompletionConfig({
messages,
@@ -846,6 +899,14 @@ export async function getFimCompletion(
providerModel: AIProviderModel,
abortController: AbortController
): Promise<string | undefined> {
// The Anthropic Messages API has no fill-in-the-middle endpoint, and Foundry
// Claude deployments don't expose the OpenAI-compatible completions surface the
// FIM proxy targets. Skip autocomplete for these models rather than issuing a
// request that can't succeed.
if (usesAnthropicMessagesApi(providerModel.provider, providerModel.model)) {
return undefined
}
const fetchOptions: {
signal: AbortSignal
headers: Record<string, string>
@@ -908,6 +969,12 @@ export async function getCompletion(
reasoningEffort?: string
}
): Promise<Stream<ChatCompletionChunk>> {
const modelProvider = options?.forceModelProvider ?? getCurrentModel()
if (usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)) {
return getAnthropicStreamingCompletion({ messages, modelProvider, abortController })
}
const { provider, config } = getProviderAndCompletionConfig({
messages,
stream: true,
@@ -19,6 +19,7 @@
}
values?: Record<string, any>
children: import('svelte').Snippet
headerAction?: import('svelte').Snippet
class?: string
}
@@ -31,6 +32,7 @@
actionButton,
values,
children,
headerAction,
class: clazz
}: Props = $props()
</script>
@@ -54,7 +56,9 @@
<Tooltip>{tooltip}</Tooltip>
{/if}
</div>
{#if actionButton}
{#if headerAction}
{@render headerAction()}
{:else if actionButton}
<Button
disabled={ee_only != undefined && !$enterpriseLicense}
variant={actionButton.variant ?? 'default'}
@@ -2,6 +2,8 @@
import WorkspaceDiffDrawer from './WorkspaceDiffDrawer.svelte'
import { ArrowRight, GitFork, Pencil } from 'lucide-svelte'
import { userWorkspaces } from '$lib/stores'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import { useSessionDeployModel } from './sessionDeployModel.svelte'
import type { DeployItem } from './sessionDeployModel'
@@ -92,6 +94,9 @@
<span class="font-medium truncate" title={ws?.name ?? workspaceId}>
{ws?.name ?? workspaceId}
</span>
{#if ws?.is_dev_workspace}
<Badge color="indigo" small>{devBadgeText(ws.dev_workspace_label)}</Badge>
{/if}
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate" title={parentWs?.name ?? parentWorkspaceId}>
{parentWs?.name ?? parentWorkspaceId}
@@ -9,6 +9,7 @@
} from './sessionState.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { Badge } from '$lib/components/common'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import { Building, ChevronDown, GitFork } from 'lucide-svelte'
let { session }: { session: Session } = $props()
@@ -67,7 +68,7 @@
{pendingFork?.name ?? currentWs?.name ?? effectiveId ?? 'Pick workspace'}
</span>
{#if !pendingFork && currentWs?.is_dev_workspace}
<Badge color="indigo" small>dev</Badge>
<Badge color="indigo" small>{devBadgeText(currentWs.dev_workspace_label)}</Badge>
{/if}
{#if pendingFork}
<span class="text-2xs text-tertiary italic shrink-0">(new)</span>
@@ -26,6 +26,7 @@
import InputError from '$lib/components/InputError.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { Badge } from '$lib/components/common'
import { devBadgeText, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { Building, Check, GitFork, Plus } from 'lucide-svelte'
type PendingFork = { parent_workspace_id: string; id: string; name: string }
@@ -414,7 +415,7 @@
disabled={rootDisabled}
title={rootDisabled
? devOfRoot
? `${root.name} is locked. Run in its dev workspace instead.`
? `${root.name} is locked. Run in its ${devLabelNoun(devOfRoot.dev_workspace_label)} instead.`
: `${root.name} is locked for direct deploys.`
: undefined}
class={`${rowBase} ${rootDisabled ? 'opacity-50 cursor-not-allowed' : ''} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${!rootDisabled && keyArrowPos === rootIdx ? 'bg-surface-hover' : !rootDisabled ? 'hover:bg-surface-hover' : ''}`}
@@ -440,7 +441,7 @@
<GitFork size={14} class="shrink-0 text-tertiary" />
<span class="truncate">{f.name}</span>
{#if f.is_dev_workspace}
<Badge color="indigo" small>dev</Badge>
<Badge color="indigo" small>{devBadgeText(f.dev_workspace_label)}</Badge>
{/if}
</button>
{/each}
@@ -1,6 +1,4 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { onMount, onDestroy } from 'svelte'
import CriticalAlertModalInner from './CriticalAlertModalInner.svelte'
import { SettingService, type CriticalAlert } from '$lib/gen'
@@ -35,26 +33,11 @@
let workspaceContext = $state(false)
let childRef: CriticalAlertModalInner | undefined = $state()
function setupApiFunctions(_ctx?) {
getCriticalAlerts = withSuperadminLogic(
SettingService.getCriticalAlerts,
SettingService.workspaceGetCriticalAlerts
)
acknowledgeCriticalAlert = withSuperadminLogic(
SettingService.acknowledgeCriticalAlert,
SettingService.workspaceAcknowledgeCriticalAlert
)
acknowledgeAllCriticalAlerts = withSuperadminLogic(
SettingService.acknowledgeAllCriticalAlerts,
SettingService.workspaceAcknowledgeAllCriticalAlerts
)
}
let checkForNewAlertsInterval: ReturnType<typeof setInterval>
let checkingForNewAlerts = false
// The returned closure reads workspaceContext / $workspaceStore / $devopsRole at
// call time, so the wrappers are stable and never need recreating.
const withSuperadminLogic = (superadminFunction, workspaceFunction) => {
return async (params = {}) => {
if (!$devopsRole || workspaceContext) {
@@ -68,12 +51,21 @@
}
}
type AckFn = (params?: {}) => Promise<any>
let getCriticalAlerts: AckFn | undefined = $state()
let acknowledgeCriticalAlert: AckFn | undefined = $state()
let acknowledgeAllCriticalAlerts: AckFn | undefined = $state()
setupApiFunctions()
let getCriticalAlerts = $derived(
withSuperadminLogic(SettingService.getCriticalAlerts, SettingService.workspaceGetCriticalAlerts)
)
let acknowledgeCriticalAlert = $derived(
withSuperadminLogic(
SettingService.acknowledgeCriticalAlert,
SettingService.workspaceAcknowledgeCriticalAlert
)
)
let acknowledgeAllCriticalAlerts = $derived(
withSuperadminLogic(
SettingService.acknowledgeAllCriticalAlerts,
SettingService.workspaceAcknowledgeAllCriticalAlerts
)
)
onMount(async () => {
await updateHasUnacknowledgedCriticalAlerts(false)
@@ -173,13 +165,10 @@
await acknowledgeCriticalAlert?.({ id })
updateHasUnacknowledgedCriticalAlerts()
}
run(() => {
setupApiFunctions(workspaceContext)
})
run(() => {
$effect(() => {
if ($isCriticalAlertsUIOpen) open = $isCriticalAlertsUIOpen
})
run(() => {
$effect(() => {
isCriticalAlertsUIOpen.set(open)
})
</script>
@@ -197,10 +186,14 @@
{#snippet headerRight()}
<List horizontal>
{#if $superadmin || $userStore?.is_admin}
<!-- Portal to `body` (not the trigger) so toggle clicks don't bubble to the melt
trigger and toggle the popover shut. `dropdown-portal` on the content root is a
`portalDivs` marker, so the enclosing Modal2's clickOutside treats the whole
popover surface — padding included — as inside a portal and stays open. -->
<Popover
floatingConfig={{ strategy: 'fixed', placement: 'bottom-end' }}
portal="#mute-settings-button"
contentClasses="p-4"
portal="body"
contentClasses="p-4 dropdown-portal"
>
{#snippet trigger()}
<div id="mute-settings-button">
@@ -246,8 +239,8 @@
{#if $superadmin}
<Popover
floatingConfig={{ strategy: 'fixed', placement: 'bottom-end' }}
portal="#settings-button"
contentClasses="p-4"
portal="body"
contentClasses="p-4 dropdown-portal"
>
{#snippet trigger()}
<div id="settings-button">
@@ -304,6 +297,7 @@
<CriticalAlertModalInner
bind:workspaceContext
{muteSettings}
{numUnacknowledgedCriticalAlerts}
{updateHasUnacknowledgedCriticalAlerts}
{getCriticalAlerts}
@@ -11,7 +11,7 @@
import CriticalAlertTable from './CriticalAlertTable.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { sendUserToast } from '$lib/toast'
import { untrack } from 'svelte'
import { onMount, untrack } from 'svelte'
let filteredAlerts: CriticalAlert[] = $state([])
@@ -30,6 +30,7 @@
acknowledgeCriticalAlert: any
acknowledgeAllCriticalAlerts: any
numUnacknowledgedCriticalAlerts: any
muteSettings?: { global?: boolean; workspace?: boolean }
workspaceContext?: boolean
}
@@ -39,9 +40,12 @@
acknowledgeCriticalAlert,
acknowledgeAllCriticalAlerts,
numUnacknowledgedCriticalAlerts,
muteSettings,
workspaceContext = $bindable(false)
}: Props = $props()
let isMuted = $derived(Boolean(muteSettings?.global || muteSettings?.workspace))
async function acknowledgeAll() {
await acknowledgeAllCriticalAlerts()
getAlerts(false)
@@ -82,6 +86,12 @@
hasCriticalAlertChannels = channels && channels.length > 0
}
// Load the channel state on mount so the "no channels" warning doesn't depend on
// there being unacknowledged alerts to trigger a refresh (muting auto-acks them).
onMount(() => {
if ($superadmin) checkCriticalAlertChannels()
})
async function acknowledgeAlert(id: number) {
await acknowledgeCriticalAlert({ id })
getAlerts(false)
@@ -133,7 +143,7 @@
</script>
<List gap="sm">
{#if !hasCriticalAlertChannels && $superadmin}
{#if $superadmin && isMuted && !hasCriticalAlertChannels}
<div class="w-full">
<Alert title="No critical alert channels are set up" type="warning" size="xs">
Go to the
@@ -29,6 +29,7 @@
import type { MenubarBuilders } from '@melt-ui/svelte'
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
import { getContrastTextColor } from '$lib/utils'
import { devBadgeText, devLabelWord } from '$lib/utils/devWorkspaceLabel'
interface Props {
isCollapsed?: boolean
@@ -133,7 +134,7 @@
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
sublabel={parentWorkspace?.name
? `${forkedWorkspace.is_dev_workspace ? 'Dev workspace of' : 'Fork of'} ${parentWorkspace.name}`
? `${forkedWorkspace.is_dev_workspace ? `${devLabelWord(forkedWorkspace.dev_workspace_label)} workspace of` : 'Fork of'} ${parentWorkspace.name}`
: undefined}
{isCollapsed}
color={$workspaceColor}
@@ -177,6 +178,7 @@
workspaceColor={workspace.color}
{isForked}
isDevWorkspace={workspace.is_dev_workspace}
devWorkspaceLabel={workspace.dev_workspace_label}
{parentName}
/>
<div class="min-w-0 flex-1">
@@ -191,7 +193,7 @@
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
</div>
{#if workspace.is_dev_workspace}
<Badge color="indigo">dev</Badge>
<Badge color="indigo">{devBadgeText(workspace.dev_workspace_label)}</Badge>
{/if}
</div>
<div
@@ -11,6 +11,7 @@
import WorkspaceIcon from './WorkspaceIcon.svelte'
import WorkspaceCard from './WorkspaceCard.svelte'
import { twMerge } from 'tailwind-merge'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
interface ExtendedWorkspace extends UserWorkspace {
_children?: ExtendedWorkspace[]
@@ -120,6 +121,7 @@
workspaceColor={workspace.color}
{isForked}
isDevWorkspace={workspace.is_dev_workspace}
devWorkspaceLabel={workspace.dev_workspace_label}
parentName={workspace.parent_workspace_id ?? undefined}
size={12}
/>
@@ -135,7 +137,7 @@
{/if}
</span>
{#if workspace.is_dev_workspace}
<Badge color="indigo">dev</Badge>
<Badge color="indigo">{devBadgeText(workspace.dev_workspace_label)}</Badge>
{/if}
<span class="text-secondary text-xs">-</span>
{#if workspace.id === 'admins'}
@@ -2,11 +2,13 @@
import { Building, GitFork } from 'lucide-svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { getContrastTextColor } from '$lib/utils'
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
interface Props {
workspaceColor?: string
isForked?: boolean
isDevWorkspace?: boolean
devWorkspaceLabel?: string | null
parentName?: string
size?: number
}
@@ -15,6 +17,7 @@
workspaceColor,
isForked = false,
isDevWorkspace = false,
devWorkspaceLabel,
parentName,
size = 14
}: Props = $props()
@@ -27,7 +30,7 @@
<Tooltip>
{#snippet text()}
{#if isForked && parentName}
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
{isDevWorkspace ? `${devLabelWord(devWorkspaceLabel)} workspace of` : 'Fork of'}
{parentName}
{/if}
{/snippet}
@@ -1,18 +1,35 @@
<script lang="ts">
import { onMount } from 'svelte'
import YAML from 'yaml'
import { createDropdownMenu, melt } from '@melt-ui/svelte'
import Button from '../common/button/Button.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import Modal2 from '../common/modal/Modal2.svelte'
import Toggle from '../Toggle.svelte'
import DropdownV2 from '../DropdownV2.svelte'
import Checkbox from '../common/checkbox/Checkbox.svelte'
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
import Label from '../Label.svelte'
import autosize from '$lib/autosize'
import { conditionalMelt } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { WorkspaceService } from '$lib/gen'
import { FolderUp, Plus, Trash2 } from 'lucide-svelte'
import { buildSkillMd, parseAndValidateSkill, parseSkillMd, type SkillUpload } from './aiSkills'
import {
ChevronDown,
ClipboardPaste,
Eye,
FolderUp,
ListChecks,
Pencil,
Plus,
Trash2
} from 'lucide-svelte'
type SkillListItem = { name: string; description: string }
type SkillUpload = { name: string; description: string; instructions: string }
// `<root>/<skill>/SKILL.md` is 3 path segments; SKILL.md files nested deeper
// are likely vendored/incidental and are skipped so importing a parent dir
@@ -20,33 +37,83 @@
const MAX_SKILL_DEPTH = 3
const MAX_SKILLS_PER_IMPORT = 50
const MAX_SKILLS_PER_WORKSPACE = 100
// `name` + `description` mirror the Claude SKILL.md spec (counted in
// characters); the body is a byte-bounded payload. Keep these in sync with
// backend `validate_skill`.
const MAX_SKILL_NAME_LENGTH = 64
const MAX_SKILL_DESCRIPTION_LENGTH = 1_024
const MAX_SKILL_INSTRUCTIONS_LENGTH = 64 * 1024
const SKILL_NAME_PATTERN = /^[a-z0-9-]+$/
const textEncoder = new TextEncoder()
const SAMPLE_SKILL_PLACEHOLDER =
'---\nname: my-skill\ndescription: what this skill helps with\n---\n\n# My skill\n\nInstructions for the assistant…'
const menuItemClass =
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'
let skills: SkillListItem[] = $state([])
let uploading: boolean = $state(false)
let pasteContent: string = $state('')
// The content the modal opened with, so Save can be gated on unsaved changes.
let originalContent: string = $state('')
let pasteModalOpen: boolean = $state(false)
// Set while the paste modal is editing an existing skill; holds the skill's
// name before edits so a rename can delete the old entry after save.
let editingOriginalName: string | undefined = $state(undefined)
let dirInput: HTMLInputElement | undefined = $state(undefined)
let toDelete: string | undefined = $state(undefined)
let pendingImport: SkillUpload[] | undefined = $state(undefined)
let pendingSkipped: string[] = $state([])
// Per-conflict overwrite choice for a folder import, keyed by skill name.
let overwriteChoices: Record<string, boolean> = $state({})
// The skill detail modal opens in read mode with rendered markdown; a header
// toggle flips it to raw SKILL.md editing.
let detailMode: 'view' | 'edit' = $state('view')
// Multi-select "manage" mode: rows gain a checkbox for batch deletion.
let manageMode: boolean = $state(false)
let selected: Record<string, boolean> = $state({})
let confirmBatchDelete: boolean = $state(false)
let listRequestId = 0
let pendingNamesPreview = $derived.by(() => {
const p = pendingImport ?? []
const shown = p
.slice(0, 12)
.map((s) => s.name)
.join(', ')
return p.length > 12 ? `${shown}, … (+${p.length - 12} more)` : shown
let existingNames = $derived(new Set(skills.map((s) => s.name)))
let selectedCount = $derived(skills.filter((s) => selected[s.name]).length)
let allSelected = $derived(skills.length > 0 && selectedCount === skills.length)
// Leave manage mode automatically once a batch delete empties it below the
// two-skill threshold that surfaces the "Manage skills" button.
$effect(() => {
if (manageMode && skills.length <= 1) exitManage()
})
let pendingConflicts = $derived(
(pendingImport ?? ([] as SkillUpload[])).filter((s) => existingNames.has(s.name))
)
let pendingNew = $derived(
(pendingImport ?? ([] as SkillUpload[])).filter((s) => !existingNames.has(s.name))
)
// Parsed view of the modal's raw content, for rendering the skill in read mode.
let viewParsed = $derived(parseSkillMd(pasteContent))
let isDirty = $derived(pasteContent !== originalContent)
// Validate through the shared schema; surfaced inline so Save can be gated
// without a toast.
let pasteResult = $derived(parseAndValidateSkill(pasteContent))
let pasteError = $derived('error' in pasteResult ? pasteResult.error : undefined)
// Reset edit mode whenever the paste modal closes so a later "Paste a skill"
// opens a blank creation form.
$effect(() => {
if (!pasteModalOpen) editingOriginalName = undefined
})
// melt dropdown for the "+ Add skills" button: arrow-key nav, outside/escape
// close and focus management come for free.
const {
elements: { trigger: addMenuTrigger, menu: addMenu, item: addMenuItem },
states: { open: addMenuOpen }
} = createDropdownMenu({
positioning: { placement: 'bottom-end', gutter: 4, fitViewport: true },
loop: true,
forceVisible: true
})
// attach the menu trigger to the design-system <Button>'s DOM node so it keeps
// its styling — melt element stores are callable on a node like `use:melt`.
let addTriggerEl: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
$effect(() => {
const el = addTriggerEl
if (!el) return
const applied = conditionalMelt(el, addMenuTrigger as any) as { destroy?: () => void }
return applied?.destroy
})
async function loadList(workspace: string | undefined) {
@@ -67,45 +134,6 @@
}
}
/** Split a SKILL.md into its frontmatter `name`/`description` and the markdown body. */
function parseSkillMd(raw: string): {
name: string | undefined
description: string | undefined
instructions: string
} {
const text = raw.replace(/^/, '')
const fm = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/.exec(text)
if (!fm) {
return { name: undefined, description: undefined, instructions: text.trim() }
}
let name: string | undefined
let description: string | undefined
try {
const data = YAML.parse(fm[1]) ?? {}
if (typeof data?.name === 'string') name = data.name.trim()
if (typeof data?.description === 'string') description = data.description.trim()
} catch {
// Malformed frontmatter — fall through so the skill is reported as
// invalid rather than silently dropped.
}
return { name, description, instructions: text.slice(fm[0].length).trim() }
}
function validateParsedSkill(skill: SkillUpload): string | undefined {
if ([...skill.name].length > MAX_SKILL_NAME_LENGTH) {
return `name is longer than ${MAX_SKILL_NAME_LENGTH} characters`
}
if (!SKILL_NAME_PATTERN.test(skill.name)) {
return `name ${JSON.stringify(skill.name)} must only contain lowercase letters, digits or '-'`
}
if ([...skill.description].length > MAX_SKILL_DESCRIPTION_LENGTH) {
return `description is longer than ${MAX_SKILL_DESCRIPTION_LENGTH} characters`
}
if (textEncoder.encode(skill.instructions).byteLength > MAX_SKILL_INSTRUCTIONS_LENGTH) {
return `body is longer than ${MAX_SKILL_INSTRUCTIONS_LENGTH} bytes`
}
}
/**
* Turn a map of `relativePath -> content` (from an imported folder) into skills.
* A skill is any `SKILL.md`; its id is the name of the folder holding it.
@@ -119,22 +147,12 @@
for (const [path, content] of Object.entries(files)) {
const segments = path.split('/')
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
const name = segments.length >= 2 ? segments[segments.length - 2] : ''
const { description, instructions } = parseSkillMd(content)
if (!name) {
skipped.push(`${path} (SKILL.md must live in a named folder)`)
} else if (!description) {
skipped.push(`${name} (missing frontmatter description)`)
} else if (!instructions) {
skipped.push(`${name} (empty body)`)
const folderName = segments.length >= 2 ? segments[segments.length - 2] : ''
const result = parseAndValidateSkill(content, folderName)
if ('error' in result) {
skipped.push(`${folderName || path} (${result.error})`)
} else {
const parsed = { name, description, instructions }
const validationError = validateParsedSkill(parsed)
if (validationError) {
skipped.push(`${name} (${validationError})`)
} else {
collected.push(parsed)
}
collected.push(result.skill)
}
}
return { skills: collected, skipped }
@@ -154,7 +172,6 @@
return false
}
// Uploads upsert, so only names not already stored count toward the cap.
const existingNames = new Set(skills.map((s) => s.name))
const newCount = parsed.filter((s) => !existingNames.has(s.name)).length
if (skills.length + newCount > MAX_SKILLS_PER_WORKSPACE) {
sendUserToast(`This workspace can store at most ${MAX_SKILLS_PER_WORKSPACE} skills.`, true)
@@ -179,37 +196,49 @@
}
}
async function addPastedSkill() {
const { name, description, instructions } = parseSkillMd(pasteContent)
if (!name) {
sendUserToast('The pasted SKILL.md needs a `name` in its frontmatter.', true)
return
}
if (!description) {
sendUserToast('The pasted SKILL.md needs a `description` in its frontmatter.', true)
return
}
if (!instructions) {
sendUserToast('The pasted SKILL.md has an empty body.', true)
return
}
const parsed = { name, description, instructions }
const validationError = validateParsedSkill(parsed)
if (validationError) {
sendUserToast(`The pasted SKILL.md ${validationError}.`, true)
return
}
if (await uploadSkills([parsed])) {
pasteContent = ''
function openPaste() {
editingOriginalName = undefined
pasteContent = ''
originalContent = ''
detailMode = 'edit'
pasteModalOpen = true
}
async function openSkill(name: string, mode: 'view' | 'edit') {
const workspace = $workspaceStore
if (!workspace) return
try {
const skill = await WorkspaceService.getAiSkill({ workspace, name })
pasteContent = buildSkillMd(skill)
originalContent = pasteContent
editingOriginalName = name
detailMode = mode
pasteModalOpen = true
} catch (e) {
sendUserToast(`Failed to load skill: ${e}`, true)
}
}
async function onDirSelected(event: Event) {
const target = event.target as HTMLInputElement
const files = Array.from(target.files ?? [])
// Reset early so re-selecting the same folder re-fires `change`.
if (dirInput) dirInput.value = ''
async function submitPastedSkill() {
// Guarded by `pasteError` disabling the button; bail defensively if reached.
if (!('skill' in pasteResult)) return
const parsed = pasteResult.skill
const renamedFrom = editingOriginalName
if (await uploadSkills([parsed])) {
// A rename saves under the new name; drop the old entry so it doesn't linger.
if (renamedFrom && renamedFrom !== parsed.name) {
await deleteSkill(renamedFrom, { silent: true })
}
pasteContent = ''
pasteModalOpen = false
}
}
/**
* Filter a folder's files down to in-depth SKILL.md, read them, and stage the
* result for confirmation.
*/
async function processFolderFiles(files: File[]) {
// Pick SKILL.md files within the depth limit BEFORE reading any content,
// so a huge tree never gets read in full.
const skipped: string[] = []
@@ -255,96 +284,247 @@
)
return
}
// Confirm before writing — the import can pull in several skills at once.
// Confirm before writing — the import can pull in several skills at once,
// and any that collide with existing skills default to overwrite.
pendingSkipped = allSkipped
overwriteChoices = Object.fromEntries(
parsed.filter((s) => existingNames.has(s.name)).map((s) => [s.name, true])
)
pendingImport = parsed
}
async function deleteSkill(name: string) {
async function onDirSelected(event: Event) {
const target = event.target as HTMLInputElement
const files = Array.from(target.files ?? [])
// Reset early so re-selecting the same folder re-fires `change`.
if (dirInput) dirInput.value = ''
await processFolderFiles(files)
}
async function deleteSkill(name: string, opts: { silent?: boolean } = {}) {
const workspace = $workspaceStore
if (!workspace) return
try {
await WorkspaceService.deleteAiSkill({ workspace, name })
sendUserToast(`Deleted skill ${name}`)
if (!opts.silent) sendUserToast(`Deleted skill ${name}`)
await loadList(workspace)
} catch (e) {
sendUserToast(`Failed to delete skill: ${e}`, true)
}
}
function exitManage() {
manageMode = false
selected = {}
}
// Escape leaves manage mode, mirroring the "Done" button — but only when no
// modal/menu is open, so it doesn't steal Escape from them.
function onWindowKeydown(e: KeyboardEvent) {
if (
e.key === 'Escape' &&
manageMode &&
!pasteModalOpen &&
!confirmBatchDelete &&
!$addMenuOpen &&
toDelete === undefined &&
pendingImport === undefined
) {
exitManage()
}
}
function toggleSelect(name: string) {
selected = { ...selected, [name]: !selected[name] }
}
function toggleSelectAll() {
selected = allSelected ? {} : Object.fromEntries(skills.map((s) => [s.name, true]))
}
async function deleteSelected() {
const workspace = $workspaceStore
const names = skills.filter((s) => selected[s.name]).map((s) => s.name)
if (!workspace || names.length === 0) return
uploading = true
try {
for (const name of names) {
await WorkspaceService.deleteAiSkill({ workspace, name })
}
sendUserToast(`Deleted ${names.length} skill(s)`)
exitManage()
} catch (e) {
sendUserToast(`Failed to delete skills: ${e}`, true)
} finally {
uploading = false
await loadList(workspace)
}
}
onMount(() => {
return workspaceStore.subscribe((workspace) => {
toDelete = undefined
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
exitManage()
void loadList(workspace)
})
})
</script>
<svelte:window onkeydown={onWindowKeydown} />
{#snippet pasteZone()}
<textarea
bind:value={pasteContent}
placeholder={SAMPLE_SKILL_PLACEHOLDER}
class="w-full min-h-24 p-2 border border-border-light rounded-md bg-surface text-primary font-mono text-xs resize-y"
rows="5"
use:autosize
></textarea>
{#if editingOriginalName || pasteContent.trim()}
<div class="flex items-center justify-between gap-2 mt-2">
<span class="text-2xs text-red-500 min-w-0">{isDirty && pasteError ? pasteError : ''}</span>
<Button
onclick={submitPastedSkill}
variant="accent"
unifiedSize="sm"
startIcon={{ icon: editingOriginalName ? Pencil : Plus }}
disabled={uploading || !isDirty || !!pasteError}
>
{editingOriginalName ? 'Save skill' : 'Add skill'}
</Button>
</div>
{/if}
{/snippet}
<SettingCard
label="Custom skills"
description="Add your own skills to the AI Chat. The expected format is the same as Claude or Codex."
>
<div class="flex flex-col gap-3 pt-1">
<Label label="Paste a SKILL.md file">
<textarea
bind:value={pasteContent}
placeholder={SAMPLE_SKILL_PLACEHOLDER}
class="w-full min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-md bg-surface text-primary font-mono text-xs resize-y"
rows="5"
use:autosize
></textarea>
<div class="flex justify-end mt-2">
{#snippet headerAction()}
<div class="flex items-center gap-2">
{#if manageMode}
<Button
onclick={addPastedSkill}
variant="accent"
destructive
unifiedSize="sm"
startIcon={{ icon: Trash2 }}
disabled={selectedCount === 0 || uploading}
onclick={() => (confirmBatchDelete = true)}
>
Delete{selectedCount ? ` (${selectedCount})` : ''}
</Button>
<Button variant="default" unifiedSize="sm" disabled={uploading} onclick={exitManage}>
Done
</Button>
{:else}
{#if skills.length > 1}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: ListChecks }}
disabled={uploading}
onclick={() => (manageMode = true)}
>
Manage skills
</Button>
{/if}
<Button
bind:element={addTriggerEl}
{...$addMenuTrigger}
variant="default"
unifiedSize="sm"
startIcon={{ icon: Plus }}
disabled={!pasteContent.trim() || uploading}
>
Add skill
</Button>
</div>
</Label>
<Label label="Import a folder of skills">
<div class="flex mt-1">
<Button
onclick={() => dirInput?.click()}
variant="default"
unifiedSize="sm"
startIcon={{ icon: FolderUp }}
endIcon={{ icon: ChevronDown }}
disabled={uploading}
>
{uploading ? 'Importing…' : 'Import folder'}
Add skills
</Button>
{/if}
</div>
{#if $addMenuOpen}
<div
use:melt={$addMenu}
class="z-[6000] flex flex-col gap-0.5 p-1 w-64 rounded-lg border border-border-light bg-surface shadow-xl focus:outline-none"
>
<button use:melt={$addMenuItem} class={menuItemClass} onclick={() => dirInput?.click()}>
<FolderUp size={16} class="shrink-0 text-tertiary" />
<span class="text-xs font-medium text-primary">Import a folder of skills</span>
</button>
<button use:melt={$addMenuItem} class={menuItemClass} onclick={openPaste}>
<ClipboardPaste size={16} class="shrink-0 text-tertiary" />
<span class="text-xs font-medium text-primary">Paste a skill</span>
</button>
</div>
<input
bind:this={dirInput}
type="file"
style="display: none;"
onchange={onDirSelected}
{...{ webkitdirectory: true, directory: true }}
/>
</Label>
{/if}
{/snippet}
{#if skills.length > 0}
<div class="rounded-md border divide-y">
<div class="flex flex-col gap-3 pt-1">
{#if skills.length === 0}
<div class="rounded-md border border-dashed px-3 py-6 text-center text-xs text-secondary">
No custom skills yet
</div>
{:else}
<div class="rounded-md border divide-y max-h-96 overflow-y-auto">
{#if manageMode}
<div class="sticky top-0 z-10 flex items-center gap-3 px-3 py-2 bg-surface-secondary">
<Checkbox
checked={allSelected}
indeterminate={selectedCount > 0 && !allSelected}
onChange={toggleSelectAll}
/>
<span class="text-2xs text-secondary">
{selectedCount ? `${selectedCount} selected` : 'Select all'}
</span>
</div>
{/if}
{#each skills as skill (skill.name)}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<div class="min-w-0">
<div class="text-xs font-semibold font-mono truncate">{skill.name}</div>
<div class="text-2xs text-secondary truncate">{skill.description}</div>
</div>
<Button
onclick={() => (toDelete = skill.name)}
variant="default"
color="red"
unifiedSize="sm"
startIcon={{ icon: Trash2 }}
iconOnly
/>
{#if manageMode}
<label class="flex items-center gap-3 min-w-0 grow cursor-pointer">
<Checkbox
checked={!!selected[skill.name]}
onChange={() => toggleSelect(skill.name)}
/>
<div class="min-w-0">
<div class="text-xs font-mono truncate">{skill.name}</div>
<div class="text-2xs text-secondary truncate">{skill.description}</div>
</div>
</label>
{:else}
<div class="min-w-0">
<div class="text-xs font-mono truncate">{skill.name}</div>
<div class="text-2xs text-secondary truncate">{skill.description}</div>
<Button
onclick={() => openSkill(skill.name, 'view')}
variant="subtle"
unifiedSize="2xs"
wrapperClasses="w-fit mt-0.5"
btnClasses="!px-0 text-2xs font-normal text-secondary hover:text-primary hover:!bg-transparent"
>
Show more
</Button>
</div>
<DropdownV2
size="sm"
items={[
{
displayName: 'Edit',
icon: Pencil,
disabled: uploading,
action: () => openSkill(skill.name, 'edit')
},
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
action: () => (toDelete = skill.name)
}
]}
/>
{/if}
</div>
{/each}
</div>
@@ -352,31 +532,104 @@
</div>
</SettingCard>
<!-- Hidden folder picker fired by the dropdown's "Import a folder of skills". -->
<input
bind:this={dirInput}
type="file"
style="display: none;"
onchange={onDirSelected}
{...{ webkitdirectory: true, directory: true }}
/>
<Modal2
title={editingOriginalName ?? 'Paste a skill'}
bind:isOpen={pasteModalOpen}
fixedWidth="md"
fixedHeight="adaptive"
>
{#snippet headerRight()}
{#if editingOriginalName}
<ToggleButtonGroup bind:selected={detailMode}>
{#snippet children({ item })}
<ToggleButton value="view" label="View" icon={Eye} {item} small />
<ToggleButton value="edit" label="Edit" icon={Pencil} {item} small />
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
<div class="w-full flex flex-col">
{#if detailMode === 'view'}
<div class="w-full flex flex-col gap-3">
{#if viewParsed.description}
<p class="text-xs text-secondary">{viewParsed.description}</p>
{/if}
<div
class="border rounded-md p-3 overflow-auto max-h-[60vh] prose prose-sm dark:prose-invert max-w-full leading-snug space-y-2 prose-ul:!pl-6
prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs
prose-code:break-words prose-a:break-words
prose-headings:font-medium prose-headings:text-emphasis prose-headings:mt-3 prose-headings:mb-1
prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs
prose-table:block prose-table:max-w-full prose-table:overflow-x-auto prose-table:text-xs"
>
<Markdown md={viewParsed.instructions} plugins={[gfmPlugin()]} />
</div>
</div>
{:else}
{@render pasteZone()}
{/if}
</div>
</Modal2>
<ConfirmationModal
open={pendingImport !== undefined}
title="Import skills"
type="info"
confirmationText="Import"
onConfirmed={async () => {
const toImport = pendingImport
const toImport = [...pendingNew, ...pendingConflicts.filter((s) => overwriteChoices[s.name])]
const skipped = pendingSkipped
pendingImport = undefined
pendingSkipped = []
if (toImport) await uploadSkills(toImport, skipped)
overwriteChoices = {}
if (toImport.length) await uploadSkills(toImport, skipped)
else sendUserToast('No skills imported.')
}}
onCanceled={() => {
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
}}
>
<span>
Add {pendingImport?.length} skill(s) to the AI chat?
<span class="font-mono text-xs">{pendingNamesPreview}</span>
{#if pendingSkipped.length}
<br /><span class="text-xs text-secondary"
>{pendingSkipped.length} file(s) will be skipped.</span
>
<div class="flex flex-col gap-3 text-xs">
{#if pendingNew.length}
<div>
<span class="font-medium text-primary">Add {pendingNew.length} new skill(s):</span>
<span class="font-mono text-secondary">{pendingNew.map((s) => s.name).join(', ')}</span>
</div>
{/if}
</span>
{#if pendingConflicts.length}
<div class="flex flex-col gap-1.5">
<span class="font-medium text-primary">
{pendingConflicts.length} skill(s) already exist — choose which to overwrite:
</span>
<div class="rounded-md border divide-y">
{#each pendingConflicts as conflict (conflict.name)}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<span class="font-mono truncate">{conflict.name}</span>
<Toggle
bind:checked={overwriteChoices[conflict.name]}
size="xs"
options={{ right: 'Overwrite' }}
/>
</div>
{/each}
</div>
</div>
{/if}
{#if pendingSkipped.length}
<span class="text-secondary">{pendingSkipped.length} file(s) will be skipped.</span>
{/if}
</div>
</ConfirmationModal>
<ConfirmationModal
@@ -394,3 +647,18 @@
Delete the skill <code>{toDelete}</code>? The AI chat will no longer be able to use it.
</span>
</ConfirmationModal>
<ConfirmationModal
open={confirmBatchDelete}
title="Delete skills"
confirmationText="Delete"
onConfirmed={async () => {
confirmBatchDelete = false
await deleteSelected()
}}
onCanceled={() => (confirmBatchDelete = false)}
>
<span>
Delete {selectedCount} selected skill(s)? The AI chat will no longer be able to use them.
</span>
</ConfirmationModal>
@@ -17,7 +17,8 @@
import { usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
import { resource } from 'runed'
import { Button } from '$lib/components/common'
import { Badge, Button } from '$lib/components/common'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { onMount } from 'svelte'
@@ -58,6 +59,12 @@
$effect(() => {
copyMembers = createAsDevWorkspace
})
// Cosmetic display label for the new dev workspace: 'dev' | 'staging'. Purely visual (badge text +
// wording); reset when the dev toggle is turned off.
let devWorkspaceLabel = $state<'dev' | 'staging'>('dev')
$effect(() => {
if (!createAsDevWorkspace) devWorkspaceLabel = 'dev'
})
// The dev-workspace option is only offered when forking a root workspace that doesn't already
// have one: a workspace gets at most one dev, and dev workspaces don't nest (a dev of a dev).
@@ -239,6 +246,7 @@
name,
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
is_dev_workspace: createAsDevWorkspace,
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
// Send the lock intent in this first phase too so the backend can reject a non-admin's
// locked-dev request before any branch is created (avoids dangling branches).
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
@@ -308,6 +316,7 @@
forked_datatables: forkedDatatables,
shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [],
is_dev_workspace: createAsDevWorkspace,
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
lock_prod_forking: createAsDevWorkspace && lockProdForking,
copy_members: copyMembers
@@ -324,7 +333,7 @@
forkCreationLoading = false
sendUserToast(
createAsDevWorkspace
? `Created dev workspace ${effectiveForkId} for ${$workspaceStore}`
? `Created ${devWorkspaceLabel === 'staging' ? 'staging' : 'dev'} workspace ${effectiveForkId} for ${$workspaceStore}`
: `Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`
)
@@ -467,6 +476,21 @@
run(() => {
id = name.toLowerCase().replace(/\s/gi, '-')
})
// When creating a dev workspace, prefill the fork name with `<root>-dev` / `<root>-stg` (the effect
// above slugifies it into the id). Only fill an empty field or one still holding a prior suggestion,
// so a user-typed name is never overwritten; flipping Dev<->Staging updates the suffix, and turning
// the dev toggle back off clears the suggestion.
let lastAutoDevName = $state<string | undefined>(undefined)
$effect(() => {
const target =
createAsDevWorkspace && $workspaceStore
? `${$workspaceStore}-${devWorkspaceLabel === 'staging' ? 'stg' : 'dev'}`
: ''
if (name === '' || name === lastAutoDevName) {
name = target
lastAutoDevName = target === '' ? undefined : target
}
})
run(() => {
validateName(id)
})
@@ -598,6 +622,18 @@
<div class="flex flex-col gap-2 pt-1">
<Toggle bind:checked={createAsDevWorkspace} options={{ right: 'Dev workspace' }} />
{#if createAsDevWorkspace}
<div class="text-2xs text-secondary">
Cosmetic label: <Badge color="indigo" small>{devBadgeText(devWorkspaceLabel)}</Badge
>
<button
type="button"
class="text-secondary hover:text-primary hover:underline"
onclick={() =>
(devWorkspaceLabel = devWorkspaceLabel === 'staging' ? 'dev' : 'staging')}
>
Change to {devWorkspaceLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-emphasis"
@@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest'
import {
MAX_SKILL_DESCRIPTION_LENGTH,
MAX_SKILL_INSTRUCTIONS_LENGTH,
MAX_SKILL_NAME_LENGTH,
buildSkillMd,
parseAndValidateSkill,
parseSkillMd,
validateSkill
} from './aiSkills'
describe('parseSkillMd', () => {
it('splits frontmatter name/description from the body', () => {
const md = '---\nname: my-skill\ndescription: does a thing\n---\n\n# Title\n\nBody text.'
expect(parseSkillMd(md)).toEqual({
name: 'my-skill',
description: 'does a thing',
instructions: '# Title\n\nBody text.'
})
})
it('trims frontmatter values and the body', () => {
const md = '---\nname: spaced \ndescription: padded \n---\n\n body '
const parsed = parseSkillMd(md)
expect(parsed.name).toBe('spaced')
expect(parsed.description).toBe('padded')
expect(parsed.instructions).toBe('body')
})
it('returns undefined name/description when there is no frontmatter', () => {
expect(parseSkillMd('just a body')).toEqual({
name: undefined,
description: undefined,
instructions: 'just a body'
})
})
it('strips a leading UTF-8 BOM before matching frontmatter', () => {
const md = '---\nname: bom-skill\ndescription: d\n---\n\nbody'
const parsed = parseSkillMd(md)
expect(parsed.name).toBe('bom-skill')
expect(parsed.description).toBe('d')
expect(parsed.instructions).toBe('body')
})
it('handles CRLF line endings in the frontmatter fence', () => {
const md = '---\r\nname: crlf\r\ndescription: d\r\n---\r\n\r\nbody'
const parsed = parseSkillMd(md)
expect(parsed.name).toBe('crlf')
expect(parsed.description).toBe('d')
})
it('leaves name/description undefined for missing frontmatter keys', () => {
const parsed = parseSkillMd('---\nname: only-name\n---\n\nbody')
expect(parsed.name).toBe('only-name')
expect(parsed.description).toBeUndefined()
expect(parsed.instructions).toBe('body')
})
it('ignores non-string frontmatter values', () => {
const parsed = parseSkillMd('---\nname: 123\ndescription: [a, b]\n---\n\nbody')
expect(parsed.name).toBeUndefined()
expect(parsed.description).toBeUndefined()
})
it('does not throw on malformed YAML frontmatter', () => {
const parsed = parseSkillMd('---\nname: "unterminated\n---\n\nbody')
expect(parsed.instructions).toBe('body')
expect(parsed.name).toBeUndefined()
})
})
describe('validateSkill', () => {
const valid = { name: 'a-skill', description: 'a description', instructions: 'body' }
it('returns undefined for a valid skill', () => {
expect(validateSkill(valid)).toBeUndefined()
})
it('flags a missing name', () => {
expect(validateSkill({ ...valid, name: '' })).toBe('name is required')
})
it('flags a missing description', () => {
expect(validateSkill({ ...valid, description: '' })).toBe('description is required')
})
it('flags a missing body', () => {
expect(validateSkill({ ...valid, instructions: '' })).toBe('body is required')
})
it('rejects names with disallowed characters and echoes the value', () => {
expect(validateSkill({ ...valid, name: 'Bad Name!' })).toBe(
`name "Bad Name!" must only contain lowercase letters, digits or '-'`
)
})
it('accepts names of lowercase letters, digits and hyphens', () => {
expect(validateSkill({ ...valid, name: 'skill-123' })).toBeUndefined()
})
it('accepts a name exactly at the length limit but rejects one over', () => {
expect(validateSkill({ ...valid, name: 'a'.repeat(MAX_SKILL_NAME_LENGTH) })).toBeUndefined()
expect(validateSkill({ ...valid, name: 'a'.repeat(MAX_SKILL_NAME_LENGTH + 1) })).toBe(
`name is longer than ${MAX_SKILL_NAME_LENGTH} characters`
)
})
it('counts the description limit in code points, not UTF-16 units', () => {
// Astral emoji are 2 UTF-16 units but 1 code point each.
const desc = '😀'.repeat(MAX_SKILL_DESCRIPTION_LENGTH)
expect(validateSkill({ ...valid, description: desc })).toBeUndefined()
expect(validateSkill({ ...valid, description: desc + '😀' })).toBe(
`description is longer than ${MAX_SKILL_DESCRIPTION_LENGTH} characters`
)
})
it('counts the body limit in bytes', () => {
// A 4-byte emoji fills the byte budget four times faster than its char count.
const bodyAtLimit = 'a'.repeat(MAX_SKILL_INSTRUCTIONS_LENGTH)
expect(validateSkill({ ...valid, instructions: bodyAtLimit })).toBeUndefined()
expect(validateSkill({ ...valid, instructions: bodyAtLimit + 'a' })).toBe(
`body is longer than ${MAX_SKILL_INSTRUCTIONS_LENGTH} bytes`
)
const multibyte = '😀'.repeat(MAX_SKILL_INSTRUCTIONS_LENGTH / 4 + 1)
expect(validateSkill({ ...valid, instructions: multibyte })).toBe(
`body is longer than ${MAX_SKILL_INSTRUCTIONS_LENGTH} bytes`
)
})
it('reports the name issue first when several fields are invalid', () => {
expect(validateSkill({ name: '', description: '', instructions: '' })).toBe('name is required')
})
})
describe('parseAndValidateSkill', () => {
it('parses and validates a well-formed SKILL.md', () => {
const md = '---\nname: good-skill\ndescription: a good one\n---\n\nbody'
expect(parseAndValidateSkill(md)).toEqual({
skill: { name: 'good-skill', description: 'a good one', instructions: 'body' }
})
})
it('uses nameOverride instead of the frontmatter name', () => {
const md = '---\nname: frontmatter-name\ndescription: d\n---\n\nbody'
const result = parseAndValidateSkill(md, 'folder-name')
expect(result).toEqual({
skill: { name: 'folder-name', description: 'd', instructions: 'body' }
})
})
it('validates the nameOverride, not the frontmatter name', () => {
const md = '---\nname: valid-frontmatter\ndescription: d\n---\n\nbody'
expect(parseAndValidateSkill(md, 'Bad Folder!')).toEqual({
error: `name "Bad Folder!" must only contain lowercase letters, digits or '-'`
})
})
it('does not fall back to the frontmatter name for an empty override', () => {
const md = '---\nname: has-name\ndescription: d\n---\n\nbody'
expect(parseAndValidateSkill(md, '')).toEqual({ error: 'name is required' })
})
it('returns an error for a missing description', () => {
expect(parseAndValidateSkill('---\nname: s\n---\n\nbody')).toEqual({
error: 'description is required'
})
})
it('returns an error for an empty body', () => {
expect(parseAndValidateSkill('---\nname: s\ndescription: d\n---\n\n')).toEqual({
error: 'body is required'
})
})
})
describe('buildSkillMd', () => {
it('round-trips a skill through parseSkillMd', () => {
const skill = { name: 'round-trip', description: 'desc', instructions: '# Body\n\ntext' }
const parsed = parseSkillMd(buildSkillMd(skill))
expect(parsed.name).toBe(skill.name)
expect(parsed.description).toBe(skill.description)
expect(parsed.instructions).toBe(skill.instructions)
})
it('quotes descriptions with YAML-special characters so they round-trip', () => {
const skill = {
name: 'colon-desc',
description: 'value: with colon, #hash and : more',
instructions: 'body'
}
const parsed = parseSkillMd(buildSkillMd(skill))
expect(parsed.description).toBe(skill.description)
expect(validateSkill(skill)).toBeUndefined()
})
})
@@ -0,0 +1,101 @@
import YAML from 'yaml'
import { z } from 'zod'
export type SkillUpload = { name: string; description: string; instructions: string }
// `name` + `description` mirror the Claude SKILL.md spec (counted in characters);
// the body is a byte-bounded payload. Keep these in sync with backend `validate_skill`.
export const MAX_SKILL_NAME_LENGTH = 64
export const MAX_SKILL_DESCRIPTION_LENGTH = 1_024
export const MAX_SKILL_INSTRUCTIONS_LENGTH = 64 * 1024
const textEncoder = new TextEncoder()
// Single source of truth for skill field validation, shared by the paste/edit
// modal and the folder importer. Lengths are code-point / byte bounded to match
// the backend, so `.refine` (not `.max`, which counts UTF-16 units) is used.
export const skillSchema = z.object({
name: z
.string()
.min(1, 'name is required')
.refine(
(v) => [...v].length <= MAX_SKILL_NAME_LENGTH,
`name is longer than ${MAX_SKILL_NAME_LENGTH} characters`
)
.refine((v) => /^[a-z0-9-]+$/.test(v), {
error: (iss) =>
`name ${JSON.stringify(iss.input)} must only contain lowercase letters, digits or '-'`
}),
description: z
.string()
.min(1, 'description is required')
.refine(
(v) => [...v].length <= MAX_SKILL_DESCRIPTION_LENGTH,
`description is longer than ${MAX_SKILL_DESCRIPTION_LENGTH} characters`
),
instructions: z
.string()
.min(1, 'body is required')
.refine(
(v) => textEncoder.encode(v).byteLength <= MAX_SKILL_INSTRUCTIONS_LENGTH,
`body is longer than ${MAX_SKILL_INSTRUCTIONS_LENGTH} bytes`
)
})
/** First validation error for a skill, or `undefined` if it is valid. */
export function validateSkill(skill: SkillUpload): string | undefined {
const result = skillSchema.safeParse(skill)
return result.success ? undefined : result.error.issues[0]?.message
}
/** Split a SKILL.md into its frontmatter `name`/`description` and the markdown body. */
export function parseSkillMd(raw: string): {
name: string | undefined
description: string | undefined
instructions: string
} {
const text = raw.replace(/^/, '')
const fm = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/.exec(text)
if (!fm) {
return { name: undefined, description: undefined, instructions: text.trim() }
}
let name: string | undefined
let description: string | undefined
try {
const data = YAML.parse(fm[1]) ?? {}
if (typeof data?.name === 'string') name = data.name.trim()
if (typeof data?.description === 'string') description = data.description.trim()
} catch {
// Malformed frontmatter — fall through so the skill is reported as invalid
// rather than silently dropped.
}
return { name, description, instructions: text.slice(fm[0].length).trim() }
}
/**
* Parse a SKILL.md and validate it in one step. `nameOverride` lets the folder
* importer supply the skill name from its containing folder instead of the
* frontmatter. Returns the validated skill or the first error message.
*/
export function parseAndValidateSkill(
raw: string,
nameOverride?: string
): { skill: SkillUpload } | { error: string } {
const parsed = parseSkillMd(raw)
const candidate: SkillUpload = {
name: nameOverride ?? parsed.name ?? '',
description: parsed.description ?? '',
instructions: parsed.instructions
}
const error = validateSkill(candidate)
return error ? { error } : { skill: candidate }
}
/** Reconstruct a SKILL.md from its stored parts for editing/rendering. */
export function buildSkillMd(skill: SkillUpload): string {
const frontmatter = YAML.stringify({
name: skill.name,
description: skill.description
}).trimEnd()
return `---\n${frontmatter}\n---\n\n${skill.instructions}\n`
}
+1
View File
@@ -43,6 +43,7 @@ export interface UserWorkspace {
operator_settings?: OperatorSettings
parent_workspace_id?: string | null
is_dev_workspace?: boolean
dev_workspace_label?: string | null
disabled: boolean
}
@@ -0,0 +1,25 @@
// Cosmetic display label for a dev workspace. The paired-fork machinery is unchanged; this only
// swaps the badge text and identity wording so a team can present the environment as "staging"
// instead of "dev". A null/unknown stored value renders as "dev" (the default).
export type DevWorkspaceLabelKey = 'dev' | 'staging'
/** Resolve the stored `dev_workspace_label` to a known key; anything unset/unknown is 'dev'. */
export function devLabelKey(label: string | null | undefined): DevWorkspaceLabelKey {
return label === 'staging' ? 'staging' : 'dev'
}
/** Short badge text: 'dev' or 'stg'. */
export function devBadgeText(label: string | null | undefined): string {
return devLabelKey(label) === 'staging' ? 'stg' : 'dev'
}
/** Capitalized word for identity wording, e.g. `${devLabelWord(l)} workspace of X`. */
export function devLabelWord(label: string | null | undefined): string {
return devLabelKey(label) === 'staging' ? 'Staging' : 'Dev'
}
/** Lowercase noun phrase for prose, e.g. "made in its ${devLabelNoun(l)}". */
export function devLabelNoun(label: string | null | undefined): string {
return devLabelKey(label) === 'staging' ? 'staging workspace' : 'dev workspace'
}
@@ -164,7 +164,7 @@
/>
<Section label="All workspace assets" class="mb-20">
<div class="flex gap-4">
<div class="flex flex-wrap gap-4">
{#snippet card(props: {
title: string
assetKind: AssetKind
@@ -174,10 +174,12 @@
favorites?: { table: string; schema?: string; assetName: string; path: string }[]
itemExtra?: import('svelte').Snippet<[{ label: string; value: string }]>
})}
<div class="flex flex-col bg-surface-tertiary drop-shadow-base rounded-md flex-1">
<div class="flex justify-between border-b">
<h3 class="text-sm font-bold mb-4 pt-5 pl-6">{props.title}</h3>
<div class="flex items-center h-fit gap-2 mt-4 mr-4">
<div
class="flex flex-col bg-surface-tertiary drop-shadow-base rounded-md grow basis-[340px] min-w-0"
>
<div class="flex flex-wrap justify-between items-center gap-2 border-b pt-5 px-6 pb-4">
<h3 class="text-sm font-bold min-w-0 truncate" title={props.title}>{props.title}</h3>
<div class="flex items-center h-fit gap-2 shrink-0">
<Button
wrapperClasses="h-fit"
btnClasses="text-accent"
@@ -204,9 +206,11 @@
{#if props.data.current?.length}
<div class="max-h-96 overflow-y-auto pb-1">
{#each props.data.current ?? [] as item}
<div class="text-xs py-2 text-primary flex justify-between items-center px-6">
{item.label}
<div class="flex items-center gap-2">
<div
class="text-xs py-2 text-primary flex justify-between items-center gap-2 px-6"
>
<span class="min-w-0 truncate" title={item.label}>{item.label}</span>
<div class="flex items-center gap-2 shrink-0">
{#if props.itemExtra}
{@render props.itemExtra(item)}
{/if}
@@ -304,16 +308,16 @@
})}
</div>
</Section>
<Section label="Latest assets used">
<Section label="Latest assets used" headerClass="whitespace-nowrap shrink-0">
{#snippet action()}
<div class="flex gap-2 grow justify-end">
<div class="flex gap-2 grow justify-end min-w-0 ml-4">
<RefreshButton
variant="default"
onClick={() => assetsQuery.reset()}
loading={assetsQuery.isLoading}
/>
<FilterSearchbar
class="grow max-w-[26rem]"
class="grow max-w-[26rem] min-w-0"
schema={assetsFilterSchema}
bind:value={filterValues.val}
placeholder="Filter assets..."
@@ -259,7 +259,9 @@
dataTests: [],
columnLineage: [],
macros: false,
useLibs: []
useLibs: [],
muteAssets: [],
muteAll: false
}
}
@@ -4,9 +4,37 @@
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { Globe } from 'lucide-svelte'
let tab = $state('button')
const sampleMarkdown = `# Heading 1
## Heading 2
Body text with **bold**, *italic*, a [link](https://windmill.dev), and \`inline code\` that must stay readable in both themes.
> A block quote should be legible too.
- First bullet
- Second bullet with \`code\`
1. Ordered one
2. Ordered two
\`\`\`ts
const block = 'code block'
console.log(block)
\`\`\`
| Column A | Column B |
| -------- | -------- |
| one | two |
| three | four |
---
`
let dropdownItems = [
{
label: 'Lorem ipsum',
@@ -19,6 +47,7 @@
<Tabs bind:selected={tab}>
<Tab value="button" label="Buttons" />
<Tab value="markdown" label="Markdown" />
{#snippet content()}
<TabContent value="button" class="p-4 flex gap-4 flex-col ">
@@ -72,5 +101,8 @@
<Button variant="default" {dropdownItems}>Lorem</Button>
</div>
</TabContent>
<TabContent value="markdown" class="p-4">
<GfmMarkdown md={sampleMarkdown} />
</TabContent>
{/snippet}
</Tabs>
+43
View File
@@ -456,6 +456,49 @@ const config = {
]
},
extend: {
// Tailwind Typography hardcodes every `prose` color (body, headings,
// borders, code, ...) to fixed gray shades. Our `.prose` usages mostly
// render without `dark:prose-invert`, so in dark mode those light-mode
// grays stay put and text/borders/code render near-black on a dark
// surface. Point the whole palette at our theme tokens — which already
// flip with the active theme — for BOTH the default and inverted sets, so
// bare `.prose` and `dark:prose-invert` usages both track the theme.
typography: {
DEFAULT: {
css: {
'--tw-prose-body': 'rgb(var(--color-text-secondary))',
'--tw-prose-headings': 'rgb(var(--color-text-primary))',
'--tw-prose-lead': 'rgb(var(--color-text-secondary))',
'--tw-prose-bold': 'rgb(var(--color-text-primary))',
'--tw-prose-counters': 'rgb(var(--color-text-tertiary))',
'--tw-prose-bullets': 'rgb(var(--color-text-tertiary))',
'--tw-prose-hr': 'rgb(var(--color-border-light))',
'--tw-prose-quotes': 'rgb(var(--color-text-secondary))',
'--tw-prose-quote-borders': 'rgb(var(--color-border-light))',
'--tw-prose-captions': 'rgb(var(--color-text-tertiary))',
'--tw-prose-code': 'rgb(var(--color-text-primary))',
'--tw-prose-pre-code': 'rgb(var(--color-text-primary))',
'--tw-prose-pre-bg': 'rgb(var(--color-surface-secondary))',
'--tw-prose-th-borders': 'rgb(var(--color-border-light))',
'--tw-prose-td-borders': 'rgb(var(--color-border-light))',
'--tw-prose-invert-body': 'rgb(var(--color-text-secondary))',
'--tw-prose-invert-headings': 'rgb(var(--color-text-primary))',
'--tw-prose-invert-lead': 'rgb(var(--color-text-secondary))',
'--tw-prose-invert-bold': 'rgb(var(--color-text-primary))',
'--tw-prose-invert-counters': 'rgb(var(--color-text-tertiary))',
'--tw-prose-invert-bullets': 'rgb(var(--color-text-tertiary))',
'--tw-prose-invert-hr': 'rgb(var(--color-border-light))',
'--tw-prose-invert-quotes': 'rgb(var(--color-text-secondary))',
'--tw-prose-invert-quote-borders': 'rgb(var(--color-border-light))',
'--tw-prose-invert-captions': 'rgb(var(--color-text-tertiary))',
'--tw-prose-invert-code': 'rgb(var(--color-text-primary))',
'--tw-prose-invert-pre-code': 'rgb(var(--color-text-primary))',
'--tw-prose-invert-pre-bg': 'rgb(var(--color-surface-secondary))',
'--tw-prose-invert-th-borders': 'rgb(var(--color-border-light))',
'--tw-prose-invert-td-borders': 'rgb(var(--color-border-light))'
}
}
},
border: {
color: 'red'
},
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.749.0"
wmill = ">=1.751.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.749.0
version: 1.751.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.749.0'
ModuleVersion = '1.751.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.749.0"
version = "1.751.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.749.0",
"version": "1.751.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.749.0",
"version": "1.751.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.749.0
1.751.0