mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
Merge remote-tracking branch 'origin/main' into explore-git-sync-improvements
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+19
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
Generated
+95
-95
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]');
|
||||
});
|
||||
Generated
+2
-2
@@ -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,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 & 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 & 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
|
||||
|
||||
+15
-1
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.749.0"
|
||||
wmill = ">=1.751.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -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,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,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,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
@@ -1 +1 @@
|
||||
1.749.0
|
||||
1.751.0
|
||||
|
||||
Reference in New Issue
Block a user