diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fdce702331..0b517c26f7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.830.0" + ".": "1.831.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 80939b1b47..98686e7917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## [1.831.0](https://github.com/windmill-labs/windmill/compare/v1.830.0...v1.831.0) (2026-10-13) + + +### Features + +* add python 3.14 and 3.15 to the supported instance versions ([#11654](https://github.com/windmill-labs/windmill/issues/11654)) ([74c9b0c](https://github.com/windmill-labs/windmill/commit/74c9b0cb2eebc16dd5e23f23be89b127bcbe6ec2)) +* add the viewer's roles to unnamed data table connections ([#11709](https://github.com/windmill-labs/windmill/issues/11709)) ([a298ed3](https://github.com/windmill-labs/windmill/commit/a298ed3fc19a1b7fc4f8e75a95c8d38652b74b5b)) +* audit declarative instance config writes ([#11684](https://github.com/windmill-labs/windmill/issues/11684)) ([afbbc0b](https://github.com/windmill-labs/windmill/commit/afbbc0b1b2da1e468b10d4cd11e9f3ac7478a34d)) +* **cli:** declare app sandbox and frontend api scopes in app yaml ([#11703](https://github.com/windmill-labs/windmill/issues/11703)) ([26afeec](https://github.com/windmill-labs/windmill/commit/26afeecbd5b0a11c084813182e92805a15d21bbb)) +* data table migration tools in ai sessions ([#11723](https://github.com/windmill-labs/windmill/issues/11723)) ([3d74f6e](https://github.com/windmill-labs/windmill/commit/3d74f6ea2a44e85d056411c293087741f6f1e12a)) +* encrypt workspace secrets with AES-256-GCM ([#11678](https://github.com/windmill-labs/windmill/issues/11678)) ([4942149](https://github.com/windmill-labs/windmill/commit/4942149b05c9384b39402f0e3cb7771565888f45)) +* external signer for OIDC tokens and a log line per mint ([#11614](https://github.com/windmill-labs/windmill/issues/11614)) ([157183a](https://github.com/windmill-labs/windmill/commit/157183ae9cad4b6b2d38f1f4d915f32464792453)) +* give app builder operators app tools in AI sessions ([#11716](https://github.com/windmill-labs/windmill/issues/11716)) ([133613c](https://github.com/windmill-labs/windmill/commit/133613c1add5b6fcdbb0937de6479976b716c90a)) +* redesign the s3 explorer and show data files as tables ([#11642](https://github.com/windmill-labs/windmill/issues/11642)) ([9fa9b77](https://github.com/windmill-labs/windmill/commit/9fa9b7792676fbedcbc4dc37ebb883f5ff5abb35)) +* support array arguments in snowflake scripts ([#11702](https://github.com/windmill-labs/windmill/issues/11702)) ([1451988](https://github.com/windmill-labs/windmill/commit/14519884e4c9aa4d0a006cbc019a776f9a038967)) + + +### Bug Fixes + +* bump git sync hub scripts to cli 1.830.0 ([#11681](https://github.com/windmill-labs/windmill/issues/11681)) ([7bbe032](https://github.com/windmill-labs/windmill/commit/7bbe03227d56688893aafda305a13d65b2bb84f4)) +* end other sessions when a user changes their password ([#11710](https://github.com/windmill-labs/windmill/issues/11710)) ([add042e](https://github.com/windmill-labs/windmill/commit/add042ef2f05828a8ea5f6d0a089204bae2322d5)) +* fill app ctx inputs from the viewer's session, not the request ([#11698](https://github.com/windmill-labs/windmill/issues/11698)) ([7627c1b](https://github.com/windmill-labs/windmill/commit/7627c1be8d82c298173ad4a7002483d1caee6662)) +* **groups:** recompute member roles when an instance group role is cleared ([#11708](https://github.com/windmill-labs/windmill/issues/11708)) ([4be67fe](https://github.com/windmill-labs/windmill/commit/4be67fe3ac09f722f31ef85e6fc757bb0a2fabe4)) +* hide the sidebar triggers header when no trigger entry is visible ([#11715](https://github.com/windmill-labs/windmill/issues/11715)) ([e443eb3](https://github.com/windmill-labs/windmill/commit/e443eb37079734469a78af8ff4bc0f4289b28d0a)) +* keep a custom error handler's slack argument on save ([#11696](https://github.com/windmill-labs/windmill/issues/11696)) ([c6692ac](https://github.com/windmill-labs/windmill/commit/c6692ace156f5295196af07015ad82aee6391116)) +* keep ai agent schema property order stable across jobs ([#11687](https://github.com/windmill-labs/windmill/issues/11687)) ([a8e2f1f](https://github.com/windmill-labs/windmill/commit/a8e2f1f1ca28e3352aff8a450188e3ff75a9637c)) +* keep preserve_step_tags on saved branch and loop flow nodes ([#11660](https://github.com/windmill-labs/windmill/issues/11660)) ([dedd775](https://github.com/windmill-labs/windmill/commit/dedd77587f3234f3e480388b00340394794ac5bb)) +* keep the app editor's parent_version out of deployed apps ([#11719](https://github.com/windmill-labs/windmill/issues/11719)) ([2c6de9d](https://github.com/windmill-labs/windmill/commit/2c6de9d896ef37993da974418133a88798afa2bb)) +* keep the resource type index whole when a refresh fails ([#11727](https://github.com/windmill-labs/windmill/issues/11727)) ([71caac7](https://github.com/windmill-labs/windmill/commit/71caac7aec49bafe8f691e8a82b3e6df84f7e4bc)) +* keep the resource type search current between daily fills, and serve it without an index ([#11712](https://github.com/windmill-labs/windmill/issues/11712)) ([a365359](https://github.com/windmill-labs/windmill/commit/a3653593f2b064b762a19a1d4294988c27baaec0)) +* load the s3 explorer table lazily to keep monaco out of the app shell ([#11701](https://github.com/windmill-labs/windmill/issues/11701)) ([a92b29d](https://github.com/windmill-labs/windmill/commit/a92b29dae6950d29c2b8970e07d055fa02ff979e)) +* parse python 3.12-3.15 syntax in the python parsers ([#11675](https://github.com/windmill-labs/windmill/issues/11675)) ([aae5f2e](https://github.com/windmill-labs/windmill/commit/aae5f2efaa772d5bc6d7d1e4537d7f6748e50e61)) +* persist hidden languages in edit default scripts drawer ([#11693](https://github.com/windmill-labs/windmill/issues/11693)) ([aeeeb04](https://github.com/windmill-labs/windmill/commit/aeeeb042f93df32214e00040d360f20e8ed8c98a)) +* pin the floating sidebar and let page clicks through it ([#11711](https://github.com/windmill-labs/windmill/issues/11711)) ([ce45ab5](https://github.com/windmill-labs/windmill/commit/ce45ab507370662266728bf57240cc873ef28eb8)) +* resolve $var: references in mcp resource headers ([#11679](https://github.com/windmill-labs/windmill/issues/11679)) ([478eef7](https://github.com/windmill-labs/windmill/commit/478eef735222e60de8ed6086f6b036e2346f1de4)) +* resolve a new item's chosen name in session preview tools ([#11688](https://github.com/windmill-labs/windmill/issues/11688)) ([cd502a5](https://github.com/windmill-labs/windmill/commit/cd502a5270e787aeb1e5cb14e5a0e0aad8bb97b3)) +* resolve variables in the url and headers of an mcp resource ([#11680](https://github.com/windmill-labs/windmill/issues/11680)) ([4bc2394](https://github.com/windmill-labs/windmill/commit/4bc2394322f957e5cf212fa1913b2b39c1b9ca06)) +* run a workflow-as-code task called from inside a task in place ([#11699](https://github.com/windmill-labs/windmill/issues/11699)) ([72d1092](https://github.com/windmill-labs/windmill/commit/72d1092de97d7449ff03c3a44835c9d23cfe5e02)) +* stop marking run form fields as disabled for assistive tech ([#11714](https://github.com/windmill-labs/windmill/issues/11714)) ([c7df009](https://github.com/windmill-labs/windmill/commit/c7df0093d0d9a38362222b7350bf387c31af9392)) +* stop session dom tools reading a raw app preview that is not current ([#11700](https://github.com/windmill-labs/windmill/issues/11700)) ([05c7b68](https://github.com/windmill-labs/windmill/commit/05c7b688e5e963c6c313cb5da8e777cee11cd122)) +* surface oauth provider errors returned with http 200 ([#11713](https://github.com/windmill-labs/windmill/issues/11713)) ([d8ac4f5](https://github.com/windmill-labs/windmill/commit/d8ac4f59c8d570777e43a7aa7ffbc97598897cc2)) + + +### Performance Improvements + +* run shellcheck directly from the lsp launcher ([#11707](https://github.com/windmill-labs/windmill/issues/11707)) ([8cdb42a](https://github.com/windmill-labs/windmill/commit/8cdb42aaae764977008f8ac4598709c7ae09af3f)) + ## [1.830.0](https://github.com/windmill-labs/windmill/compare/v1.829.0...v1.830.0) (2026-10-09) diff --git a/Dockerfile b/Dockerfile index c6a4ccc55f..4e022d4b53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -152,11 +152,12 @@ ARG WITH_HELM=true ARG WITH_GIT=true ARG features="" -# To change latest stable version: -# 1. Change placeholder in instanceSettings.ts -# 2. Change LATEST_STABLE_PY in dockerfile -# 3. Change #[default] annotation for PyVersion in backend +# Instances keep the Python version they were initialized with, so both are preinstalled: +# LATEST_STABLE_PY is what instances initialized before the version was raised are on +# (PyVAlias::default() in the backend), NEW_INSTANCE_PY the one new instances start on +# (PyVAlias::NEW_INSTANCE). When raising the latter, keep installing every earlier value. ARG LATEST_STABLE_PY=3.12 +ARG NEW_INSTANCE_PY=3.13 ENV UV_PYTHON_INSTALL_DIR=/tmp/windmill/cache/py_runtime ENV UV_PYTHON_PREFERENCE=only-managed @@ -249,6 +250,7 @@ RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releas # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11 --compile-bytecode RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode RUN curl -sL https://deb.nodesource.com/setup_24.x | bash - diff --git a/ai_evals/README.md b/ai_evals/README.md index 44870ba2a2..9b8850c435 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -196,7 +196,10 @@ not have read is itself the failure — e.g. `write_variable.value` on a secret Global (and flow) initial fixtures can seed `workspace.datatables` so the `list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql` tools return seeded data during evals. Each entry is -`{ datatable_name, schemas: { : { : { columns, rows? } } } }`. +`{ datatable_name, schemas: { : {
: { columns, rows? } } } }`, +plus `migrations_enabled: true` and `migrations: [{ timestamp, name, code_up, code_down?, applied? }]` +for a datatable that keeps its schema as migrations, which the migration tools then +read and write in memory. SQL runs through a small in-memory engine (`datatableSqlEngine.ts`), not a real database. Writes are **stateful within a case**: `CREATE`/`DROP`/`INSERT`/`UPDATE`/ `DELETE` mutate the seeded datatable in place, so a later `list_datatables`, diff --git a/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts index f32bf1e22e..b6af04e6ec 100644 --- a/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts +++ b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts @@ -17,6 +17,7 @@ import { previewLocationLabel, promptSafe, resolvePreviewTab, + slotShowsPage, } from "../../../../../frontend/src/lib/components/sessions/previewRouter"; import type { ArtifactVersionTarget } from "../../../../../frontend/src/lib/components/sessions/previewRouter"; import type { SessionTarget } from "../../../../../frontend/src/lib/components/sessions/sessionState.svelte"; @@ -136,9 +137,8 @@ export function createEvalPreviewPanel(input: { activePreview: () => { const tab = owner.displayedTab; if (!tab) return undefined; - // Artifact and editor tabs are not iframes: they carry no page location, and an - // artifact's pinned version reaches the chat only through get_preview_status. - if (resolvePreviewTab(tab.url).kind !== "iframe") return undefined; + // An artifact's pinned version reaches the chat only through get_preview_status. + if (!slotShowsPage(resolvePreviewTab(tab.url).kind)) return undefined; return previewLocationContext(whereIs(tab)); }, dispose: () => { diff --git a/ai_evals/adapters/frontend/datatableSqlEngine.ts b/ai_evals/adapters/frontend/datatableSqlEngine.ts index 453eca286e..96a62f6804 100644 --- a/ai_evals/adapters/frontend/datatableSqlEngine.ts +++ b/ai_evals/adapters/frontend/datatableSqlEngine.ts @@ -30,6 +30,17 @@ export interface BenchmarkDatatableSeed { [table: string]: BenchmarkDatatableTableSeed } } + /** Whether the datatable keeps its schema as migrations. */ + migrations_enabled?: boolean + migrations?: BenchmarkDatatableMigrationSeed[] +} + +export interface BenchmarkDatatableMigrationSeed { + timestamp: number + name: string + code_up: string + code_down?: string + applied?: boolean } export interface DatatableSqlResult { diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 2f47e85be6..d8284039b9 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -692,10 +692,138 @@ export function listBenchmarkDatatables(workspace: string): DataTableTables[] | datatable_name: datatable.datatable_name, schemas: Object.fromEntries( Object.entries(datatable.schemas).map(([schema, tables]) => [schema, Object.keys(tables)]) - ) + ), + ...(datatable.migrations_enabled && { migrations_enabled: true }) })) } +// ============= Datatable migrations ============= + +function benchmarkDatatable(workspace: string, datatableName: string): BenchmarkDatatableSeed { + const datatable = (benchmarkWorkspaceRunnables.get(workspace)?.datatables ?? []).find( + (entry) => entry.datatable_name === datatableName + ) + if (!datatable) { + throw new Error(`datatable "${datatableName}" not found`) + } + return datatable +} + +/** Run a migration body through the in-memory engine, one statement at a time and without the + * transaction frame, so the tables it creates show up in later reads. */ +function applyBenchmarkMigrationSql(datatable: BenchmarkDatatableSeed, sql: string): void { + // Split on the semicolons outside single-quoted literals. + for (const statement of sql.match(/(?:'(?:[^']|'')*'|[^;'])+/g) ?? []) { + const trimmed = statement.replace(/^\s*--.*$/gm, '').trim() + if (trimmed !== '' && !/^(begin|end|commit)$/i.test(trimmed)) { + applyDatatableSql(datatable, trimmed) + } + } +} + +export function getBenchmarkDatatableMigrationsStatus(input: { + workspace: string + datatableName: string +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + if (!datatable.migrations_enabled) { + return { enabled: false, migrations: [] } + } + return { + enabled: true, + migrations: (datatable.migrations ?? []).map(({ applied, ...migration }) => ({ + ...migration, + status: applied ? ('ran' as const) : ('not_run' as const) + })) + } +} + +export function createBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + requestBody: { name: string; code_up: string; code_down?: string } +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + if (!datatable.migrations_enabled) { + throw new Error( + `Migrations are not enabled for data table '${input.datatableName}'. Enable them first.` + ) + } + const migrations = (datatable.migrations ??= []) + const timestamp = Math.max(20260101000000, ...migrations.map((m) => m.timestamp)) + 1 + migrations.push({ timestamp, ...input.requestBody }) + return { datatable: input.datatableName, timestamp, ...input.requestBody } +} + +export function upsertBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + requestBody: { timestamp: number; name: string; code_up: string; code_down?: string } +}): string { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const migrations = (datatable.migrations ??= []) + const existing = migrations.find((m) => m.timestamp === input.requestBody.timestamp) + if (existing?.applied) { + throw new Error( + `Migration ${existing.timestamp} on data table '${input.datatableName}' has already been applied and cannot be modified` + ) + } + if (existing) { + // Replaced whole, as the server does: a down left out is stored as none. + delete existing.code_down + Object.assign(existing, input.requestBody) + } else { + migrations.push({ ...input.requestBody }) + migrations.sort((a, b) => a.timestamp - b.timestamp) + } + return `Upserted migration ${input.requestBody.timestamp} in ${input.datatableName}` +} + +export function runBenchmarkDatatableMigrations(input: { + workspace: string + datatableName: string + upTo?: number + only?: number +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const applied: { version: number; name: string }[] = [] + for (const migration of datatable.migrations ?? []) { + if ( + migration.applied || + (input.only !== undefined && migration.timestamp !== input.only) || + (input.only === undefined && input.upTo !== undefined && migration.timestamp > input.upTo) + ) { + continue + } + applyBenchmarkMigrationSql(datatable, migration.code_up) + migration.applied = true + applied.push({ version: migration.timestamp, name: migration.name }) + } + return { applied } +} + +export function rollbackBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + only?: number +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const last = (datatable.migrations ?? []) + .filter((m) => m.applied && (input.only === undefined || m.timestamp === input.only)) + .at(-1) + if (!last) { + return { rolled_back: [] } + } + if (last.code_down === undefined) { + throw new Error( + `Cannot roll back migration ${last.timestamp} (${last.name}): it has no down migration` + ) + } + applyBenchmarkMigrationSql(datatable, last.code_down) + last.applied = false + return { rolled_back: [{ version: last.timestamp, name: last.name }] } +} + // ============= DuckLake catalogs and declared metrics ============= /** Seeded DuckLake names, or `null` for a non-benchmark workspace. */ diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 288c67e3eb..cb7469b45e 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -92,6 +92,11 @@ vi.mock('$lib/gen', async () => { getBenchmarkCompletedJob, getBenchmarkCompletedJobResultMaybe, getBenchmarkDatatableSchema, + getBenchmarkDatatableMigrationsStatus, + createBenchmarkDatatableMigration, + upsertBenchmarkDatatableMigration, + runBenchmarkDatatableMigrations, + rollbackBenchmarkDatatableMigration, getBenchmarkDraftForUser, getBenchmarkFlowByPath, getBenchmarkFlowAllResults, @@ -400,7 +405,27 @@ vi.mock('$lib/gen', async () => { schemaName: data.schemaName, tableName: data.tableName }) - : actual.WorkspaceService.getDataTableTableSchema(data) + : actual.WorkspaceService.getDataTableTableSchema(data), + getDatatableMigrationsStatus: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkDatatableMigrationsStatus(data) + : actual.WorkspaceService.getDatatableMigrationsStatus(data), + createDatatableMigration: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? createBenchmarkDatatableMigration(data) + : actual.WorkspaceService.createDatatableMigration(data), + upsertDatatableMigration: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? upsertBenchmarkDatatableMigration(data) + : actual.WorkspaceService.upsertDatatableMigration(data), + runDatatableMigrations: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? runBenchmarkDatatableMigrations(data) + : actual.WorkspaceService.runDatatableMigrations(data), + rollbackDatatableMigrations: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? rollbackBenchmarkDatatableMigration(data) + : actual.WorkspaceService.rollbackDatatableMigrations(data) }), DataMetricService: wrapService(actual.DataMetricService, { listDataMetrics: async (data: { workspace: string }) => diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index b66af3e8dd..4b6704b229 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -901,6 +901,57 @@ - treats creating the table as a normal SQL statement and does not claim a separate registration step is needed - does not write a script to create the table +# The same request on a data table that keeps its schema as migrations: the table is +# created by a migration, since plain SQL would leave it out of the history. +- id: global-datatable-schema-change-uses-migration + prompt: |- + Add a new table called refunds to our workspace data table, with an order id and a refund amount. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - create_datatable_migration + forbiddenToolsUsed: + - write_script + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: create_datatable_migration + field: code_up + stringIncludesAnyOf: + - create table + skipJudge: true + judgeChecklist: + - notices that the data table keeps its schema as migrations + - creates the refunds table through a migration rather than a plain CREATE TABLE statement + - includes an order id and a refund amount column + - gives the migration a down that drops the table + +# Row data is not schema: it is written with plain SQL even where migrations are enabled. +- id: global-datatable-row-change-skips-migration + prompt: |- + Mark order number 2 as shipped in our workspace data table. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json + runtime: + maxTurns: 12 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - exec_datatable_sql + forbiddenToolsUsed: + - create_datatable_migration + - write_script + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - runs an UPDATE on the orders table setting the status of order id 2 to shipped + - does not create a migration for a change to row data + - id: global-test25-datatable-mutate-rows prompt: |- Mark order number 2 as shipped in our workspace data table. diff --git a/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json b/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json new file mode 100644 index 0000000000..d5227d9af8 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json @@ -0,0 +1,36 @@ +{ + "workspace": { + "datatables": [ + { + "datatable_name": "main", + "migrations_enabled": true, + "migrations": [ + { + "timestamp": 20260501090000, + "name": "create_orders", + "code_up": "BEGIN;\n\nCREATE TABLE orders (id serial PRIMARY KEY, customer_id int, total numeric, status text, created_at timestamptz DEFAULT now());\n\nEND;", + "code_down": "BEGIN;\n\nDROP TABLE orders;\n\nEND;", + "applied": true + } + ], + "schemas": { + "public": { + "orders": { + "columns": { + "id": "int4", + "customer_id": "int4", + "total": "numeric", + "status": "text", + "created_at": "timestamptz" + }, + "rows": [ + { "id": 1, "customer_id": 1, "total": 42.5, "status": "shipped", "created_at": "2026-05-01T10:00:00Z" }, + { "id": 2, "customer_id": 2, "total": 19.99, "status": "pending", "created_at": "2026-05-02T11:30:00Z" } + ] + } + } + } + } + ] + } +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1bd38513a7..9ee5635c37 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -864,7 +864,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -903,7 +903,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -960,7 +960,7 @@ dependencies = [ "manyhow 0.14.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -976,7 +976,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1029,11 +1029,11 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ - "aws-lc-sys 0.45.0", + "aws-lc-sys 0.46.0", "untrusted 0.7.1", "zeroize", ] @@ -1052,9 +1052,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -1336,9 +1336,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.12" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -1421,7 +1421,7 @@ dependencies = [ "pin-project-lite", "rustls 0.21.12", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -2010,7 +2010,7 @@ dependencies = [ "prettyplease 0.3.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2034,7 +2034,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2145,7 +2145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c719d56f7e96194cfc53460976d3ba51c85719747c9c62ed99981847b551152b" dependencies = [ "rust_decimal", - "schemars 1.2.2", + "schemars 1.2.3", "serde", "utf8-width", ] @@ -2189,7 +2189,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2366,9 +2366,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -2521,7 +2521,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2793,6 +2793,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -3214,7 +3220,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3269,7 +3275,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4575,9 +4581,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] @@ -4766,7 +4772,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4786,7 +4792,7 @@ dependencies = [ "serde", "smallvec", "strck", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4880,7 +4886,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5181,7 +5187,7 @@ checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5679,7 +5685,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5889,7 +5895,7 @@ checksum = "28f0daa179069133df60523e0f59d0a6bd2d3db9ccae01b630896d269c4a802e" dependencies = [ "attribute-derive", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6735,7 +6741,7 @@ dependencies = [ "hyper-util", "log", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "tokio", "tokio-rustls 0.26.6", "tower-service", @@ -7801,9 +7807,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.29" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" dependencies = [ "cc", "libc", @@ -8037,7 +8043,7 @@ dependencies = [ "manyhow-macros 0.14.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -9972,7 +9978,7 @@ dependencies = [ "md-5 0.11.0", "memchr", "rand 0.10.3", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", ] @@ -10041,7 +10047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10160,14 +10166,15 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha 0.9.0", + "rand 0.10.3", "rand_xorshift", "regex-syntax 0.8.11", "unarray", @@ -10417,7 +10424,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10576,11 +10583,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -10773,7 +10780,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10863,7 +10870,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "serde", "serde_json", @@ -11078,7 +11085,7 @@ dependencies = [ "rand 0.10.3", "reqwest 0.13.5", "rmcp-macros", - "schemars 1.2.2", + "schemars 1.2.3", "serde", "serde_json", "sse-stream", @@ -11102,7 +11109,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11498,9 +11505,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -11548,7 +11555,7 @@ dependencies = [ "log", "once_cell", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-platform-verifier-android", "rustls-webpki 0.103.15", "security-framework 3.7.0", @@ -11735,14 +11742,14 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive 1.2.2", + "schemars_derive 1.2.3", "serde", "serde_json", ] @@ -11761,14 +11768,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", "serde_derive_internals 0.30.0", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11966,7 +11973,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11988,15 +11995,16 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", "indexmap 2.14.2", "itoa", "memchr", @@ -12042,7 +12050,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -12094,7 +12102,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -12110,7 +12118,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -12223,9 +12231,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -12414,9 +12422,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -13345,9 +13353,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -13405,7 +13413,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -13809,7 +13817,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14070,7 +14078,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14261,7 +14269,7 @@ dependencies = [ "httparse", "rand 0.8.5", "ring 0.17.14", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -14383,7 +14391,7 @@ dependencies = [ "percent-encoding", "pin-project", "prost 0.13.5", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "socket2 0.5.10", "tokio", "tokio-rustls 0.26.6", @@ -14664,9 +14672,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +checksum = "0d15f4f5898bcfc5d8a8e45ed30b2f108d710c1a2e5489dbf80e078f707181bc" dependencies = [ "serde", "stable_deref_trait", @@ -14774,7 +14782,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14862,9 +14870,9 @@ checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -15043,9 +15051,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -15224,7 +15232,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -15267,7 +15275,7 @@ checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -15478,7 +15486,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -15568,7 +15576,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.830.0" +version = "1.831.0" dependencies = [ "async-stream", "async-trait", @@ -15603,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15616,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "argon2", @@ -15759,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15782,7 +15790,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15799,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -15825,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.830.0" +version = "1.831.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15835,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15852,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "base64 0.22.1", @@ -15874,7 +15882,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -15897,7 +15905,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15913,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15935,7 +15943,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15957,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15971,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -16007,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16033,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16061,7 +16069,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16083,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16103,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16145,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16174,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.830.0" +version = "1.831.0" dependencies = [ "lazy_static", "serde", @@ -16186,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.830.0" +version = "1.831.0" dependencies = [ "argon2", "axum", @@ -16210,7 +16218,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16224,7 +16232,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16263,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.830.0" +version = "1.831.0" dependencies = [ "chrono", "lazy_static", @@ -16278,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16297,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.830.0" +version = "1.831.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16368,7 +16376,7 @@ dependencies = [ "reqwest-retry", "rsa", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "ryu-js", "schemars 0.8.22", "semver", @@ -16413,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.830.0" +version = "1.831.0" dependencies = [ "chrono", "futures", @@ -16433,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.830.0" +version = "1.831.0" dependencies = [ "regex", "serde", @@ -16450,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16477,7 +16485,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "futures", @@ -16494,7 +16502,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.830.0" +version = "1.831.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16510,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -16523,6 +16531,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "sse-stream", "tokio", "tokio-util", "tracing", @@ -16531,7 +16540,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -16562,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "arc-swap", @@ -16587,7 +16596,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-stream", @@ -16623,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "futures", @@ -16642,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.830.0" +version = "1.831.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16651,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16663,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16675,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "gosyn", @@ -16687,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16699,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16711,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "nu-parser", @@ -16722,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16733,7 +16742,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16745,7 +16754,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16756,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -16779,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16791,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16805,7 +16814,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16822,7 +16831,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16835,7 +16844,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde", @@ -16847,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16865,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16881,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16898,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16912,7 +16921,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -16951,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "const_format", @@ -16989,7 +16998,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.830.0" +version = "1.831.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17000,7 +17009,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -17035,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17059,7 +17068,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17092,7 +17101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17119,7 +17128,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17152,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17172,7 +17181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17206,7 +17215,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17243,7 +17252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17266,7 +17275,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17290,7 +17299,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -17314,7 +17323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17349,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17377,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17402,7 +17411,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17421,7 +17430,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-once-cell", @@ -17483,7 +17492,7 @@ dependencies = [ "rsa", "rust_decimal", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pemfile 2.2.0", "serde", "serde_json", @@ -17540,7 +17549,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.830.0" +version = "1.831.0" dependencies = [ "bytes", "futures", @@ -18083,9 +18092,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "version_check", ] @@ -18260,7 +18269,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -18301,7 +18310,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -18357,7 +18366,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index aaf5a2e8df..a72480cab2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.830.0" +version = "1.831.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.830.0" +version = "1.831.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e82e597fd9..aba51ba117 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -d5971fb924d4fdb849e73da6657021902acaacbd +9cd6a70486df465294f23071fbf383f21ef47ba9 diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 3c4de3b082..b5ebd80e2a 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -445,5 +445,90 @@ "mailboxes.read" ], "req_body_auth": true + }, + "outlook": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Mail.Send", "label": "Send mail" }, + { "value": "Mail.Read", "label": "Read and search mail" }, + { "value": "Mail.ReadWrite", "label": "Read, draft, move and modify mail" }, + { "value": "MailboxSettings.Read", "label": "Read mailbox settings and categories" }, + { "value": "Contacts.Read", "label": "Read contacts" }, + { "value": "Contacts.ReadWrite", "label": "Read, create and edit contacts" }, + { "value": "People.Read", "label": "Search relevant people" }, + { "value": "Calendars.Read", "label": "Read calendars and events" }, + { "value": "Calendars.ReadWrite", "label": "Read, create, edit and delete events" }, + { "value": "Calendars.Read.Shared", "label": "Read shared calendars and find meeting times" }, + { "value": "Mail.Send.Shared", "label": "Send mail from shared mailboxes" }, + { "value": "Mail.Read.Shared", "label": "Read mail in shared mailboxes" }, + { "value": "Mail.ReadWrite.Shared", "label": "Read and modify mail in shared mailboxes" } + ], + "connect_config_template": { + "display_name": "Outlook", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"] + } + }, + "hubspot": { + "auth_url": "https://app.hubspot.com/oauth/authorize", + "token_url": "https://api.hubapi.com/oauth/v1/token", + "scopes": [ + "oauth", + "crm.objects.contacts.read", + "crm.objects.contacts.write", + "crm.objects.companies.read", + "crm.objects.companies.write", + "crm.objects.deals.read", + "crm.objects.deals.write", + "crm.objects.owners.read" + ], + "scope_options": [ + { "value": "oauth", "label": "Basic OAuth access (always required by HubSpot)" }, + { "value": "crm.objects.contacts.read", "label": "Read contacts, and notes, tasks, meetings and calls" }, + { "value": "crm.objects.contacts.write", "label": "Create and edit contacts, and notes, tasks, meetings and calls" }, + { "value": "crm.objects.companies.read", "label": "Read companies" }, + { "value": "crm.objects.companies.write", "label": "Create and edit companies" }, + { "value": "crm.objects.deals.read", "label": "Read deals and deal pipelines" }, + { "value": "crm.objects.deals.write", "label": "Create and edit deals" }, + { "value": "crm.objects.owners.read", "label": "Read owners (users assignable to records)" }, + { "value": "tickets", "label": "Read and edit tickets and ticket pipelines" }, + { "value": "crm.objects.line_items.read", "label": "Read line items" }, + { "value": "crm.objects.line_items.write", "label": "Create and edit line items" }, + { "value": "e-commerce", "label": "Read and edit products and line items" }, + { "value": "crm.objects.quotes.read", "label": "Read quotes" }, + { "value": "crm.objects.quotes.write", "label": "Create and edit quotes" }, + { "value": "crm.objects.leads.read", "label": "Read leads" }, + { "value": "crm.objects.leads.write", "label": "Create and edit leads" }, + { "value": "crm.objects.custom.read", "label": "Read custom object records" }, + { "value": "crm.objects.custom.write", "label": "Create and edit custom object records" }, + { "value": "crm.schemas.custom.read", "label": "Read custom object schemas" }, + { "value": "crm.lists.read", "label": "Read lists (segments)" }, + { "value": "crm.lists.write", "label": "Edit lists and their members" }, + { "value": "sales-email-read", "label": "Read the content of logged one-to-one emails" }, + { "value": "forms", "label": "Read and edit forms and their submissions" }, + { "value": "files", "label": "Read and upload files in the file manager" }, + { "value": "content", "label": "Read and edit marketing emails, blog posts, pages and templates" }, + { "value": "crm.objects.marketing_events.read", "label": "Read marketing events" }, + { "value": "marketing.campaigns.read", "label": "Read marketing campaigns (Marketing Hub Professional)" }, + { "value": "communication_preferences.read_write", "label": "Read and change email subscription preferences" }, + { "value": "automation", "label": "Read, create and enroll into workflows (Professional)" }, + { "value": "automation.sequences.read", "label": "Read sequences and sequence enrollments (Sales Hub Professional)" }, + { "value": "automation.sequences.enrollments.write", "label": "Enroll contacts in sequences (Sales Hub Professional)" }, + { "value": "conversations.read", "label": "Read inboxes, threads and messages" }, + { "value": "conversations.write", "label": "Send messages and update threads" }, + { "value": "scheduler.meetings.meeting-link.read", "label": "Read meeting links and their availability" } + ], + "extra_params": { + "optional_scope": "tickets crm.objects.line_items.read crm.objects.line_items.write e-commerce crm.objects.quotes.read crm.objects.quotes.write crm.objects.leads.read crm.objects.leads.write crm.objects.custom.read crm.objects.custom.write crm.schemas.custom.read crm.lists.read crm.lists.write sales-email-read forms files content crm.objects.marketing_events.read marketing.campaigns.read communication_preferences.read_write automation automation.sequences.read automation.sequences.enrollments.write conversations.read conversations.write scheduler.meetings.meeting-link.read" + }, + "req_body_auth": true } } diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 75c10528df..c0d6aa95d4 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -2,21 +2,22 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.7.5" @@ -24,11 +25,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" dependencies = [ "cfg-if", - "cipher", + "cipher 0.3.0", "cpufeatures 0.2.17", "opaque-debug", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" @@ -389,21 +415,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.21.7" @@ -539,7 +550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e" dependencies = [ "block-padding", - "cipher", + "cipher 0.3.0", ] [[package]] @@ -763,6 +774,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1095,6 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1107,6 +1129,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1163,7 +1194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac41dd49fb554432020d52c875fc290e110113f864c6b1b525cd62c7e7747a5d" dependencies = [ "byteorder", - "cipher", + "cipher 0.3.0", "opaque-debug", ] @@ -1636,10 +1667,14 @@ dependencies = [ ] [[package]] -name = "gimli" -version = "0.32.3" +name = "ghash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] [[package]] name = "git-version" @@ -2101,6 +2136,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -2116,17 +2160,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "io-uring" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -2455,7 +2488,7 @@ version = "3.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c42f95f9d296f2dcb50665f507ed5a68a171453142663ce44d77a4eb217b053" dependencies = [ - "aes", + "aes 0.7.5", "base64 0.21.7", "block-modes", "crc-any", @@ -2619,9 +2652,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -3220,6 +3253,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "postgres-native-tls" version = "0.5.1" @@ -4031,12 +4076,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -5350,34 +5389,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.7", ] [[package]] @@ -5875,6 +5911,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unscanny" version = "0.1.0" @@ -6294,8 +6340,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.830.0" +version = "1.831.0" dependencies = [ + "aes-gcm", "aho-corasick", "anyhow", "arc-swap", @@ -6323,6 +6370,7 @@ dependencies = [ "git-version", "globset", "hex", + "hkdf", "hmac 0.12.1", "hyper", "indexmap", @@ -6382,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.830.0" +version = "1.831.0" dependencies = [ "proc-macro2", "quote", @@ -6394,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.830.0" +version = "1.831.0" dependencies = [ "convert_case", "serde", @@ -6403,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6415,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6427,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "gosyn", @@ -6439,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6451,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6463,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "nu-parser", @@ -6474,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6485,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6497,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6508,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -6531,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6543,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6557,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "convert_case", @@ -6574,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6587,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde", @@ -6599,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6617,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6633,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6650,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6682,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6696,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 8aebf20597..d72119afd4 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.830.0" +version = "1.831.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/tests/app_publisher_permit.rs b/backend/tests/app_publisher_permit.rs new file mode 100644 index 0000000000..e590efaeb2 --- /dev/null +++ b/backend/tests/app_publisher_permit.rs @@ -0,0 +1,40 @@ +//! A refused publisher-mode app run is not remembered: once the caller is granted +//! read access to the app, the very next run in the same process is allowed. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base", "app_publisher_permit"))] +async fn test_publisher_app_refusal_is_not_cached(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = format!( + "http://localhost:{port}/api/w/test-workspace/apps_u/execute_component/u/test-user/papp" + ); + + let run = || async { + let resp = reqwest::Client::new() + .post(&url) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ "component": "c", "path": "script/u/test-user/hello", "args": {} })) + .send() + .await?; + let status = resp.status().as_u16(); + anyhow::Ok((status, resp.text().await?)) + }; + + let (status, body) = run().await?; + assert_eq!(status, 401, "unshared app must be refused: {body}"); + assert!(body.contains("Missing read permissions"), "{body}"); + + sqlx::query("UPDATE app SET extra_perms = '{\"u/test-user-2\": false}' WHERE id = 999101") + .execute(&db) + .await?; + let (status, body) = run().await?; + assert_eq!(status, 200, "a grant must apply to the next run: {body}"); + + Ok(()) +} diff --git a/backend/tests/fixtures/app_publisher_permit.sql b/backend/tests/fixtures/app_publisher_permit.sql new file mode 100644 index 0000000000..c169e53ec5 --- /dev/null +++ b/backend/tests/fixtures/app_publisher_permit.sql @@ -0,0 +1,10 @@ +-- A publisher-mode app owned by `test-user`, not shared with anyone, whose one +-- runnable is a deployed script. + +INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES + ('test-workspace', 'test-user', 'export async function main() { return "ok" }', '{}', '', '', 'u/test-user/hello', 7788101, 'deno', ''); + +INSERT INTO app (id, workspace_id, path, summary, policy, versions) VALUES + (999101, 'test-workspace', 'u/test-user/papp', 'publisher app', + '{"execution_mode": "publisher", "on_behalf_of": "u/test-user", "on_behalf_of_email": "test@windmill.dev", + "triggerables_v2": {"c:script/u/test-user/hello": {"static_inputs": {}, "one_of_inputs": {}}}}'::jsonb, '{}'); diff --git a/backend/tests/fixtures/mcp_resource_authz.sql b/backend/tests/fixtures/mcp_resource_authz.sql index ecf3f01dc3..17fcc553b9 100644 --- a/backend/tests/fixtures/mcp_resource_authz.sql +++ b/backend/tests/fixtures/mcp_resource_authz.sql @@ -23,3 +23,19 @@ VALUES ( '{}', 'test-user' ); + +-- MCP resource whose URL lives in a variable. The host only appears in the +-- variable, so an error naming it proves the reference was resolved. +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'u/test-user/mcp_url', 'https://mcp-from-var.invalid.windmill.test/key', false, '', '{}'); + +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ( + 'test-workspace', + 'u/test-user/var_mcp', + '{"name": "var_mcp", "url": "$var:u/test-user/mcp_url"}', + 'MCP resource with its URL in a variable', + 'mcp', + '{}', + 'test-user' +); diff --git a/backend/tests/mcp_resource_authz.rs b/backend/tests/mcp_resource_authz.rs index 0fa35933f4..6ccf5f4f2d 100644 --- a/backend/tests/mcp_resource_authz.rs +++ b/backend/tests/mcp_resource_authz.rs @@ -23,9 +23,9 @@ use windmill_common::client::AuthedClient; use windmill_test_utils::*; use windmill_worker::{load_mcp_tools, McpResourceConfig}; -fn config() -> Vec { +fn config(resource_path: &str) -> Vec { vec![McpResourceConfig { - resource_path: "$res:f/private/admin_mcp".to_string(), + resource_path: resource_path.to_string(), include_tools: None, exclude_tools: None, }] @@ -34,7 +34,16 @@ fn config() -> Vec { // The Ok variant ((HashMap<_, Arc>, Vec)) does not implement // Debug, so `expect_err` is unavailable — extract the error explicitly. async fn load_err(db: &Pool, client: &AuthedClient, ctx: &str) -> String { - match load_mcp_tools(db, "test-workspace", config(), client).await { + load_err_for(db, client, "$res:f/private/admin_mcp", ctx).await +} + +async fn load_err_for( + db: &Pool, + client: &AuthedClient, + resource_path: &str, + ctx: &str, +) -> String { + match load_mcp_tools(db, "test-workspace", config(resource_path), client).await { Ok(_) => panic!("{ctx}"), Err(e) => e.to_string(), } @@ -100,3 +109,31 @@ async fn test_mcp_resource_not_loaded_without_authorization( Ok(()) } + +/// A `$var:` in the resource's `url` is resolved before connecting, rather than +/// being handed to the client as a literal (and rejected as a relative URL). +#[sqlx::test(fixtures("base", "mcp_resource_authz"))] +async fn test_mcp_resource_url_variable_is_resolved(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let client = AuthedClient::new( + format!("http://localhost:{}", server.addr.port()), + "test-workspace".to_string(), + "SECRET_TOKEN".to_string(), + None, + ); + let msg = load_err_for( + &db, + &client, + "$res:u/test-user/var_mcp", + "the variable's URL is non-resolvable, so the connect step must fail", + ) + .await; + assert!( + msg.contains("mcp-from-var.invalid.windmill.test"), + "the URL should come from the variable: {msg}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 6e4d6a1c71..a470648cbb 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -9,16 +9,11 @@ use windmill_common::DEFAULT_HUB_BASE_URL; #[cfg(feature = "embedding")] use windmill_common::HUB_BASE_URL; -use axum::Router; - -#[cfg(feature = "embedding")] use axum::{ extract::{Path, Query}, - Json, + routing::get, + Extension, Json, Router, }; - -#[cfg(feature = "embedding")] -use axum::routing::get; #[cfg(feature = "embedding")] use candle_core::{Device, Tensor}; #[cfg(feature = "embedding")] @@ -27,10 +22,7 @@ use candle_nn::VarBuilder; use candle_transformers::models::bert::{BertModel, Config, DTYPE}; #[cfg(feature = "embedding")] use hf_hub::api::tokio::Api; -#[cfg(feature = "embedding")] -use serde::Deserialize; -#[cfg(feature = "embedding")] -use serde::Serialize; +use serde::{Deserialize, Serialize}; #[cfg(feature = "embedding")] use sqlx::{Pool, Postgres}; #[cfg(feature = "embedding")] @@ -44,9 +36,7 @@ use tokenizers::Tokenizer; use tokio::sync::RwLock; #[cfg(feature = "embedding")] use windmill_common::utils::http_get_from_hub; - -#[cfg(feature = "embedding")] -use windmill_common::error::JsonResult; +use windmill_common::{db::DB, error::JsonResult}; #[cfg(feature = "embedding")] use windmill_store::resources::ResourceType; @@ -60,6 +50,7 @@ lazy_static::lazy_static! { // full pulling interval, so a transient startup error doesn't leave the // embeddings DB uninitialized for a whole day. pub static ref HUB_EMBEDDINGS_RETRY_INTERVAL_SECS: u64 = std::env::var("HUB_EMBEDDINGS_RETRY_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(60); + pub static ref RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS: u64 = std::env::var("RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(60); } #[cfg(feature = "embedding")] @@ -104,14 +95,12 @@ async fn query_hub_scripts( } } -#[cfg(feature = "embedding")] #[derive(Deserialize)] struct ResourceTypesQuery { text: String, limit: Option, } -#[cfg(feature = "embedding")] #[derive(Serialize)] pub struct ResourceTypeResult { name: String, @@ -138,24 +127,107 @@ fn trim_to_top_score( .take_while(|r| (top_score - score(r)) / top_score <= max_relative_drop) .collect() } -#[cfg(feature = "embedding")] async fn query_resource_types( + Extension(db): Extension, Query(query): Query, Path(w_id): Path, ) -> JsonResult> { - let embeddings_db = EMBEDDINGS_DB.read().await; - - if let Some(embeddings_db) = embeddings_db.as_ref() { - let results = embeddings_db - .query_resource_types(w_id, &query.text, query.limit) - .await?; - - Ok(Json(results)) - } else { - Err(windmill_common::error::Error::internal_err( - "Embeddings db not initialized".to_string(), - )) + #[cfg(feature = "embedding")] + if let Some(embeddings_db) = EMBEDDINGS_DB.read().await.as_ref() { + return Ok(Json( + embeddings_db + .query_resource_types(w_id, &query.text, query.limit) + .await?, + )); } + Ok(Json( + match_resource_types_by_text(&db, &w_id, &query.text, query.limit).await?, + )) +} + +/// Serves the search when there is no embeddings index: a build without the `embedding` +/// feature, or one whose index never loaded (air-gapped, `DISABLE_EMBEDDING`). The AI chat +/// has no other way to read the instance's resource types. +async fn match_resource_types_by_text( + db: &DB, + w_id: &str, + text: &str, + limit: Option, +) -> windmill_common::error::Result> { + let words: Vec = text + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect(); + let rows: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT workspace_id, name, description FROM resource_type WHERE workspace_id = $1 OR workspace_id = 'admins'", + ) + .bind(w_id) + .fetch_all(db) + .await?; + + let mut ranked: Vec<(f32, String, String)> = rows + .into_iter() + .map(|(workspace_id, name, description)| { + let score = text_match_score(&words, &name, description.as_deref().unwrap_or_default()); + (score, workspace_id, name) + }) + .filter(|(score, _, _)| *score > 0.0) + .collect(); + ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.2.cmp(&b.2))); + ranked.truncate(limit.unwrap_or(10).max(0) as usize); + + // Schemas are read for the kept rows only: a synced instance holds megabytes of them. + let (workspaces, names): (Vec<&str>, Vec<&str>) = ranked + .iter() + .map(|(_, workspace_id, name)| (workspace_id.as_str(), name.as_str())) + .unzip(); + let mut schemas: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT workspace_id, name, schema FROM resource_type WHERE (workspace_id, name) IN (SELECT * FROM unnest($1::text[], $2::text[]))", + ) + .bind(&workspaces) + .bind(&names) + .fetch_all(db) + .await?; + + Ok(ranked + .into_iter() + .map(|(score, workspace_id, name)| { + let schema = schemas + .iter_mut() + .find(|(w, n, _)| *w == workspace_id && *n == name) + .and_then(|(_, _, schema)| schema.take()); + ResourceTypeResult { score, name, schema } + }) + .collect()) +} + +/// A word found in the name counts twice a word found in the description: names are what +/// a query for a service spells out ("postgres" in `postgresql`, "sheets" in `gsheets`). +/// A word under 3 characters ("s3", "ai") would sit inside too many names, so it counts only +/// as a whole name or one of its `_`-separated parts. +fn text_match_score(words: &[String], name: &str, description: &str) -> f32 { + let name = name.to_lowercase(); + let description = description.to_lowercase(); + words + .iter() + .map(|word| { + let short = word.len() < 3; + let in_name = if short { + name.split('_').any(|part| part == word) + } else { + name.contains(word.as_str()) + }; + if in_name { + 2.0 + } else if !short && description.contains(word.as_str()) { + 1.0 + } else { + 0.0 + } + }) + .sum() } #[cfg(feature = "embedding")] @@ -288,17 +360,60 @@ impl ModelInstance { pub struct EmbeddingsDb { db: Db, model_instance: Arc, + /// Kept from the last full fill, so the resource types can be re-indexed between fills + /// without downloading the hub's embeddings again. + hub_resource_type_embeddings: Arc>>, + /// The `resource_types_fingerprint` the resource types collection was built from. + resource_types_fingerprint: Option, + /// See `ResourceTypeIndex::custom_vectors`. + custom_resource_type_vectors: Arc>>, +} + +/// A built resource types collection, with what a later refresh needs to tell it is stale and +/// to reuse its vectors. +#[cfg(feature = "embedding")] +struct ResourceTypeIndex { + fingerprint: Option, + embeddings: Vec, + /// Vectors of the types the hub has none for, by the `name;description` they embed: a + /// refresh runs the model only on types that are new or changed, not on every custom type. + custom_vectors: Arc>>, } #[cfg(feature = "embedding")] impl EmbeddingsDb { + fn empty(model_instance: Arc) -> Self { + Self { + db: Db::new(), + model_instance, + hub_resource_type_embeddings: Arc::new(HashMap::new()), + resource_types_fingerprint: None, + custom_resource_type_vectors: Arc::new(HashMap::new()), + } + } + pub async fn new(pg_db: &Pool, model_instance: Arc) -> Result { - let db = Db::new(); - - let mut embeddings_db = Self { db, model_instance: model_instance.clone() }; - + let mut embeddings_db = Self::empty(model_instance); embeddings_db.fill_db(pg_db).await?; + Ok(embeddings_db) + } + /// The resource types alone, all embedded locally, for when the full fill cannot download + /// the hub's embeddings: an air-gapped instance, or a private hub serving none. Hub script + /// search keeps failing, since it has no collection to search. + async fn resource_types_only( + pg_db: &Pool, + model_instance: Arc, + ) -> Result { + let mut embeddings_db = Self::empty(model_instance); + let index = resource_type_index( + pg_db, + &embeddings_db.model_instance, + &embeddings_db.hub_resource_type_embeddings, + &embeddings_db.custom_resource_type_vectors, + ) + .await?; + embeddings_db.replace_resource_types(index)?; Ok(embeddings_db) } @@ -310,13 +425,6 @@ impl EmbeddingsDb { self.db .create_collection("scripts".to_string(), 384, Distance::Cosine)?; - if self.db.get_collection("resource_types").is_some() { - self.db.delete_collection("resource_types")?; - } - - self.db - .create_collection("resource_types".to_string(), 384, Distance::Cosine)?; - let hub_base_url = (**HUB_BASE_URL.load()).clone(); let response = match hub_base_url.as_str() { @@ -425,44 +533,40 @@ impl EmbeddingsDb { )); } let hub_resource_types = response.json::>().await?; + self.hub_resource_type_embeddings = Arc::new( + hub_resource_types + .into_iter() + .map(|rt| (rt.name, rt.embedding)) + .collect(), + ); - let resource_types: Vec = - sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) - .fetch_all(pg_db) - .await?; + let index = resource_type_index( + pg_db, + &self.model_instance, + &self.hub_resource_type_embeddings, + &self.custom_resource_type_vectors, + ) + .await?; + self.replace_resource_types(index) + } - for rt in resource_types { - let mut hm = HashMap::new(); - hm.insert("name".to_string(), rt.name.clone()); - if let Some(schema) = rt.schema.clone() { - hm.insert("schema".to_string(), serde_json::to_string(&schema)?); - } - hm.insert("workspace".to_string(), rt.workspace_id.clone()); - let hub_rt = hub_resource_types.iter().find(|hrt| hrt.name == rt.name); - - let vector = if let Some(hub_rt) = hub_rt { - hub_rt.embedding.clone() - } else { - self.model_instance - .clone() - .create_embedding(&format!( - "{};{}", - rt.name, - rt.description.unwrap_or_default() - )) - .await? - }; - - let embedding = Embedding { - id: format!("{}_{}", rt.workspace_id, rt.name), - vector, - metadata: Some(hm), - }; - - self.db - .insert_into_collection("resource_types", embedding)?; + fn replace_resource_types(&mut self, index: ResourceTypeIndex) -> Result<()> { + // Built aside and swapped in whole: an insert the collection refuses must not leave + // searches on a half-filled index. + const NEXT: &str = "resource_types_next"; + self.db.collections.remove(NEXT); + self.db + .create_collection(NEXT.to_string(), 384, Distance::Cosine)?; + for embedding in index.embeddings { + self.db.insert_into_collection(NEXT, embedding)?; } - + if let Some(next) = self.db.collections.remove(NEXT) { + self.db + .collections + .insert("resource_types".to_string(), next); + } + self.resource_types_fingerprint = index.fingerprint; + self.custom_resource_type_vectors = index.custom_vectors; Ok(()) } @@ -617,6 +721,101 @@ impl EmbeddingsDb { } } +/// Covers everything an indexed resource type is built from, whichever path wrote it: the +/// create and update routes, a hub sync's direct upserts, a delete. +/// +/// A sum of per-row hashes needs no sort. Don't go back to an ORDER BY aggregate: sorting +/// every schema spills to disk once they exceed work_mem, on every check of every server. +#[cfg(feature = "embedding")] +async fn resource_types_fingerprint(pg_db: &Pool) -> Result> { + Ok(sqlx::query_scalar( + "SELECT count(*)::text || ':' || coalesce(sum(('x' || left(md5(workspace_id || '/' || name || '/' || coalesce(description, '') || '/' || coalesce(schema::text, '')), 15))::bit(60)::bigint), 0)::text FROM resource_type", + ) + .fetch_one(pg_db) + .await?) +} + +/// The fingerprint is read before the rows: a write landing in between leaves it stale, so +/// the next check indexes again rather than keeping an index that misses the write. +#[cfg(feature = "embedding")] +async fn resource_type_index( + pg_db: &Pool, + model_instance: &Arc, + hub_embeddings: &HashMap>, + known_custom_vectors: &HashMap>, +) -> Result { + let fingerprint = resource_types_fingerprint(pg_db).await?; + let resource_types: Vec = + sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) + .fetch_all(pg_db) + .await?; + + let mut embeddings = Vec::with_capacity(resource_types.len()); + let mut custom_vectors = HashMap::new(); + for rt in resource_types { + let mut hm = HashMap::new(); + hm.insert("name".to_string(), rt.name.clone()); + if let Some(schema) = rt.schema.clone() { + hm.insert("schema".to_string(), serde_json::to_string(&schema)?); + } + hm.insert("workspace".to_string(), rt.workspace_id.clone()); + + let vector = match hub_embeddings.get(&rt.name) { + Some(vector) => vector.clone(), + None => { + let text = format!("{};{}", rt.name, rt.description.unwrap_or_default()); + let vector = match known_custom_vectors + .get(&text) + .or_else(|| custom_vectors.get(&text)) + { + Some(vector) => vector.clone(), + None => model_instance.clone().create_embedding(&text).await?, + }; + custom_vectors.insert(text, vector.clone()); + vector + } + }; + + embeddings.push(Embedding { + // Neither part can hold a `/`, so two types never share an id; with `_` between + // them, `acme_c` + `c_x` and `acme` + `c_c_x` would. + id: format!("{}/{}", rt.workspace_id, rt.name), + vector, + metadata: Some(hm), + }); + } + Ok(ResourceTypeIndex { fingerprint, embeddings, custom_vectors: Arc::new(custom_vectors) }) +} + +/// The full fill runs once a day, so a resource type created or synced in between would stay +/// unsearchable until then; this re-indexes the resource types alone when the table changed. +#[cfg(feature = "embedding")] +pub async fn refresh_resource_type_embeddings(pg_db: &Pool) -> Result<()> { + if EMBEDDINGS_DB.read().await.is_none() { + return Ok(()); + } + let fingerprint = resource_types_fingerprint(pg_db).await?; + let (model_instance, hub_embeddings, custom_vectors) = match EMBEDDINGS_DB.read().await.as_ref() + { + Some(db) if db.resource_types_fingerprint != fingerprint => ( + db.model_instance.clone(), + db.hub_resource_type_embeddings.clone(), + db.custom_resource_type_vectors.clone(), + ), + _ => return Ok(()), + }; + let index = + resource_type_index(pg_db, &model_instance, &hub_embeddings, &custom_vectors).await?; + if let Some(db) = EMBEDDINGS_DB.write().await.as_mut() { + // A full fill that landed meanwhile indexed the types from newer hub vectors than + // the ones this index was built from. + if Arc::ptr_eq(&db.hub_resource_type_embeddings, &hub_embeddings) { + db.replace_resource_types(index)?; + } + } + Ok(()) +} + #[cfg(feature = "embedding")] fn normalize_l2(v: &Tensor) -> Result { Ok(v.broadcast_div(&v.sqr()?.sum_keepdim(1)?.sqrt()?)?) @@ -630,6 +829,19 @@ pub fn load_embeddings_db(db: &Pool) -> () { .unwrap_or(false); if !disable_embedding { + let resource_types_db = db.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs( + *RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS, + )) + .await; + if let Err(e) = refresh_resource_type_embeddings(&resource_types_db).await { + tracing::warn!("Failed to refresh resource type embeddings: {e:#}"); + } + } + }); + let db_clone = db.clone(); tokio::spawn(async move { // Keep retrying model init: a transient failure here must not @@ -664,6 +876,9 @@ pub fn load_embeddings_db(db: &Pool) -> () { backoff_secs = *HUB_EMBEDDINGS_RETRY_INTERVAL_SECS; *HUB_EMBEDDINGS_PULLING_INTERVAL_SECS } else { + if let Err(e) = index_resource_types_alone(&db_clone).await { + tracing::warn!("Failed to index the resource types alone: {e:#}"); + } let secs = backoff_secs; backoff_secs = backoff_secs .saturating_mul(2) @@ -696,7 +911,29 @@ pub async fn update_embeddings_db(db: &Pool) -> bool { } } +/// After a failed full fill, so a fill still downloading at startup is not raced. A full fill +/// that succeeds later replaces this index. #[cfg(feature = "embedding")] +async fn index_resource_types_alone(pg_db: &Pool) -> Result<()> { + if EMBEDDINGS_DB.read().await.is_some() { + return Ok(()); + } + let Some(model_instance) = MODEL_INSTANCE.read().await.clone() else { + return Ok(()); + }; + let started = std::time::Instant::now(); + let embeddings_db = EmbeddingsDb::resource_types_only(pg_db, model_instance).await?; + let mut current = EMBEDDINGS_DB.write().await; + if current.is_none() { + *current = Some(embeddings_db); + tracing::info!( + "Indexed the resource types alone in {:?}, without the hub's embeddings", + started.elapsed() + ); + } + Ok(()) +} + pub fn workspaced_service() -> Router { Router::new().route("/query_resource_types", get(query_resource_types)) } @@ -706,11 +943,6 @@ pub fn global_service() -> Router { Router::new().route("/query_hub_scripts", get(query_hub_scripts)) } -#[cfg(not(feature = "embedding"))] -pub fn workspaced_service() -> Router { - Router::new() -} - #[cfg(not(feature = "embedding"))] pub fn global_service() -> Router { Router::new() @@ -768,3 +1000,34 @@ mod tests { assert_eq!(trim_to_top_score(vec![0.42f32], 0.05, |s| *s), vec![0.42]); } } + +#[cfg(test)] +mod text_match_tests { + use super::text_match_score; + + // A query names the service, which a type's name only contains ("postgres" in + // `postgresql`): an exact comparison finds nothing for "postgres database". + #[test] + fn ranks_a_name_match_above_a_description_match() { + let words = vec!["postgres".to_string(), "database".to_string()]; + assert_eq!( + text_match_score(&words, "PostgreSQL", "PostgreSQL connection"), + 2.0 + ); + assert_eq!( + text_match_score(&words, "mysql", "MySQL database credentials"), + 1.0 + ); + assert_eq!(text_match_score(&words, "slack", "Slack bot token"), 0.0); + } + + // Many services go by a short name; matching it inside other names would rank `ms365` + // with `s3`. + #[test] + fn matches_a_short_word_only_as_a_whole_name_part() { + let words = vec!["s3".to_string()]; + assert_eq!(text_match_score(&words, "s3", "Amazon S3 bucket"), 2.0); + assert_eq!(text_match_score(&words, "aws_s3", ""), 2.0); + assert_eq!(text_match_score(&words, "ms365", "mentions s3"), 0.0); + } +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 9c167ef70c..823c415818 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -751,6 +751,65 @@ async fn test_resource_cache_handles_job_context(db: Pool) -> anyhow:: Ok(()) } +/// Without an embeddings index the resource type search reads the table, so it has to apply the +/// same scope as the index: the workspace's own types and the global ones in `admins`, never +/// another workspace's. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_resource_type_search_without_embeddings(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')") + .execute(&db) + .await?; + for (workspace, name, description) in [ + ( + "test-workspace", + "c_acme_billing", + "Acme internal billing API", + ), + ( + "other-workspace", + "c_other_billing", + "Another workspace's billing API", + ), + ] { + sqlx::query( + "INSERT INTO resource_type (workspace_id, name, schema, description, created_by) VALUES ($1, $2, '{}'::jsonb, $3, 'test-user')", + ) + .bind(workspace) + .bind(name) + .bind(description) + .execute(&db) + .await?; + } + + let search = |text: &str| { + let url = format!( + "http://localhost:{port}/api/w/test-workspace/embeddings/query_resource_types?text={text}" + ); + async move { + let resp = authed(client().get(url)).send().await.unwrap(); + assert_eq!(resp.status(), 200); + resp.json::>() + .await + .unwrap() + .into_iter() + .map(|rt| rt["name"].as_str().unwrap().to_string()) + .collect::>() + } + }; + + let billing = search("billing").await; + assert_eq!(billing.first().map(String::as_str), Some("c_acme_billing")); + assert!(!billing.contains(&"c_other_billing".to_string())); + // `ai_skill` is seeded in `admins` by the migrations. + assert!(search("skill").await.contains(&"ai_skill".to_string())); + + Ok(()) +} + #[cfg(feature = "mcp")] #[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))] async fn test_mcp_tools(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index e63e69d4d5..21e460359e 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -880,7 +880,11 @@ struct DatatableMigrationsStatusResult { /// Whether the SQL-migrations feature is enabled for a data table. Honors the /// explicit `migrations_enabled` flag; when unset (data tables predating the /// feature) it is considered enabled only if migrations already exist. -async fn datatable_migrations_enabled(db: &DB, w_id: &str, datatable_name: &str) -> Result { +pub(crate) async fn datatable_migrations_enabled( + db: &DB, + w_id: &str, + datatable_name: &str, +) -> Result { let flag: Option = sqlx::query_scalar!( "SELECT (ws.datatable->'datatables'->$2->>'migrations_enabled')::boolean \ FROM workspace_settings ws WHERE ws.workspace_id = $1", diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 3bfcef5c96..bdb3b59e6e 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2325,6 +2325,9 @@ struct DataTableTables { /// What the role the listing connected as may create. can_create_schema: bool, creatable_schemas: Vec, + /// Whether its schema is kept as versioned migrations, which a change made with plain SQL + /// would bypass. + migrations_enabled: bool, } #[derive(Deserialize)] @@ -2570,8 +2573,17 @@ async fn list_one_datatable_tables( default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(), can_create_schema: false, creatable_schemas: vec![], + migrations_enabled: false, }; let result: Result<()> = async { + // Read from the workspace settings alone, so it is known even when the database is not + // reachable. + entry.migrations_enabled = crate::datatable_migrations::datatable_migrations_enabled( + db, + w_id, + &entry.datatable_name, + ) + .await?; let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?; entry.instance = governing.role_cluster().is_some(); let usable = diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 433d7c00be..5295c5765f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.830.0 + version: 1.831.0 title: Windmill API contact: @@ -37336,6 +37336,7 @@ components: default_role, can_create_schema, creatable_schemas, + migrations_enabled, ] properties: datatable_name: @@ -37369,6 +37370,9 @@ components: description: the schemas the role the listing connected as may create in items: type: string + migrations_enabled: + type: boolean + description: whether its schema is kept as versioned migrations, which a change made with plain SQL would bypass DataTableTableSchema: type: object diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 2c2179d830..db8a38f2dd 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4734,34 +4734,48 @@ async fn execute_component( if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode(), opt_authed.as_ref()) { lazy_static! { - /// Cache for the permit to execute an app component. - static ref PERMIT_CACHE: cache::Cache<[u8; 32], bool> = cache::Cache::new(1000); + /// When each (caller, app) was last granted the permit to execute. + static ref PERMIT_CACHE: cache::Cache<[u8; 32], std::time::Instant> = + cache::Cache::new(1000); } + // The outcome follows the app's `extra_perms` and the caller's groups and + // folders, all mutable and absent from the key. Only grants are cached, and + // briefly: a refusal is rechecked every time so a new grant applies at once, + // and a revocation takes effect within the TTL. + const PERMIT_TTL: std::time::Duration = std::time::Duration::from_secs(60); - // Avoid allocation for the permit key using a sha256 hash of: - // - the user email, - // - the application path, - // - the workspace id. - let permit_key: [u8; 32] = [authed.email.as_bytes(), path.as_bytes(), &w_id.as_bytes()] + let permit_key: [u8; 32] = [authed.email.as_bytes(), path.as_bytes(), w_id.as_bytes()] .iter() - .fold(Sha256::new(), |hasher, bytes| hasher.chain_update(bytes)) + .fold(Sha256::new(), |hasher, bytes| { + hasher + .chain_update((bytes.len() as u32).to_be_bytes()) + .chain_update(bytes) + }) .finalize() .into(); - let permit_fut = PERMIT_CACHE.get_or_insert_async(&permit_key, async { - let mut tx = user_db.clone().begin(authed).await?; - // Permissions are checked by the database; just fetch a row from app using `user_db`: - let row = sqlx::query_scalar!( - "SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2 LIMIT 1", - path, - &w_id, - ) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - Result::Ok(row.is_some_and(|x| x.is_some())) - }); - if !permit_fut.await? { + let granted = match PERMIT_CACHE.get(&permit_key) { + Some(at) if at.elapsed() < PERMIT_TTL => true, + _ => { + let mut tx = user_db.clone().begin(authed).await?; + // Permissions are checked by the database; just fetch a row from app using `user_db`: + let row = sqlx::query_scalar!( + "SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2 LIMIT 1", + path, + &w_id, + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + let granted = row.is_some_and(|x| x.is_some()); + if granted { + PERMIT_CACHE.insert(permit_key, std::time::Instant::now()); + } + granted + } + }; + + if !granted { return Err(Error::NotAuthorized(format!( "Missing read permissions on the `{}` app to execute `{}` runnable", path, payload.component diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index 9619e96ebc..21b738891e 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -17,7 +17,9 @@ use tokio::task::JoinHandle; pub use windmill_common::db::DB; use windmill_common::{ error::Error, + global_settings::INSTANCE_PYTHON_VERSION_SETTING, utils::{generate_lock_id, GIT_VERSION}, + worker::PyVAlias, }; #[allow(unused_imports)] @@ -298,6 +300,12 @@ pub async fn migrate( let migrator = db.acquire().await?; let mut custom_migrator = CustomMigrator { inner: migrator }; + let new_instance = + sqlx::query_scalar::<_, bool>("SELECT to_regclass('_sqlx_migrations') IS NULL") + .fetch_one(custom_migrator.connection()) + .await + .unwrap_or(false); + if let Err(err) = sqlx::query!( "DELETE FROM _sqlx_migrations WHERE version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR @@ -372,12 +380,55 @@ pub async fn migrate( } crate::live_migrations::custom_migrations(&mut custom_migrator).await?; + if let Err(e) = seed_instance_python_version(custom_migrator.connection(), new_instance).await { + tracing::error!("Could not record the instance python version: {e:#}"); + } Ok(Some(crate::live_migrations::spawn_background_migrations( db.clone(), killpill_rx, ))) } +/// Gives `instance_python_version` a concrete value when the instance has none, so the version +/// scripts resolve around is recorded per instance instead of following whatever a release +/// compiles in. Only the process that created the schema knows the instance is new, so its value +/// replaces what a server that started alongside it wrote; every other start fills in the frozen +/// default, and only where no version was chosen (`default` is what the settings page stored +/// for that). +/// +/// Runs on the migration connection: a `DATABASE_CONNECTIONS=1` pool has no second one to give. +async fn seed_instance_python_version( + conn: &mut PgConnection, + new_instance: bool, +) -> Result<(), Error> { + let (version, on_conflict) = if new_instance { + ( + PyVAlias::NEW_INSTANCE, + "DO UPDATE SET value = EXCLUDED.value", + ) + } else { + ( + PyVAlias::default(), + "DO UPDATE SET value = EXCLUDED.value WHERE global_settings.value = '\"default\"'::jsonb", + ) + }; + // The stored setting outranks the `INSTANCE_PYTHON_VERSION` environment variable, so an + // instance configured through it would otherwise be moved off its version by this write. + let version = std::env::var("INSTANCE_PYTHON_VERSION") + .ok() + .filter(|v| !v.is_empty() && v.split('.').all(|part| part.parse::().is_ok())) + .unwrap_or_else(|| version.version_string()); + sqlx::query(&format!( + "INSERT INTO global_settings (name, value) VALUES ($1, to_jsonb($2::text)) + ON CONFLICT (name) {on_conflict}" + )) + .bind(INSTANCE_PYTHON_VERSION_SETTING) + .bind(version) + .execute(conn) + .await?; + Ok(()) +} + pub async fn wait_for_migrations( db: &DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 2b93c6daeb..778f504c54 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -1908,7 +1908,7 @@ async fn require_job_access( // are immutable after creation, and every mutable caller-side input // (is_admin / username / username_override / groups / folders) is folded into // the key — so a permission change yields a new key rather than a stale hit, and - // no TTL is needed (size-bounded LRU; mirrors apps' PERMIT_CACHE). + // no TTL is needed (size-bounded LRU). let cache_key = job_read_access_cache_key(authed, w_id, job_id); let visible = if let Some(visible) = JOB_READ_ACCESS_CACHE.get(&cache_key) { visible diff --git a/backend/windmill-api/src/mcp_tools.rs b/backend/windmill-api/src/mcp_tools.rs index 1e07f374de..a9049951a3 100644 --- a/backend/windmill-api/src/mcp_tools.rs +++ b/backend/windmill-api/src/mcp_tools.rs @@ -125,8 +125,10 @@ async fn connect_mcp_client( None }; - // Header secrets go through the same permissioned path as the token. - for value in mcp_resource.headers.iter_mut().flatten().map(|(_, v)| v) { + // The URL and header secrets go through the same permissioned path as the token. + for value in std::iter::once(&mut mcp_resource.url) + .chain(mcp_resource.headers.iter_mut().flatten().map(|(_, v)| v)) + { if let Some(var_path) = value.strip_prefix("$var:") { check_scopes(authed, || format!("variables:read:{}", var_path))?; let db_authed = DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone())); diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 7c76fab2ee..d19de41dcc 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1063,6 +1063,7 @@ pub const PROTECTED_SETTINGS: &[&str] = &[ "rsa_keys", "jwt_secret", "min_keep_alive_version", + "instance_python_version", // Deleting it would let a worker sign OIDC tokens with a local key again. "oidc_external_signer", ]; diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 58223c5d4a..30eb2403b1 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -2891,6 +2891,9 @@ pub fn lockfile_line_has_continuation(line: &str) -> bool { pub enum PyVAlias { Py310 = 10, Py311, + // Frozen: what a lock with no `# py:` line runs on, and what the server writes into + // `instance_python_version` for an instance that never chose one. Raise `NEW_INSTANCE` + // instead, which existing instances never pick up. #[default] Py312, Py313, @@ -2911,6 +2914,11 @@ impl Into for PyVAlias { } impl PyVAlias { + /// What the server writes into `instance_python_version` when it initializes an instance. + /// Raising it also means the images must keep preinstalling every earlier value, or + /// instances that started on one download their runtime on first use. + pub const NEW_INSTANCE: PyVAlias = PyVAlias::Py313; + pub fn all>() -> Vec { use PyVAlias::*; vec![ @@ -2922,6 +2930,11 @@ impl PyVAlias { Py315.into(), ] } + /// `MAJOR.MINOR`, the form the instance settings store. + pub fn version_string(self) -> String { + format!("{}.{}", self.major(), self as u32) + } + // Get MAJOR part of alias. (semver: MAJOR.MINOR.PATCH) fn major(&self) -> u32 { use PyVAlias::*; diff --git a/backend/windmill-mcp/Cargo.toml b/backend/windmill-mcp/Cargo.toml index 8ac23e885d..2c07b92d44 100644 --- a/backend/windmill-mcp/Cargo.toml +++ b/backend/windmill-mcp/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true serde_json.workspace = true tracing.workspace = true rmcp.workspace = true +sse-stream = "0.2" sqlx = { workspace = true, optional = true } chrono = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index 5d13a40314..6f3928e112 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -3,11 +3,13 @@ //! This module provides functionality for connecting to external MCP servers //! and executing tools on them. +mod redacting; mod types; pub use types::{McpResource, McpToolSource}; use anyhow::{Context, Result}; +use redacting::UrlRedactingClient; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use rmcp::{ model::{ @@ -97,7 +99,8 @@ impl McpClient { // Create the HTTP transport with custom client let config = StreamableHttpClientTransportConfig::with_uri(resource.url.as_str()); - let transport = StreamableHttpClientTransport::with_client(reqwest_client, config); + let transport = + StreamableHttpClientTransport::with_client(UrlRedactingClient(reqwest_client), config); // Set up client info let client_info = ClientInfo::new( diff --git a/backend/windmill-mcp/src/client/redacting.rs b/backend/windmill-mcp/src/client/redacting.rs new file mode 100644 index 0000000000..6abcc50f50 --- /dev/null +++ b/backend/windmill-mcp/src/client/redacting.rs @@ -0,0 +1,151 @@ +use std::{borrow::Cow, collections::HashMap, future::Future, sync::Arc}; + +use futures::stream::BoxStream; +use reqwest::header::{HeaderName, HeaderValue}; +use rmcp::{ + model::ClientJsonRpcMessage, + transport::streamable_http_client::{ + SseError, StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::Sse; +use tracing::{instrument::WithSubscriber, Dispatch}; + +type Error = StreamableHttpError; + +/// reqwest names the URL it failed on in its errors, and an MCP server URL can +/// carry the server's key. rmcp logs those errors as well as returning them, so +/// the URL is dropped before the transport sees one, and rmcp's reqwest client, +/// which logs the errors it swallows, runs without a subscriber. +#[derive(Clone)] +pub(super) struct UrlRedactingClient(pub(super) reqwest::Client); + +async fn redacted(request: impl Future>) -> Result { + match request.with_subscriber(Dispatch::none()).await { + Err(StreamableHttpError::Client(e)) => Err(StreamableHttpError::Client(e.without_url())), + // An owned message is `HTTP : `, and the body is the server's + // to write: a common one echoes the request target (`Cannot POST /mcp/`). + Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned(message))) => { + let status = message.split_once(": ").map_or("", |(status, _)| status); + let status = status.to_string(); + Err(StreamableHttpError::UnexpectedServerResponse(status.into())) + } + r => r, + } +} + +impl StreamableHttpClient for UrlRedactingClient { + type Error = reqwest::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + ) -> Result { + redacted( + self.0 + .post_message(uri, message, session_id, auth_header, custom_headers), + ) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result { + redacted(self.0.post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_header, + custom_headers, + max_sse_event_size, + )) + .await + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> Result<(), Error> { + redacted( + self.0 + .delete_session(uri, session_id, auth_header, custom_headers), + ) + .await + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + ) -> Result>, Error> { + redacted( + self.0 + .get_stream(uri, session_id, last_event_id, auth_header, custom_headers), + ) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result>, Error> { + redacted(self.0.get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_header, + custom_headers, + max_sse_event_size, + )) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn server_error_body_is_dropped() { + for (message, expected) in [ + ( + "HTTP 404 Not Found: Cannot POST /mcp/KEY", + "HTTP 404 Not Found", + ), + ("Cannot POST /mcp/KEY", ""), + ] { + let err = redacted(async { + Err::<(), Error>(StreamableHttpError::UnexpectedServerResponse( + message.to_string().into(), + )) + }) + .await + .unwrap_err(); + let StreamableHttpError::UnexpectedServerResponse(got) = err else { + panic!("unexpected variant: {err}"); + }; + assert_eq!(got, expected); + } + } +} diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 15f5ac6b9e..9768911813 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -790,7 +790,26 @@ pub async fn exchange_code( .with_client(http_client) .execute::() .await - .map_err(|e| error::Error::InternalErr(format!("{:?}", e))) + .map_err(|e| error::Error::InternalErr(token_error_message(&e))) +} + +/// Message for a failed token request. Some providers (Zoho) answer an OAuth error with +/// HTTP 200, which the client reports as an unparseable token response, not an error one. +pub fn token_error_message(e: &ExecuteError) -> String { + match e { + ExecuteError::ErrorResponse { error, .. } => error.to_string(), + ExecuteError::BadResponse { status, body, .. } => { + serde_json::from_slice::(body) + .map(|r| r.to_string()) + .unwrap_or_else(|_| { + format!( + "unexpected response ({status}): {}", + String::from_utf8_lossy(body) + ) + }) + } + e => format!("{e:?}"), + } } /// Internal token exchange implementation @@ -1212,6 +1231,17 @@ mod tests { assert!(verifier.verify("123", "body", "wrong_sig").is_err()); } + #[test] + fn token_error_message_reads_an_error_sent_with_http_200() { + let body: &[u8] = br#"{"error":"invalid_client"}"#; + let e = ExecuteError::BadResponse { + status: reqwest::StatusCode::OK, + body: body.into(), + error: serde_json::from_slice::(body).unwrap_err(), + }; + assert_eq!(token_error_message(&e), "invalid_client"); + } + // Sage Intacct's token endpoint rejects HTTP Basic client authentication on the // refresh_token grant (`invalid_client`), so its credentials must go in the form body. #[test] diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index f0a0b5c20f..44513c806e 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -615,6 +615,17 @@ pub async fn load_mcp_tools( None }; + // A URL can carry the server's key, so it goes through the same + // permissioned path as the token. + if let Some(var_path) = mcp_resource.url.strip_prefix("$var:") { + mcp_resource.url = client.get_variable_value(var_path).await.map_err(|e| { + Error::internal_err(format!( + "Failed to resolve variable {} for the url of MCP resource {}: {}", + var_path, resource_name, e + )) + })?; + } + // Header secrets go through the same permissioned path as the token. for (name, value) in mcp_resource.headers.iter_mut().flatten() { if let Some(var_path) = value.strip_prefix("$var:") { diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs index 9c8a5179e3..e0d5f39568 100644 --- a/backend/windmill-worker/src/prepare_deps.rs +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -355,7 +355,7 @@ fn uv_registry_args(registry: &RegistryConfig) -> Vec { } } if *NATIVE_CERT { - args.push("--native-tls".to_string()); + args.push("--system-certs".to_string()); } args } diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 7f2291099a..4ab3d50df8 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -367,7 +367,7 @@ pub async fn uv_pip_compile( args.extend(["--cert", cert_path]); } if *NATIVE_CERT { - args.extend(["--native-tls"]); + args.extend(["--system-certs"]); } if let Some(exclude_newer) = uv_exclude_newer.as_deref() { args.extend(["--exclude-newer", exclude_newer]); @@ -2247,7 +2247,7 @@ async fn spawn_uv_install( vars.push(("TRUSTED_HOST", host)); } if *NATIVE_CERT { - vars.push(("UV_NATIVE_TLS", "true")); + vars.push(("UV_SYSTEM_CERTS", "true")); } if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() { vars.push(("UV_HTTP_TIMEOUT", timeout.as_str())); @@ -2371,7 +2371,7 @@ async fn spawn_uv_install( }); } if *NATIVE_CERT { - command_args.extend(["--native-tls"]); + command_args.extend(["--system-certs"]); } // TODO: // Track https://github.com/astral-sh/uv/issues/6715 diff --git a/backend/windmill-worker/src/python_versions.rs b/backend/windmill-worker/src/python_versions.rs index 11674efcd6..3a15de17b5 100644 --- a/backend/windmill-worker/src/python_versions.rs +++ b/backend/windmill-worker/src/python_versions.rs @@ -14,6 +14,7 @@ use tokio::{fs::DirBuilder, process::Command, sync::RwLock}; use uuid::Uuid; use windmill_common::{ error::{self, Error}, + global_settings::{load_value_from_global_settings, INSTANCE_PYTHON_VERSION_SETTING}, worker::{try_parse_locked_python_version_from_requirements, Connection, PyVAlias}, }; @@ -49,10 +50,6 @@ impl From for PyVAlias { } } -// To change latest stable version: -// 1. Change placeholder in instanceSettings.ts -// 2. Change LATEST_STABLE_PY in dockerfile -// 3. Change #[default] annotation for PyVersion in backend #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct PyV(pub pep440_rs::Version); @@ -260,7 +257,29 @@ impl PyV { conn: Option, ) -> Self { let mut err = None; - let pyv = match INSTANCE_PYTHON_VERSION.read().await.clone() { + let mut setting = INSTANCE_PYTHON_VERSION.read().await.clone(); + // A worker that loaded its settings while the server was still initializing the instance + // read no version, and may have started listening for setting changes only after it was + // written. Jobs only exist once it is, so reading it here settles that worker for good. + if setting.is_none() { + if let Some(Connection::Sql(db)) = conn.as_ref() { + // Same precedence as the settings load: forced value, stored value, environment. + let env = |name: &str| std::env::var(name).ok().filter(|v| !v.is_empty()); + setting = match env("FORCE_INSTANCE_PYTHON_VERSION") { + Some(forced) => Some(forced), + None => load_value_from_global_settings(db, INSTANCE_PYTHON_VERSION_SETTING) + .await + .ok() + .flatten() + .and_then(|v| serde_json::from_value::(v).ok()) + .or_else(|| env("INSTANCE_PYTHON_VERSION")), + }; + if setting.is_some() { + *INSTANCE_PYTHON_VERSION.write().await = setting.clone(); + } + } + } + let pyv = match setting { Some(v) if &v == "default" => PyVAlias::default().into(), Some(v) => pep440_rs::Version::from_str(&v).unwrap_or_else(|_| { let v = PyVAlias::default().into(); @@ -556,7 +575,7 @@ impl PyV { child_cmd.env("SSL_CERT_FILE", cert_path); } if *NATIVE_CERT { - child_cmd.env("UV_NATIVE_TLS", "true"); + child_cmd.env("UV_SYSTEM_CERTS", "true"); } if let Some(mirror) = UV_PYTHON_INSTALL_MIRROR.read().await.as_ref() { @@ -664,7 +683,7 @@ impl PyV { } if *NATIVE_CERT { - vars.push(("UV_NATIVE_TLS", "true")); + vars.push(("UV_SYSTEM_CERTS", "true")); } let output = child_cmd // .current_dir(job_dir) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 3aa377c904..cb4b378547 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -711,7 +711,7 @@ lazy_static::lazy_static! { /// registry configuration. pub static ref TRUSTED_HOST: Option = non_empty_env("PY_TRUSTED_HOST").or_else(|| non_empty_env("PIP_TRUSTED_HOST")); pub static ref INDEX_CERT: Option = non_empty_env("PY_INDEX_CERT").or_else(|| non_empty_env("PIP_INDEX_CERT")); - pub static ref NATIVE_CERT: bool = non_empty_env("PY_NATIVE_CERT").or_else(|| non_empty_env("UV_NATIVE_TLS")).map(|flag| flag == "true").unwrap_or(false); + pub static ref NATIVE_CERT: bool = non_empty_env("PY_NATIVE_CERT").or_else(|| non_empty_env("UV_SYSTEM_CERTS")).or_else(|| non_empty_env("UV_NATIVE_TLS")).map(|flag| flag == "true").unwrap_or(false); /// uv's HTTP request timeout (seconds). The uv invocations use env_clear(), so a /// UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly. /// Only forwarded when set; otherwise uv keeps its own default. Lets operators diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 34e4da5cf3..63e9a8a5d4 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -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.830.0"; +export const VERSION = "v1.831.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 74f6914f37..91e9b9a081 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.830.0", + "version": "1.831.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.830.0", + "version": "1.831.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 26f5f785be..296be0e680 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.830.0", + "version": "1.831.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/bun.lock b/cli/bun.lock index 0e0885f563..db6fd253bf 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -19,14 +19,14 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.56.8", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", - "windmill-parser-wasm-py-imports": "1.693.1", + "windmill-parser-wasm-py": "1.830.0", + "windmill-parser-wasm-py-imports": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", @@ -291,7 +291,7 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.753.0", "", {}, "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg=="], + "windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.830.0", "", {}, "sha512-q4HSaMOd5cF8pmfPcEGiGeQbO8vhtv89us7zbO12IlgpxpWT8ojvw66J75y4yUt1PaEuaoztSaX8Gkxxn/6MXw=="], "windmill-parser-wasm-csharp": ["windmill-parser-wasm-csharp@1.510.1", "", {}, "sha512-qm09YmnbeYHLwYn1jUnObVzPhYO9NZKMlIO7nlo7zPJBXqksgG5fK/KCtwGw9rChrnz+DsvM9wP5FhrwRLMtwQ=="], @@ -303,9 +303,9 @@ "windmill-parser-wasm-php": ["windmill-parser-wasm-php@1.647.1", "", {}, "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg=="], - "windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.782.0", "", {}, "sha512-ZnpEvhbffaJI+Uq7HH1mVO6kUgR4uiAIIxaLa30Pqb1/oz0h4abT6W7bFOApIE5X9SDOuycoaDBEr9Ogm6ZLzQ=="], + "windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.830.0", "", {}, "sha512-+P4mIyp3lXkgjsr3/20Oc31ank8orQDd4K/v8TnjTQnCyxyy+VB9rynLESnkH6OlcEVSk2qf9pDM9c367E8DoQ=="], - "windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.693.1", "", {}, "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g=="], + "windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.830.0", "", {}, "sha512-Pi9gHStNFTEqfcDrjSxK5SnI/1dTG4xKcVUD3BHlR1aQ7E/VxkTD/D0INJm7mNC2usKb2bO21NMdKmGh+RAXEw=="], "windmill-parser-wasm-r": ["windmill-parser-wasm-r@1.668.1", "", {}, "sha512-5YNeUibxpNBvYrxCgQcz1PxGhTFx2CyEpg2udtIhq7bx0d4gF/KDZVupMeQmAObmrEtTSFGUWNRJ4zXSWNrSpQ=="], diff --git a/cli/package.json b/cli/package.json index 5e7623a7ab..5efe8a2499 100644 --- a/cli/package.json +++ b/cli/package.json @@ -31,14 +31,14 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.56.8", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", - "windmill-parser-wasm-py-imports": "1.693.1", + "windmill-parser-wasm-py": "1.830.0", + "windmill-parser-wasm-py-imports": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 21aeb07f60..b6c029daa2 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -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.830.0"; +export const VERSION = "1.831.0"; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index d90a401260..0a880b05cf 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -34,6 +34,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "resources", description: "MUST use when managing resources." }, { name: "write-workflow-as-code", description: "MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow." }, { name: "write-pipeline", description: "MUST use when creating or modifying a data pipeline, a set of scripts marked `pipeline` and wired together by `on` / `materialize` annotations." }, + { name: "datatables", description: "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions." }, { name: "cli-commands", description: "MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`." }, { name: "preview", description: "MUST use when opening the Windmill dev page / visual preview of a flow, script, or app. Triggers on words like preview, open, navigate to, visualize, see the flow/app/script, and after writing a flow/script/app for visual verification." }, ]; @@ -8123,6 +8124,53 @@ Node \`f/sales/orders_daily\` (runs when \`orders\` is produced, writes a rollup SELECT date_trunc('day', ts) AS day, count(*) AS n FROM ducklake.main.orders GROUP BY 1 \`\`\` +`, + "datatables": `--- +name: datatables +description: MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions. +--- + +# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, \`main\` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an \`up\` and an optional \`down\`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in \`BEGIN; ... END;\` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as \`CREATE INDEX CONCURRENTLY\`: such a migration can fail half applied. +- Give it a \`down\` that undoes the \`up\` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. + +\`wmill datatable migrate new \` scaffolds \`migrations/datatable//_.up.sql\` and \`.down.sql\`, \`wmill datatable migrate up\` pushes the local files and applies what is pending, \`wmill datatable migrate down\` rolls back the most recent one and \`wmill datatable migrate status\` lists them. All take \`-d \` and default to \`main\`. + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (\`CREATE SCHEMA IF NOT EXISTS\`), and point what you are building at it. A raw app names its schema in its \`data\` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. \`admin\` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (\`wmill.datatable('main', { role: 'analyst' })\`, \`role='analyst'\` in Python, \`datatable://main?role=analyst\`) and holds that role alone, or names none and is resolved from the data table's default role; +- \`permission denied\` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to \`admin\` to get past it; +- a migration runs as the role its SQL declares on its first line, \`-- role \`, the same one in its \`down\`. One that declares none runs as \`admin\`, which only those who may use \`admin\` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. `, "cli-commands": `--- name: cli-commands diff --git a/debugger/README.md b/debugger/README.md index 1dba49d977..040fb4ef67 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -139,7 +139,7 @@ the debug service. Where two names are listed the first wins; a worker reads the | `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - | | `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - | | `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - | -| `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false | +| `PY_NATIVE_CERT` / `UV_SYSTEM_CERTS` | `true` to also trust the platform certificate store (`--system-certs`) | false | | `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default | | `DAP_REGISTRY_CONFIG_TIMEOUT_MS` | How long to wait on the settings fetch before installing without it | 10000 | @@ -172,7 +172,7 @@ and the container still boots. `INIT_SCRIPT` remains the hook for anything more the CA update it aborts startup when it fails. Note what the system store does *not* cover, which is most of what a debug session installs with: -uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_NATIVE_TLS` is `true`, Bun and Node read +uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_SYSTEM_CERTS` is `true`, Bun and Node read only `NODE_EXTRA_CA_CERTS`, and `requests` carries certifi. Registering the CA fixes Python's stdlib `ssl`, `curl` and `git`; the rest still needs the variables above. diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index a37ced0eca..ef976f83d3 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -30,6 +30,7 @@ FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app ARG LATEST_STABLE_PY=3.12.13 +ARG NEW_INSTANCE_PY=3.13.16 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -65,6 +66,7 @@ RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releas # under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode # Copy to final location with world-writable permissions for arbitrary UID support RUN mkdir -p /tmp/windmill/cache && \ diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 687b1506ce..67de36eac7 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -30,6 +30,7 @@ FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app ARG LATEST_STABLE_PY=3.12.13 +ARG NEW_INSTANCE_PY=3.13.16 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -65,6 +66,7 @@ RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releas # under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode # Copy to final location with world-writable permissions for arbitrary UID support RUN mkdir -p /tmp/windmill/cache && \ diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md index 4f844beebf..8ace42a1e5 100644 --- a/docs/operator-builder-rights.md +++ b/docs/operator-builder-rights.md @@ -111,8 +111,8 @@ rule added for deploys must hold for drafts too. Where the two differ, `write` d fills in an omitted sandbox or mode, since it stores the policy it checked, while a draft skips `triggerables_v2` and must not name another user's run identity. Every frontend writer of a builder draft must conform the policy first with `conformBuilderAppPolicy`, since a policy loaded -from someone else's app breaks these rules: the editor and the AI chat do. The AI Sessions raw-app -editor, open to operators only behind a per-browser opt-in, does not, so its autosaves are refused. +from someone else's app breaks these rules: the raw-app editor component does, which covers both +the standalone editor and the AI Sessions one, and so does the AI chat. The raw-app editor previews an app isolated only when its policy has `sandbox`, and otherwise runs the bundle same-origin with the session of whoever opens it. A builder's draft is therefore diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c8250f9eca..3b5ecd97be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -95,19 +95,19 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", + "windmill-parser-wasm-py": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.695.0", - "windmill-parser-wasm-wac": "1.668.6", + "windmill-parser-wasm-wac": "1.830.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "1.8.2", @@ -14644,9 +14644,9 @@ } }, "node_modules/windmill-parser-wasm-asset": { - "version": "1.753.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.753.0.tgz", - "integrity": "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.830.0.tgz", + "integrity": "sha512-q4HSaMOd5cF8pmfPcEGiGeQbO8vhtv89us7zbO12IlgpxpWT8ojvw66J75y4yUt1PaEuaoztSaX8Gkxxn/6MXw==" }, "node_modules/windmill-parser-wasm-csharp": { "version": "1.510.1", @@ -14674,9 +14674,9 @@ "integrity": "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg==" }, "node_modules/windmill-parser-wasm-py": { - "version": "1.782.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.782.0.tgz", - "integrity": "sha512-ZnpEvhbffaJI+Uq7HH1mVO6kUgR4uiAIIxaLa30Pqb1/oz0h4abT6W7bFOApIE5X9SDOuycoaDBEr9Ogm6ZLzQ==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.830.0.tgz", + "integrity": "sha512-+P4mIyp3lXkgjsr3/20Oc31ank8orQDd4K/v8TnjTQnCyxyy+VB9rynLESnkH6OlcEVSk2qf9pDM9c367E8DoQ==" }, "node_modules/windmill-parser-wasm-r": { "version": "1.668.1", @@ -14704,9 +14704,9 @@ "integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw==" }, "node_modules/windmill-parser-wasm-wac": { - "version": "1.668.6", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.668.6.tgz", - "integrity": "sha512-/ovcLWlIO+TQMrnWwcWoXquJI6hTZ+Zpo4POhV8z6e+pb4cVModGysASI9mERn2e9uj8/xSRmYqk54TLG8oUbQ==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.830.0.tgz", + "integrity": "sha512-M67qPkzDnuMNWGrZTj9V4ajDoCQrTsQJ89UMHq308t5CtCdtiX8Z3freL+8SoRAG/dVlqQ8roK6dzncHIEM0hQ==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.770.0", diff --git a/frontend/package.json b/frontend/package.json index ddfc6b410b..33c24f43bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.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", @@ -161,19 +161,19 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", + "windmill-parser-wasm-py": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.695.0", - "windmill-parser-wasm-wac": "1.668.6", + "windmill-parser-wasm-wac": "1.830.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "1.8.2", diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 56bff89d8f..4dc05972ca 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -25,7 +25,7 @@ import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry' import { createEventDispatcher, onDestroy, tick } from 'svelte' import Path from './Path.svelte' - import { ListRow, RadioCard, Skeleton } from './common' + import { Badge, ListRow, RadioCard, Skeleton } from './common' import { useListHighlight } from './common/listRow/listHighlight.svelte' import ApiConnectForm from './ApiConnectForm.svelte' import SearchItems from './SearchItems.svelte' @@ -322,6 +322,15 @@ (registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false)) ) + /** The provider also has a browser sign-in, unavailable here only because the + * instance holds no OAuth client for it */ + let signInNeedsInstanceClient = $derived.by( + () => + (registryEntry()?.grant_types?.includes('authorization_code') ?? false) && + connectClient != '' && + !(connects?.includes(connectClient) ?? false) + ) + /** Clear CC inputs and scopes so a previous selection never leaks into a new one */ function resetClientCredentialsState() { supportsClientCredentials = false @@ -1399,11 +1408,7 @@ {#if apiTokenApps[resourceType].img}
- connect + connect
{/if} {/if} @@ -1514,14 +1519,14 @@ {#if supportsClientCredentials}

Authentication

- {#if ccOnly || ccBringYourOwn} + {#if (ccOnly || ccBringYourOwn) && !signInNeedsInstanceClient}
{#if useSharedInstanceCreds} {resourceType} connects server-to-server using the credentials configured for this - instance. The token is acquired and refreshed automatically. + instance. {:else} - {resourceType} connects server-to-server. Enter a client ID and secret; the token is - acquired and refreshed automatically. + {resourceType} connects server-to-server with the client credentials grant. Enter a + client ID and secret. {/if}
{:else} @@ -1534,17 +1539,24 @@ > + {#snippet setupNeededBadge()} + Instance setup needed + {/snippet} enableClientCredentials()} /> diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index f211650df2..d1dd5fa08b 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -97,7 +97,8 @@ 'docusign', 'salesforce', 'outreach', - 'notion' + 'notion', + 'hubspot' ] // Providers whose registry entry (`backend/oauth_connect.json`) carries a // `sandbox` URL block. Each one gets a sibling `_sandbox` dropdown diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 1a468ac33a..0e2348336c 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -40,7 +40,7 @@ import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import PathEditPopover from '$lib/components/PathEditPopover.svelte' - import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { writable } from 'svelte/store' import CenteredPage from './CenteredPage.svelte' import { Button } from './common' @@ -230,19 +230,14 @@ let pathSnapshot = $state(undefined) let topbarWidth = $state(0) - // In the page header the buttons share the row with the breadcrumb, so they collapse sooner: - // the trail and the summary take ~560px before either truncates, and the full group — the - // dropdown, Diff, the preview trio and Deploy — is ~610px. - const compactBelow = $derived(ownsPageHeader ? 1200 : 720) - const compactTopbar = $derived.by(() => { - const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth - return w > 0 && w < compactBelow - }) - // A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons - // stand down, since there is no room to run a flow beside its own name. - const phoneTopbar = $derived( - ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR + // In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has + // given all it can; anywhere else this editor's own top bar is theirs alone. + const compactTopbar = $derived( + ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720 ) + // The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons + // stand down, since there is no room to run a flow beside its own name. + const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2) const diffEnabled = $derived(customUi?.topBar?.diff != false) // Nothing to compare against until a deployed version exists. @@ -1597,6 +1592,7 @@ : undefined }} actions={flowHeaderActions} + actionFolds={2} contexts={headerContexts} separator="none" /> diff --git a/frontend/src/lib/components/MemoryFootprintViewer.svelte b/frontend/src/lib/components/MemoryFootprintViewer.svelte index 9547861bc3..14ad1a2b06 100644 --- a/frontend/src/lib/components/MemoryFootprintViewer.svelte +++ b/frontend/src/lib/components/MemoryFootprintViewer.svelte @@ -3,7 +3,7 @@ import { type MetricDataPoint, MetricsService } from '$lib/gen' import { displayTime } from '$lib/utils' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from './operatingWorkspace.svelte' import { CategoryScale, Chart as ChartJS, @@ -26,6 +26,8 @@ let { jobId, jobUpdateLastFetch }: Props = $props(); + const operatingWorkspace = useOperatingWorkspace() + let jobMetricsLastFetch: Date | undefined = undefined let jobMemoryStats: MetricDataPoint[] | undefined = $state(undefined) @@ -38,7 +40,7 @@ async function loadMetricsData() { try { let jobStatsPromise = MetricsService.getJobMetrics({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, id: jobId, requestBody: { from_timestamp: jobMetricsLastFetch?.toISOString(), diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index 5b64061244..8238aa5db6 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -16,7 +16,6 @@ not this component) — so the lead can run the whole line. import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte' import { Menu, Menubar } from '$lib/components/meltComponents' import MeltButton from '$lib/components/meltComponents/MeltButton.svelte' - import BreadcrumbItemContent from './BreadcrumbItemContent.svelte' import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte' import BreadcrumbNavPicker from './BreadcrumbNavPicker.svelte' import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores' @@ -33,14 +32,25 @@ not this component) — so the lead can run the whole line. import { KIND_LABEL_LOWER } from '$lib/components/workspacePicker' import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte' import type { Snippet } from 'svelte' + import { + BAR_STAGES, + FLOOR, + shownPathLevels, + type Part, + type TrailMeasures, + type TrailStage + } from './pageHeaderStages' let { item, section, afterName, actingWorkspaceId, - narrow = false, - nameOnly = false + nameOnly = false, + stage = BAR_STAGES[0], + caps = {}, + scopesGive = false, + measures = $bindable() }: { item?: PageHeaderItem section?: PageHeaderSection @@ -49,9 +59,14 @@ not this component) — so the lead can run the whole line. afterName?: Snippet /** The workspace the page acts on, when it differs from the one the app is pointed at. */ actingWorkspaceId?: string - /** The bar is short of room: the path holds a tighter cap, so it cannot claim a third of the - * line before anything else has given way. The names truncate at every width either way. */ - narrow?: boolean + /** Which parts the bar has dropped for room (see pageHeaderStages). */ + stage?: TrailStage + /** Max widths the bar holds the names to, once dropping whole parts is not enough. */ + caps?: Partial> + /** The folders above the name truncate too: even the name at its last floor does not fit. */ + scopesGive?: boolean + /** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */ + measures?: TrailMeasures /** Only the page's own name, with no workspace part and no picker — the band inside a * session's preview frame, where the workspace is the host's and leading the reader out of * it is exactly what that band must not offer. The page still has to say what it is. */ @@ -156,15 +171,8 @@ not this component) — so the lead can run the whole line. twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD) ) - // Capped: a draft's path is a 40-character uuid slug, and the trail yields so grudgingly that - // one would push a page's buttons off the end of the bar. const pathSegment = $derived( - twMerge( - SEGMENT, - 'gap-1.5 overflow-hidden', - itemIsLast ? LAST : LEAD, - narrow ? 'max-w-[11rem]' : 'max-w-[16rem]' - ) + twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD) ) // Copying is the one thing a segment does that leaves no mark on the page, so it is the one // without a hover fill: the pointer and the confirmation under it are the affordance. A segment @@ -191,6 +199,18 @@ not this component) — so the lead can run the whole line. const scopeKind = $derived(item?.path?.split('/')[0]) /** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */ const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean)) + + // The off-screen copies stand in a fixed-width box for what a page renders into the trail: a + // second render of a page's snippet would be a second live copy of its controls. + let afterNameWidth = $state(0) + let sectionContentWidth = $state(0) + + /** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */ + function capStyle(part: Part, measure: boolean): string | undefined { + if (measure) return `max-width: ${FLOOR[part]}px` + const cap = caps[part] + return cap === undefined ? undefined : `max-width: ${cap}px` + } {#snippet workspaceDisc()} @@ -235,11 +255,17 @@ not this component) — so the lead can run the whole line. {/if} {/snippet} -{#snippet navPicker()} +{#snippet navPicker(measure: boolean)} {#if !nameOnly} - + {#if measure} + + + + {:else} + + {/if} {/if} {/snippet} @@ -249,89 +275,129 @@ not this component) — so the lead can run the whole line. {/snippet} -