Merge remote-tracking branch 'origin/main' into header-narrow-widths

# Conflicts:
#	frontend/src/lib/components/PageHeaderBar.svelte
#	frontend/src/lib/components/RunsPage.svelte
This commit is contained in:
Guilhem Lemouel
2026-10-13 08:39:36 -07:00
105 changed files with 3002 additions and 596 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.830.0"
".": "1.831.0"
}
+47
View File
@@ -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)
+6 -4
View File
@@ -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 -
+4 -1
View File
@@ -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: { <schema>: { <table>: { columns, rows? } } } }`.
`{ datatable_name, schemas: { <schema>: { <table>: { 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`,
@@ -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: () => {
@@ -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 {
+129 -1
View File
@@ -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. */
@@ -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 }) =>
+51
View File
@@ -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.
@@ -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" }
]
}
}
}
}
]
}
}
+178 -169
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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 <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
40ab35596e6fcb82a4dfc56e186befb807f8e2af
118785eaa78675089b9a676d24e5d0a2e03f3d9a
+85
View File
@@ -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
}
}
+24 -24
View File
@@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -6430,7 +6430,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6442,7 +6442,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"convert_case",
"serde",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6487,7 +6487,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6499,7 +6499,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6511,7 +6511,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6533,7 +6533,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6545,7 +6545,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6556,7 +6556,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6579,7 +6579,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6591,7 +6591,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6605,7 +6605,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6622,7 +6622,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6635,7 +6635,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde",
@@ -6647,7 +6647,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6665,7 +6665,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6681,7 +6681,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6698,7 +6698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6730,7 +6730,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6744,7 +6744,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.830.0"
version = "1.831.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+40
View File
@@ -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<Postgres>) -> 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(())
}
+10
View File
@@ -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, '{}');
+16
View File
@@ -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'
);
+40 -3
View File
@@ -23,9 +23,9 @@ use windmill_common::client::AuthedClient;
use windmill_test_utils::*;
use windmill_worker::{load_mcp_tools, McpResourceConfig};
fn config() -> Vec<McpResourceConfig> {
fn config(resource_path: &str) -> Vec<McpResourceConfig> {
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<McpResourceConfig> {
// The Ok variant ((HashMap<_, Arc<McpClient>>, Vec<Tool>)) does not implement
// Debug, so `expect_err` is unavailable — extract the error explicitly.
async fn load_err(db: &Pool<Postgres>, 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<Postgres>,
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<Postgres>) -> 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(())
}
+343 -80
View File
@@ -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::<u64>().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::<u64>().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<i64>,
}
#[cfg(feature = "embedding")]
#[derive(Serialize)]
pub struct ResourceTypeResult {
name: String,
@@ -138,24 +127,107 @@ fn trim_to_top_score<T>(
.take_while(|r| (top_score - score(r)) / top_score <= max_relative_drop)
.collect()
}
#[cfg(feature = "embedding")]
async fn query_resource_types(
Extension(db): Extension<DB>,
Query(query): Query<ResourceTypesQuery>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<ResourceTypeResult>> {
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<i64>,
) -> windmill_common::error::Result<Vec<ResourceTypeResult>> {
let words: Vec<String> = 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<String>)> = 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<serde_json::Value>)> = 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<ModelInstance>,
/// 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<HashMap<String, Vec<f32>>>,
/// The `resource_types_fingerprint` the resource types collection was built from.
resource_types_fingerprint: Option<String>,
/// See `ResourceTypeIndex::custom_vectors`.
custom_resource_type_vectors: Arc<HashMap<String, Vec<f32>>>,
}
/// 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<String>,
embeddings: Vec<Embedding>,
/// 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<HashMap<String, Vec<f32>>>,
}
#[cfg(feature = "embedding")]
impl EmbeddingsDb {
fn empty(model_instance: Arc<ModelInstance>) -> 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<Postgres>, model_instance: Arc<ModelInstance>) -> Result<Self> {
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<Postgres>,
model_instance: Arc<ModelInstance>,
) -> Result<Self> {
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::<Vec<HubResourceType>>().await?;
self.hub_resource_type_embeddings = Arc::new(
hub_resource_types
.into_iter()
.map(|rt| (rt.name, rt.embedding))
.collect(),
);
let resource_types: Vec<ResourceType> =
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<Postgres>) -> Result<Option<String>> {
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<Postgres>,
model_instance: &Arc<ModelInstance>,
hub_embeddings: &HashMap<String, Vec<f32>>,
known_custom_vectors: &HashMap<String, Vec<f32>>,
) -> Result<ResourceTypeIndex> {
let fingerprint = resource_types_fingerprint(pg_db).await?;
let resource_types: Vec<ResourceType> =
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<Postgres>) -> 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<Tensor> {
Ok(v.broadcast_div(&v.sqr()?.sum_keepdim(1)?.sqrt()?)?)
@@ -630,6 +829,19 @@ pub fn load_embeddings_db(db: &Pool<Postgres>) -> () {
.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<Postgres>) -> () {
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<Postgres>) -> 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<Postgres>) -> 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);
}
}
@@ -751,6 +751,65 @@ async fn test_resource_cache_handles_job_context(db: Pool<Postgres>) -> 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<Postgres>) -> 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::<Vec<serde_json::Value>>()
.await
.unwrap()
.into_iter()
.map(|rt| rt["name"].as_str().unwrap().to_string())
.collect::<Vec<_>>()
}
};
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<Postgres>) -> anyhow::Result<()> {
@@ -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<bool> {
pub(crate) async fn datatable_migrations_enabled(
db: &DB,
w_id: &str,
datatable_name: &str,
) -> Result<bool> {
let flag: Option<bool> = sqlx::query_scalar!(
"SELECT (ws.datatable->'datatables'->$2->>'migrations_enabled')::boolean \
FROM workspace_settings ws WHERE ws.workspace_id = $1",
@@ -2325,6 +2325,9 @@ struct DataTableTables {
/// What the role the listing connected as may create.
can_create_schema: bool,
creatable_schemas: Vec<String>,
/// 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 =
+5 -1
View File
@@ -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
+36 -22
View File
@@ -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
+51
View File
@@ -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::<u32>().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<()>,
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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()));
@@ -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",
];
+13
View File
@@ -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<u32> 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<T: From<PyVAlias>>() -> Vec<T> {
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::*;
+1
View File
@@ -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 }
+4 -1
View File
@@ -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(
@@ -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::Error>;
/// 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<T>(request: impl Future<Output = Result<T, Error>>) -> Result<T, Error> {
match request.with_subscriber(Dispatch::none()).await {
Err(StreamableHttpError::Client(e)) => Err(StreamableHttpError::Client(e.without_url())),
// An owned message is `HTTP <status>: <body>`, and the body is the server's
// to write: a common one echoes the request target (`Cannot POST /mcp/<key>`).
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<str>,
message: ClientJsonRpcMessage,
session_id: Option<Arc<str>>,
auth_header: Option<String>,
custom_headers: HashMap<HeaderName, HeaderValue>,
) -> Result<StreamableHttpPostResponse, Error> {
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<str>,
message: ClientJsonRpcMessage,
session_id: Option<Arc<str>>,
auth_header: Option<String>,
custom_headers: HashMap<HeaderName, HeaderValue>,
max_sse_event_size: usize,
) -> Result<StreamableHttpPostResponse, Error> {
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<str>,
session_id: Arc<str>,
auth_header: Option<String>,
custom_headers: HashMap<HeaderName, HeaderValue>,
) -> Result<(), Error> {
redacted(
self.0
.delete_session(uri, session_id, auth_header, custom_headers),
)
.await
}
async fn get_stream(
&self,
uri: Arc<str>,
session_id: Option<Arc<str>>,
last_event_id: Option<String>,
auth_header: Option<String>,
custom_headers: HashMap<HeaderName, HeaderValue>,
) -> Result<BoxStream<'static, Result<Sse, SseError>>, 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<str>,
session_id: Option<Arc<str>>,
last_event_id: Option<String>,
auth_header: Option<String>,
custom_headers: HashMap<HeaderName, HeaderValue>,
max_sse_event_size: usize,
) -> Result<BoxStream<'static, Result<Sse, SseError>>, 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);
}
}
}
+31 -1
View File
@@ -790,7 +790,26 @@ pub async fn exchange_code<T: DeserializeOwned>(
.with_client(http_client)
.execute::<T>()
.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::<oauth2::ErrorResponse>(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::<TokenResponse>(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]
+11
View File
@@ -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:") {
+1 -1
View File
@@ -355,7 +355,7 @@ fn uv_registry_args(registry: &RegistryConfig) -> Vec<String> {
}
}
if *NATIVE_CERT {
args.push("--native-tls".to_string());
args.push("--system-certs".to_string());
}
args
}
@@ -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
+26 -7
View File
@@ -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<PyV> 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<Connection>,
) -> 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::<String>(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)
+1 -1
View File
@@ -711,7 +711,7 @@ lazy_static::lazy_static! {
/// registry configuration.
pub static ref TRUSTED_HOST: Option<String> = non_empty_env("PY_TRUSTED_HOST").or_else(|| non_empty_env("PIP_TRUSTED_HOST"));
pub static ref INDEX_CERT: Option<String> = 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
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.830.0";
export const VERSION = "v1.831.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.830.0";
export const VERSION = "1.831.0";
+48
View File
@@ -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 <name>\` scaffolds \`migrations/datatable/<datatable>/<timestamp>_<name>.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 <datatable>\` 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 <name>\`, 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
+2 -2
View File
@@ -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.
+2
View File
@@ -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 && \
+2
View File
@@ -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 && \
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -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",
@@ -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 @@
</div>
{#if apiTokenApps[resourceType].img}
<div class="mt-4 w-full overflow-hidden">
<img
class="m-auto max-h-60"
alt="connect"
src={apiTokenApps[resourceType].img}
/>
<img class="m-auto max-h-60" alt="connect" src={apiTokenApps[resourceType].img} />
</div>
{/if}
{/if}
@@ -1514,14 +1519,14 @@
{#if supportsClientCredentials}
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis mb-1">Authentication</h3>
{#if ccOnly || ccBringYourOwn}
{#if (ccOnly || ccBringYourOwn) && !signInNeedsInstanceClient}
<div class="text-xs text-secondary font-normal mb-2">
{#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}
</div>
{:else}
@@ -1534,17 +1539,24 @@
>
<RadioCard
label={`Sign in through ${resourceType}`}
description="Opens a browser window to log in and authorize. Connects as you."
description={signInNeedsInstanceClient
? `Authorization code grant. Needs an OAuth client for ${resourceType} in the instance settings, added by a superadmin.`
: 'Authorization code grant. Opens a browser window to log in and authorize. Connects as you.'}
selected={!useClientCredentials}
onSelect={selectAuthCodeGrant}
disabled={signInNeedsInstanceClient}
badge={signInNeedsInstanceClient ? setupNeededBadge : undefined}
/>
{#snippet setupNeededBadge()}
<Badge verySmall class="px-2">Instance setup needed</Badge>
{/snippet}
<RadioCard
label={useSharedInstanceCreds
? 'Use the configured instance credentials'
: 'Use a client ID and secret'}
description={useSharedInstanceCreds
? "Runs server-to-server with this instance's credentials. No input needed."
: 'Runs server-to-server. Best for automation or service accounts.'}
? "Client credentials grant. Runs server-to-server with this instance's credentials. No input needed."
: 'Client credentials grant. Runs server-to-server. Best for automation or service accounts.'}
selected={useClientCredentials}
onSelect={() => enableClientCredentials()}
/>
@@ -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 `<name>_sandbox` dropdown
@@ -178,7 +178,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba
{#if (navDetached.val || panelled) && !navHidden}
<!-- Reveals the hidden sidebar, and only that: the card follows the pointer, holding while it
is on the handle, on the card, or in a menu either opened, and sliding back out when it
leaves. Where no pointer hovers, a tap opens it and it stays until dismissed. Attaching
leaves. A click or tap pins it instead, until another one here, a press on the page or Esc
puts it back. Attaching
it for good belongs to the toggle in the sidebar's own footer, where detaching it
happened. Docked, the sidebar speaks for itself and nothing leads the bar. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -196,7 +197,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
iconOnly
startIcon={{ icon: PanelLeft }}
aria-label="Show sidebar"
onclick={() => navHandleSlot.open()}
selected={navHandleSlot.pinned}
aria-pressed={navHandleSlot.pinned}
onclick={() => navHandleSlot.toggle()}
/>
</div>
{/if}
+54 -37
View File
@@ -11,14 +11,12 @@
import { sendUserToast } from '$lib/toast'
import { displayDate } from '$lib/utils'
import { userWorkspaces, superadmin, devopsRole, enterpriseLicense } from '$lib/stores'
import { useHostedPage } from '$lib/components/hostedPage'
import {
userStore,
workspaceStore,
userWorkspaces,
superadmin,
devopsRole,
enterpriseLicense
} from '$lib/stores'
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { Button, Drawer, DrawerContent, Skeleton, Tab, Tabs } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RunChart from '$lib/components/RunChart.svelte'
@@ -80,6 +78,13 @@
let { initialPath }: Props = $props()
// Under a host — an AI session's preview tab — the document URL, the page header band and
// the navigation workspace all belong to the host: the filters live where it keeps them,
// the filter row renders in the page, and requests go to the workspace it names.
const hosted = useHostedPage()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
let paths: string[] = $state([])
let usernames: string[] = $state([])
let folders: string[] = $state([])
@@ -90,7 +95,7 @@
folders,
jobTriggerKinds,
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
isAdminsWorkspace: $operatingWorkspace === 'admins'
})
)
let perPage = useLocalStorageValue('runs_per_page', 1000, 'number')
@@ -114,6 +119,8 @@
// what was seeded at mount is gone. Re-apply it. Editing a filter writes with `replaceState`,
// which never reaches `page.url`, so a filter the user clears stays cleared.
$effect(() => {
// A host remounts the page when it re-points it, and its document URL is not this page's.
if (hosted) return
page.url.href
untrack(() => filters.seed(filterSeeds()))
})
@@ -170,7 +177,9 @@
let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined)
let graph: 'RunChart' | 'ConcurrencyChart' = $state(
typeOfChart(page.url.searchParams.get('graph'))
typeOfChart(
(hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph')
)
)
let innerWidth = $state(window.innerWidth)
let jobsLoader = useJobsLoader(() => ({
@@ -182,7 +191,7 @@
resultError,
perPage: perPage.val,
lookback: graph === 'RunChart' ? 0 : lookback,
currentWorkspace: $workspaceStore ?? ''
currentWorkspace: $operatingWorkspace ?? ''
}))
let batchProgress = $derived(jobsLoader.batchProgress)
let currentBatchSize = $derived(jobsLoader.currentBatchSize)
@@ -210,18 +219,18 @@
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
}
async function loadFolders(): Promise<void> {
folders = await FolderService.listFolders({
workspace: $workspaceStore!
workspace: $operatingWorkspace!
}).then((x) => x.map((y) => y.name))
}
async function loadPaths() {
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' })
const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' })
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $operatingWorkspace ?? '' })
const npaths_flows = await FlowService.listFlowPaths({ workspace: $operatingWorkspace ?? '' })
paths = npaths_scripts.concat(npaths_flows).sort()
}
@@ -264,7 +273,7 @@
const resultFilter = filters.val.result && JSON.parse(filters.val.result)
const { minTs, maxTs } = timeframe.computeMinMax()
return {
workspace: $workspaceStore ?? '',
workspace: $operatingWorkspace ?? '',
startedBefore: maxTs ?? undefined,
startedAfter: minTs ?? undefined,
schedulePath: filters.val.schedule_path ?? undefined,
@@ -322,7 +331,7 @@
}
async function cancelJobs(uuidsToCancel: string[], forceCancel: boolean = false) {
const uuids = await JobService.cancelSelection({
workspace: $workspaceStore ?? '',
workspace: $operatingWorkspace ?? '',
requestBody: uuidsToCancel,
forceCancel: forceCancel,
allWorkspaces: filters.val.all_workspaces || undefined
@@ -349,7 +358,7 @@
for (const j of jobs ?? []) if (j.workspace_id) workspaceById.set(j.id, j.workspace_id)
const byWorkspace = new Map<string, string[]>()
for (const id of jobIds) {
const ws = workspaceById.get(id) ?? $workspaceStore ?? ''
const ws = workspaceById.get(id) ?? $operatingWorkspace ?? ''
const bucket = byWorkspace.get(ws)
if (bucket) bucket.push(id)
else byWorkspace.set(ws, [id])
@@ -427,7 +436,7 @@
}
async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) {
if (!$workspaceStore) return
if (!$operatingWorkspace) return
if (askingForConfirmation) {
askingForConfirmation.loading = true
@@ -441,7 +450,7 @@
// workaround because EventSource does not support POST requests
// https://medium.com/@david.richards.tech/sse-server-sent-events-using-a-post-request-without-eventsource-1c0bd6f14425
const response = await fetch(`${OpenAPI.BASE}/w/${$workspaceStore}/jobs/run/batch_rerun_jobs`, {
const response = await fetch(`${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
@@ -554,7 +563,7 @@
}
$effect(() => {
if ($workspaceStore) {
if ($operatingWorkspace) {
untrack(() => {
loadUsernames()
loadFolders()
@@ -604,8 +613,9 @@
let resolutionNote = $state('')
// The bar folds this row (`actionFolds` below) once its breadcrumb has given all it can: first
// the counters, which are a glance and repeat the chart below; then the session button; then
// the filter field moves to a row of its own rather than shrinking past usable.
const fold = $derived(pageHeader.actionFold)
// the filter field moves to a row of its own rather than shrinking past usable. Hosted, the row
// is the page's own and wraps instead.
const fold = $derived(hosted ? 0 : pageHeader.actionFold)
const filterBelow = $derived(fold >= 3)
</script>
@@ -624,12 +634,12 @@
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
isAdminsWorkspace: $operatingWorkspace === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus
autofocus={!hosted}
/>
{/snippet}
@@ -641,7 +651,7 @@
<!-- The page's own row inside the band's actions box: these controls are denser than a page's
two or three buttons, so they take a wider gap than the band's own. Every control keeps its
width: the bar measures the row and folds it rather than squeezing it. -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 {hosted ? 'flex-wrap justify-end' : ''}">
<!-- Always the icon form: the bar has room for two counters, never for the two labelled
blocks. pr-2 on top of the row's own gap because the counts hang outside their icons,
so without it the badge butts against the first filter. -->
@@ -830,11 +840,11 @@
<!-- The filters are shallow-routed, so the search has to come off `window.location` at click
time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. -->
<div class={fold >= 2 ? 'hidden' : 'contents'}>
<div class={fold >= 2 || hosted ? 'hidden' : 'contents'}>
<OpenInSessionButton
source={{
page: () => RUNS_PATH + window.location.search,
workspaceId: $workspaceStore ?? undefined
workspaceId: $operatingWorkspace ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
@@ -897,26 +907,33 @@
</DrawerContent>
</Drawer>
<!-- The document's history is the host's: its Back is not a move on this page. -->
<svelte:window
onpopstate={() => {
reset()
if (!hosted) reset()
}}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.runs}
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.runs}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}>
<PageHeaderContent
afterName={runsHint}
actions={headerActions}
actionFolds={3}
belowBar={filterRow}
belowBarFold={3}
/>
{#if hosted}
<div class="flex px-4 pt-3 pb-2">
{@render headerActions()}
</div>
{:else}
<PageHeaderContent
afterName={runsHint}
actions={headerActions}
actionFolds={3}
belowBar={filterRow}
belowBarFold={3}
/>
{/if}
<!-- Graph -->
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 border rounded-md">
@@ -1107,7 +1124,7 @@
batchRerunOptionsIsOpen = true
}
},
...(!$userStore?.operator
...(!operatingUser.current?.operator
? [
{
// Operators are rejected by the endpoint, so offering it would only 403.
@@ -300,7 +300,14 @@
<!-- {JSON.stringify(schema)} -->
<div
class="w-full {className} {flexWrap ? 'flex flex-row flex-wrap gap-x-6 ' : ''} {nestedClasses}"
use:dragHandleZone={dndConfig ?? { items: [], dragDisabled: true }}
use:dragHandleZone={dndConfig ?? {
items: [],
dragDisabled: true,
// A drag-disabled zone otherwise sets aria-disabled="true" on itself, which screen readers
// and Playwright apply to every field inside it.
autoAriaDisabled: true,
zoneTabIndex: -1
}}
onfinalize={bubble('finalize')}
onconsider={bubble('consider')}
>
@@ -67,15 +67,16 @@
let appPerms = $state<{ path: string; extraPerms: Record<string, boolean> } | undefined>(
undefined
)
const editRights = useEditRights()
const canWriteApp = $derived(
!!appPerms && editRights.canEditItem('app', appPerms.path, appPerms.extraPerms)
)
/** Raw vs low-code, read from the app itself rather than from the route:
* both kinds render here and either route serves either kind (links to a raw
* app point at /apps/get all over the app), so only the app can say which
* editor the Edit button must open. */
let isRawApp = $state(false)
const editRights = useEditRights()
const canWriteApp = $derived(
!!appPerms &&
editRights.canEditItem(isRawApp ? 'raw_app' : 'app', appPerms.path, appPerms.extraPerms)
)
/** The app's execution mode, from the same lite read as the permissions and for the same
* reason: a sandboxed low-code app loads inside the opaque iframe, so `app` here stays
* undefined and cannot say whether the app answers at a public url. `policy.execution_mode` is
@@ -10,6 +10,7 @@
onSelect,
disabled = false,
icon = undefined,
badge = undefined,
showRadio = true,
class: className = ''
}: {
@@ -25,6 +26,8 @@
disabled?: boolean
/** Optional leading icon, rendered after the radio */
icon?: Snippet
/** Optional badge rendered right after the label */
badge?: Snippet
/** Draw the radio glyph. Turn it off where the card itself is the only
* control and the border and tint already say which one is picked — the dot
* is then a second, redundant answer to the same question. The group still
@@ -51,8 +54,10 @@
// (FileExplorer, TriggersTable, RunnableRow, ArtifactVersionPicker).
selected
? 'border-border-selected bg-surface-accent-selected'
: 'border-border-light hover:bg-surface-hover',
disabled && 'opacity-50 cursor-not-allowed',
: 'border-border-light enabled:hover:bg-surface-hover',
// `disabled:` variants, not plain utilities: the global `button:disabled`
// rule sets opacity and cursor and outranks a single class.
'disabled:opacity-50 disabled:cursor-not-allowed',
className
)}
>
@@ -68,7 +73,10 @@
<div class="shrink-0 mt-0.5">{@render icon()}</div>
{/if}
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-emphasis">{label}</div>
<div class="flex items-center gap-2 text-xs font-semibold text-emphasis">
{label}
{@render badge?.()}
</div>
{#if describedBySnippet}
<div class="text-xs font-normal text-secondary mt-0.5">
{@render (description as Snippet)()}
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { statusMock, createMock, upsertMock, runMock } = vi.hoisted(() => ({
upsertMock: vi.fn(),
statusMock: vi.fn(),
createMock: vi.fn(),
runMock: vi.fn()
}))
vi.mock('./shared', () => ({
createToolDef: (_schema: unknown, name: string, description: string) => ({
type: 'function',
function: { name, description, parameters: {} }
})
}))
vi.mock('$lib/gen', () => ({
WorkspaceService: {
getDatatableMigrationsStatus: statusMock,
createDatatableMigration: createMock,
upsertDatatableMigration: upsertMock,
runDatatableMigrations: runMock
}
}))
import { getDatatableMigrationTools } from './datatableMigrationTools'
function create(args: Record<string, unknown>) {
const tool = getDatatableMigrationTools().find(
(entry) => entry.def.function.name === 'create_datatable_migration'
)!
return tool.fn({
args: {
datatable_name: 'main',
name: 'add_refunds',
code_up: 'BEGIN;\nCREATE TABLE refunds ();\nEND;',
...args
},
workspace: 'test-workspace',
helpers: {},
toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() } as any,
toolId: 'tool-1'
})
}
const applied = { timestamp: 10, name: 'init', code_up: '', status: 'ran' }
const created = { timestamp: 30, name: 'add_refunds', code_up: '', status: 'not_run' }
beforeEach(() => {
for (const mock of [statusMock, createMock, upsertMock, runMock]) mock.mockReset()
createMock.mockResolvedValue({ timestamp: 30 })
statusMock.mockResolvedValue({ enabled: true, migrations: [applied, created] })
runMock.mockResolvedValue({ applied: [{ version: 30, name: 'add_refunds' }] })
})
describe('create_datatable_migration', () => {
it('applies only the migration it created', async () => {
const result = await create({})
expect(runMock).toHaveBeenCalledWith({
workspace: 'test-workspace',
datatableName: 'main',
only: 30
})
expect(result).toContain('Applied migration 30 (add_refunds)')
})
it('leaves it pending when an earlier migration is not applied', async () => {
statusMock.mockResolvedValue({
enabled: true,
migrations: [
applied,
{ timestamp: 20, name: 'earlier', code_up: '', status: 'not_run' },
created
]
})
const result = await create({})
expect(runMock).not.toHaveBeenCalled()
expect(result).toContain('20 (earlier)')
})
it('keeps a migration that fails to apply and rewrites it on request', async () => {
runMock.mockRejectedValueOnce({ body: 'relation "refunds" already exists' })
const failed = await create({})
expect(failed).toContain('relation "refunds" already exists')
expect(failed).toContain('with version 30')
statusMock.mockResolvedValue({
enabled: true,
migrations: [applied, { ...created, code_down: 'DROP TABLE refunds;' }]
})
await create({ version: 30, code_up: 'BEGIN;\nCREATE TABLE refunds2 ();\nEND;' })
expect(createMock).toHaveBeenCalledTimes(1)
expect(upsertMock).toHaveBeenCalledWith({
workspace: 'test-workspace',
datatableName: 'main',
requestBody: {
timestamp: 30,
code_down: 'DROP TABLE refunds;',
name: 'add_refunds',
code_up: 'BEGIN;\nCREATE TABLE refunds2 ();\nEND;'
}
})
expect(runMock).toHaveBeenLastCalledWith({
workspace: 'test-workspace',
datatableName: 'main',
only: 30
})
})
it('refuses to rewrite a version that is not pending', async () => {
const result = await create({ version: 10 })
expect(upsertMock).not.toHaveBeenCalled()
expect(runMock).not.toHaveBeenCalled()
expect(result).toContain('cannot be rewritten')
})
it('does not call unreadable earlier migrations pending', async () => {
statusMock.mockResolvedValue({
enabled: true,
migrations: [
{ ...applied, status: 'unknown' },
{ ...created, status: 'unknown' }
],
error: 'connection refused'
})
const result = await create({})
expect(runMock).not.toHaveBeenCalled()
expect(result).toContain('connection refused')
expect(result).not.toContain('10 (init)')
})
it('declares the role above the SQL of both directions', async () => {
await create({ role: 'app_rw', code_down: 'DROP TABLE refunds;', apply: false })
expect(createMock.mock.calls[0][0].requestBody).toEqual({
name: 'add_refunds',
code_up: '-- role app_rw\nBEGIN;\nCREATE TABLE refunds ();\nEND;',
code_down: '-- role app_rw\nDROP TABLE refunds;'
})
expect(runMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,391 @@
import { z } from 'zod'
import { WorkspaceService } from '$lib/gen'
import { withMigrationRole } from '$lib/components/datatableMigrationRole'
import { createToolDef, type ToolCallbacks } from './shared'
import { NONE, RUN_PREVIEW, type SessionTool } from './sessionCapabilities'
/**
* Tools over a data table's versioned SQL migrations (`datatable_migrations.rs`).
*
* A data table with migrations enabled keeps its schema as an ordered list of definitions, each
* applied once per database and recorded there. That list is what git sync and a workspace merge
* carry, so a schema change made with plain SQL exists in one database only.
*/
const memo = <T>(factory: () => T): (() => T) => {
let cached: T | undefined
return () => (cached ??= factory())
}
function apiErrorMessage(e: unknown): string {
const err = e as { body?: unknown; message?: unknown } | undefined
const body = err?.body
if (typeof body === 'string' && body !== '') return body
if (typeof err?.message === 'string') return err.message
return String(e)
}
function fail(toolCallbacks: ToolCallbacks, toolId: string, message: string): string {
toolCallbacks.setToolStatus(toolId, { content: message, error: message })
return message
}
const migrationLabel = (m: { version: number; name: string }) => `${m.version} (${m.name})`
const datatableNameSchema = () =>
z.string().describe('The datatable the migrations belong to, e.g. "main".')
// ============= Tool definitions =============
const getListMigrationsSchema = memo(() =>
z.object({
datatable_name: datatableNameSchema(),
version: z
.number()
.optional()
.describe('Return the SQL of this one migration instead of the list.')
})
)
const getListMigrationsToolDef = memo(() =>
createToolDef(
getListMigrationsSchema(),
'list_datatable_migrations',
"List a datatable's migrations in order, each with its version, name and whether it is applied or pending. Pass `version` to read one migration's SQL."
)
)
const getCreateMigrationSchema = memo(() =>
z.object({
datatable_name: datatableNameSchema(),
name: z
.string()
.regex(/^[A-Za-z0-9_-]+$/)
.describe(
'Short name of the change, e.g. "add_refunds_table". Letters, digits, "_" and "-" only.'
),
code_up: z
.string()
.describe(
'The SQL that makes the change. Wrap the statements in `BEGIN; ... END;` so they apply atomically, unless one of them cannot run in a transaction.'
),
code_down: z
.string()
.optional()
.describe(
'The SQL that undoes `code_up`. Without it the migration cannot be rolled back, so provide it whenever the change is reversible.'
),
role: z
.string()
.regex(/^[A-Za-z0-9_-]{1,63}$/)
.optional()
.describe(
'The datatable role the migration runs as, on a datatable that has roles. Omit to run as admin, which only the datatable admins may do.'
),
version: z
.number()
.optional()
.describe(
'Rewrite this migration instead of adding one: only for a pending migration, typically the one that just failed to apply.'
),
apply: z
.boolean()
.optional()
.describe('Set false to save the migration as pending without running it. Defaults to true.')
})
)
const getCreateMigrationToolDef = memo(() =>
createToolDef(
getCreateMigrationSchema(),
'create_datatable_migration',
'Change the schema of a datatable that has migrations enabled: saves the change as a new migration and applies it. One that fails to apply stays saved as pending: call this again with its `version` to fix it.'
)
)
const getRunMigrationsSchema = memo(() => z.object({ datatable_name: datatableNameSchema() }))
const getRunMigrationsToolDef = memo(() =>
createToolDef(
getRunMigrationsSchema(),
'run_datatable_migrations',
"Apply a datatable's pending migrations, oldest first. It stops at the first one that fails."
)
)
const getRollbackMigrationSchema = memo(() =>
z.object({
datatable_name: datatableNameSchema(),
version: z
.number()
.describe('The applied migration to undo, normally the most recent one, as listed.')
})
)
const getRollbackMigrationToolDef = memo(() =>
createToolDef(
getRollbackMigrationSchema(),
'rollback_datatable_migration',
'Undo one applied migration of a datatable by running its down SQL. The migration stays in the list as pending.'
)
)
// ============= Tools =============
export function getDatatableMigrationTools(): SessionTool<{}>[] {
return [
{
requires: NONE,
def: getListMigrationsToolDef(),
planModeSafe: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const { datatable_name, version } = getListMigrationsSchema().parse(args)
toolCallbacks.setToolStatus(toolId, {
content: `Listing migrations of "${datatable_name}"...`
})
try {
const status = await WorkspaceService.getDatatableMigrationsStatus({
workspace,
datatableName: datatable_name
})
if (!status.enabled) {
toolCallbacks.setToolStatus(toolId, {
content: `Migrations are not enabled on "${datatable_name}"`
})
return `Migrations are not enabled on datatable "${datatable_name}": its schema is changed with plain SQL and there is no history to list. Enabling them is a workspace admin's decision, made in Workspace settings → Data Tables.`
}
if (version !== undefined) {
const migration = status.migrations.find((m) => m.timestamp === version)
if (!migration) {
return fail(
toolCallbacks,
toolId,
`No migration ${version} on datatable "${datatable_name}"`
)
}
toolCallbacks.setToolStatus(toolId, {
content: `Read migration ${migrationLabel({ version, name: migration.name })}`
})
return JSON.stringify(
{
version,
name: migration.name,
status: migrationStatus(migration.status),
code_up: migration.code_up,
...(migration.code_down != null && { code_down: migration.code_down })
},
null,
2
)
}
toolCallbacks.setToolStatus(toolId, {
content: `Listed ${status.migrations.length} migration(s) of "${datatable_name}"`
})
return JSON.stringify(
{
migrations: status.migrations.map((m) => ({
version: m.timestamp,
name: m.name,
status: migrationStatus(m.status)
})),
// Set when the database could not be read, which is why every status is unknown.
...(status.error && { error: status.error })
},
null,
2
)
} catch (e) {
return fail(toolCallbacks, toolId, `Error listing migrations: ${apiErrorMessage(e)}`)
}
}
},
{
// `ensure_not_operator` in datatable_migrations.rs: the condition `run_preview` stands for.
requires: RUN_PREVIEW,
def: getCreateMigrationToolDef(),
requiresConfirmation: true,
confirmationMessage: (args) =>
`${args?.version === undefined ? 'Create' : 'Rewrite'}${args?.apply === false ? '' : ' and apply'} data table migration`,
queuedLabel: (args) => `Write migration "${args?.name ?? ''}"`,
showDetails: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = getCreateMigrationSchema().parse(args)
const datatableName = parsed.datatable_name
toolCallbacks.setToolStatus(toolId, {
content: `Saving migration "${parsed.name}" on "${datatableName}"...`
})
// The role is an annotation in the SQL, read from above everything else, and a rollback
// has to run as the role that created what it drops.
const definition = {
name: parsed.name,
code_up: withRole(parsed.code_up, parsed.role),
...(parsed.code_down !== undefined && {
code_down: withRole(parsed.code_down, parsed.role)
})
}
let version: number
try {
if (parsed.version === undefined) {
const created = await WorkspaceService.createDatatableMigration({
workspace,
datatableName,
requestBody: definition
})
version = created.timestamp
} else {
version = parsed.version
// The server inserts at a version it does not know, which would wedge a new
// migration into the middle of the history, and stores a down left out as none.
const { migrations } = await WorkspaceService.getDatatableMigrationsStatus({
workspace,
datatableName
})
const existing = migrations.find((m) => m.timestamp === version)
if (existing?.status !== 'not_run') {
return fail(
toolCallbacks,
toolId,
`Migration ${version} is not a pending migration of datatable "${datatableName}", so it cannot be rewritten. Omit \`version\` to add a new migration.`
)
}
await WorkspaceService.upsertDatatableMigration({
workspace,
datatableName,
requestBody: {
timestamp: version,
...(existing.code_down != null && { code_down: existing.code_down }),
...definition
}
})
}
} catch (e) {
return fail(toolCallbacks, toolId, `Error saving migration: ${apiErrorMessage(e)}`)
}
const label = migrationLabel({ version, name: parsed.name })
if (parsed.apply === false) {
toolCallbacks.setToolStatus(toolId, { content: `Saved migration ${label} as pending` })
return `Saved migration ${label} as pending. It is not applied yet.`
}
// Applying it alone would run it ahead of migrations it may depend on, and applying
// those with it would run SQL the user was never shown.
let earlierPending: string[]
try {
const status = await WorkspaceService.getDatatableMigrationsStatus({
workspace,
datatableName
})
const earlier = status.migrations.filter((m) => m.timestamp < version)
// Unreadable is not pending: reporting it so would send the user to apply
// migrations that already ran.
if (status.error || earlier.some((m) => m.status === 'unknown')) {
throw new Error(status.error ?? 'the applied migrations could not be read')
}
earlierPending = earlier
.filter((m) => m.status === 'not_run')
.map((m) => migrationLabel({ version: m.timestamp, name: m.name }))
} catch (e) {
return fail(
toolCallbacks,
toolId,
`Saved migration ${label} as pending, but could not check what else is pending, so it was not applied: ${apiErrorMessage(e)}`
)
}
if (earlierPending.length > 0) {
toolCallbacks.setToolStatus(toolId, {
content: `Saved migration ${label}; ${earlierPending.length} earlier migration(s) still pending`
})
return `Saved migration ${label} as pending without applying it: earlier migrations are not applied yet (${earlierPending.join(', ')}) and it may depend on them. Tell the user, then apply them all in order with run_datatable_migrations.`
}
toolCallbacks.setToolStatus(toolId, { content: `Applying migration ${label}...` })
try {
await WorkspaceService.runDatatableMigrations({
workspace,
datatableName,
only: version
})
} catch (e) {
// Kept whatever failed: part of it may be applied, or all of it with only the record
// missing, and the definition with its down is then the one trace of that.
return fail(
toolCallbacks,
toolId,
`Migration ${label} is saved but failed to apply: ${apiErrorMessage(e)}. It stays pending and blocks later migrations. If nothing of it was applied, fix it by calling this tool again with version ${version}; otherwise, or to drop it, tell the user, who can edit or delete it in Workspace settings → Data Tables.`
)
}
toolCallbacks.setToolStatus(toolId, { content: `Applied migration ${label}` })
return `Applied migration ${label} on datatable "${datatableName}".`
}
},
{
requires: RUN_PREVIEW,
def: getRunMigrationsToolDef(),
requiresConfirmation: true,
confirmationMessage: 'Apply pending data table migrations',
queuedLabel: (args) => `Apply migrations on "${args?.datatable_name ?? 'datatable'}"`,
showDetails: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const { datatable_name } = getRunMigrationsSchema().parse(args)
toolCallbacks.setToolStatus(toolId, {
content: `Applying migrations on "${datatable_name}"...`
})
try {
const { applied } = await WorkspaceService.runDatatableMigrations({
workspace,
datatableName: datatable_name
})
if (applied.length === 0) {
toolCallbacks.setToolStatus(toolId, { content: 'No pending migration to apply' })
return `No pending migration to apply on datatable "${datatable_name}".`
}
toolCallbacks.setToolStatus(toolId, {
content: `Applied ${applied.length} migration(s) on "${datatable_name}"`
})
return `Applied ${applied.length} migration(s) on datatable "${datatable_name}": ${applied.map(migrationLabel).join(', ')}.`
} catch (e) {
// Those before the failing one are applied: the list is what says where it stopped.
return fail(
toolCallbacks,
toolId,
`Error applying migrations: ${apiErrorMessage(e)}. Migrations before the failing one may have been applied: list them to see.`
)
}
}
},
{
requires: RUN_PREVIEW,
def: getRollbackMigrationToolDef(),
requiresConfirmation: true,
confirmationMessage: (args) => `Roll back data table migration ${args?.version ?? ''}`,
queuedLabel: (args) => `Roll back migration ${args?.version ?? ''}`,
showDetails: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const { datatable_name, version } = getRollbackMigrationSchema().parse(args)
toolCallbacks.setToolStatus(toolId, {
content: `Rolling back migration ${version} on "${datatable_name}"...`
})
try {
const { rolled_back } = await WorkspaceService.rollbackDatatableMigrations({
workspace,
datatableName: datatable_name,
only: version
})
if (rolled_back.length === 0) {
toolCallbacks.setToolStatus(toolId, { content: `Migration ${version} is not applied` })
return `Migration ${version} is not applied on datatable "${datatable_name}", so there is nothing to roll back.`
}
const label = rolled_back.map(migrationLabel).join(', ')
toolCallbacks.setToolStatus(toolId, { content: `Rolled back migration ${label}` })
return `Rolled back migration ${label} on datatable "${datatable_name}". It is pending again.`
} catch (e) {
return fail(toolCallbacks, toolId, `Error rolling back: ${apiErrorMessage(e)}`)
}
}
}
]
}
function migrationStatus(status: 'ran' | 'not_run' | 'unknown'): 'applied' | 'pending' | 'unknown' {
return status === 'ran' ? 'applied' : status === 'not_run' ? 'pending' : 'unknown'
}
function withRole(sql: string, role: string | undefined): string {
return role === undefined ? sql : withMigrationRole(sql, role)
}
@@ -286,6 +286,7 @@ export function getDatatableTools(): SessionTool<{}>[] {
datatable_name: d.datatable_name,
schemas: d.schemas,
...(d.error && { error: d.error }),
...(d.migrations_enabled && { migrations_enabled: true }),
...(d.permissioned && { usable_roles: d.usable_roles, default_role: d.default_role })
})),
null,
@@ -55,6 +55,7 @@ import { evalValue } from '$lib/components/flows/utils.svelte'
import { quickjsReady } from '$lib/utils/quickjsEval.svelte'
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
import {
BUILDER_FRAMEWORK_TEMPLATES,
FRAMEWORK_TEMPLATES,
STARTER_RUNNABLES,
type FrameworkKey
@@ -121,6 +122,7 @@ import {
} from '../skills/skillResources'
import { MAX_SKILL_DESCRIPTION_LENGTH, MAX_SKILL_INSTRUCTIONS_LENGTH } from '../skills/skillMd'
import {
getDatatablePrompt,
getDatatableSdkReference,
getFlowPrompt,
getPipelinePrompt,
@@ -161,6 +163,7 @@ import { searchDocsTool, readDocsPageTool } from '../docs/core'
import { createDbSchemaTool } from '../script/core'
import type { ContextElement } from '../context'
import { getDatatableTools } from '../datatableTools'
import { getDatatableMigrationTools } from '../datatableMigrationTools'
import { getDucklakeTools } from '../ducklakeTools'
import { fileTools } from '../files/fileTools'
import type { AttachedFilesStore } from '../files/attachedFiles.svelte'
@@ -219,6 +222,7 @@ import {
NONE,
RUN_FLOW_PREVIEW,
RUN_PREVIEW,
WRITE_APP_DRAFT,
WRITE_DRAFT,
WRITE_FLOW_DRAFT,
type SessionAccess,
@@ -363,7 +367,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum)
const getInstructionsSchema = z.object({
subject: instructionSubjectSchema.describe(
'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.'
'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.'
),
language: scriptLangSchema
.optional()
@@ -1204,16 +1208,20 @@ const openPreviewViewOnlySchema = openPreviewSchema.extend({
)
})
/** A builder operator: the flow editor, and the deployed page for everything else. */
const openPreviewFlowEditorSchema = openPreviewSchema.extend({
kind: openPreviewNoPipelineKind,
mode: z
.enum(['edit', 'view'])
.optional()
.describe(
'Which side to show. "edit" opens the flow editor and works for flows only: the only editor you can open in this workspace. Pass "view" for a script or app, the deployed page with its run form, triggers and past runs.'
)
})
/** A builder operator: the editors of the kinds they compose, and the deployed page for
* everything else. */
const openPreviewBuilderEditorSchema = (flows: boolean, apps: boolean) =>
openPreviewSchema.extend({
kind: openPreviewNoPipelineKind,
mode: z
.enum(['edit', 'view'])
.optional()
.describe(
flows && apps
? 'Which side to show. "edit" opens the flow or app editor and works for flows and raw apps only: the only editors you can open in this workspace. Pass "view" for a script, the deployed page with its run form, triggers and past runs.'
: `Which side to show. "edit" opens the ${flows ? 'flow' : 'app'} editor and works for ${flows ? 'flows' : 'raw apps'} only: the only editor you can open in this workspace. Pass "view" for a script or ${flows ? 'app' : 'flow'}, the deployed page with its run form, triggers and past runs.`
)
})
const OPEN_PREVIEW_DESCRIPTION =
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
@@ -1337,6 +1345,8 @@ const initAppSchema = z.object({
)
})
const INIT_APP_DESCRIPTION = 'Initialize a draft raw app from a framework template.'
// Mirrors the backend VALID_FOLDER_NAME check so an invalid name fails before the
// network round-trip (the server enforces the same rule).
const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/
@@ -1425,10 +1435,16 @@ const buildGlobalSystemPrompt = (
// rule cannot outlive the tool it describes. An unresolved profile keeps every block.
const canWriteDraft = !access || access.has('write_draft')
const canWriteFlowDraft = !access || access.has('write_flow_draft')
// A builder operator: flow drafts and nothing else. Blocks about drafting in general
// follow `canWriteAnyDraft`; blocks naming scripts, apps or the other kinds keep `canWriteDraft`.
const flowsOnly = canWriteFlowDraft && !canWriteDraft
const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft
const canWriteAppDraft = !access || access.has('write_app_draft')
// A builder operator: drafts of the kinds the workspace lets them compose, and nothing else.
// Blocks about drafting in general follow `canWriteAnyDraft`; blocks naming scripts or the
// other kinds keep `canWriteDraft`.
const buildsFlows = canWriteFlowDraft && !canWriteDraft
const buildsApps = canWriteAppDraft && !canWriteDraft
const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft || canWriteAppDraft
const refusedCode = ['scripts', !buildsFlows && 'flows', !buildsApps && 'apps'].filter(
(k): k is string => !!k
)
const canRunPreview = !access || access.has('run_preview')
const canManageSchedules = !access || access.has('manage_schedules')
const canManageTriggers = !access || access.has('manage_triggers')
@@ -1453,6 +1469,16 @@ const buildGlobalSystemPrompt = (
? '\n- If the user message includes an ACTIVE PREVIEW section, that is the page the side panel is showing — resolve "this page", "here" and "it" against it, and against `open` (the item the user has open in its editor) when there is one. It already tells you what get_preview_status would, so do not call that tool to learn what is on screen; call it only to check the panel\'s *other* tabs.'
: ''
const pipelineBullet = `- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on <ref>\` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow. list_pipelines lists the existing ones by folder.${pipelineAlphaNote}`
// Roles need a self-hosted Enterprise Edition. The edition is knowable only in the browser,
// so elsewhere the guidance stays and nothing is claimed about the instance.
const datatableRolesAvailable = !isCloudHosted() && (!BROWSER || !!get(enterpriseLicense))
const datatableRolesBullet = datatableRolesAvailable
? `
- A datatable can be under roles; list_datatables then reports its \`usable_roles\` and \`default_role\`. When working on a raw app that pins a role (\`data.roles\` in its raw_app.yaml), pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can. For anything else about roles, grants or a \`permission denied\`, call get_instructions with subject "datatable".`
: BROWSER
? `
- Datatable roles and permissions are not available on this instance (they need a self-hosted Enterprise Edition): everyone who can use a datatable reaches all of it, so do not promise per-user, per-schema or per-row access on one.`
: ''
// Hosting and edition come from the hostname and a store the app populates at init, so
// they are knowable only in the browser: a non-browser caller reads false for both and
// would be told "self-hosted Community Edition" whatever it targets. No base URL here
@@ -1472,9 +1498,12 @@ The current user's workspace username is "${username}".${instanceLine}
${
canWriteDraft
? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.'
: `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${flowsOnly ? '' : 'flows, '}scripts, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${flowsOnly ? 'scripts and apps' : 'scripts, flows and apps'} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when(
flowsOnly,
: `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${buildsFlows ? '' : 'flows, '}scripts, ${buildsApps ? '' : 'apps, '}schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when(
buildsFlows,
'\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.'
)}${when(
buildsApps,
`\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.`
)}`
}${when(
canWriteAnyDraft,
@@ -1537,7 +1566,7 @@ ${pipelineBullet}`
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.
- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.`
)}${when(
flowsOnly && (!access || access.has('run_flow_preview')),
buildsFlows && (!access || access.has('run_flow_preview')),
`
- After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.`
)}
@@ -1565,9 +1594,11 @@ ${pipelineBullet}`
!canWriteDraft,
`
- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${
flowsOnly
? 'The flow editor is the only editor you can open here: after writing or editing a flow draft, show it with open_preview(kind="flow", path, mode="edit").'
: 'You cannot open item editors in this workspace, so it only ever opens the deployed page.'
buildsFlows && buildsApps
? 'The flow and app editors are the only editors you can open here: after writing or editing a flow or app draft, show it with open_preview(kind="flow" or "raw_app", path, mode="edit").'
: buildsFlows || buildsApps
? `The ${buildsFlows ? 'flow' : 'app'} editor is the only editor you can open here: after writing or editing ${buildsFlows ? 'a flow' : 'an app'} draft, show it with open_preview(kind="${buildsFlows ? 'flow' : 'raw_app'}", path, mode="edit").`
: 'You cannot open item editors in this workspace, so it only ever opens the deployed page.'
}`
)}${when(
canWriteDraft,
@@ -1608,12 +1639,16 @@ Flows:
Raw apps:
- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app.
- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when(
canWriteDraft,
canWriteAppDraft,
`
- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.
- Use write_app_file, patch_app_file, and delete_app_file for frontend files.
- Use write_app_runnable and delete_app_runnable for backend runnables.
- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app's data.
- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. ${
buildsApps
? 'An app this user builds reads and stores data only through the deployed runnables it calls: do not ask about a data setup or pass init_app a data config.'
: 'When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app\'s data.'
}
- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.`
)}
@@ -1623,13 +1658,9 @@ Data Tables:
- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.${when(
canRunPreview,
`
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.`
)}${
isCloudHosted()
? ''
: `
- A raw app may use a datatable through a role (\`data.roles\` in its raw_app.yaml). When working on such an app, pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can.`
}${when(
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.
- A datatable that list_datatables reports with \`migrations_enabled\` keeps its schema as versioned migrations: change its schema with create_datatable_migration, never exec_datatable_sql. list_datatable_migrations reads them, run_datatable_migrations applies what is pending, rollback_datatable_migration undoes one. A migration is applied for real, not drafted, so call get_instructions with subject "datatable" before your first one.`
)}${datatableRolesBullet}${when(
canWriteDraft,
`
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.`
@@ -2433,7 +2464,10 @@ function getScriptInstructions(language: ScriptLang | undefined): string {
${getScriptPrompt(selected)}`
}
async function getFlowInstructions(workspace: string | undefined): Promise<string> {
async function getFlowInstructions(
workspace: string | undefined,
composeOnly: boolean
): Promise<string> {
const aiAgentProviders = formatAiAgentProvidersPrompt(
await getAiAgentProviderCatalog(workspace),
{
@@ -2449,15 +2483,22 @@ async function getFlowInstructions(workspace: string | undefined): Promise<strin
- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with \`patch_flow_json\`: ${FLOW_VALUE_SETTINGS_KEYS.join(', ')}. For example, \`chat_input_enabled: true\` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
- Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it.
- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
- When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first.
${
composeOnly
? '- Compose only path modules (`script` / `flow`) that reference items already deployed in the workspace. Rawscript modules and hub scripts are refused for this user.'
: `- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
- When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first.`
}
## Groups and notes
- Add \`groups\` to any non-trivial flow without being asked; "Organizing Flows: Groups and Notes" in the reference below gives the fields and the rules the editor enforces. Pass \`null\` to \`write_flow\` to clear them, and the same for \`notes\`.
- With \`patch_flow_json\`, edit \`groups\` and \`notes\` the same way as any other field — they appear as top-level keys in the compact flow value.
## Compact view: how rawscript bodies surface in tool I/O
${
composeOnly
? ''
: `## Compact view: how rawscript bodies surface in tool I/O
- \`read_workspace_item\` and \`patch_flow_json\` operate on a **compact view** of the flow: every rawscript module's \`value.content\` is replaced with the placeholder \`"inline_script.<moduleId>"\` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view.
- Inline rawscript content is **not** part of the JSON \`patch_flow_json\` sees. Edits to inline bodies happen via dedicated tools:
@@ -2468,15 +2509,16 @@ async function getFlowInstructions(workspace: string | undefined): Promise<strin
- \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments are **non-compact** flow modules: inline each rawscript body directly in \`content\` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a \`write_flow\` call comes back with a JSON parse error — create that module with **empty content** (\`"content": ""\`) and fill it with \`set_flow_module_code(path, module_id, code)\`, whose \`code\` is a plain argument with no nested escaping. \`write_flow\` reports which modules still have empty bodies so you know what to fill.
- When overwriting an **existing** flow, set \`"content": "inline_script.<moduleId>"\` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.
${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''}
`
}${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''}
# Windmill flow authoring reference
${getFlowPrompt()}`
${composeOnly ? 'This reference is written for every author. For this user, skip what it says about inline code (rawscript steps, inline AI agent tools) and hub scripts: the server refuses them. Compose only path steps that reference deployed scripts and flows.\n\n' : ''}${getFlowPrompt()}`
}
type InstructionSubject = (typeof ALL_INSTRUCTION_SUBJECTS)[number]
function getAppInstructions(language?: ScriptLang): string {
function getAppInstructions(language: ScriptLang | undefined, composeOnly: boolean): string {
// getRawAppPrompt swaps in the Python SDK for python3, so the pointer has to swap with
// it: telling the model to re-fetch a reference it is already holding wastes a turn, and
// naming the wrong SDK contradicts the text right below the sentence.
@@ -2488,21 +2530,40 @@ function getAppInstructions(language?: ScriptLang): string {
- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.
- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`)${
composeOnly
? '. "Starting a new app" below decides the framework. No data setup: the app reads and stores data only through the deployed runnables it calls.'
: ' and, for an app that stores data, its `data` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with `askUserQuestion`, one question per call with the suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool.'
} \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
${
composeOnly
? '- `init_app` starts from a static page with no backend runnables.'
: `- \`init_app\` seeds the starter inline runnables ${Object.keys(STARTER_RUNNABLES)
.map((k) => `\`${k}\``)
.join(
', '
)} so the demo tour works on first render. Replace or remove them once you start building real backend runnables.`
}
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
- Backend inline runnables are addressed as \`backend/<key>/main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`).
- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly.
- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item.
${
composeOnly
? '- Every backend runnable is a path runnable (`script` / `flow`) referencing an item already deployed in the workspace, created with `write_app_runnable` and removed with `delete_app_runnable`. Inline and hub runnables are refused for this user.'
: `- Backend inline runnables are addressed as \`backend/<key>/main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`).`
}
- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly.${
composeOnly
? ''
: `
- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item.`
}
- When a path runnable points at a draft (see "Draft code vs deployed code" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.
${sdkLine}
- Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.
${composeOnly ? '' : `${sdkLine}\n`}- Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.
- Use \`read_workspace_item\` with \`type: 'app'\` for a metadata summary (file paths and runnable list, no contents). Use \`read_app_file\` to read an individual file; large files are truncated to a head slice, so pass \`offset\`/\`limit\` to page through the rest rather than re-reading the whole file.
- To find where a symbol or string lives across the app, call \`search_app\` (greps every frontend file and inline runnable, returns matching \`file:line\` rows) instead of reading files one by one — then \`read_app_file\` only the ranges you need. The loop is list (\`read_workspace_item\`) → locate (\`search_app\`) → inspect (\`read_app_file\` with \`offset\`/\`limit\`).
# Windmill raw app authoring reference
${getRawAppPrompt(language)}`
${composeOnly ? 'This reference is written for every author. For this user, skip what it says about inline runnables, hub scripts and the data setup: the server refuses inline and hub runnables. Every runnable is a path runnable referencing a deployed script or flow.\n\n' : ''}${getRawAppPrompt(language)}`
}
function getResourceInstructions(): string {
@@ -2525,7 +2586,9 @@ ${getResourcePrompt()}`
function getDatatableInstructions(language?: ScriptLang): string {
// Default to the TypeScript SDK so we return only what's needed, not both.
const lang = language ?? 'bun'
return `# Datatable SQL SDK reference
return `${getDatatablePrompt()}
# Datatable SQL SDK reference
Datatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.
@@ -2542,17 +2605,18 @@ function getPipelineInstructions(): string {
function getInstructions(
subject: InstructionSubject,
language: ScriptLang | undefined,
workspace: string | undefined
workspace: string | undefined,
composeOnly: boolean
): string | Promise<string> {
switch (subject) {
case 'script':
return getScriptInstructions(language)
case 'flow':
return getFlowInstructions(workspace)
return getFlowInstructions(workspace, composeOnly)
case 'resource':
return getResourceInstructions()
case 'app':
return getAppInstructions(language)
return getAppInstructions(language, composeOnly)
case 'datatable':
return getDatatableInstructions(language)
case 'pipeline':
@@ -2597,7 +2661,11 @@ export function getSessionContextPromptSection(
// Concatenated onto an already capability-gated prompt, so it has to honour the same
// profile rather than assume the gating happened upstream.
const canDeploy = !access || access.has('deploy')
const canWriteDraft = !access || access.has('write_draft') || access.has('write_flow_draft')
const canWriteDraft =
!access ||
access.has('write_draft') ||
access.has('write_flow_draft') ||
access.has('write_app_draft')
const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview')
// The kinds no deploy rule reaches, unless the workspace withdrew them from this user.
const unruledKinds = [
@@ -3483,7 +3551,7 @@ export const globalTools: SessionTool<{}>[] = [
def: createToolDef(
getInstructionsSchema,
'get_instructions',
'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or the datatable SQL SDK (wmill.datatable()) used inside runnables.'
'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.'
),
planModeSafe: true,
fn: async (ctx) => {
@@ -3495,10 +3563,15 @@ export const globalTools: SessionTool<{}>[] = [
const canAuthor =
!access ||
access.has('write_draft') ||
(parsed.subject === 'flow' && access.has('write_flow_draft'))
(parsed.subject === 'flow' && access.has('write_flow_draft')) ||
(parsed.subject === 'app' && access.has('write_app_draft'))
if (!canAuthor) {
const message = access?.has('write_flow_draft')
? 'This session can only compose flows, so flow is the one subject with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.'
const composed = [
access?.has('write_flow_draft') && 'flow',
access?.has('write_app_draft') && 'app'
].filter((k): k is string => !!k)
const message = composed.length
? `This session can only compose ${composed.map((k) => `${k}s`).join(' and ')}, so ${composed.join(' and ')} ${composed.length > 1 ? 'are the subjects' : 'is the one subject'} with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.`
: 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.'
toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' })
return message
@@ -3508,7 +3581,10 @@ export const globalTools: SessionTool<{}>[] = [
? `${parsed.subject} (${parsed.language})`
: parsed.subject
toolCallbacks.setToolStatus(toolId, { content: `Loaded ${label} instructions` })
return getInstructions(parsed.subject, parsed.language, ctx.workspace)
// Past the gate without `write_draft` means a builder: guidance for inline code would
// steer the model at writes the server refuses and tools it was not given.
const composeOnly = !!access && !access.has('write_draft')
return getInstructions(parsed.subject, parsed.language, ctx.workspace, composeOnly)
}
},
createSearchHubScriptsTool(false),
@@ -4211,12 +4287,12 @@ export const globalTools: SessionTool<{}>[] = [
// turns on the kind. Each kind's handler, reached through `deployDraft`'s switch:
requires: NONE,
kindRequires: {
// scripts.rs `create_script_internal` and apps.rs `create_app_raw`/`update_app_raw`
// refuse operators, flows.rs `create_flow`/`update_flow` those without builder
// rights; all run the rules.
// scripts.rs `create_script_internal` refuses operators, flows.rs `create_flow`/
// `update_flow` and apps.rs `create_app_raw`/`update_app_raw` those without the
// matching builder right; all run the rules.
script: ['deploy', 'manage_code'],
flow: ['deploy', 'manage_flows'],
app: ['deploy', 'manage_code'],
app: ['deploy', 'manage_apps'],
resource: DEPLOY,
variable: DEPLOY,
// No deploy rules here, but `gate_operator_writes` on their routers.
@@ -4244,7 +4320,7 @@ export const globalTools: SessionTool<{}>[] = [
kindRequires: {
script: WRITE_DRAFT,
flow: WRITE_FLOW_DRAFT,
app: WRITE_DRAFT,
app: WRITE_APP_DRAFT,
resource: WRITE_DRAFT,
variable: WRITE_DRAFT,
schedule: WRITE_DRAFT,
@@ -4294,10 +4370,10 @@ export const globalTools: SessionTool<{}>[] = [
kindRequires: {
// scripts.rs `delete_script_by_path` calls `require_admin`; a non-admin archives.
script: ['admin'],
// apps.rs `delete_app` refuses operators, flows.rs `delete_flow_by_path` those
// without builder rights.
// flows.rs `delete_flow_by_path` and apps.rs `delete_app` refuse operators without
// the matching builder right.
flow: ['deploy', 'manage_flows'],
app: ['deploy', 'manage_code'],
app: ['deploy', 'manage_apps'],
resource: DEPLOY,
variable: DEPLOY,
schedule: ['manage_schedules'],
@@ -4442,13 +4518,15 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
def: createToolDef(
initAppSchema,
'init_app',
'Initialize a draft raw app from a framework template.',
{ strict: false }
),
requires: WRITE_APP_DRAFT,
def: createToolDef(initAppSchema, 'init_app', INIT_APP_DESCRIPTION, { strict: false }),
// A builder has no data setup: it needs SQL and inline runnables.
schemaFor: async (helpers) => {
const access = (helpers as GlobalToolHelpers | undefined)?.access
const schema =
access && !access.has('write_draft') ? initAppSchema.omit({ data: true }) : initAppSchema
return createToolDef(schema, 'init_app', INIT_APP_DESCRIPTION, { strict: false })
},
showDetails: true,
showFade: true,
fn: async (ctx) => {
@@ -4483,7 +4561,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
writeAppFileSchema,
'write_app_file',
@@ -4498,7 +4576,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
deleteAppFileSchema,
'delete_app_file',
@@ -4510,7 +4588,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
patchAppFileSchema,
'patch_app_file',
@@ -4525,7 +4603,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
writeAppRunnableSchema,
'write_app_runnable',
@@ -4541,7 +4619,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: WRITE_APP_DRAFT,
def: createToolDef(
deleteAppRunnableSchema,
'delete_app_runnable',
@@ -4579,8 +4657,14 @@ export const globalTools: SessionTool<{}>[] = [
if (canOpenEditor(access, 'script')) {
return createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
}
return canOpenEditor(access, 'flow')
? createToolDef(openPreviewFlowEditorSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
const flows = canOpenEditor(access, 'flow')
const apps = canOpenEditor(access, 'raw_app')
return flows || apps
? createToolDef(
openPreviewBuilderEditorSchema(flows, apps),
'open_preview',
OPEN_PREVIEW_DESCRIPTION
)
: createToolDef(
openPreviewViewOnlySchema,
'open_preview',
@@ -4773,6 +4857,7 @@ export const globalTools: SessionTool<{}>[] = [
},
// Workspace-scoped datatable tools (unrestricted: no whitelist, no creation policy)
...getDatatableTools(),
...getDatatableMigrationTools(),
// Workspace DuckLake: pipeline storage prerequisite, and declared measures
...getDucklakeTools(),
// Read-only tools over files the user attached to the conversation
@@ -4936,7 +5021,7 @@ async function openSessionPreview(
// a model asking outside the enum can't act outside it either.
if (mode === 'edit' && !canOpenEditor(access, args.kind)) {
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'flow' ? 'flow' : 'item'} editors in this workspace.`
return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'raw_app' ? 'app' : args.kind} editors in this workspace.`
}
// The draft capability is workspace-wide and says nothing about this path, so an edit
// that survived it is still checked against the item's own permissions.
@@ -6696,11 +6781,12 @@ async function initApp(
content: `Saving app "${path}" draft (${framework} template)…`
})
const template = FRAMEWORK_TEMPLATES[framework]
// A builder can save no inline runnable, so they start from a static page instead of the tour.
const builder = operatorBuilderAppsIn(workspace)
const value: AppDraftValue = {
summary,
files: { ...template },
runnables: structuredClone(STARTER_RUNNABLES),
files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES)[framework] },
runnables: builder ? {} : structuredClone(STARTER_RUNNABLES),
...(data && {
data: {
tables: (data.tables ?? []).map((ref) => appTableRef(data.datatable, data.schema, ref)),
@@ -6713,7 +6799,7 @@ async function initApp(
const result = await saveAppDraft(workspace, path, value)
return finishAppDraftWrite(result, ctx, () => ({
content: `Saved app "${path}" draft (${framework})`,
message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}`
message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). ${builder ? 'The template is a static page with no runnables: replace its UI, and add only deployed scripts and flows, by path.' : `The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable).`} Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}`
}))
}
@@ -47,12 +47,14 @@ describe('resolveSessionAccess', () => {
const caps = await capabilitiesFor({})
expect([...caps].sort()).toEqual([
'deploy',
'manage_apps',
'manage_code',
'manage_flows',
'manage_schedules',
'manage_triggers',
'run_flow_preview',
'run_preview',
'write_app_draft',
'write_draft',
'write_flow_draft'
])
@@ -82,6 +84,7 @@ describe('resolveSessionAccess', () => {
'deploy',
'manage_schedules',
'manage_triggers',
'write_app_draft',
'write_draft',
'write_flow_draft'
])
@@ -118,6 +121,23 @@ describe('resolveSessionAccess', () => {
])
})
// apps.rs and drafts.rs let an app builder through; nothing runs an app preview server-side.
it('gives an app builder operator the app capabilities alone', () => {
const caps = capabilitiesForRole({
isAdmin: false,
operator: true,
deployRulesPass: true,
operatorSettings: { builder_apps: true }
})
expect([...caps].sort()).toEqual([
'deploy',
'manage_apps',
'manage_schedules',
'manage_triggers',
'write_app_draft'
])
})
// A withdrawable right: only `false` withdraws it, as `gate_operator_writes` reads it.
it('withholds schedule and trigger writes only where the workspace set them to false', () => {
const caps = capabilitiesForRole({
@@ -8,17 +8,19 @@ import type { SessionAccess, SessionCapability } from '../sessionCapabilities'
type OperatorRights = Pick<
NonNullable<OperatorSettings>,
'builder_flows' | 'manage_schedules' | 'manage_triggers'
'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers'
>
const ALL_CAPABILITIES: SessionCapability[] = [
'write_draft',
'write_flow_draft',
'write_app_draft',
'run_preview',
'run_flow_preview',
'deploy',
'manage_code',
'manage_flows',
'manage_apps',
'manage_schedules',
'manage_triggers',
'admin'
@@ -54,6 +56,9 @@ export function capabilitiesForRole(role: {
if (roleCanDraft('flow', user, role.operatorSettings)) {
capabilities.add('write_flow_draft')
}
if (roleCanDraft('raw_app', user, role.operatorSettings)) {
capabilities.add('write_app_draft')
}
if (authorsCode) {
capabilities.add('run_preview')
capabilities.add('manage_code')
@@ -62,6 +67,9 @@ export function capabilitiesForRole(role: {
capabilities.add('run_flow_preview')
capabilities.add('manage_flows')
}
if (roleCanAuthor('raw_app', user, role.operatorSettings)) {
capabilities.add('manage_apps')
}
if (roleCanAuthor('schedule', user, role.operatorSettings)) {
capabilities.add('manage_schedules')
}
@@ -80,6 +80,8 @@ const REACHABLE_PROFILES = [
[
undefined,
{ builder_flows: true },
{ builder_apps: true },
{ builder_flows: true, builder_apps: true },
{ manage_schedules: false, manage_triggers: false }
].map((operatorSettings) => {
const access = capabilitiesForRole({
@@ -137,10 +139,12 @@ describe('session tool policies', () => {
const developerCaps: SessionCapability[] = [
'write_draft',
'write_flow_draft',
'write_app_draft',
'run_preview',
'run_flow_preview',
'manage_code',
'manage_flows',
'manage_apps',
'manage_schedules',
'manage_triggers'
]
@@ -167,6 +171,17 @@ describe('session tool policies', () => {
const builder = accessWith(['deploy', 'manage_flows', 'manage_schedules', 'manage_triggers'])
expect(kindsOf('deploy_workspace_item', builder)).toContain('flow')
expect(kindsOf('deploy_workspace_item', builder)).not.toContain('script')
expect(kindsOf('deploy_workspace_item', builder)).not.toContain('app')
// An app builder gets the app kind alone, and rebases its own stale app drafts.
const appBuilder = accessWith(['deploy', 'manage_apps', 'write_app_draft'])
expect(kindsOf('deploy_workspace_item', appBuilder)).toContain('app')
expect(kindsOf('deploy_workspace_item', appBuilder)).not.toContain('flow')
expect(kindsOf('rebase_draft', appBuilder)).toEqual(['app'])
// A session left with no kind must not be offered an empty enum.
expect(shippedSessionTools(operator).map((t) => t.def.function.name)).not.toContain(
'rebase_draft'
)
// Deleting a script is admin-only, where deploying one is not.
const developer = accessWith([...developerCaps, 'deploy'])
@@ -216,9 +231,9 @@ describe('session tool policies', () => {
// ships to every profile with guidance written around the draft tools by name.
it('does not hand authoring guidance naming withheld tools to a session that cannot draft', async () => {
const tool = globalTools.find((t) => t.def.function.name === 'get_instructions')!
const call = (access: SessionAccess) =>
const call = (access: SessionAccess, subject = 'script') =>
tool.fn({
args: { subject: 'script', language: 'bun' },
args: { subject, language: 'bun' },
workspace: 'ws',
helpers: { access },
toolId: 't1',
@@ -231,6 +246,30 @@ describe('session tool policies', () => {
expect(withheld.filter((n) => restricted.includes(n))).toEqual([])
// The gate is the whole test, so pin that it is not simply refusing everyone.
expect(await call(fullSessionAccess())).toContain('write_script')
// A builder gets the guide for their kind, rewritten without the inline-code tools.
for (const [capability, subject, used] of [
['write_flow_draft', 'flow', 'patch_flow_json'],
['write_app_draft', 'app', 'write_app_runnable']
] as const) {
const builder = accessWith(['deploy', capability])
const guide = await call(builder, subject)
expect(guide).toContain(used)
// The shared reference below still teaches inline code, so the guide must say to skip it.
expect(guide).toContain('skip what it says')
expect(withheldNames(builder).filter((n) => guide.includes(n))).toEqual([])
}
})
// A data setup needs SQL and inline runnables, neither of which a builder has.
it('offers a builder no init_app data setup', async () => {
const tool = globalTools.find((t) => t.def.function.name === 'init_app')!
const properties = async (access: SessionAccess) =>
((await tool.schemaFor!({ access })).function.parameters as any).properties
const builder = await properties(accessWith(['deploy', 'write_app_draft']))
const full = await properties(fullSessionAccess())
expect(builder.data).toBeUndefined()
expect(full.data).toBeDefined()
})
// A full-access profile must gate nothing at all: the text has to match the ungated
@@ -24,13 +24,16 @@ export function filterSessionTools<T extends FilterableTool>(
access: SessionAccess | undefined
): T[] {
if (!access) return tools
return tools.filter((t) => sessionToolAllowed(t, access)).map((t) => narrowKinds(t, access))
return tools
.filter((t) => sessionToolAllowed(t, access))
.flatMap((t) => narrowKinds(t, access) ?? [])
}
/** Returns the tool itself when every kind is allowed, so full access ships the same bytes:
* tool defs are part of every iteration's cached prefix. Builds new objects otherwise — the
* defs are module singletons shared by every session. */
function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): T {
* defs are module singletons shared by every session. `undefined` when no kind is left: an
* empty enum would offer a tool no call can satisfy. */
function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): T | undefined {
const kindRequires = tool.kindRequires
if (!kindRequires) return tool
const parameters = tool.def.function.parameters as {
@@ -40,6 +43,7 @@ function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess):
// A kind with no entry is withheld, as a tool with no `requires` is.
const allowed = kinds.filter((k) => kindRequires[k] && satisfies(kindRequires[k], access))
if (allowed.length === kinds.length) return tool
if (allowed.length === 0) return undefined
const def = structuredClone(tool.def)
;(def.function.parameters as typeof parameters).properties.type.enum = allowed
return { ...tool, def }
@@ -15,6 +15,8 @@ export type SessionCapability =
/** Passes `require_can_write_path` for a flow draft, which an operator also passes where the
* workspace grants `builder_flows`. The server then refuses a draft carrying inline code. */
| 'write_flow_draft'
/** The same for a raw app draft, under `builder_apps`. */
| 'write_app_draft'
/** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */
| 'run_preview'
/** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an
@@ -22,12 +24,14 @@ export type SessionCapability =
| 'run_flow_preview'
/** Passes `check_deploy_rules`. */
| 'deploy'
/** Passes the operator refusal the script and app handlers make before creating or
/** Passes the operator refusal the script handlers make before creating or
* deleting one. The same condition as `run_preview` today, but another handler family's
* check, so each follows its own gate if the two ever diverge. */
| 'manage_code'
/** Passes the flow handlers' `check_operator_can_build_flows`. */
| 'manage_flows'
/** Passes the raw app handlers' `check_operator_can_build`, read from `builder_apps`. */
| 'manage_apps'
/** Passes `gate_operator_writes` on the schedule router: withheld only from an operator whose
* workspace set `operator_settings.manage_schedules` to false. */
| 'manage_schedules'
@@ -50,6 +54,7 @@ export type SessionToolPolicy = readonly SessionCapability[]
export const NONE: SessionToolPolicy = []
export const WRITE_DRAFT: SessionToolPolicy = ['write_draft']
export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft']
export const WRITE_APP_DRAFT: SessionToolPolicy = ['write_app_draft']
export const RUN_PREVIEW: SessionToolPolicy = ['run_preview']
export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview']
export const DEPLOY: SessionToolPolicy = ['deploy']
@@ -62,7 +67,9 @@ export type EditorKind = 'script' | 'flow' | 'raw_app' | 'pipeline'
* resolved yet, or not a session) answers true, as the toolset filter does. */
export function canOpenEditor(access: SessionAccess | undefined, kind: EditorKind): boolean {
if (!access) return true
return access.has(kind === 'flow' ? 'write_flow_draft' : 'write_draft')
return access.has(
kind === 'flow' ? 'write_flow_draft' : kind === 'raw_app' ? 'write_app_draft' : 'write_draft'
)
}
/**
@@ -0,0 +1,94 @@
<script lang="ts">
interface Props {
height?: string
width?: string
size?: number
}
let { height, width, size = 24 }: Props = $props()
let effectiveWidth = $derived(width ?? `${size}px`)
let effectiveHeight = $derived(height ?? `${size}px`)
</script>
<!-- Microsoft's Outlook-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com, served on microsoft.com/microsoft-365/outlook), paths and gradient stops verbatim.
Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. -->
<svg
width={effectiveWidth}
height={effectiveHeight}
viewBox="0 0 48 48"
xmlns="http://www.w3.org/2000/svg"
fill="none"
>
<path d="M30.9322 9.34307L7.97588 23.8943L6.00165 20.7797V18.0959C6.00165 17.1188 6.49634 16.2081 7.31603 15.6762L20.6606 7.01716C22.6937 5.69792 25.3125 5.69771 27.3458 7.01665L30.9322 9.34307Z" fill="url(#ms-outlook-0)"/>
<path d="M27.1402 6.88928C27.2092 6.93033 27.2777 6.97284 27.3455 7.01682L37.76 13.7724L11.9375 30.1404L7.97498 23.8891L26.9262 11.8533C28.7213 10.7133 28.7999 8.14871 27.1402 6.88928Z" fill="url(#ms-outlook-1)"/>
<path d="M27.1402 6.88928C27.2092 6.93033 27.2777 6.97284 27.3455 7.01682L37.76 13.7724L11.9375 30.1404L7.97498 23.8891L26.9262 11.8533C28.7213 10.7133 28.7999 8.14871 27.1402 6.88928Z" fill="url(#ms-outlook-2)" fill-opacity="0.2"/>
<path d="M22.2402 33.2659L11.9377 30.1406L33.842 16.2557C35.6868 15.0864 35.682 12.3932 33.8331 11.2304L33.7344 11.1684L34.0185 11.3451L40.685 15.6695C41.5049 16.2014 41.9998 17.1122 41.9998 18.0895V20.687L22.2402 33.2659Z" fill="url(#ms-outlook-3)"/>
<path d="M22.2402 33.2659L11.9377 30.1406L33.842 16.2557C35.6868 15.0864 35.682 12.3932 33.8331 11.2304L33.7344 11.1684L34.0185 11.3451L40.685 15.6695C41.5049 16.2014 41.9998 17.1122 41.9998 18.0895V20.687L22.2402 33.2659Z" fill="url(#ms-outlook-4)" fill-opacity="0.2"/>
<path d="M27.3458 7.01665C25.3125 5.69771 22.6937 5.69792 20.6605 7.01716L7.31603 15.6762C6.49634 16.2081 6.00165 17.1188 6.00165 18.0959V18.2272C6.03382 19.208 6.55002 20.1121 7.38373 20.6378L23.9764 31.0985L40.6108 20.6537C41.4747 20.1112 41.999 19.1628 41.999 18.1427V20.6873L41.9994 18.0896C41.9994 17.1123 41.5045 16.2015 40.6846 15.6696L27.3458 7.01665Z" fill="url(#ms-outlook-5)"/>
<path d="M21.0513 42.0035H35.748C39.1998 42.0035 41.998 39.2053 41.998 35.7535L41.998 18.1426C41.998 19.1627 41.4736 20.1111 40.6098 20.6536L18.7495 34.3798C17.5703 35.1202 16.8546 36.4149 16.8547 37.8073C16.8549 40.1248 18.7337 42.0035 21.0513 42.0035Z" fill="url(#ms-outlook-6)"/>
<path d="M21.0513 42.0035H35.748C39.1998 42.0035 41.998 39.2053 41.998 35.7535L41.998 18.1426C41.998 19.1627 41.4736 20.1111 40.6098 20.6536L18.7495 34.3798C17.5703 35.1202 16.8546 36.4149 16.8547 37.8073C16.8549 40.1248 18.7337 42.0035 21.0513 42.0035Z" fill="url(#ms-outlook-7)" fill-opacity="0.4"/>
<path d="M21.0513 42.0035H35.748C39.1998 42.0035 41.998 39.2053 41.998 35.7535L41.998 18.1426C41.998 19.1627 41.4736 20.1111 40.6098 20.6536L18.7495 34.3798C17.5703 35.1202 16.8546 36.4149 16.8547 37.8073C16.8549 40.1248 18.7337 42.0035 21.0513 42.0035Z" fill="url(#ms-outlook-8)" fill-opacity="0.5"/>
<path d="M27.0268 42.0023H12.2492C8.79745 42.0023 5.99923 39.2041 5.99923 35.7523V18.1297C5.99923 19.1478 6.52165 20.0948 7.38292 20.6377L29.2215 34.4058C30.4171 35.1595 31.1423 36.4741 31.1421 37.8874C31.1419 40.1601 29.2995 42.0023 27.0268 42.0023Z" fill="url(#ms-outlook-9)"/>
<path d="M27.0268 42.0023H12.2492C8.79745 42.0023 5.99923 39.2041 5.99923 35.7523V18.1297C5.99923 19.1478 6.52165 20.0948 7.38292 20.6377L29.2215 34.4058C30.4171 35.1595 31.1423 36.4741 31.1421 37.8874C31.1419 40.1601 29.2995 42.0023 27.0268 42.0023Z" fill="url(#ms-outlook-10)"/>
<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-outlook-11)"/>
<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-outlook-12)" fill-opacity="0.5"/>
<path d="M11.959 35.5999C10.636 35.5999 9.54994 35.186 8.70069 34.3583C7.85144 33.5306 7.42682 32.4505 7.42682 31.1179C7.42682 29.7107 7.85785 28.5726 8.7199 27.7035C9.58195 26.8345 10.7107 26.3999 12.1062 26.3999C13.4249 26.3999 14.4982 26.8158 15.3261 27.6477C16.1583 28.4795 16.5744 29.5762 16.5744 30.9378C16.5744 32.3367 16.1433 33.4644 15.2813 34.3211C14.4235 35.1736 13.3161 35.5999 11.959 35.5999ZM11.9974 33.8431C12.7186 33.8431 13.299 33.5968 13.7386 33.1044C14.1781 32.6119 14.3979 31.9269 14.3979 31.0496C14.3979 30.1349 14.1845 29.4231 13.7578 28.9141C13.331 28.405 12.7613 28.1505 12.0486 28.1505C11.3146 28.1505 10.7235 28.4133 10.2754 28.9389C9.82733 29.4604 9.60328 30.1515 9.60328 31.0123C9.60328 31.8856 9.82733 32.5767 10.2754 33.0857C10.7235 33.5906 11.2975 33.8431 11.9974 33.8431Z" fill="white"/>
<path d="M11.9554 35.7233C10.6375 35.7233 9.55551 35.2982 8.7095 34.448C7.8635 33.5977 7.44049 32.4881 7.44049 31.1192C7.44049 29.6737 7.86987 28.5046 8.72864 27.6119C9.5874 26.7191 10.7119 26.2727 12.102 26.2727C13.4157 26.2727 14.4849 26.7 15.3097 27.5545C16.1387 28.409 16.5532 29.5356 16.5532 30.9343C16.5532 32.3712 16.1238 33.5297 15.265 34.4097C14.4105 35.2855 13.3073 35.7233 11.9554 35.7233ZM11.9936 33.9187C12.7121 33.9187 13.2903 33.6657 13.7282 33.1598C14.1661 32.6539 14.385 31.9503 14.385 31.049C14.385 30.1095 14.1724 29.3783 13.7473 28.8554C13.3222 28.3325 12.7546 28.071 12.0447 28.071C11.3134 28.071 10.7246 28.341 10.2782 28.8809C9.83185 29.4165 9.60865 30.1265 9.60865 31.0108C9.60865 31.9078 9.83185 32.6178 10.2782 33.1407C10.7246 33.6593 11.2964 33.9187 11.9936 33.9187Z" fill="white"/>
<defs>
<linearGradient id="ms-outlook-0" x1="9.98908" y1="22.3649" x2="30.9322" y2="9.37495" gradientUnits="userSpaceOnUse">
<stop stop-color="#20A7FA"/>
<stop offset="0.4" stop-color="#3BD5FF"/>
<stop offset="1" stop-color="#C4B0FF"/>
</linearGradient>
<linearGradient id="ms-outlook-1" x1="17.1972" y1="26.7945" x2="28.8562" y2="8.12575" gradientUnits="userSpaceOnUse">
<stop stop-color="#165AD9"/>
<stop offset="0.5008" stop-color="#1880E5"/>
<stop offset="1" stop-color="#8587FF"/>
</linearGradient>
<linearGradient id="ms-outlook-2" x1="25.7005" y1="27.0484" x2="12.7563" y2="16.5013" gradientUnits="userSpaceOnUse">
<stop offset="0.236946" stop-color="#448AFF" stop-opacity="0"/>
<stop offset="0.792113" stop-color="#0032B1"/>
</linearGradient>
<linearGradient id="ms-outlook-3" x1="24.0534" y1="31.1099" x2="44.51" y2="18.0177" gradientUnits="userSpaceOnUse">
<stop stop-color="#1A43A6"/>
<stop offset="0.492267" stop-color="#2052CB"/>
<stop offset="1" stop-color="#5F20CB"/>
</linearGradient>
<linearGradient id="ms-outlook-4" x1="29.8281" y1="30.3273" x2="17.3975" y2="19.5708" gradientUnits="userSpaceOnUse">
<stop stop-color="#0045B9" stop-opacity="0"/>
<stop offset="0.669859" stop-color="#0D1F69"/>
</linearGradient>
<radialGradient id="ms-outlook-5" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.0018 6.81788) rotate(-90) scale(27.0027 29.2262)">
<stop offset="0.568182" stop-color="#275FF0" stop-opacity="0"/>
<stop offset="0.992424" stop-color="#002177"/>
</radialGradient>
<linearGradient id="ms-outlook-6" x1="41.998" y1="29.9431" x2="23.8517" y2="29.9431" gradientUnits="userSpaceOnUse">
<stop stop-color="#4DC4FF"/>
<stop offset="0.196145" stop-color="#0FAFFF"/>
</linearGradient>
<radialGradient id="ms-outlook-7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(28.0928 37.9117) rotate(-45) scale(11.572)">
<stop offset="0.259477" stop-color="#0060D1"/>
<stop offset="0.908166" stop-color="#0383F1" stop-opacity="0"/>
</radialGradient>
<radialGradient id="ms-outlook-8" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(10.6314 46.472) rotate(-52.6577) scale(39.2814 35.5204)">
<stop offset="0.732317" stop-color="#F4A7F7" stop-opacity="0"/>
<stop offset="1" stop-color="#F4A7F7"/>
</radialGradient>
<radialGradient id="ms-outlook-9" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(18.5708 27.5319) rotate(123.339) scale(20.7258 53.7859)">
<stop stop-color="#49DEFF"/>
<stop offset="0.724349" stop-color="#29C3FF"/>
</radialGradient>
<linearGradient id="ms-outlook-10" x1="3.45756" y1="37.8723" x2="20.9291" y2="37.8597" gradientUnits="userSpaceOnUse">
<stop offset="0.205882" stop-color="#6CE0FF"/>
<stop offset="0.535" stop-color="#50D5FF" stop-opacity="0"/>
</linearGradient>
<radialGradient id="ms-outlook-11" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(3.94291 23.6154) rotate(46.9242) scale(21.0618)">
<stop offset="0.038877" stop-color="#0091FF"/>
<stop offset="0.919119" stop-color="#183DAD"/>
</radialGradient>
<radialGradient id="ms-outlook-12" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(12 32.7439) rotate(90) scale(11.2 12.9188)">
<stop offset="0.557796" stop-color="#0FA5F7" stop-opacity="0"/>
<stop offset="1" stop-color="#74C6FF"/>
</radialGradient>
</defs>
</svg>
@@ -90,6 +90,7 @@ import ResendIcon from './ResendIcon.svelte'
import PushoverIcon from './PushoverIcon.svelte'
import QuickbooksIcon from './QuickbooksIcon.svelte'
import MsTeamsIcon from './MSTeamsIcon.svelte'
import OutlookIcon from './OutlookIcon.svelte'
import MailgunIcon from './MailgunIcon.svelte'
import IpinfoIcon from './IpinfoIcon.svelte'
import GoogleCloudIcon from './GoogleCloudIcon.svelte'
@@ -445,6 +446,7 @@ export const APP_TO_ICON_COMPONENT = {
quickbooks: QuickbooksIcon,
ms_teams_webhook: MsTeamsIcon,
teams: MsTeamsIcon,
outlook: OutlookIcon,
mailgun: MailgunIcon,
ipinfo: IpinfoIcon,
gworkspace: GoogleIcon,
@@ -776,6 +778,7 @@ export {
PushoverIcon,
QuickbooksIcon,
MsTeamsIcon,
OutlookIcon,
MailgunIcon,
IpinfoIcon,
GoogleCloudIcon,
@@ -838,17 +838,8 @@ export const settings: Record<string, Setting[]> = {
description: 'Default python version for newly deployed scripts',
key: 'instance_python_version',
fieldType: 'select_python',
// 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
placeholder: '3.10,3.11,3.12,3.13,3.14,3.15',
select_items: [
{
label: 'Latest Stable',
value: 'default',
tooltip: 'python-3.12'
},
{
label: '3.10'
},
@@ -88,8 +88,11 @@
import { randomSecret, randomUUID } from '$lib/utils/uuid'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { conformBuilderAppPolicy } from './builderAppPolicy'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderApps = useOperatorBuilderApps()
interface Props {
files?: Record<string, string>
@@ -413,6 +416,11 @@
if (url) URL.revokeObjectURL(url)
}
})
// The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user
// deployed carries, so the editor conforms the policy as soon as it opens.
$effect(() => {
if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore)
})
// An app with sandbox isolation previews the way it runs once deployed: in the
// served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce
// in its URL authenticates its `windmill:ready`, as in `RawAppPreview`.
@@ -13,7 +13,7 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import PanelSection from '../apps/editor/settingsPanel/common/PanelSection.svelte'
import { JobService, type InputTransform, type ListSelectedJobGroupsResponse } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from '../operatingWorkspace.svelte'
import type { Schema } from '$lib/common'
import InputTransformForm from '../InputTransformForm.svelte'
import type { FlowPropPickerConfig, PropPickerContext } from '../prop_picker'
@@ -43,6 +43,8 @@
onConfirm: (options: BatchReRunOptions) => void
} = $props()
const operatingWorkspace = useOperatingWorkspace()
let selected: JobGroup | undefined = $state()
let options: BatchReRunOptions = $state({ flow: {}, script: {} })
watch(
@@ -65,7 +67,7 @@
const listSelectedJobsSchemaCache = new Map<string, JobGroup>()
async function fetchJobGroups(): Promise<JobGroup[]> {
if (!$workspaceStore) return []
if (!$operatingWorkspace) return []
const cachedSelectedIds = selectedIds.filter((id) => listSelectedJobsSchemaCache.has(id))
const nonCachedSelectedIds = selectedIds.filter((id) => !listSelectedJobsSchemaCache.has(id))
@@ -75,7 +77,7 @@
)
const newJobGroups = nonCachedSelectedIds.length
? await JobService.listSelectedJobGroups({
workspace: $workspaceStore,
workspace: $operatingWorkspace,
requestBody: nonCachedSelectedIds
})
: []
@@ -7,7 +7,7 @@
import { Skeleton, Tab, Tabs } from '../common'
import HighlightCode from '../HighlightCode.svelte'
import FlowStatusViewer from '../FlowStatusViewer.svelte'
import { workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from '../operatingWorkspace.svelte'
import WorkflowTimeline from '../WorkflowTimeline.svelte'
import { isFlowPreview, isScriptPreview, type StateStore } from '$lib/utils'
import { setContext, untrack, createEventDispatcher } from 'svelte'
@@ -26,6 +26,7 @@
let { id, workspace, onResolutionChanged }: Props = $props()
const dispatch = createEventDispatcher()
const operatingWorkspace = useOperatingWorkspace()
let job: (Job & { result_stream?: string }) | undefined = $state(undefined)
@@ -185,7 +186,7 @@
<div class="text-xs text-emphasis font-semibold mb-1">Inputs</div>
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
workspace={job?.workspace_id ?? $operatingWorkspace ?? 'no_w'}
args={job?.args}
/>
</div>
@@ -21,6 +21,7 @@
import { forLater } from '$lib/forLater'
import { twMerge } from 'tailwind-merge'
import Portal from '$lib/components/Portal.svelte'
import { useHostedPage } from '../hostedPage'
import WaitTimeWarning from '../common/waitTimeWarning/WaitTimeWarning.svelte'
import DropdownV2 from '../DropdownV2.svelte'
@@ -29,6 +30,7 @@
import './runs-grid.css'
const dispatch = createEventDispatcher()
const hosted = useHostedPage()
interface Props {
job: Job
@@ -60,7 +62,10 @@
</script>
<Portal name="run-row">
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
<ScheduleEditor
onUpdate={() => (hosted ? hosted.openLink('/schedules') : goto('/schedules'))}
bind:this={scheduleEditor}
/>
</Portal>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -13,7 +13,6 @@
Wrench
} from 'lucide-svelte'
import Popover from '../Popover.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import './runs-grid.css'
import { useKeyPressed } from '$lib/svelte5Utils.svelte'
import { twMerge } from 'tailwind-merge'
@@ -21,6 +20,9 @@
import DropdownMenu, { type Props as DropdownMenuProps } from '../DropdownMenu.svelte'
import { clickOutside, isJobCancelable, isJobReRunnable, isJobResolvable } from '$lib/utils'
import { goto } from '$lib/navigation'
import { useHostedPage } from '../hostedPage'
import { useOperatingUser, useOperatingWorkspace } from '../operatingWorkspace.svelte'
import { withWorkspaceParam } from '../sessions/sessionMode.svelte'
import BarsStaggered from '../icons/BarsStaggered.svelte'
interface Props {
@@ -201,6 +203,16 @@
}
const dispatch = createEventDispatcher()
const hosted = useHostedPage()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
// Under a host the link opens where it decides, in the workspace the run belongs to: the
// document's own router would leave the host for the navigation workspace.
function open(href: string) {
if (!hosted) goto(href)
else hosted.openLink(withWorkspaceParam(href, selectedWorkspace ?? $operatingWorkspace))
}
let scrollToIndex = $state(0)
@@ -272,7 +284,7 @@
actions.push({
label: 'Show run details',
icon: ExternalLinkIcon,
onClick: () => goto(`/run/${selectedIds[0]}`)
onClick: () => open(`/run/${selectedIds[0]}`)
})
const job = flatJobs?.find(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
@@ -282,14 +294,20 @@
actions.push({
label: 'Go to script page',
icon: Code2Icon,
onClick: () => goto(`/scripts/get/${job.job.script_hash}`)
onClick: () =>
open(
// A host addresses an item by its path, and pins a version in the query.
hosted
? `/scripts/get/${job.job.script_path}?version=${job.job.script_hash}`
: `/scripts/get/${job.job.script_hash}`
)
})
}
if (job.job.job_kind === 'flow') {
actions.push({
label: 'Go to flow page',
icon: BarsStaggered,
onClick: () => goto(`/flows/get/${job.job.script_path}`)
onClick: () => open(`/flows/get/${job.job.script_path}`)
})
}
}
@@ -314,7 +332,7 @@
onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null)
})
// Operators are rejected by the endpoint, so offering the action would only 403.
const canResolve = !$userStore?.operator
const canResolve = !operatingUser.current?.operator
const resolvable = canResolve ? selectedIdsPossibleActions.resolvableJobIds.length : 0
const unresolvable = canResolve ? selectedIdsPossibleActions.unresolvableJobIds.length : 0
if (resolvable)
@@ -401,7 +419,7 @@
? jobCountString(jobs.length + externalJobs.length, lastFetchWentToEnd)
: ''}<Tooltip>{externalJobs.length} jobs obscured</Tooltip>
</div>
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
{:else if $operatingWorkspace !== 'admins' && omittedObscuredJobs}
<div class="flex flex-row">
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
<Popover>
@@ -3,8 +3,14 @@
import { Loader2 } from 'lucide-svelte'
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { setHostedPage, type HostedPage } from '$lib/components/hostedPage'
import { pageItemForListPath, parsePreviewItemRoute } from './previewPaths'
import { previewLocationLabel } from './previewRouter'
import {
RUNS_PATH,
barePath,
isRunsListPath,
pageItemForListPath,
parsePreviewItemRoute
} from './previewPaths'
import { parseRunRoute, previewLocationLabel } from './previewRouter'
import { whereIs } from './sessionPreviewTabs.svelte'
import type { SessionPreviewTab } from './sessionState.svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
@@ -44,20 +50,48 @@
? { pathname: loc, search: '' }
: { pathname: loc.slice(0, at), search: loc.slice(at) }
}
// The location this page last wrote itself, and the one it was last moved to from outside.
let ownLoc: string | undefined
let movedTo: string | undefined
let moves = 0
// Bumped when the location moves other than by the page's own write. Derived rather than
// effected: it has to flip in the same flush as the location, before the page's own
// effects read the new query and write their defaults back over the stored preferences.
const moved = $derived.by(() => {
const loc = whereIs(tab)
if (loc !== ownLoc && loc !== movedTo) {
movedTo = loc
moves++
}
return moves
})
const host: HostedPage = {
get search() {
return locationParts().search
},
setSearch(search) {
runtime.previewTabs.observeLocation(tab.id, locationParts().pathname + search)
ownLoc = locationParts().pathname + search
movedTo = undefined
runtime.previewTabs.observeLocation(tab.id, ownLoc)
},
openItem(itemPath) {
const ref = pageItemForListPath(path, itemPath)
if (ref) runtime.previewTabs.open({ type: 'pageitem', ref })
},
openLink(href) {
const run = parseRunRoute(href)
if (run) {
runtime.manager.openRunInPreview?.({
jobId: run.jobId,
workspace: run.workspace ?? workspaceId,
label: previewLocationLabel(`/run/${run.jobId}`)
})
return
}
const item = parsePreviewItemRoute(href)
if (item) {
// A link to one version stays a page tab: opening an item focuses a tab already
// on it as it stands, where a page tab's URL is written whole, pin included.
if (item && !new URL(href, 'http://_').searchParams.has('version')) {
runtime.previewTabs.open({
type: 'item',
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' },
@@ -83,18 +117,32 @@
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const a = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
if (!a || a.hasAttribute('download')) return
const url = new URL(a.href, window.location.href)
if (url.origin !== window.location.origin) return
const href = url.pathname + url.search + url.hash
// A run's link asks for a new tab so the list keeps its place: here that tab is a
// preview tab, not a browser one that would leave the session.
if (a.target && a.target !== '_self' && !parseRunRoute(href)) return
e.preventDefault()
if (a.getAttribute('href')?.startsWith('#')) return
host.openLink(url.pathname + url.search + url.hash)
host.openLink(href)
}
el.addEventListener('click', onClick)
return () => el.removeEventListener('click', onClick)
})
const listed = $derived(pageItemForListPath(path, ''))
// `/runs/<item path>` lists one item's runs.
const runsOf = $derived.by(() => {
if (!isRunsListPath(path)) return undefined
const itemPath = barePath(path).slice(RUNS_PATH.length + 1)
try {
return decodeURIComponent(itemPath)
} catch {
return itemPath
}
})
</script>
{#snippet loading()}
@@ -130,6 +178,15 @@
{:then Module}
<Module.default triggerKind={listed.triggerKind} />
{/await}
{:else if runsOf !== undefined}
<!-- A move from outside remounts it, so it seeds the filters the user keeps stored. -->
{#key moved}
{#await import('$lib/components/RunsPage.svelte')}
{@render loading()}
{:then Module}
<Module.default initialPath={runsOf} />
{/await}
{/key}
{/if}
{/key}
</div>
@@ -23,6 +23,7 @@
import SessionEditorTarget from './SessionEditorTarget.svelte'
import { runResetToDeployed } from '$lib/userDraftToast'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { operatorBuilderAppsIn } from '$lib/operatorWriteRights'
import type {
RawAppRuntimeLogRequester,
RawAppRunsProvider,
@@ -97,7 +98,10 @@
const setUp = {
...val,
files: { ...result.files },
runnables: { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) },
runnables: {
...result.runnables,
...(operatorBuilderAppsIn(workspaceId) ? {} : structuredClone(STARTER_RUNNABLES))
},
data: result.data,
policy: result.policy
}
@@ -84,6 +84,17 @@ export function isPageItemListPath(path: string): boolean {
return pageItemForListPath(path, '') !== undefined
}
/** Whether a path is the runs list: every run, or `/runs/<item path>` for one item's. */
export function isRunsListPath(path: string): boolean {
const clean = barePath(path)
return clean === RUNS_PATH || clean.startsWith(`${RUNS_PATH}/`)
}
/** Whether a path is a list page a session preview mounts in process rather than frames. */
export function isPreviewListPath(path: string): boolean {
return isPageItemListPath(path) || isRunsListPath(path)
}
/** The page item a list page's row names, or undefined for a page that lists none. */
export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined {
const clean = barePath(pagePath)
@@ -145,7 +156,13 @@ export const pageKey = (path: string) => `page:${path}`
/** Strip the query and hash from a preview path so it can be matched against
* `PREVIEW_PAGES` / parsed as an item route. */
export function barePath(path: string): string {
return path.split('?')[0].split('#')[0] || '/'
// The router serves `/runs/` as `/runs`, and links are written both ways.
return (
path
.split('?')[0]
.split('#')[0]
.replace(/(.)\/+$/, '$1') || '/'
)
}
/** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the
@@ -313,7 +313,7 @@ describe('itemDisplayName', () => {
describe('resolvePreviewTab', () => {
it('routes a static page to the iframe fallback', () => {
expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' })
expect(resolvePreviewTab('/audit_logs')).toEqual({ kind: 'iframe' })
})
it('mounts a run in process, in the workspace its URL names', () => {
@@ -324,8 +324,19 @@ describe('resolvePreviewTab', () => {
})
expect(resolvePreviewTab('/run/01a0')).toEqual({ kind: 'run', jobId: '01a0' })
expect(resolvePreviewTab('/run/01a0?workspace=fork&view_token=t')).toEqual({ kind: 'iframe' })
// The runs of one item are a list page, not a run.
expect(resolvePreviewTab('/runs/f/a/b')).toEqual({ kind: 'iframe' })
})
it('mounts the runs list in process, for every run or for one item', () => {
expect(resolvePreviewTab('/runs?status=failure')).toEqual({ kind: 'pagelist', path: '/runs' })
expect(resolvePreviewTab('/runs/f/a/b')).toEqual({ kind: 'pagelist', path: '/runs/f/a/b' })
expect(resolvePreviewTab('/runsheet')).toEqual({ kind: 'iframe' })
})
it('tells two runs lists apart by their filters, trailing slash or not', () => {
expect(resolvePreviewTab('/runs/?status=failure')).toEqual({ kind: 'pagelist', path: '/runs' })
expect(showsView('/runs/?schedule_path=a', '/runs/?schedule_path=b')).toBe(false)
expect(showsView('/runs?schedule_path=a', '/runs/?schedule_path=a')).toBe(true)
expect(showsView('/runs/f/a/b?status=failure', '/runs/f/a/b')).toBe(false)
})
it('mounts the list page of a page item kind in process, whatever its filters', () => {
@@ -3,7 +3,8 @@ import {
AUDIT_LOGS_PATH,
FOLDERS_PATH,
GROUPS_PATH,
isPageItemListPath,
isPreviewListPath,
isRunsListPath,
pageItemForListPath,
pageItemListPath,
pageItemPageHref,
@@ -247,9 +248,14 @@ function pageRequestParamTable(): Record<string, readonly string[]> {
})
}
// The page whose filters a path takes: one item's runs filter as every run does.
function filterPageOf(path: string): string {
return isRunsListPath(path) ? RUNS_PATH : path
}
/** The query params a request can set on `path` — empty for a page that takes none. */
export function pageRequestParams(path: string): readonly string[] {
return pageRequestParamTable()[barePath(path)] ?? []
return pageRequestParamTable()[filterPageOf(barePath(path))] ?? []
}
/** What a preview location means, read against the page it points at. */
@@ -286,7 +292,7 @@ export function describeLocation(loc: string): PreviewLocation {
const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : ''
return {
identity: path,
view: requestedParams(query, pageRequestParamTable()[path]),
view: requestedParams(query, pageRequestParamTable()[filterPageOf(path)]),
anchor: drawerAnchorFor(canonical) ?? ''
}
}
@@ -319,7 +325,7 @@ export function showsView(observed: string, commanded: string): boolean {
const y = describeLocation(commanded)
if (x.identity !== y.identity || x.anchor !== y.anchor) return false
if (x.view === y.view) return true
const seeded = PAGE_SEEDED_PARAMS[x.identity]
const seeded = PAGE_SEEDED_PARAMS[filterPageOf(x.identity)]
if (!seeded?.length) return false
// Both views come out of `requestedParams` already sorted and encoded, so they compare
// as strings once the seeded params are dropped. One direction only: the page may add
@@ -594,7 +600,8 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
// flow, raw app) mounts its per-(kind,path) cell editor on `/edit/` and its viewer
// on `/get/`; a `/pipeline/<folder>` route mounts the data-pipeline graph editor of
// that folder (`path` is the folder); the list page of a page item kind mounts that
// list; a `/run/<id>` route without a share token mounts that run's page; everything
// list, and so does the runs list; a `/run/<id>` route without a share token mounts that
// run's page; everything
// else (static pages, regular drag-and-drop apps, any other route) stays an iframe.
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
@@ -606,6 +613,18 @@ export type PreviewSlot =
| { kind: 'run'; jobId: string; workspace?: string }
| { kind: 'iframe' }
/** Whether a tab on this slot shows a page, framed or mounted in process, and so has a
* location to stamp as the chat's ACTIVE PREVIEW. Editors register as the ACTIVE EDITOR. */
export function slotShowsPage(kind: PreviewSlot['kind']): boolean {
return (
kind === 'iframe' ||
kind === 'pageitem' ||
kind === 'pagelist' ||
kind === 'run' ||
kind === 'viewer'
)
}
export function resolvePreviewTab(url: string): PreviewSlot {
const artifact = parseArtifactRoute(url)
if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version }
@@ -614,7 +633,7 @@ export function resolvePreviewTab(url: string): PreviewSlot {
const pageItem = parsePageItemRoute(url)
if (pageItem) return { kind: 'pageitem', ref: pageItem }
const route = barePath(url)
if (isPageItemListPath(route)) return { kind: 'pagelist', path: route }
if (isPreviewListPath(route)) return { kind: 'pagelist', path: route }
const run = parseRunRoute(url)
// A share link's token authorizes every request of the window that installs it, so a run
// opened through one keeps a frame of its own rather than lending the token to the session.
@@ -1,6 +1,7 @@
import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
import { get } from 'svelte/store'
import { userWorkspaces } from '$lib/stores'
import { roleCanAuthor } from '$lib/editRights'
import {
checkDeployPermission,
checkDeployRules,
@@ -231,9 +232,9 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
// button disables with a reason instead of failing on click. `ok` defaults
// true while resolving (fail-open).
let deployPerm = $state<DeployPermission>({ ok: true })
// `checkDeployPermission` refuses every operator, but flows.rs lets one with the
// workspace's `builder_flows` right deploy flows, under the rules alone.
let builderFlowPerm = $state<DeployPermission | undefined>(undefined)
// `checkDeployPermission` refuses every operator, but flows.rs and apps.rs let one with the
// workspace's matching builder right deploy that kind, under the rules alone.
let builder = $state<{ perm: DeployPermission; kinds: Kind[] } | undefined>(undefined)
let deployPermFetchedFor = ''
$effect(() => {
const ws = getArgs().workspaceId
@@ -243,22 +244,24 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
// Reset to fail-open for the new workspace and drop a stale resolution
// (a slower fetch for the previous workspace must not gate this one).
deployPerm = { ok: true }
builderFlowPerm = undefined
builder = undefined
void checkDeployPermission(ws).then(async (perm) => {
if (deployPermFetchedFor !== ws) return
deployPerm = perm
const builder =
get(userWorkspaces).find((w) => w.id === ws)?.operator_settings?.builder_flows === true
if (perm.refusedBy === 'operator' && builder) {
const settings = get(userWorkspaces).find((w) => w.id === ws)?.operator_settings
const kinds = (['flow', 'raw_app'] as const).filter((k) =>
roleCanAuthor(k, { operator: true }, settings)
)
if (perm.refusedBy === 'operator' && kinds.length) {
const rules = await checkDeployRules(ws)
if (deployPermFetchedFor === ws) builderFlowPerm = rules
if (deployPermFetchedFor === ws) builder = { perm: rules, kinds }
}
})
})
})
function permissionFor(kind: Kind): DeployPermission {
if (kind === 'flow' && builderFlowPerm) return deployPermissionForKind(builderFlowPerm, kind)
if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind)
return deployPermissionForKind(deployPerm, kind)
}
@@ -23,8 +23,8 @@ export function useSessionPermissions(workspace: () => string | undefined) {
/** Whether this user can save the drafts the `kind` editor writes. */
canOpenEditor: (kind: string) => rights.roleCanDraft(kind),
/** The same, and this item's own permissions let them write it. */
canEditItem: (item: Pick<WorkspaceItem, 'kind' | 'path' | 'extraPerms'>) =>
rights.canDraftItem(item.kind, item.path, item.extraPerms),
canEditItem: (item: Pick<WorkspaceItem, 'kind' | 'raw_app' | 'path' | 'extraPerms'>) =>
rights.canDraftItem(item.raw_app ? 'raw_app' : item.kind, item.path, item.extraPerms),
/** Whether the page loads for this user rather than refusing them. */
pageAllowed: (page: PreviewPage) => previewPageAllowed(page, user.current, entry)
}
@@ -30,7 +30,12 @@ type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow }
import type { HiddenRunnable } from '$lib/components/apps/types'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { react19Template, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates'
import {
BUILDER_FRAMEWORK_TEMPLATES,
react19Template,
STARTER_RUNNABLES
} from '$lib/components/raw_apps/templates'
import { operatorBuilderAppsIn, workspaceListLoaded } from '$lib/operatorWriteRights'
import { random_adj } from '$lib/components/random_positive_adjetive'
import { copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
@@ -72,6 +77,7 @@ import {
promptSafe,
parseRunFormRoute,
resolvePreviewTab,
slotShowsPage,
type PreviewSlot
} from './previewRouter'
import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
@@ -466,15 +472,7 @@ function createRuntime(session: Session): SessionRuntime {
// point those at a page the user cannot see.
const tab = owner?.displayedTab
if (!tab) return undefined
const slotKind = resolvePreviewTab(tab.url).kind
if (
slotKind !== 'iframe' &&
slotKind !== 'pageitem' &&
slotKind !== 'pagelist' &&
slotKind !== 'run' &&
slotKind !== 'viewer'
)
return undefined
if (!slotShowsPage(resolvePreviewTab(tab.url).kind)) return undefined
return previewLocationContext(whereIs(tab))
}
// Pre-flight: materialise the (still-transient) session, then commit
@@ -1053,10 +1051,13 @@ function createRuntime(session: Session): SessionRuntime {
} catch (err) {
if (seedIfMissing && !deployedOnly && err instanceof ApiError && err.status === 404) {
const user = get(userStore)
// A builder can save no inline runnable, so the regular starter could never be saved.
await workspaceListLoaded()
const builder = operatorBuilderAppsIn(workspace)
saved.val = undefined
store.val = {
files: { ...react19Template },
runnables: structuredClone(STARTER_RUNNABLES),
files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) },
runnables: builder ? {} : structuredClone(STARTER_RUNNABLES),
data: { ...DEFAULT_DATA },
policy: {
on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`,
@@ -367,6 +367,10 @@
return !page.url.pathname.includes(link.href) && !$usedTriggerKinds.includes(link.kind)
})
)
let showAddTriggerMenu = $derived(extraTriggerLinks.length > 0 && allows('triggers'))
let showTriggersGroup = $derived(
showAddTriggerMenu || triggerMenuLinks.some((link) => !link.disabled)
)
// Admins, superadmins, and fork creators reach workspace settings (the latter
// for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user).
const canManageWorkspace = $derived(
@@ -502,6 +506,8 @@
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
</div>
{/if}
{#if showMain && showTriggersGroup}
<div class="pt-4">
{#if isCollapsed}
<div class="text-secondary text-[0.5rem] uppercase transition-opacity opacity-0">
@@ -529,7 +535,7 @@
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && allows('triggers')}
{#if showAddTriggerMenu}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MeltButton
@@ -6,6 +6,13 @@ let opener: (() => void) | undefined
// Closing it once the pointer is off both the handle and the card, which the layout owns because
// the card is its markup.
let hoverClose: { schedule: () => void; cancel: () => void } | undefined
type CardState = {
isOpen: () => boolean
isPinned: () => boolean
pin: () => void
close: () => void
}
let card: CardState | undefined
// Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than
// the card starting at whatever height the control that opened it happens to have. Measured at open
@@ -21,10 +28,34 @@ export const navHandleSlot = {
get cardTop(): number {
return cardTop
},
/** The root layout owns the card, so it registers how to open and hover-close it. */
setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) {
/** The card is open and pinned, so the handle shows as selected. Reactive. */
get pinned(): boolean {
return !!card?.isOpen() && card.isPinned()
},
/** The root layout owns the card, so it registers how to open, pin, close and hover-close it. */
setOpener(
open: () => void,
close?: { schedule: () => void; cancel: () => void },
state?: CardState
) {
opener = open
hoverClose = close
card = state
},
/**
* A click on the handle pins the card, opening it first if it is closed: a pinned card stays
* when the pointer leaves, until a press on the page, Esc, or the next click here closes it. A
* card the pointer opened is not pinned yet, so the click that follows the hover pins it — and
* a touch tap, which sends that mouseenter just before its click, pins it too.
*/
toggle(anchor?: Element | null) {
if (card?.isOpen() && card.isPinned()) {
card.close()
return
}
if (!card?.isOpen()) this.open(anchor)
hoverClose?.cancel()
card?.pin()
},
/** Pointer left the handle: close, unless it lands on the card before the delay is up. */
scheduleClose() {
@@ -86,6 +86,7 @@
import { twMerge } from 'tailwind-merge'
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte'
import { overlayStack } from '$lib/components/common/overlayHost.svelte'
import PageHeaderBar from '$lib/components/PageHeaderBar.svelte'
import { BAR_HEIGHT_PX, pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes'
@@ -110,7 +111,7 @@
import { isGlobalAiEnabled, sessionsAllowedFor } from '$lib/components/copilot/chat/global/gate'
import { copilotInfo } from '$lib/aiStore'
import {
isPageItemListPath,
isPreviewListPath,
parsePreviewItemRoute,
barePath
} from '$lib/components/sessions/previewPaths'
@@ -467,7 +468,7 @@
}
// A list page mounts in process too, where its rows open as tabs of their own.
const listUrl = navigation.to?.url
if (listUrl && isPageItemListPath(barePath(listUrl.pathname))) {
if (listUrl && isPreviewListPath(barePath(listUrl.pathname))) {
navigation.cancel()
const u = new URL(listUrl.href)
u.searchParams.delete('nomenubar')
@@ -514,8 +515,11 @@
function pointerInMenu(): boolean {
return !!document.querySelector('[role="menu"]:hover')
}
// A click on the handle pins the card: it then outlives the pointer leaving. Only meaningful
// while open — every open from closed starts unpinned.
let menuPinned = $state(false)
const { debounced: schedulePeekClose, clearDebounce: cancelPeekClose } = debounce(() => {
if (!peeksOnHover) return
if (!peeksOnHover || menuPinned) return
if (pointerInMenu()) {
schedulePeekClose()
return
@@ -528,10 +532,56 @@
const floatBand = $derived(pageHeader.content?.barRightInset != null)
// The handle and the edge band both open the card this layout owns.
navHandleSlot.setOpener(() => (menuOpen = true), {
schedule: schedulePeekClose,
cancel: cancelPeekClose
})
navHandleSlot.setOpener(
() => {
if (!menuOpen) menuPinned = false
menuOpen = true
},
{ schedule: schedulePeekClose, cancel: cancelPeekClose },
{
isOpen: () => menuOpen,
isPinned: () => menuPinned,
pin: () => (menuPinned = true),
close: () => (menuOpen = false)
}
)
// The open card is dismissed by any press on the page, and that press still reaches its target:
// a layer that caught it would cost every dismissal a second click. Only presses inside the page
// column count — the card itself and the menus it portals to `body` sit outside it — and the
// handle toggles the card itself. Capture phase, so a target that stops propagation still closes it.
let pageColumn: HTMLElement | undefined = $state()
function closeCardOnPagePress(e: PointerEvent) {
if (!menuOpen || !useDrawer) return
const target = e.target
if (!(target instanceof Element) || !pageColumn?.contains(target)) return
if (target.closest('[data-nav-handle]')) return
menuOpen = false
}
// A press inside an iframe (a session's preview) never reaches this window; focus moving into one does.
function closeCardOnFrameFocus() {
if (!menuOpen || !useDrawer) return
setTimeout(() => {
if (document.activeElement instanceof HTMLIFrameElement) menuOpen = false
})
}
const viewportOverlays = overlayStack()
function onWindowKeydown(e: KeyboardEvent) {
onSearchShortcutBeforeLoad(e)
if (e.key !== 'Escape' || !menuOpen || !useDrawer) return
// Read now: the overlay this Esc closes is off the stack by the time the timeout runs.
if (viewportOverlays.val.length > 0) return
// This listener is attached before any drawer's or menu's, which mount later: wait for
// them, so an Esc that closes one of those leaves the card open.
setTimeout(() => {
if (e.defaultPrevented || document.querySelector('[role="menu"]:not([hidden])')) return
const focusInCard = !!document.activeElement?.closest('[data-nav-card]')
menuOpen = false
// The closed card is inert and would drop focus to `body`.
if (focusInCard) document.querySelector<HTMLElement>('[data-nav-handle] button')?.focus()
})
}
// Set once the user docks or detaches, so the rail that mounts from then on skips its
// app-entry animation (`wm-sidebar-in`) and only plays `railMorph`.
@@ -1041,7 +1091,12 @@
})
</script>
<svelte:window bind:innerWidth onkeydown={onSearchShortcutBeforeLoad} />
<svelte:window
bind:innerWidth
onkeydown={onWindowKeydown}
onpointerdowncapture={closeCardOnPagePress}
onblur={closeCardOnFrameFocus}
/>
<!-- Home + Runs lifted to the top of the workspace nav, sitting with Favorites
and Search as the primary quick-access cluster. Excluded from SidebarContent
@@ -1365,20 +1420,18 @@
inert={!menuOpen}
role="dialog"
>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<!-- The card floats over the page, inset and rounded. Over an app it is not: the app
owns the window, and the sidebar revealed beside it reads as the panel the header
sits on — flush to the left and bottom edges, square, with one border down its
right side. -->
<!-- This outside-click catcher starts where the card does rather than at the top of
the viewport: it would otherwise cover the header's toggle, whose hover opened the
card, and swallow the click meant to put the sidebar back. -->
<!-- The frame spans the page only to position the card; it lets every press through
(closeCardOnPagePress dismisses the card). -->
<div
class={classNames('fixed left-0 right-0 bottom-0 flex z-40', 'pl-1 pb-2')}
class={classNames(
'fixed left-0 right-0 bottom-0 flex z-40 pointer-events-none',
'pl-1 pb-2'
)}
style:top="{navHandleSlot.cardTop}px"
onclick={(e) => {
if (e.target === e.currentTarget) menuOpen = false
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -1390,7 +1443,7 @@
schedulePeekClose()
}}
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform shadow-lg overflow-hidden rounded-lg border',
'pointer-events-auto relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform shadow-lg overflow-hidden rounded-lg border',
menuOpen ? 'translate-x-0' : '-translate-x-[calc(100%+0.5rem)]'
)}
>
@@ -1416,7 +1469,7 @@
</div>
{/if}
{/if}
<div class="flex flex-col h-full w-full relative">
<div bind:this={pageColumn} class="flex flex-col h-full w-full relative">
<!-- Not gated on `$enterpriseLicense`: it paints the cached accent before the license
resolves, and checks the license itself. -->
<InstanceUiSync />
@@ -5,7 +5,6 @@
import { AppService } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { useOperatorBuilderApps, workspaceListLoaded } from '$lib/operatorWriteRights'
import { conformBuilderAppPolicy } from '$lib/components/raw_apps/builderAppPolicy'
import { readFieldsRecursively } from '$lib/utils'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
@@ -538,12 +537,6 @@
return $operatorBuilderApps ? {} : structuredClone(STARTER_RUNNABLES)
}
// The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user
// deployed carries, so the editor conforms the policy as soon as it opens.
$effect(() => {
if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore)
})
async function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) {
const picked = {
files: { ...result.files },
@@ -80,7 +80,7 @@
tabsToReload,
viewerTabsToReload
} from '$lib/components/sessions/previewReload'
import { isPageItemListPath, barePath } from '$lib/components/sessions/previewPaths'
import { isPreviewListPath, barePath } from '$lib/components/sessions/previewPaths'
import {
leafKeyFor,
loadKind,
@@ -928,7 +928,7 @@
// A preview frame navigating to a list page: re-point the active tab to the list
// mounted in process, whose rows open as tabs of their own.
if (d.type === 'wm.session.openList') {
if (typeof d.href !== 'string' || !isPageItemListPath(barePath(d.href))) return
if (typeof d.href !== 'string' || !isPreviewListPath(barePath(d.href))) return
owner?.navigate({ type: 'page', href: d.href, label: previewLocationLabel(d.href) })
return
}
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.830.0"
wmill = ">=1.831.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.830.0
version: 1.831.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.830.0'
ModuleVersion = '1.831.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.830.0"
version = "1.831.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1
View File
@@ -46,6 +46,7 @@ a CLI-only workflow file (`flow-cli.md`, `script-cli.md`, `raw-app-cli.md`).
| resources | `getResourcePrompt()` | `resources` | `resources.md` |
| workflow-as-code | `getWorkflowAsCodePrompt(lang)` | `write-workflow-as-code` | `workflow-as-code.md`, the WAC SDK |
| pipeline | `getPipelinePrompt()` | `write-pipeline` | `pipeline-base.md` |
| data tables | `getDatatablePrompt()` | `datatables` | `datatable.md` |
| triggers, schedules, preview, CLI commands | — | same names | CLI only |
The app chat embeds `RAW_APP_BASE` directly (`chat/app/core.ts`).
+1
View File
@@ -4,5 +4,6 @@ export declare function getFlowPrompt(): string;
export declare function getResourcePrompt(): string;
export declare function getRawAppPrompt(language?: string): string;
export declare function getPipelinePrompt(): string;
export declare function getDatatablePrompt(): string;
export declare function getDatatableSdkReference(language?: string): string;
export declare function getWorkflowAsCodePrompt(language?: string): string;
+7
View File
@@ -73,6 +73,13 @@ export function getPipelinePrompt(): string {
].filter(Boolean).join('\n\n');
}
// Helper for data table schema changes, migrations and permissions (chat consumers)
export function getDatatablePrompt(): string {
return [
prompts.DATATABLE_BASE
].filter(Boolean).join('\n\n');
}
// Helper to get the datatable SQL SDK reference (wmill.datatable()).
// Pass a language to get only that SDK; omit it to get both.
export function getDatatableSdkReference(language?: string): string {
+1
View File
@@ -5,6 +5,7 @@ export declare const FLOW_BASE: string;
export declare const RESOURCES_BASE: string;
export declare const RAW_APP_BASE: string;
export declare const PIPELINE_BASE: string;
export declare const DATATABLE_BASE: string;
export declare const WORKFLOW_AS_CODE_BASE: string;
export declare const FLOW_CHAT_SPECIAL_MODULES: string;
export declare const SDK_TYPESCRIPT: string;
+41
View File
@@ -1338,6 +1338,47 @@ FROM ducklake.main.orders GROUP BY 1
\`\`\`
`;
export const DATATABLE_BASE = `# 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.
## 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 <name>\`, 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.
`;
export const WORKFLOW_AS_CODE_BASE = `# Windmill Workflow-as-Code Writing Guide
## Scope
@@ -0,0 +1,46 @@
---
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 <name>` scaffolds `migrations/datatable/<datatable>/<timestamp>_<name>.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 <datatable>` 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 <name>`, 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.
+43
View File
@@ -0,0 +1,43 @@
# 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.
<!-- cli-only -->
`wmill datatable migrate new <name>` scaffolds `migrations/datatable/<datatable>/<timestamp>_<name>.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 <datatable>` and default to `main`.
<!-- /cli-only -->
## 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 <name>`, 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.
+13
View File
@@ -1696,6 +1696,12 @@ TOPICS: dict[str, dict] = {
'chat_helper': 'getPipelinePrompt',
'parts': ['pipeline-base.md'],
},
'datatable': {
'skill': 'datatables',
'description': "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions.",
'chat_helper': 'getDatatablePrompt',
'parts': ['datatable.md'],
},
'cli_commands': {
'skill': 'cli-commands',
'description': 'MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`.',
@@ -1715,6 +1721,7 @@ CHAT_CONSTANTS = {
'resources.md': 'RESOURCES_BASE',
'raw-app.md': 'RAW_APP_BASE',
'pipeline-base.md': 'PIPELINE_BASE',
'datatable.md': 'DATATABLE_BASE',
'workflow-as-code.md': 'WORKFLOW_AS_CODE_BASE',
'flow-chat-special-modules.md': 'FLOW_CHAT_SPECIAL_MODULES',
}
@@ -2650,6 +2657,11 @@ export function getPipelinePrompt(): string {
__RETURN_pipeline__
}
// Helper for data table schema changes, migrations and permissions (chat consumers)
export function getDatatablePrompt(): string {
__RETURN_datatable__
}
// Helper to get the datatable SQL SDK reference (wmill.datatable()).
// Pass a language to get only that SDK; omit it to get both.
export function getDatatableSdkReference(language?: string): string {
@@ -2711,6 +2723,7 @@ export declare function getFlowPrompt(): string;
export declare function getResourcePrompt(): string;
export declare function getRawAppPrompt(language?: string): string;
export declare function getPipelinePrompt(): string;
export declare function getDatatablePrompt(): string;
export declare function getDatatableSdkReference(language?: string): string;
export declare function getWorkflowAsCodePrompt(language?: string): string;
"""

Some files were not shown because too many files have changed in this diff Show More