Merge remote-tracking branch 'origin/header-narrow-widths' into breadcrumb-leaf-navigation-popover

# Conflicts:
#	backend/ee-repo-ref.txt
#	frontend/src/lib/components/NavBreadcrumb.svelte
#	frontend/src/lib/components/PageHeaderBar.svelte
This commit is contained in:
Guilhem Lemouel
2026-10-13 08:52:25 -07:00
127 changed files with 6029 additions and 2460 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 @@
d5971fb924d4fdb849e73da6657021902acaacbd
9cd6a70486df465294f23071fbf383f21ef47ba9
+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
}
}
+131 -83
View File
@@ -2,21 +2,22 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aes"
version = "0.7.5"
@@ -24,11 +25,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8"
dependencies = [
"cfg-if",
"cipher",
"cipher 0.3.0",
"cpufeatures 0.2.17",
"opaque-debug",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes 0.8.4",
"cipher 0.4.4",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.7.8"
@@ -389,21 +415,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -539,7 +550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e"
dependencies = [
"block-padding",
"cipher",
"cipher 0.3.0",
]
[[package]]
@@ -763,6 +774,16 @@ dependencies = [
"generic-array",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -1095,6 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -1107,6 +1129,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -1163,7 +1194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac41dd49fb554432020d52c875fc290e110113f864c6b1b525cd62c7e7747a5d"
dependencies = [
"byteorder",
"cipher",
"cipher 0.3.0",
"opaque-debug",
]
@@ -1636,10 +1667,14 @@ dependencies = [
]
[[package]]
name = "gimli"
version = "0.32.3"
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "git-version"
@@ -2101,6 +2136,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "interpolator"
version = "0.5.0"
@@ -2116,17 +2160,6 @@ dependencies = [
"rustversion",
]
[[package]]
name = "io-uring"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62"
dependencies = [
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2455,7 +2488,7 @@ version = "3.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c42f95f9d296f2dcb50665f507ed5a68a171453142663ce44d77a4eb217b053"
dependencies = [
"aes",
"aes 0.7.5",
"base64 0.21.7",
"block-modes",
"crc-any",
@@ -2619,9 +2652,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
@@ -3220,6 +3253,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "postgres-native-tls"
version = "0.5.1"
@@ -4031,12 +4076,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -5350,34 +5389,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.47.1"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn 3.0.7",
]
[[package]]
@@ -5875,6 +5911,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "unscanny"
version = "0.1.0"
@@ -6294,8 +6340,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"aes-gcm",
"aho-corasick",
"anyhow",
"arc-swap",
@@ -6323,6 +6370,7 @@ dependencies = [
"git-version",
"globset",
"hex",
"hkdf",
"hmac 0.12.1",
"hyper",
"indexmap",
@@ -6382,7 +6430,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6394,7 +6442,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"convert_case",
"serde",
@@ -6403,7 +6451,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6415,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6427,7 +6475,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6439,7 +6487,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6451,7 +6499,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6463,7 +6511,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6474,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6485,7 +6533,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6497,7 +6545,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6508,7 +6556,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6531,7 +6579,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6543,7 +6591,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6557,7 +6605,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6574,7 +6622,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6587,7 +6635,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde",
@@ -6599,7 +6647,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6617,7 +6665,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6633,7 +6681,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6650,7 +6698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6682,7 +6730,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6696,7 +6744,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.830.0"
version = "1.831.0"
dependencies = [
"anyhow",
"bitflags",
@@ -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",
+6 -6
View File
@@ -19,14 +19,14 @@
"pg-gateway": "0.3.0-beta.4",
"svelte": "^5.56.8",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-asset": "1.753.0",
"windmill-parser-wasm-asset": "1.830.0",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.761.0",
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.782.0",
"windmill-parser-wasm-py-imports": "1.693.1",
"windmill-parser-wasm-py": "1.830.0",
"windmill-parser-wasm-py-imports": "1.830.0",
"windmill-parser-wasm-r": "1.668.1",
"windmill-parser-wasm-regex": "1.764.0",
"windmill-parser-wasm-ruby": "1.526.1",
@@ -291,7 +291,7 @@
"utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.753.0", "", {}, "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg=="],
"windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.830.0", "", {}, "sha512-q4HSaMOd5cF8pmfPcEGiGeQbO8vhtv89us7zbO12IlgpxpWT8ojvw66J75y4yUt1PaEuaoztSaX8Gkxxn/6MXw=="],
"windmill-parser-wasm-csharp": ["windmill-parser-wasm-csharp@1.510.1", "", {}, "sha512-qm09YmnbeYHLwYn1jUnObVzPhYO9NZKMlIO7nlo7zPJBXqksgG5fK/KCtwGw9rChrnz+DsvM9wP5FhrwRLMtwQ=="],
@@ -303,9 +303,9 @@
"windmill-parser-wasm-php": ["windmill-parser-wasm-php@1.647.1", "", {}, "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg=="],
"windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.782.0", "", {}, "sha512-ZnpEvhbffaJI+Uq7HH1mVO6kUgR4uiAIIxaLa30Pqb1/oz0h4abT6W7bFOApIE5X9SDOuycoaDBEr9Ogm6ZLzQ=="],
"windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.830.0", "", {}, "sha512-+P4mIyp3lXkgjsr3/20Oc31ank8orQDd4K/v8TnjTQnCyxyy+VB9rynLESnkH6OlcEVSk2qf9pDM9c367E8DoQ=="],
"windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.693.1", "", {}, "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g=="],
"windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.830.0", "", {}, "sha512-Pi9gHStNFTEqfcDrjSxK5SnI/1dTG4xKcVUD3BHlR1aQ7E/VxkTD/D0INJm7mNC2usKb2bO21NMdKmGh+RAXEw=="],
"windmill-parser-wasm-r": ["windmill-parser-wasm-r@1.668.1", "", {}, "sha512-5YNeUibxpNBvYrxCgQcz1PxGhTFx2CyEpg2udtIhq7bx0d4gF/KDZVupMeQmAObmrEtTSFGUWNRJ4zXSWNrSpQ=="],
+3 -3
View File
@@ -31,14 +31,14 @@
"pg-gateway": "0.3.0-beta.4",
"svelte": "^5.56.8",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-asset": "1.753.0",
"windmill-parser-wasm-asset": "1.830.0",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.761.0",
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.782.0",
"windmill-parser-wasm-py-imports": "1.693.1",
"windmill-parser-wasm-py": "1.830.0",
"windmill-parser-wasm-py-imports": "1.830.0",
"windmill-parser-wasm-r": "1.668.1",
"windmill-parser-wasm-regex": "1.764.0",
"windmill-parser-wasm-ruby": "1.526.1",
+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
+14 -14
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": {
@@ -95,19 +95,19 @@
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
"vscode-ws-jsonrpc": "~3.5.0",
"windmill-parser-wasm-asset": "1.753.0",
"windmill-parser-wasm-asset": "1.830.0",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.761.0",
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.782.0",
"windmill-parser-wasm-py": "1.830.0",
"windmill-parser-wasm-r": "1.668.1",
"windmill-parser-wasm-regex": "1.764.0",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.647.1",
"windmill-parser-wasm-ts": "1.695.0",
"windmill-parser-wasm-wac": "1.668.6",
"windmill-parser-wasm-wac": "1.830.0",
"windmill-parser-wasm-yaml": "1.770.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
"windmill-utils-internal": "1.8.2",
@@ -14644,9 +14644,9 @@
}
},
"node_modules/windmill-parser-wasm-asset": {
"version": "1.753.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.753.0.tgz",
"integrity": "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg=="
"version": "1.830.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.830.0.tgz",
"integrity": "sha512-q4HSaMOd5cF8pmfPcEGiGeQbO8vhtv89us7zbO12IlgpxpWT8ojvw66J75y4yUt1PaEuaoztSaX8Gkxxn/6MXw=="
},
"node_modules/windmill-parser-wasm-csharp": {
"version": "1.510.1",
@@ -14674,9 +14674,9 @@
"integrity": "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg=="
},
"node_modules/windmill-parser-wasm-py": {
"version": "1.782.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.782.0.tgz",
"integrity": "sha512-ZnpEvhbffaJI+Uq7HH1mVO6kUgR4uiAIIxaLa30Pqb1/oz0h4abT6W7bFOApIE5X9SDOuycoaDBEr9Ogm6ZLzQ=="
"version": "1.830.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.830.0.tgz",
"integrity": "sha512-+P4mIyp3lXkgjsr3/20Oc31ank8orQDd4K/v8TnjTQnCyxyy+VB9rynLESnkH6OlcEVSk2qf9pDM9c367E8DoQ=="
},
"node_modules/windmill-parser-wasm-r": {
"version": "1.668.1",
@@ -14704,9 +14704,9 @@
"integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw=="
},
"node_modules/windmill-parser-wasm-wac": {
"version": "1.668.6",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.668.6.tgz",
"integrity": "sha512-/ovcLWlIO+TQMrnWwcWoXquJI6hTZ+Zpo4POhV8z6e+pb4cVModGysASI9mERn2e9uj8/xSRmYqk54TLG8oUbQ=="
"version": "1.830.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.830.0.tgz",
"integrity": "sha512-M67qPkzDnuMNWGrZTj9V4ajDoCQrTsQJ89UMHq308t5CtCdtiX8Z3freL+8SoRAG/dVlqQ8roK6dzncHIEM0hQ=="
},
"node_modules/windmill-parser-wasm-yaml": {
"version": "1.770.0",
+4 -4
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",
@@ -161,19 +161,19 @@
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
"vscode-ws-jsonrpc": "~3.5.0",
"windmill-parser-wasm-asset": "1.753.0",
"windmill-parser-wasm-asset": "1.830.0",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.761.0",
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.782.0",
"windmill-parser-wasm-py": "1.830.0",
"windmill-parser-wasm-r": "1.668.1",
"windmill-parser-wasm-regex": "1.764.0",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.647.1",
"windmill-parser-wasm-ts": "1.695.0",
"windmill-parser-wasm-wac": "1.668.6",
"windmill-parser-wasm-wac": "1.830.0",
"windmill-parser-wasm-yaml": "1.770.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
"windmill-utils-internal": "1.8.2",
@@ -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
+9 -13
View File
@@ -40,7 +40,7 @@
import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -230,19 +230,14 @@
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full group — the
// dropdown, Diff, the preview trio and Deploy — is ~610px.
const compactBelow = $derived(ownsPageHeader ? 1200 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons
// stand down, since there is no room to run a flow beside its own name.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons
// stand down, since there is no room to run a flow beside its own name.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
const diffEnabled = $derived(customUi?.topBar?.diff != false)
// Nothing to compare against until a deployed version exists.
@@ -1597,6 +1592,7 @@
: undefined
}}
actions={flowHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="none"
/>
@@ -3,7 +3,7 @@
import { type MetricDataPoint, MetricsService } from '$lib/gen'
import { displayTime } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from './operatingWorkspace.svelte'
import {
CategoryScale,
Chart as ChartJS,
@@ -26,6 +26,8 @@
let { jobId, jobUpdateLastFetch }: Props = $props();
const operatingWorkspace = useOperatingWorkspace()
let jobMetricsLastFetch: Date | undefined = undefined
let jobMemoryStats: MetricDataPoint[] | undefined = $state(undefined)
@@ -38,7 +40,7 @@
async function loadMetricsData() {
try {
let jobStatsPromise = MetricsService.getJobMetrics({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
id: jobId,
requestBody: {
from_timestamp: jobMetricsLastFetch?.toISOString(),
+260 -125
View File
@@ -16,7 +16,6 @@ not this component) — so the lead can run the whole line.
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
import { Menu, Menubar } from '$lib/components/meltComponents'
import MeltButton from '$lib/components/meltComponents/MeltButton.svelte'
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
import BreadcrumbNavPicker from './BreadcrumbNavPicker.svelte'
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
@@ -33,14 +32,25 @@ not this component) — so the lead can run the whole line.
import { KIND_LABEL_LOWER } from '$lib/components/workspacePicker'
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
import type { Snippet } from 'svelte'
import {
BAR_STAGES,
FLOOR,
shownPathLevels,
type Part,
type TrailMeasures,
type TrailStage
} from './pageHeaderStages'
let {
item,
section,
afterName,
actingWorkspaceId,
narrow = false,
nameOnly = false
nameOnly = false,
stage = BAR_STAGES[0],
caps = {},
scopesGive = false,
measures = $bindable()
}: {
item?: PageHeaderItem
section?: PageHeaderSection
@@ -49,9 +59,14 @@ not this component) — so the lead can run the whole line.
afterName?: Snippet
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
actingWorkspaceId?: string
/** The bar is short of room: the path holds a tighter cap, so it cannot claim a third of the
* line before anything else has given way. The names truncate at every width either way. */
narrow?: boolean
/** Which parts the bar has dropped for room (see pageHeaderStages). */
stage?: TrailStage
/** Max widths the bar holds the names to, once dropping whole parts is not enough. */
caps?: Partial<Record<Part, number>>
/** The folders above the name truncate too: even the name at its last floor does not fit. */
scopesGive?: boolean
/** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */
measures?: TrailMeasures
/** Only the page's own name, with no workspace part and no picker — the band inside a
* session's preview frame, where the workspace is the host's and leading the reader out of
* it is exactly what that band must not offer. The page still has to say what it is. */
@@ -156,15 +171,8 @@ not this component) — so the lead can run the whole line.
twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD)
)
// Capped: a draft's path is a 40-character uuid slug, and the trail yields so grudgingly that
// one would push a page's buttons off the end of the bar.
const pathSegment = $derived(
twMerge(
SEGMENT,
'gap-1.5 overflow-hidden',
itemIsLast ? LAST : LEAD,
narrow ? 'max-w-[11rem]' : 'max-w-[16rem]'
)
twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD)
)
// Copying is the one thing a segment does that leaves no mark on the page, so it is the one
// without a hover fill: the pointer and the confirmation under it are the affordance. A segment
@@ -191,6 +199,18 @@ not this component) — so the lead can run the whole line.
const scopeKind = $derived(item?.path?.split('/')[0])
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean))
// The off-screen copies stand in a fixed-width box for what a page renders into the trail: a
// second render of a page's snippet would be a second live copy of its controls.
let afterNameWidth = $state(0)
let sectionContentWidth = $state(0)
/** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */
function capStyle(part: Part, measure: boolean): string | undefined {
if (measure) return `max-width: ${FLOOR[part]}px`
const cap = caps[part]
return cap === undefined ? undefined : `max-width: ${cap}px`
}
</script>
{#snippet workspaceDisc()}
@@ -235,11 +255,17 @@ not this component) — so the lead can run the whole line.
{/if}
{/snippet}
{#snippet navPicker()}
{#snippet navPicker(measure: boolean)}
<!-- The page's own name is the last segment, so the chevron after it moves to a sibling page or
item. Not in a session's preview band, which must not lead out of the host's workspace. -->
{#if !nameOnly}
<BreadcrumbNavPicker {item} pagePath={routePage?.path} {actingWorkspaceId} iconSize={ICON} />
{#if measure}
<span class="flex items-center h-9 px-1.5 shrink-0">
<ChevronDown size={ICON} class="flex-shrink-0" />
</span>
{:else}
<BreadcrumbNavPicker {item} pagePath={routePage?.path} {actingWorkspaceId} iconSize={ICON} />
{/if}
{/if}
{/snippet}
@@ -249,89 +275,129 @@ not this component) — so the lead can run the whole line.
<span class={twMerge('shrink-0 text-hint/40 text-xs', extra)} aria-hidden="true">/</span>
{/snippet}
<nav
aria-label="Breadcrumb"
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
>
{#snippet workspaceMenu_(measure: boolean)}
{#if measure}
<span class="flex items-center h-9 px-1.5 shrink-0">
<ChevronDown size={ICON} class="flex-shrink-0" />
</span>
{:else}
<Menubar>
{#snippet children({ createMenu })}
<Menu
bind:this={workspaceMenu}
{createMenu}
usePointerDownOutside
placement="bottom-start"
class="h-auto"
contentStyle="margin-left: {-nameWidth}px"
>
{#snippet triggr({ trigger })}
<MeltButton
meltElement={trigger}
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
title="Switch workspace"
>
<ChevronDown size={ICON} class="flex-shrink-0" />
</MeltButton>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
{/snippet}
</Menu>
{/snippet}
</Menubar>
{/if}
{/snippet}
{#snippet trail(f: TrailStage, measure: boolean)}
{@const scopeTitle = showFork ? `${familyName} / ${scopeName}` : familyName}
{#if !nameOnly}
<!-- One part for where the user is: the name of the workspace they are standing in — a fork
included, in its own colour — leading home, and one chevron whose picker changes it. The
picker lists the families and expands one to reach its forks, so a fork needs no part of
its own here. -->
<!-- `shrink-[999]`: the whole trail can give width back, and this part gives it first. Flex
takes it from the parts in proportion to what they ask for, so a factor this far above
the rest means the names here are eaten down to their icons before the page's own name
loses a character. -->
<div class="flex items-center min-w-0 shrink-[999]">
included, in its own colour — leading home, and one chevron whose picker changes it. The
picker lists the families and expands one to reach its forks, so a fork needs no part of
its own here. Its parts never shrink on their own: the bar drops them whole and caps the
names left, in the order pageHeaderStages sets. -->
<div class="flex items-center shrink-0">
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
boxes reacting to one pass of the pointer read as two controls fighting. -->
boxes reacting to one pass of the pointer read as two controls fighting. -->
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
the name to line its left edge up with the disc. -->
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
<a
href={homeHref}
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
the name to line its left edge up with the disc. -->
<div
class="flex items-center"
bind:clientWidth={() => nameWidth, (w) => !measure && (nameWidth = w)}
>
<svelte:element
this={measure ? 'span' : 'a'}
href={measure ? undefined : homeHref}
class={f.wsButton ? 'flex items-center gap-1 pl-1 py-0.5 rounded' : scopeChipClass}
title={scopeTitle}
>
<!-- The family's name goes before the fork's: the fork is where the work happens, the
family is which product it is part of, and of the two that is the one a reader can
do without. Both truncate to nothing rather than disappearing at a breakpoint —
what is left then is what the part cannot be read without, the disc in the
workspace's own colour, the fork mark and the environment. The hover title
carries the names whole, and the picker beside it names them all. -->
<span class="flex items-center gap-1 min-w-0 shrink-[999]">
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
</span>
{#if showFork}
{@render slash('mx-0.5')}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{@render envBadgeMark()}
{#if f.wsButton}
<!-- Down to its disc, the part still leads home: the fork it stands in rides on the
disc as a mark, and the environment stays beside it. -->
<span class="relative flex shrink-0">
{@render workspaceDisc()}
{#if showFork}
<span
class={twMerge(
'absolute -right-1 -bottom-0.5 size-3.5 rounded-full grid place-items-center ring-2 ring-surface',
forkAccent
? 'bg-[color:var(--fork-accent-badge)] text-white'
: 'bg-surface-sunken text-primary'
)}
style={forkAccent}
>
<GitFork size={9} />
</span>
{/if}
</span>
{:else}
{@render envBadgeMark()}
{:else}
<!-- The family's name goes before the fork's: the fork is where the work happens,
the family is which product it is part of, and of the two that is the one a
reader can do without. Outside a fork it is the workspace's own name and stays
until the part is down to its disc. -->
<span class="flex items-center gap-1 shrink-0">
{@render workspaceDisc()}
{#if f.familyName || !showFork}
<span class="truncate" style={capStyle('family', measure)}>{familyName}</span>
{/if}
</span>
{#if showFork}
{@render slash('mx-0.5')}
<span class={twMerge(forkChipClass, 'shrink-0')} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
{#if f.forkName}
<span class="truncate" style={capStyle('fork', measure)}>{scopeName}</span>
{/if}
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
{/if}
{/if}
</a>
</svelte:element>
</div>
<Menubar>
{#snippet children({ createMenu })}
<Menu
bind:this={workspaceMenu}
{createMenu}
usePointerDownOutside
placement="bottom-start"
class="h-auto"
contentStyle="margin-left: {-nameWidth}px"
>
{#snippet triggr({ trigger })}
<MeltButton
meltElement={trigger}
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
title="Switch workspace"
>
<ChevronDown size={ICON} class="flex-shrink-0" />
</MeltButton>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
{/snippet}
</Menu>
{/snippet}
</Menubar>
{@render workspaceMenu_(measure)}
</div>
{/if}
{#if !nameOnly && (item || section || routePage)}
<!-- The summary that follows is the item's name for a person, so on a short bar it is the path
that goes (see `TrailStage.path`). What the page hangs after the name stays. -->
{@const pathShown = f.path || itemIsLast}
{#if !nameOnly && (item ? pathShown : section || routePage)}
{@render slash()}
{/if}
{#if item}
{#if item.path}
{#if !pathShown}
{@render afterNameSlot(measure)}
{:else if item.path}
{@const shown = shownPathLevels(pathLevels, f)}
<!-- The path whole, as one label rather than a row of pickers: it is what a person reads
to know which item this is, and what they reach for to paste somewhere else. -->
<div class="relative flex items-center min-w-0">
@@ -342,19 +408,28 @@ not this component) — so the lead can run the whole line.
{:else}
{@render folderIcon()}
{/if}
{#each pathLevels as level, i (i)}
{#each shown as level, i (i)}
{#if i > 0}{@render slash()}{/if}
<!-- The scopes above the name absorb the squeeze, the name itself last: a path is
read from its end, and which folder a thing is in is answerable from the
picker while the thing's own name is not. -->
<span
class={i === pathLevels.length - 1
? 'truncate'
: 'truncate min-w-0 shrink-[999] max-w-[8rem]'}>{level}</span
>
{#if i === shown.length - 1}
<span class="truncate shrink-0" style={capStyle('name', measure)}>{level}</span>
{:else if level === '…' && !f.middleLevels}
<span class="shrink-0 text-tertiary">…</span>
{:else}
<!-- The folder or user gives width only once the name is down to a few
characters: it is short and says whose thing this is, while a name long
enough to need truncating still reads from its first words. -->
<span
class={twMerge(
'truncate max-w-[8rem]',
scopesGive && !measure ? 'min-w-0' : 'shrink-0'
)}>{level}</span
>
{/if}
{/each}
{/snippet}
{#if item.pathTrigger}
{#if measure}
<span class={pathSegment}>{@render pathLabel()}</span>
{:else if item.pathTrigger}
<!-- The page hangs its own rename off this segment, anchored here so the editor
opens under the path rather than beside the summary. It wears the segment's
own classes, so a clickable path reads the same as one that only copies. -->
@@ -369,9 +444,9 @@ not this component) — so the lead can run the whole line.
{@render pathLabel()}
</button>
{/if}
{#if itemIsLast}{@render navPicker()}{/if}
{#if afterName}{@render afterName()}{/if}
{#if pathCopied}
{#if itemIsLast}{@render navPicker(measure)}{/if}
{@render afterNameSlot(measure)}
{#if pathCopied && !measure}
<span
class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap"
role="status"
@@ -395,16 +470,14 @@ not this component) — so the lead can run the whole line.
</span>
{/if}
{:else if section?.kind}
<div class="flex items-center min-w-0">
<span
class={twMerge(SEGMENT, LAST, 'hover:bg-transparent hover:text-emphasis')}
aria-current="page"
>
{@render sectionKindIcon()}
<span class="truncate">{KIND_LABEL_LOWER[section.kind]}</span>
</span>
{@render navPicker()}
</div>
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current={measure ? undefined : 'page'}
>
{@render sectionKindIcon()}
<span class="truncate">{KIND_LABEL_LOWER[section.kind]}</span>
</span>
{@render navPicker(measure)}
{:else if section}
{#if section.parent}
<!-- Gives width back with the workspace part, before the section's own name does. -->
@@ -415,36 +488,98 @@ not this component) — so the lead can run the whole line.
</span>
{@render slash()}
{/if}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<div class="flex items-center min-w-0">
{#if section.content && measure}
<span class="shrink-0" style="width: {sectionContentWidth}px"></span>
{:else}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class={twMerge(
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
section.content ? '' : 'truncate'
section.content ? '' : 'shrink-0'
)}
aria-current="page"
aria-current={measure ? undefined : 'page'}
bind:clientWidth={
() => sectionContentWidth, (w) => !measure && section.content && (sectionContentWidth = w)
}
>
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
<!-- A plain label is the page's name, so it truncates the way an item's name does, in
its turn: a run's id, left to flex, is squeezed to nothing under the buttons. -->
{#if section.content}{@render section.content()}{:else}<span
class="truncate"
style={capStyle('name', measure)}>{section.label}</span
>{/if}
</span>
{@render navPicker()}
</div>
{#if afterName}{@render afterName()}{/if}
{/if}
{@render navPicker(measure)}
{@render afterNameSlot(measure)}
{:else if routePage}
{@const RouteIcon = routePage.icon}
<!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping
whole, so the workspace and fork names give way first when the bar is full. -->
<div class="flex items-center shrink-0">
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current="page"
>
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
{@render navPicker()}
</div>
{#if afterName}{@render afterName()}{/if}
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current={measure ? undefined : 'page'}
>
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
{@render navPicker(measure)}
{@render afterNameSlot(measure)}
{/if}
{/snippet}
{#snippet afterNameSlot(measure: boolean)}
{#if afterName}
{#if measure}
<span class="shrink-0" style="width: {afterNameWidth}px"></span>
{:else}
<span class="flex items-center shrink-0" bind:clientWidth={afterNameWidth}>
{@render afterName()}
</span>
{/if}
{/if}
{/snippet}
<nav
aria-label="Breadcrumb"
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
>
{@render trail(stage, false)}
</nav>
{#if measures}
<!-- The trail at every stage, each name at its floor, for the bar to choose a stage with; and
each name whole, for it to cap them with. Same snippet, same classes, so the widths are the
ones the trail on screen would take. -->
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
{#each BAR_STAGES as s, i (i)}
<nav
class="flex items-center gap-1.5 w-max text-xs font-normal text-primary"
bind:clientWidth={measures.trail[i]}
>
{@render trail(s, true)}
</nav>
{/each}
<!-- Always rendered, empty where the page has no such part: a width of 0 is how the bar
knows the part is not there. -->
<span
class={twMerge(scopeChipClass, 'inline-block whitespace-nowrap p-0')}
bind:clientWidth={measures.family}>{nameOnly ? '' : familyName}</span
>
<span
class={twMerge(forkChipClass, 'inline-block whitespace-nowrap p-0')}
bind:clientWidth={measures.fork}>{!nameOnly && showFork ? scopeName : ''}</span
>
<span
class={twMerge('inline-block whitespace-nowrap text-xs', itemIsLast || !item ? LAST : LEAD)}
bind:clientWidth={measures.name}
>{item?.path
? (pathLevels[pathLevels.length - 1] ?? '')
: !item && section && !section.kind && !section.content
? section.label
: ''}</span
>
</div>
{/if}
+264 -130
View File
@@ -7,7 +7,7 @@ section name — and the route's own buttons at the far end.
The row's height matches the sidebar's own header row, so the two read as one band.
-->
<script lang="ts">
import { PanelLeft } from 'lucide-svelte'
import { ChevronDown, PanelLeft } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { navDetached } from './sidebar/navDetached.svelte'
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
@@ -17,6 +17,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
import ContextBridge from './ContextBridge.svelte'
import { afterNavigate } from '$app/navigation'
import { twMerge } from 'tailwind-merge'
import { onDestroy } from 'svelte'
import type { Attachment } from 'svelte/attachments'
import { BAR_STAGES, FLOOR, fitBar, type BarFit, type TrailMeasures } from './pageHeaderStages'
let {
navHidden = false,
@@ -34,15 +37,74 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const item = $derived(content?.item)
const section = $derived(content?.section)
const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary))
const pickerAfterSummary = $derived(hasSummary && !navHidden && !!item?.path)
// Below this the trail holds a tighter cap on the path it draws. The names in it truncate on
// their own at any width (see NavBreadcrumb), so this is not a breakpoint the look changes at —
// it only stops a long path from claiming a third of a short bar before the squeeze starts.
const NARROW_BAR = 1000
const narrow = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < NARROW_BAR)
/** A phone's bar: the trail gives way before the page's buttons do (see the markup). */
/** A phone's bar: a tighter gutter around the same row. */
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
// What the bar drops, and when the page's actions fold, follows from the widths of what is in
// it (see pageHeaderStages): measured here and in NavBreadcrumb, never guessed per page.
/** `gap-1` between the bar's boxes. */
const GAP = 4
/** Actions that shrink or fill the bar have no width of their own to plan around: the trail
* leaves them this much and they take whatever is left. */
const FLEXIBLE_ACTIONS_RESERVE = 240
let trailMeasures = $state<TrailMeasures>({ trail: [], family: 0, fork: 0, name: 0 })
/** The summary held at its floor, with the dot before it. */
let summaryFloorWidth = $state(0)
let summaryTextWidth = $state(0)
/** A page's own summary control, last seen uncapped. */
let summaryContentWidth = $state(0)
let handleWidth = $state(0)
/** The actions box at each fold level it has been seen at, for this page. */
let actionWidths = $state<(number | undefined)[]>([])
let actionsEpoch = $state(0)
const handleShown = $derived((navDetached.val || panelled) && !navHidden)
// The bar's own padding, in px, as the classes on the row below set it.
const padLeft = $derived(navHidden ? 16 : phone ? 4 : 8)
const padRight = $derived(content?.actionsFill ? 0 : phone ? 8 : 16)
const actionsRigid = $derived(!content?.actionsFlexible && !content?.actionsFill)
const FULL: BarFit = { stage: 0, fold: 0, caps: {}, scopesGive: false }
const fit = $derived.by((): BarFit => {
// Unmeasured counts as wide: the bar measures itself on mount, and starting narrow would fold
// a page's buttons for one frame and pop them back out the next.
if (pageHeader.barWidth === 0) return FULL
return fitBar({
barWidth: pageHeader.barWidth,
chrome: padLeft + padRight + (handleShown ? handleWidth + GAP : 0),
trail: trailMeasures.trail,
summary: BAR_STAGES.map(() => (hasSummary ? GAP + summaryFloorWidth : 0)),
actions:
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
earlyFolds: content?.earlyActionFolds,
natural: {
family: trailMeasures.family,
fork: trailMeasures.fork,
summary: hasSummary ? (item?.summaryContent ? summaryContentWidth : summaryTextWidth) : 0,
name: trailMeasures.name
}
})
})
const stage = $derived(BAR_STAGES[fit.stage])
onDestroy(pageHeader.provideActionFold(() => fit.fold))
// Re-attached whenever the fold level changes, so the first reading at a level is taken even
// when the row comes out exactly as wide as it was at the last one.
function measureActions(fold: number, _epoch: number): Attachment<HTMLElement> {
return (node) => {
const ro = new ResizeObserver(() => (actionWidths[fold] = GAP + node.offsetWidth))
ro.observe(node)
return () => ro.disconnect()
}
}
// Until the row has been seen at the level the bar picked, it is laid out but not shown: the bar
// may find it does not fit and fold it further, and that should not flash.
const actionsUnmeasured = $derived(actionsRigid && actionWidths[fit.fold] === undefined)
// The band's edge is there to say "something is passing under me", so it is drawn only while
// something is. Scroll events do not bubble, but they do capture, so one listener on the window
// sees every box under the band — which matters because most pages scroll inside their own
@@ -70,8 +132,14 @@ The row's height matches the sidebar's own header row, so the two read as one ba
scrolledUnder = next
}
// A page left while scrolled would otherwise hand its edge to the next one, which may have
// nothing to scroll at all.
afterNavigate(() => (scrolledUnder = false))
// nothing to scroll at all. Its actions' widths go with it.
afterNavigate(({ from, to }) => {
scrolledUnder = false
if (from?.url.pathname !== to?.url.pathname) {
actionWidths = []
actionsEpoch++
}
})
const separator = $derived(pageHeader.content?.separator ?? 'onScroll')
const showEdge = $derived(separator === 'always' || (separator === 'onScroll' && scrolledUnder))
@@ -87,147 +155,213 @@ The row's height matches the sidebar's own header row, so the two read as one ba
data-page-header
bind:clientWidth={() => pageHeader.barWidth, (w) => pageHeader.setBarWidth(w)}
class={twMerge(
'flex items-center gap-1 shrink-0 min-w-0 bg-surface transition-shadow duration-150',
// One row at every width. A wrapping row never squeezes anything — flex moves an item to the
// next line instead of taking it below its content width — so wrapping here would hand the
// trail a line of its own and leave it whole, which is the opposite of what a narrow bar
// wants: the trail truncating from its start.
phone ? `${BAR_HEIGHT} pl-1 pr-2` : `${BAR_HEIGHT} pl-2 pr-4`,
// The filling surface is what meets the right edge, so the bar keeps no gutter of its own
// there.
content?.actionsFill && 'pr-0',
// The small left padding is there to sit a button against: the sidebar handle, or the
// workspace disc, both of which carry their own visual margin. Embedded there is neither,
// and the page's name would start as bare text 8px from the edge.
navHidden && 'pl-4',
'flex flex-col shrink-0 min-w-0 bg-surface transition-shadow duration-150',
showEdge &&
'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]'
)}
>
{#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
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 -->
<div
data-nav-handle
onmouseenter={() => navHandleSlot.open()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
does better than a label popping up over it. -->
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: PanelLeft }}
aria-label="Show sidebar"
onclick={() => navHandleSlot.open()}
/>
</div>
{/if}
<!-- The breadcrumb yields width more slowly than the page's controls (shrink-[0.3]) but it does
yield, at every width: the trail truncates from its start, so the squeeze is spent on the
workspace and the folders before it ever reaches the name of the thing the page is about.
The 5rem floor is what is left when every name in it has gone — the workspace disc, the
fork mark and the environment badge, which is as small as the part can be and still say
where the user is.
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
the trail would offer to navigate the host's workspace, while the name is what says which
page the controls beside it belong to.
The 0.3 only holds against a summary or an actions box that shrinks too: alone, a factor
sum below 1 makes flex hand out only that share of the overflow, and the bar overflows
instead. -->
<div
class={twMerge(
'flex shrink-[0.3] min-w-[5rem]',
!hasSummary &&
(actions.length === 0 || (phone && !content?.actionsFlexible && !content?.actionsFill)) &&
'shrink'
'flex items-center gap-1 min-w-0',
// No flex-wrap. A wrapping row never squeezes anything — flex moves an item to the next line
// instead of taking it below its content width — so wrapping here would hand the trail a
// line of its own and leave it whole. What needs a line of its own asks for one through
// `belowBar`, at a fold the bar picks.
phone ? `${BAR_HEIGHT} pl-1 pr-2` : `${BAR_HEIGHT} pl-2 pr-4`,
// The filling surface is what meets the right edge, so the bar keeps no gutter of its own
// there.
content?.actionsFill && 'pr-0',
// The small left padding is there to sit a button against: the sidebar handle, or the
// workspace disc, both of which carry their own visual margin. Embedded there is neither,
// and the page's name would start as bare text 8px from the edge.
navHidden && 'pl-4'
)}
>
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
{#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. 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 -->
<div
data-nav-handle
bind:clientWidth={handleWidth}
onmouseenter={() => navHandleSlot.open()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
does better than a label popping up over it. -->
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: PanelLeft }}
aria-label="Show sidebar"
selected={navHandleSlot.pinned}
aria-pressed={navHandleSlot.pinned}
onclick={() => navHandleSlot.toggle()}
/>
</div>
{/if}
<!-- The breadcrumb's parts are rigid and the bar sizes them (see `fit`): it drops whole parts
and caps the names left, so this box only shrinks past what they add up to on a bar too
narrow for even the last stage.
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
the trail would offer to navigate the host's workspace, while the name is what says which
page the controls beside it belong to. -->
<div class="flex min-w-0">
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
the tree that named it, and the rename editor in there asks that tree who the acting user
is before it offers to rename anything. -->
<!-- Each fragment the band renders comes from another tree and reads that tree's state. The
<!-- Each fragment the band renders comes from another tree and reads that tree's state. The
registry already tolerates a registration that cannot answer while its owner's data is
being cleared (see `read`); the same window reaches rendering, and an error here would
otherwise travel to the root and blank the app rather than the one fragment. A
boundary per fragment keeps that to the fragment. -->
<svelte:boundary onerror={(e) => console.error('page header: breadcrumb failed to render', e)}>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
<NavBreadcrumb
{item}
{section}
{narrow}
nameOnly={navHidden}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{#if item && hasSummary}
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
<!-- A dot rather than a slash: the summary names the same item the path just located, it
is not another level of it. Inside this box so that it narrows with the summary. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#if item.summaryContent}
<svelte:boundary onerror={(e) => console.error('page header: summary failed to render', e)}>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render item.summaryContent()}
</ContextBridge>
{/key}
</svelte:boundary>
{:else}
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
{/if}
<!-- The summary ends the trail here, so the breadcrumb's chevron follows it instead. Not
before the path lands: the picker opens in the item's folder, which it reads once. -->
{#if !navHidden && item.path}
<BreadcrumbNavPicker {item} actingWorkspaceId={content?.actingWorkspaceId} />
{/if}
<svelte:boundary
onerror={(e) => console.error('page header: breadcrumb failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
<NavBreadcrumb
{item}
{section}
{stage}
caps={fit.caps}
scopesGive={fit.scopesGive}
bind:measures={trailMeasures}
nameOnly={navHidden}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{/if}
{#if actions.length > 0}
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
here that can give width back, and it can only do that if this box may shrink. A phone
only tightens the gutter — pages fold their own buttons into a menu at that width, and
a box that refused to shrink would make the one flexible field take its max instead. -->
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
{#if item && hasSummary}
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class="flex items-center gap-1 shrink-0">
<!-- A dot after the path: the summary names the same item the path just located, it is not
another level of it. With the path dropped it follows the workspace, and the slash is
the trail's own separator. Fixed width, so either fits the width measured below. -->
<span class="shrink-0 w-2 text-center text-hint/40 text-xs" aria-hidden="true"
>{stage.path ? '·' : '/'}</span
>
{#if item.summaryContent}
<!-- Read only while uncapped: capped, the box says how wide the bar let it be, not how
wide the page's control wants to be. -->
<div
class="flex min-w-0 overflow-hidden"
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
bind:clientWidth={
() => summaryContentWidth,
(w) => fit.caps.summary === undefined && (summaryContentWidth = w)
}
>
<svelte:boundary
onerror={(e) => console.error('page header: summary failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render item.summaryContent()}
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{:else}
<span
class="truncate text-xs font-medium text-emphasis"
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
>{item.summary}</span
>
{/if}
<!-- The summary ends the trail here, so the breadcrumb's chevron follows it instead. Not
before the path lands: the picker opens in the item's folder, which it reads once. -->
{#if pickerAfterSummary}
<BreadcrumbNavPicker {item} actingWorkspaceId={content?.actingWorkspaceId} />
{/if}
</div>
{/if}
{#if actions.length > 0}
<!-- A plain row of buttons holds its width (shrink-0): squeezing it only pushes buttons out
of the bar, so the bar makes room by dropping breadcrumb parts and then by asking the
page to fold (`actionFolds`). Flexible actions (the Runs filter row) are a control that
can give width back, which it can only do if this box may shrink. -->
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
bar and run to its right edge, and they stretch its full height because that surface
sits on the bottom edge. -->
<div
{@attach measureActions(fit.fold, actionsEpoch)}
class={twMerge(
'flex',
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
// belongs here rather than inside that surface, where it would be filled.
content?.actionsFill
? 'flex-1 min-w-0 self-stretch items-stretch pl-4'
: 'ml-auto items-center',
!content?.actionsFill && (actionsRigid ? 'shrink-0' : 'min-w-0'),
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4'),
actionsUnmeasured && 'invisible'
)}
>
{#each actions as entry, i (i)}
<svelte:boundary
onerror={(e) => console.error('page header: actions failed to render', e)}
>
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
{@render entry.render()}
</ContextBridge>
{/key}
</svelte:boundary>
{/each}
</div>
{/if}
{#if item && hasSummary}
<!-- The summary at its floor, which is what a stage that keeps it needs; and whole, which is
what the bar caps it from. A page's own control stands in at the width last seen. -->
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
<div class="flex items-center gap-1 w-max" bind:clientWidth={summaryFloorWidth}>
<span class="shrink-0 w-2 text-center text-hint/40 text-xs">·</span>
{#if item.summaryContent}
<span style="width: {Math.min(summaryContentWidth, FLOOR.summary)}px"></span>
{:else}
<span class="truncate text-xs font-medium" style="max-width: {FLOOR.summary}px"
>{item.summary}</span
>
{/if}
{#if pickerAfterSummary}
<span class="flex items-center h-9 px-1.5 shrink-0"><ChevronDown size={14} /></span>
{/if}
</div>
<span
class="inline-block whitespace-nowrap text-xs font-medium"
bind:clientWidth={summaryTextWidth}>{item.summary ?? ''}</span
>
</div>
{/if}
</div>
{#if content?.belowBar && fit.fold >= (content.belowBarFold ?? 0)}
<!-- Same gutter as the row above, so what moved down lines up with the breadcrumb's start. -->
<div
class={twMerge(
'flex',
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
// belongs here rather than inside that surface, where it would be filled.
content?.actionsFill
? 'flex-1 min-w-0 self-stretch items-stretch pl-4'
: 'ml-auto items-center',
!content?.actionsFill && (phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0'),
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4')
)}
class={twMerge('flex items-center pb-2', phone ? 'px-2' : 'pl-3 pr-4', navHidden && 'pl-4')}
>
{#each actions as entry, i (i)}
<svelte:boundary onerror={(e) => console.error('page header: actions failed to render', e)}>
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
{@render entry.render()}
</ContextBridge>
{/key}
</svelte:boundary>
{/each}
<svelte:boundary
onerror={(e) => console.error('page header: second row failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render content.belowBar()}
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{/if}
</div>
@@ -18,6 +18,10 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
actionsFlexible,
actionsFill,
actionFolds,
earlyActionFolds,
belowBar,
belowBarFold,
separator,
contexts,
actingWorkspaceId,
@@ -33,6 +37,10 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
actionsFlexible,
actionsFill,
actionFolds,
earlyActionFolds,
belowBar,
belowBarFold,
separator,
contexts,
actingWorkspaceId,
+83 -65
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'
@@ -37,9 +35,8 @@
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { RUNS_PATH } from '$lib/components/sessions/previewPaths'
import { twMerge } from 'tailwind-merge'
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte'
@@ -81,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([])
@@ -91,7 +95,7 @@
folders,
jobTriggerKinds,
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
isAdminsWorkspace: $operatingWorkspace === 'admins'
})
)
let perPage = useLocalStorageValue('runs_per_page', 1000, 'number')
@@ -115,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()))
})
@@ -171,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(() => ({
@@ -183,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)
@@ -211,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()
}
@@ -265,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,
@@ -323,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
@@ -350,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])
@@ -428,7 +436,7 @@
}
async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) {
if (!$workspaceStore) return
if (!$operatingWorkspace) return
if (askingForConfirmation) {
askingForConfirmation.loading = true
@@ -442,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)
@@ -555,7 +563,7 @@
}
$effect(() => {
if ($workspaceStore) {
if ($operatingWorkspace) {
untrack(() => {
loadUsernames()
loadFolders()
@@ -603,7 +611,12 @@
openedRunId = undefined
}
let resolutionNote = $state('')
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
// 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. Hosted, the row
// is the page's own and wraps instead.
const fold = $derived(hosted ? 0 : pageHeader.actionFold)
const filterBelow = $derived(fold >= 3)
</script>
{#snippet runsHint()}
@@ -615,18 +628,34 @@
</Tooltip>
{/snippet}
{#snippet runsFilter(cls: string)}
<FilterSearchbar
class={cls}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $operatingWorkspace === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus={!hosted}
/>
{/snippet}
{#snippet filterRow()}
{@render runsFilter('relative w-full min-w-0')}
{/snippet}
{#snippet headerActions()}
<!-- 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. `flex-1 min-w-0` passes
the band's flexibility down to the search field, the one control here that gives width
back; the rest keep theirs or drop out of the row entirely at a width. -->
<div class="flex items-center gap-3 flex-1 min-w-0">
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 {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.
A phone's bar has room for the filter field and the timeframe and nothing else: the
counters are a glance, and the same numbers are on the chart below. -->
<div class="flex items-center pr-2 {phone ? 'hidden' : ''}">
so without it the badge butts against the first filter. -->
<div class="flex items-center pr-2 {fold >= 1 ? 'hidden' : ''}">
<RunsQueue
success={filters.val.status ?? null}
{queue_count}
@@ -797,32 +826,10 @@
</div>
</div>
<!-- The one control in the band that gives way: everything else keeps the width it asked
for, or drops out of the row at a width, and this takes what is left. -->
<FilterSearchbar
class={twMerge(
// A width of its own, and only ever less. As `flex-1` the field was whatever the row
// had left over, so a chip appearing or a filter committing resized it under the
// cursor and the last pixels came off the breadcrumb. 18rem is its width whenever the
// row fits, and it still shrinks when the row runs short, which is what keeps the row
// inside a box the bar lets shrink (`actionsFlexible`). `min-w-0` is what makes that
// shrink unconditional: a flex item's floor is its min-content width, which the chips
// inside the field raise, so without it a typed filter would push the field wider
// again exactly when the row has no room to give.
'relative w-[18rem] min-w-0 max-w-full',
// A phone's bar cannot spare that much before the timeframe beside it.
phone && 'w-[10rem]'
)}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus
/>
<!-- A width of its own, never what the row has left over: as `flex-1` a chip appearing or a
filter committing resized it under the cursor. `shrink-0` keeps it at that width, which
is the row's width the bar measures and folds. -->
{#if !filterBelow}{@render runsFilter('relative w-[18rem] shrink-0')}{/if}
<TimeframeSelect
unifiedSize="sm"
onClick={() => jobsLoader?.loadJobs(true)}
@@ -833,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={phone ? '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' }}
/>
@@ -900,22 +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}>
<!-- `actionsFlexible`: the filter row holds a search field that can give width back, so the
bar lets this box shrink on a phone instead of holding its content width. -->
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
{#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">
@@ -1106,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')}
>
@@ -96,7 +96,7 @@
import EditorHeader from './EditorHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
import Modal from './common/modal/Modal.svelte'
@@ -206,19 +206,14 @@
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full button group is
// ~515px — about 1100px between them, plus slack for a longer path than most.
const compactBelow = $derived(ownsPageHeader ? 1200 : 900)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900
)
// The second fold holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// The workspace this editor operates on: deploy, save-draft, trigger loading
// and the AutosaveIndicator all target it. Falls back to the full-page
@@ -2326,6 +2321,7 @@
}}
afterName={scriptMarks}
actions={scriptHeaderActions}
actionFolds={2}
separator="none"
/>
{:else}
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { emptyString, isOwner } from '$lib/utils'
import { Alert, Button } from '$lib/components/common'
import { Alert, Button, CopyButton } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Path from '$lib/components/Path.svelte'
@@ -326,14 +326,62 @@
<!-- Nothing to open, but the host still drew this into a slot of its own: give back what it
handed over rather than this component's own block, which belongs in a row it owns. -->
{@render label()}
{:else if compact}
<!-- Nothing to edit, but the band truncates the summary and, on a short bar, drops the path:
the same click shows both whole, to read and to copy. -->
<Popover
class={triggerClass}
placement="bottom-start"
contentClasses="p-4"
triggerAttrs={{
title: emptyString(summary) ? path : summary,
'aria-label': 'Show summary and path'
}}
usePointerDownOutside
excludeSelectors=".drawer"
>
{#snippet trigger()}
<span class="block truncate text-xs font-medium text-emphasis">
{emptyString(summary) ? (path ?? '') : summary}
</span>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-4 w-[420px] max-w-[calc(100vw-2rem)]">
<div>
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
{#if emptyString(summary)}
<p class="text-xs text-tertiary italic">No summary</p>
{:else}
<p class="text-xs text-primary break-words">{summary}</p>
{/if}
</div>
<div>
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
<div class="flex items-center gap-1">
<span class="min-w-0 text-xs font-mono text-secondary break-all">{path}</span>
<CopyButton value={path ?? ''} title="Copy path" />
</div>
</div>
{#if labels?.length || inheritedLabels?.length}
<div class="flex flex-wrap items-center gap-0.5">
{#each labels ?? [] as labelText}
<Badge color="blue" verySmall class="px-1" title="Label: {labelText}"
>{labelText}</Badge
>
{/each}
<InheritedLabels labels={inheritedLabels} />
</div>
{/if}
<p class="text-2xs text-tertiary">You can't edit this {kind}'s summary or path.</p>
</div>
{/snippet}
</Popover>
{:else}
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
{#if !emptyString(summary) && !compact}
<div class="min-w-0 truncate flex items-center flex-col px-2">
{#if !emptyString(summary)}
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
{/if}
<span
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} text-emphasis truncate"
>
<span class="text-sm font-semibold text-emphasis truncate">
{emptyString(summary) ? (path ?? '') : summary}
</span>
</div>
@@ -54,7 +54,7 @@
import EditorHeader from '$lib/components/EditorHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
@@ -261,24 +261,16 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they shed the canvas
// toggles and fold Debug runs into the menu sooner: the whole group is ~840px and the trail
// with the summary takes ~430px of the same row.
// Measured on the band rather than estimated: with the canvas toggles shown the actions alone
// are 826-911px and the trail with the summary another ~500, so the row overflows the bar at
// every width up to about 1470 — 1350 was not high enough either. Below this the toggles stand
// down and the menu carries all three, which is what keeps them reachable rather than merely
// out of the way.
const compactBelow = $derived(ownsPageHeader ? 1500 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and Deploy. Which panels are open, the export and the
// editor/preview switch join the menu compact already shows.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone. Folded, the canvas
// toggles stand down and the menu carries them and Debug runs, which keeps them reachable
// rather than merely out of the way.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold holds the trail and Deploy. Which panels are open, the export and the
// editor/preview switch join the menu compact already shows.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
function closeSaveDrawer() {
saveDrawerOpen = false
@@ -1014,6 +1006,7 @@
pathTrigger: appPathTrigger
}}
actions={appHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="always"
/>
@@ -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'
)
}
/**
@@ -17,14 +17,16 @@
type MainButton = {
label: string
/** Shown under the label once the button has collapsed into a menu. */
/** Shown under the label once the button is a row of a menu. */
description?: string
buttonProps: ButtonProps
/** Where the button lands below the `lg` breakpoint, where the bar cannot hold every
* button beside the summary: the ellipsis menu, or the dropdown of the enabled bar button
* labelled `dropdownOf` (the menu when there is none). Unset keeps it in the bar at every
* width. */
narrow?: 'menu' | { dropdownOf: string }
/** A secondary action: a bar button while the bar has room for every one, then, from the
* first fold, in the ellipsis menu or in the dropdown of the enabled bar button labelled
* `dropdownOf` (the menu when there is none). Unset makes it a main button, which goes to
* the menu only on the folds after that. */
placement?: 'menu' | { dropdownOf: string }
/** In its `placement` at every width, never a bar button of its own. */
alwaysPlaced?: boolean
}
type ButtonProps = any
@@ -70,6 +72,9 @@
wide?: boolean
/** Controls ahead of the menu, such as the way an agent's page runs it. */
leading_actions?: import('svelte').Snippet
/** Marks that belong to the item's name, such as its favorite star: drawn right after the
* summary rather than among the badges and buttons at the far end. */
name_marks?: import('svelte').Snippet
}
let {
@@ -89,7 +94,8 @@
trigger_badges,
ownsPageHeader = false,
wide = true,
leading_actions
leading_actions,
name_marks
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -98,26 +104,49 @@
// no error handler and says so itself.
const renameKind = $derived(itemKind ?? errorHandlerKind)
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
// own box is narrow while the window is not — a session's preview panel — and says so through
// `wide`. The band knows its own width, which the host cannot see: these buttons share that row
// with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured
// (0) counts as wide, because the bar measures itself on mount and starting narrow would pop
// the buttons out of the menu a frame later.
// Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row
// has to fold before that rather than at the ~1150 the two parts add up to on paper.
const COLLAPSE_BELOW = 1250
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
const wideRow = $derived(wide && roomInBar)
// The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below):
// first the secondary ones go to their `placement`, then every bar button but the last, which
// is what the page is for (Edit), then that one too. The host knows when its own box is narrow
// while the window is not — a session's preview panel — and says so through `wide`, which only
// sends the error handler's toggle to the menu.
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : 0)
const wideRow = $derived(wide && fold <= 1)
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
const rowButtons = $derived(mainButtons.filter((b) => !b.placement))
const barButtons = $derived(
fold === 0
? mainButtons.filter((b) => !b.alwaysPlaced)
: fold === 1
? rowButtons
: fold === 2
? rowButtons.slice(-1)
: []
)
function dropdownHost(btn: MainButton): MainButton | undefined {
if (typeof btn.narrow !== 'object') return undefined
const label = btn.narrow.dropdownOf
if (typeof btn.placement !== 'object' || barButtons.includes(btn)) return undefined
const label = btn.placement.dropdownOf
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
}
/** What the menu holds of the buttons: the folded bar buttons, each followed by what its
* dropdown would have held, then the secondary actions with no bar button to hang from. */
const menuButtons = $derived.by(() => {
const folded = rowButtons.filter((b) => !barButtons.includes(b))
const homeless = mainButtons.filter(
(b) => b.placement && !barButtons.includes(b) && !dropdownHost(b)
)
const childOf = (host: MainButton) =>
homeless.filter(
(b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label
)
const placed = new Set(folded.flatMap(childOf))
return [
...folded.flatMap((host) => [host, ...childOf(host)]),
...homeless.filter((b) => !placed.has(b))
]
})
async function toggleErrorHandler() {
if (!errorHandlerKind || !scriptOrFlowPath) return
const next = await toggleWorkspaceErrorHandler(
@@ -130,7 +159,7 @@
}
const allMenuItems: Item[] = $derived([
...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
...menuButtons.map((b) => ({
displayName: b.label,
description: b.description,
icon: b.buttonProps.startIcon,
@@ -155,12 +184,11 @@
action: item.onclick,
disabled: item.disabled,
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
separatorTop: i === 0 && !wideRow
separatorTop: i === 0 && (menuButtons.length > 0 || (!wideRow && !!errorHandlerKind))
}))
])
function dropdownItemsOf(host: MainButton) {
if (wideRow) return undefined
const items = mainButtons
.filter((b) => dropdownHost(b) === host)
.map((b) => ({
@@ -176,15 +204,18 @@
</script>
{#snippet summaryContent()}
<SummaryPathDisplay
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={renameKind}
compact
/>
<div class="flex items-center gap-1 min-w-0">
<SummaryPathDisplay
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={renameKind}
compact
/>
{#if name_marks}<span class="flex items-center shrink-0">{@render name_marks()}</span>{/if}
</div>
{/snippet}
<!-- The same editor as the summary's, hung off the band's path segment so it opens under the path.
@@ -278,6 +309,8 @@
pathTrigger: onSaved ? pathTrigger : undefined
}}
{actions}
actionFolds={3}
earlyActionFolds={1}
contexts={headerContexts}
separator="always"
/>
@@ -301,6 +334,7 @@
kind={renameKind}
/>
</div>
{@render name_marks?.()}
{@render badges('md')}
</div>
<div class="flex gap-1 items-center pr-4">
@@ -369,7 +369,7 @@
buttons.push({
label: 'Fork',
description: `Start a new flow from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: operatingHref(`/flows/add?template=${flow.path}`),
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
@@ -390,7 +390,7 @@
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('flow', workspace, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('flow', flow.path, workspace),
onClick: (e: Event | undefined) => editInFork(e, flow.path),
@@ -418,7 +418,7 @@
buttons.push({
label: `History`,
narrow: 'menu',
placement: 'menu',
buttonProps: {
onClick: () => flowHistory?.open(),
unifiedSize: 'md',
@@ -435,7 +435,8 @@
if (!actingUser?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
@@ -682,6 +683,12 @@
}
: undefined}
>
{#snippet name_marks()}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace && flow && workspace === $workspaceStore}
<Star kind="flow" path={flow.path} summary={flow.summary} />
{/if}
{/snippet}
{#snippet trigger_badges()}
<TriggersBadge
showOnlyWithCount={true}
@@ -698,12 +705,6 @@
small={false}
/>
{/snippet}
{#if workspace && flow}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace === $workspaceStore}
<Star kind="flow" path={flow.path} summary={flow.summary} />
{/if}
{/if}
<OnBehalfOfBadge
onBehalfOf={flow?.on_behalf_of}
onBehalfOfEmail={flow?.on_behalf_of_email}
@@ -532,7 +532,7 @@
buttons.push({
label: 'Fork',
description: `Start a new script from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: operatingHref(`/scripts/add?template=${script.path}`),
onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`),
@@ -553,7 +553,7 @@
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('script', workspace, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('script', script.path, workspace),
onClick: (e: Event | undefined) => editInFork(e, script.path),
@@ -586,7 +586,7 @@
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
buttons.push({
label: `History`,
narrow: 'menu',
placement: 'menu',
buttonProps: {
onClick: () => {
versionsDrawerOpen = !versionsDrawerOpen
@@ -602,7 +602,8 @@
if (!actingUser?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)
@@ -870,6 +871,12 @@
}
: undefined}
>
{#snippet name_marks()}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace && script && workspace === $workspaceStore}
<Star kind="script" path={script.path} summary={script.summary} />
{/if}
{/snippet}
{#snippet trigger_badges()}
<TriggersBadge
showOnlyWithCount={true}
@@ -887,12 +894,6 @@
}}
/>
{/snippet}
{#if workspace && script}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace === $workspaceStore}
<Star kind="script" path={script.path} summary={script.summary} />
{/if}
{/if}
{#if script?.auto_kind === 'wac'}
<Popover notClickable>
{#snippet text()}
@@ -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'
},
@@ -52,6 +52,22 @@ export type PageHeaderContent = {
* controls share one shape that opens into the page below. The bar gives up its right padding
* to it, since the surface is what meets the edge. */
actionsFill?: boolean
/** How many levels past the full row these actions can fold through, each a narrower row than
* the last. The bar picks the level from the room its breadcrumb leaves once it has dropped
* all it can, and the page reads it back as `pageHeader.actionFold`. Without it the actions
* never fold and the breadcrumb gives up width around them. */
actionFolds?: number
/** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons
* that are there only while the bar has room for everything. */
earlyActionFolds?: number
/** A second row of the band, under the breadcrumb and the actions: where a control too wide
* for one row goes at a fold that has no room for it beside them, rather than being squeezed
* until it is useless. The band is taller while it is there. */
belowBar?: Snippet
/** The fold from which the bar shows `belowBar`. The bar decides rather than the page passing
* the row in only at that fold: the fold is derived from this registration, so a registration
* that read it would be a cycle. */
belowBarFold?: number
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
* which at the far end of the bar would be a journey away from what it edits. */
@@ -91,10 +107,9 @@ let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
let nextId = 0
/**
* Below this bar width a page keeps one button — the thing the page is for — and folds the rest
* into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet
* in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the
* line sits above the tablet rather than between the two.
* A phone's bar: a tighter gutter, and the width below which controls that cannot fold through
* `actionFolds` — a flexible filter row, a banner — slim down. A phone in portrait is ~390px of
* bar and a tablet in portrait ~720, which is why the line sits above the tablet.
*/
export const PHONE_BAR = 800
@@ -112,6 +127,9 @@ export const BAR_HEIGHT_PX = 48
// share that row with the breadcrumb, so it is the width they crowd against — not the window's,
// which the sidebar and a page's side panel both take from.
let barWidth = $state(0)
// The bar owns the fold level: it is derived from the bar's own measurements, so the bar hands
// over a getter rather than writing a copy that an effect would have to keep in step.
let actionFoldSource = $state<(() => number) | undefined>(undefined)
/**
* One registration's content, or undefined while it cannot answer.
@@ -151,6 +169,10 @@ export const pageHeader = {
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill
if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds
if (c.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds
if (c.belowBar !== undefined) merged.belowBar = c.belowBar
if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold
if (c.separator !== undefined) merged.separator = c.separator
}
return merged
@@ -176,6 +198,17 @@ export const pageHeader = {
setBarWidth(w: number) {
barWidth = w
},
/** How far the page's actions are folded: 0 for the full row, up to its `actionFolds`. */
get actionFold(): number {
return actionFoldSource?.() ?? 0
},
/** Called by the bar once; returns the release to call when the bar goes away. */
provideActionFold(get: () => number): () => void {
actionFoldSource = get
return () => {
if (actionFoldSource === get) actionFoldSource = undefined
}
},
/** Registers on mount and returns the id to release on destroy. */
register(get: () => PageHeaderContent): number {
const id = nextId++
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { BAR_STAGES, fitBar, type BarMeasure } from './pageHeaderStages'
// Each stage 50px narrower than the last, so the bar width alone says which one fits.
const trail = BAR_STAGES.map((_, i) => 600 - i * 50)
const base: BarMeasure = {
barWidth: 2000,
chrome: 0,
trail,
summary: BAR_STAGES.map(() => 0),
actions: [300, 200, 100],
folds: 2,
natural: { family: 0, fork: 0, summary: 0, name: 0 }
}
describe('fitBar', () => {
it('picks the first stage that fits beside the full row of actions', () => {
expect(fitBar({ ...base, barWidth: 900 }).stage).toBe(0)
expect(fitBar({ ...base, barWidth: 800 })).toMatchObject({ stage: 2, fold: 0 })
})
it('folds the actions only once the breadcrumb is at its last stage, and keeps it there', () => {
// 350 + 300 overflows a 600px bar; 350 + 200 fits. Stage 0 would fit beside the folded row
// too, but the parts a narrowing bar dropped do not come back when the buttons fold.
expect(fitBar({ ...base, barWidth: 600 })).toMatchObject({ stage: 5, fold: 1 })
expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 })
})
it('spends early folds before the breadcrumb drops anything', () => {
// 600 + 200 fits an 800px bar at the first fold; without early folds stage 2 would have
// dropped parts beside the full row instead.
expect(fitBar({ ...base, barWidth: 800, earlyFolds: 1 })).toMatchObject({ stage: 0, fold: 1 })
// Past that, the breadcrumb gives way at the early fold before the later folds start.
expect(fitBar({ ...base, barWidth: 700, earlyFolds: 1 })).toMatchObject({ stage: 2, fold: 1 })
})
it('tries a level it has not seen at the width of the next one folded further', () => {
// Only the second fold has been measured: the levels before it are assumed no wider, so the
// bar tries the full row first and goes on to the first fold once it has seen it overflow.
const unseen = { ...base, barWidth: 600, actions: [undefined, undefined, 100] }
expect(fitBar(unseen).fold).toBe(0)
expect(fitBar({ ...unseen, actions: [300, undefined, 100] }).fold).toBe(1)
})
it("takes the squeeze from the path's name before the summary", () => {
const fit = fitBar({
...base,
barWidth: 360,
actions: [0],
folds: 0,
trail: trail.map((w) => w - 400),
summary: BAR_STAGES.map(() => 100),
natural: { family: 0, fork: 0, summary: 300, name: 300 }
})
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 100, so 180
// has to go: all 140 the name has above its floor, then 40 from the summary.
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 260 }, scopesGive: false })
})
it('keeps the summary and drops the path when only one of them fits', () => {
const natural = { family: 0, fork: 0, summary: 300, name: 300 }
// Stage 2 is the first without the path: the name is no longer a part the bar sizes.
const fit = fitBar({ ...base, barWidth: 520, natural, actions: [0], folds: 0 })
expect(fit.stage).toBe(2)
expect(fit.caps.name).toBeUndefined()
})
})
@@ -0,0 +1,205 @@
/**
* How the page header gives up width. Whole parts drop in a fixed order, and the bar shows the
* first stage whose parts, each held at its floor width, fit beside the page's actions. Which
* stage that is depends on the names in the bar rather than on a pixel breakpoint. Past the last
* stage the page's actions fold, one level at a time; past their last level the item's name and
* its summary truncate, then the folders above it.
*/
/** What the breadcrumb itself drops. */
export type TrailStage = {
/** Inside a fork, the family's name before the fork's. Outside one it is the workspace's own
* name and stays until `wsButton`. */
familyName: boolean
/** The item's path. Dropped only for an item that has a summary: the summary is what a
* person calls the thing, while its path is where it is filed. Without one, the path is the
* item's only name and stays. */
path: boolean
forkName: boolean
/** The workspace part as its disc alone, still leading home, beside its picker's chevron. */
wsButton: boolean
/** The folders between the first one and the name; collapsed, they read as `…`. */
middleLevels: boolean
}
export type BarStage = TrailStage & { label: string }
export const BAR_STAGES: BarStage[] = [
{
label: 'Full',
familyName: true,
path: true,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Family name dropped',
familyName: false,
path: true,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Path dropped for the summary',
familyName: false,
path: false,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Fork name dropped',
familyName: false,
path: false,
forkName: false,
wsButton: false,
middleLevels: true
},
{
label: 'Workspace as its disc',
familyName: false,
path: false,
forkName: false,
wsButton: true,
middleLevels: true
},
{
label: 'Middle folders collapsed',
familyName: false,
path: false,
forkName: false,
wsButton: true,
middleLevels: false
}
]
export type Part = 'family' | 'fork' | 'summary' | 'name'
/** The trail at each stage with its names at their floors, and each name's own width. */
export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number }
/** The width a part may truncate to before the next stage drops something whole. The summary's
* is wide: below it the path goes rather than the summary losing most of what it says. */
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 200, name: 160 }
/** Past the last stage and fold, the item's name and then its summary keep this much before the
* folders give any. */
export const SQUEEZED_FLOOR = 40
/** Middle folders collapsed to one `…`; the first folder and the name always stay. */
export function shownPathLevels(levels: string[], stage: TrailStage): string[] {
const scopes = levels.slice(0, -1)
if (stage.middleLevels || scopes.length <= 1) return levels
return [scopes[0], '…', levels[levels.length - 1]]
}
/** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */
export function partVisible(part: Part, stage: BarStage, natural: Record<Part, number>): boolean {
if (natural[part] <= 0) return false
switch (part) {
case 'summary':
return true
case 'family':
// Outside a fork the family's name is the workspace's: it goes only with the whole part.
return !stage.wsButton && (stage.familyName || natural.fork <= 0)
case 'fork':
return !stage.wsButton && stage.forkName
case 'name':
return stage.path || natural.summary <= 0
}
}
export type BarMeasure = {
barWidth: number
/** The bar's padding and the sidebar handle: what is there at every stage. */
chrome: number
/** Per stage: the breadcrumb with each name at its floor. */
trail: number[]
/** Per stage: the summary at its floor with the separator before it, 0 where there is none. */
summary: number[]
/** Per fold level: the actions with the gap before them, undefined for a level not yet seen. */
actions: (number | undefined)[]
/** How many levels past the full row the page's actions can fold through. */
folds: number
/** How many of those levels fold before the breadcrumb drops anything: buttons that are there
* only while there is room for everything. */
earlyFolds?: number
natural: Record<Part, number>
}
export type BarFit = {
stage: number
fold: number
/** Max widths for the parts that give width, in the order they give it. */
caps: Partial<Record<Part, number>>
/** Even the name at its squeezed floor does not fit: the folders above it truncate too. */
scopesGive: boolean
}
/**
* Flex shrinks every part in proportion, so however lopsided the factors, the name gives a few
* pixels while the summary still has room, and a few pixels are an ellipsis. The parts are rigid
* instead and the deficit is taken from them in a strict order, each down to its floor.
*/
export function fitBar(m: BarMeasure): BarFit {
// A level not yet seen is assumed as narrow as the next one folded further: trying it is how it
// gets measured, and once it has been, the bar never picks it again unless it fits.
const actionsAt = (fold: number) => {
for (let f = fold; f < m.actions.length; f++)
if (m.actions[f] !== undefined) return m.actions[f]!
return 0
}
const last = BAR_STAGES.length - 1
const floorWidth = (s: number, f: number) =>
m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f)
const early = Math.min(m.earlyFolds ?? 0, m.folds)
let stage = last
let fold = m.folds
choose: {
for (let f = 0; f < early; f++) {
if (floorWidth(0, f) <= m.barWidth) {
stage = 0
fold = f
break choose
}
}
for (let s = 0; s <= last; s++) {
if (floorWidth(s, early) <= m.barWidth) {
stage = s
fold = early
break choose
}
}
// The other levels fold only once the breadcrumb has given all it can, so a narrowing bar
// only ever loses things: the parts it dropped do not come back when the buttons fold.
for (let f = early + 1; f <= m.folds; f++) {
if (floorWidth(last, f) <= m.barWidth) {
fold = f
break choose
}
}
}
const at = BAR_STAGES[stage]
// The summary gives last: it is what a person calls the item, so the path's name goes first.
const parts = (['family', 'fork', 'name', 'summary'] as Part[]).filter((p) =>
partVisible(p, at, m.natural)
)
const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p]))
// The stage was measured with its parts at their floors; this is its width with them whole.
let deficit = floorWidth(stage, fold) + parts.reduce((s, p) => s + slack(p), 0) - m.barWidth
const caps: Partial<Record<Part, number>> = {}
const take = (p: Part, floor: number) => {
if (deficit <= 0 || !parts.includes(p)) return
const current = caps[p] ?? m.natural[p]
const give = Math.min(deficit, Math.max(0, current - Math.min(m.natural[p], floor)))
if (give > 0) caps[p] = current - give
deficit -= give
}
for (const p of parts) take(p, FLOOR[p])
take('name', SQUEEZED_FLOOR)
take('summary', SQUEEZED_FLOOR)
return { stage, fold, caps, scopesGive: deficit > 0 }
}
@@ -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`.
@@ -80,7 +80,7 @@
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -318,20 +318,15 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff,
// Jobs, Export, AI and Deploy — is ~560px.
const compactBelow = $derived(ownsPageHeader ? 1350 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
// in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
// in the menu, Jobs from `compactTopbar` (true at the second fold too) and the export always.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// Top-bar button size + bar height. Condensed (session preview) uses the
// smallest well-supported unified size (`sm`) so the bar is thinner.
@@ -914,8 +909,8 @@
</a>
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
it can be fetched (S3, GitHub raw, your docs site) and link
<span class="font-mono">/replay?src=&lt;url&gt;</span> to have it load itself. Windmill
keeps no copy.
<span class="font-mono">/replay?src=&lt;url&gt;</span> to have it load itself. Windmill keeps
no copy.
</span>
</div>
<div class="flex-1 min-h-0">
@@ -960,6 +955,7 @@
pathTrigger: rawAppPathTrigger
}}
actions={rawAppHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="none"
/>
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -6,7 +6,10 @@
import { resource } from 'runed'
import { canSkipRetryChainQuery } from './scriptRetryChain'
let { job }: { job: Job } = $props()
let {
job,
onNavigate = goto
}: { job: Job; onNavigate?: (url: string) => void | Promise<void> } = $props()
type AttemptColor = 'green' | 'red' | 'blue' | 'gray'
type Attempt = { id: string; color: AttemptColor; current: boolean }
@@ -141,7 +144,7 @@
size="xs"
color="gray"
variant="border"
onclick={() => goto(`/run/${self?.handledJob}?workspace=${ws}`)}
onclick={() => onNavigate(`/run/${self?.handledJob}?workspace=${ws}`)}
>
Handled run
</Button>
@@ -163,7 +166,7 @@
size="xs"
color={attempt.color}
variant={attempt.current ? 'contained' : 'border'}
onclick={() => goto(`/run/${attempt.id}?workspace=${ws}`)}
onclick={() => onNavigate(`/run/${attempt.id}?workspace=${ws}`)}
>
{i === 0 ? 'Original' : `Attempt ${i}`}
</Button>
@@ -180,7 +183,7 @@
size="xs"
color={handler.color}
variant="border"
onclick={() => goto(`/run/${handler.id}?workspace=${ws}`)}
onclick={() => onNavigate(`/run/${handler.id}?workspace=${ws}`)}
>
{HANDLER_LABEL[handler.kind]}
</Button>
@@ -28,6 +28,8 @@
kind,
path,
version,
locationHash = '',
dbtRetryFrom,
workspaceId,
tabId,
container,
@@ -42,6 +44,10 @@
path: string
/** Deployed version this tab is pinned to, from its URL's `?version=`. */
version?: string
/** `#`-fragment of the tab's URL: the args a run's `Run again` prefills the form with. */
locationHash?: string
/** The failed dbt run that `Run again` offers to resume, from `?dbt_retry_from=`. */
dbtRetryFrom?: string
workspaceId: string
/** Whether this side is the one on screen. Hidden sides stay mounted, so this gates
* the window-level keyboard handler — several instances listen on the window. */
@@ -83,10 +89,20 @@
reloadKey++
}
const searchParams = $derived(version ? new URLSearchParams({ version }) : undefined)
const searchParams = $derived(
version || dbtRetryFrom
? new URLSearchParams({
...(version ? { version } : {}),
...(dbtRetryFrom ? { dbt_retry_from: dbtRetryFrom } : {})
})
: undefined
)
// Remounts the detail page below, which loads on mount.
const loadKey = $derived(`${workspaceId}/${kind}/${path}/${version ?? ''}/${reloadKey}`)
// The fragment is part of it: the detail pages seed their form from it once, on mount.
const loadKey = $derived(
`${workspaceId}/${kind}/${path}/${version ?? ''}/${locationHash}/${reloadKey}`
)
// Nothing deployed at this path. Stamped with the load it answers, so a refetch or a
// re-pointed tab is never answered from an earlier load's verdict.
let notFoundFor: string | undefined = $state(undefined)
@@ -214,6 +230,7 @@
hash={path}
workspace={workspaceId}
{searchParams}
{locationHash}
{onNavigate}
{active}
embedded
@@ -224,6 +241,7 @@
{path}
workspace={workspaceId}
{searchParams}
{locationHash}
{onNavigate}
{active}
embedded
@@ -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>
@@ -97,6 +97,9 @@
? (new URL(tab.url, 'http://x').searchParams.get('version') ?? undefined)
: undefined
)
// What a run's `Run again` hands the deployed page it opens: the run's args in the
// fragment, and for a dbt run the failure it could resume instead.
const viewerUrl = $derived(slot.kind === 'viewer' ? new URL(tab.url, 'http://x') : undefined)
// The path an editor reports is its deployed one, which a staged rename can make
// differ from this tab's — so the view opens on what actually exists.
@@ -147,6 +150,7 @@
// A page item's editor reads its draft only when it loads, so a reload remounts it.
let pageItemReloadNonce = $state(0)
let pageListReloadNonce = $state(0)
let runReloadNonce = $state(0)
export function reload() {
// A live editor shares the runtime store the chat mutates, so generic chat
@@ -168,6 +172,10 @@
pageListReloadNonce++
return
}
if (slot.kind === 'run') {
runReloadNonce++
return
}
try {
const win = frame?.contentWindow
if (!win) return
@@ -393,6 +401,8 @@
kind={itemKind}
path={itemPath}
version={pinnedVersion}
locationHash={viewerUrl?.hash}
dbtRetryFrom={viewerUrl?.searchParams.get('dbt_retry_from') ?? undefined}
{workspaceId}
tabId={tab.id}
container={overlayHostEl}
@@ -444,6 +454,29 @@
{/await}
{/if}
</div>
{:else if slot.kind === 'run' && mounted && runtime}
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
{#if overlayHostEl}
{#await import('./RunViewerView.svelte')}
{@render editorLoading()}
{:then Module}
<!-- A run stays in the workspace it ran in, which the tab's URL names: the
session's own can change under an open tab (a staged fork committing). -->
<Module.default
{runtime}
jobId={slot.jobId}
workspaceId={slot.workspace ?? workspaceId}
tabId={tab.id}
container={overlayHostEl}
reloadNonce={runReloadNonce}
/>
{/await}
{/if}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div
bind:this={overlayHostEl}
@@ -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
}
@@ -0,0 +1,106 @@
<script lang="ts">
/*
* A run in a session preview tab, rendered by the component the workspace `/run/<id>` page
* renders. Everything it reaches is scoped to the run's workspace through `workspace`, and
* every navigation it attempts is caught here and turned into a move inside the preview
* panel, so nothing takes the browser out of the session.
*/
import { setContext, untrack } from 'svelte'
import RunDetail from '$lib/components/runs/RunDetail.svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import {
parseHistoricalScriptEdit,
parsePreviewItemRoute,
parseRunRoute,
previewLocationLabel,
barePath
} from './previewRouter'
let {
runtime,
jobId,
workspaceId,
tabId,
container,
reloadNonce = 0
}: {
runtime: SessionRuntime
jobId: string
workspaceId: string
/** The preview tab hosting this run, which a move to another run re-points. */
tabId: string
/** The tab's host element, which also holds the overlays the page portals out. */
container: HTMLElement | undefined
/** Bumped to load the run again from the server. */
reloadNonce?: number
} = $props()
// Mounted in the preview panel, outside the chat's own subtree, as ItemViewerView is: the
// run page's AI entry points would otherwise offer a session from inside one.
setContext(
'aiChatManager',
untrack(() => runtime.manager)
)
// Everything the run page navigates to, resolved against the preview panel. Another run —
// a re-run, a retry, the parent job — is still this tab's subject, so it re-points in
// place; an item or any other page opens beside it. Nothing reaches the top-level router.
function onNavigate(url: string): void {
const parsed = new URL(url, window.location.origin)
const href = `${parsed.pathname}${parsed.search}${parsed.hash}`
if (parseRunRoute(href)) {
runtime.previewTabs.navigate({ type: 'page', href, label: previewLocationLabel(href) }, tabId)
return
}
const route = parsePreviewItemRoute(href)
// Legacy drag-and-drop apps and historical script edits have no in-panel host, so they
// fall through to a page tab. So does `Run again`, whose args ride in the fragment, and
// a link to one version: opening an item focuses a tab already on it as it stands,
// where a page tab's URL is written whole, pin and fragment included.
if (
route &&
(route.kind !== 'app' || route.raw_app) &&
!parseHistoricalScriptEdit(href) &&
!parsed.hash &&
!parsed.searchParams.has('version')
) {
runtime.previewTabs.open({
type: 'item',
item: { path: route.itemPath, summary: '', kind: route.kind, raw_app: route.raw_app },
mode: route.mode
})
return
}
runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(barePath(href)) })
}
// Every in-app link under the tab, not only the ones that call `onNavigate`: a plain anchor
// followed by the browser would take it out of the session. Listened for on `body`, as
// ItemViewerView does and for the same reason.
$effect(() => {
const el = container
if (!el) return
function onClick(e: MouseEvent) {
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const target = e.target as Element | null
if (!target || !el!.contains(target)) return
const a = target.closest?.('a[href]') as HTMLAnchorElement | null
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
const url = new URL(a.href, window.location.href)
if (url.origin !== window.location.origin) return
e.preventDefault()
if (a.getAttribute('href')?.startsWith('#')) return
onNavigate(`${url.pathname}${url.search}${url.hash}`)
}
document.body.addEventListener('click', onClick)
return () => document.body.removeEventListener('click', onClick)
})
</script>
<div class="flex h-full min-h-0 flex-col overflow-auto">
<!-- Keyed: the run page binds one job's loaders and panels for its lifetime. -->
{#key `${workspaceId}/${jobId}/${reloadNonce}`}
<RunDetail id={jobId} workspace={workspaceId} {onNavigate} embedded />
{/key}
</div>
@@ -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)
@@ -146,7 +157,13 @@ export const isPageKey = (key: string) => key.startsWith('page:')
/** 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,30 @@ 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', () => {
expect(resolvePreviewTab('/run/01a0?workspace=fork')).toEqual({
kind: 'run',
jobId: '01a0',
workspace: 'fork'
})
expect(resolvePreviewTab('/run/01a0')).toEqual({ kind: 'run', jobId: '01a0' })
expect(resolvePreviewTab('/run/01a0?workspace=fork&view_token=t')).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', () => {

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