Merge remote-tracking branch 'origin/main' into operator-fork-workspace-ruleset

# Conflicts:
#	backend/ee-repo-ref.txt
#	frontend/src/lib/components/runs/RunDetail.svelte
This commit is contained in:
Guilhem Lemouel
2026-10-14 00:29:12 -07:00
167 changed files with 6008 additions and 1964 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.830.0"
".": "1.833.0"
}
+3 -2
View File
@@ -34,8 +34,9 @@ Open-source platform for internal tools, workflows, API integrations, background
lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership)
and how to run one locally
- **Data table viewer roles**: `docs/datatable-viewer-roles.md`, on what a connection naming no
role holds: the default role as a floor, where the viewer comes from, why an admin's roles are
not the viewer's, the per-role-set logins and the order their rows and logins are written in
role holds: the default role as a floor, whose roles are added (the job's own, or its viewer's
for an inline app runnable that opts in), why an admin's roles are not added, the per-role-set
logins and the order their rows and logins are written in
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are
enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option`
+78
View File
@@ -1,5 +1,83 @@
# Changelog
## [1.833.0](https://github.com/windmill-labs/windmill/compare/v1.832.0...v1.833.0) (2026-10-13)
### Features
* make the assistant aware of the user's role permissions ([#11722](https://github.com/windmill-labs/windmill/issues/11722)) ([633135b](https://github.com/windmill-labs/windmill/commit/633135bb917ce1be711a75991531c3e478d73463))
* read data as the job's identity, viewer is opt-in per runnable ([#11744](https://github.com/windmill-labs/windmill/issues/11744)) ([b7b3306](https://github.com/windmill-labs/windmill/commit/b7b33061952fde247447fa652663ffd730990d5e))
* show a raw app's document.title in the page tab ([#11747](https://github.com/windmill-labs/windmill/issues/11747)) ([5153a5e](https://github.com/windmill-labs/windmill/commit/5153a5e7b81e9d56ff860129be7c49f444c3c4e2))
* shrink the page header in stages and fold page actions by measured width ([#11732](https://github.com/windmill-labs/windmill/issues/11732)) ([3857ce3](https://github.com/windmill-labs/windmill/commit/3857ce36931dff40322db2b765ab1e65763eac42))
### Bug Fixes
* encrypt ai session backups with aes-256-gcm ([#11734](https://github.com/windmill-labs/windmill/issues/11734)) ([c7ee201](https://github.com/windmill-labs/windmill/commit/c7ee2016559b8083c83c696c41d226d9ca04ed68))
* error handler picker keeps no per-tab state ([#11742](https://github.com/windmill-labs/windmill/issues/11742)) ([2f9a111](https://github.com/windmill-labs/windmill/commit/2f9a111bbb14a40e1341879e1ef63c3457e7a08d))
* show the email handler's SMTP status to non-superadmin admins ([#11745](https://github.com/windmill-labs/windmill/issues/11745)) ([130adfd](https://github.com/windmill-labs/windmill/commit/130adfdd20af0e30d87986cf77b61023a9732186))
## [1.832.0](https://github.com/windmill-labs/windmill/compare/v1.831.0...v1.832.0) (2026-10-13)
### Features
* record each instance's python version and start new ones on 3.13 ([#11656](https://github.com/windmill-labs/windmill/issues/11656)) ([7558ce2](https://github.com/windmill-labs/windmill/commit/7558ce27a9736bef1a9b9db9b0799150dccb11a5))
### Bug Fixes
* badge the unavailable sign-in option in the oauth resource form ([#11728](https://github.com/windmill-labs/windmill/issues/11728)) ([9c44f5a](https://github.com/windmill-labs/windmill/commit/9c44f5a917fc628e870073c126bf9480c98c7f73))
* serve s3 proxy objects with nosniff and a sandbox csp ([#11731](https://github.com/windmill-labs/windmill/issues/11731)) ([e29a0dc](https://github.com/windmill-labs/windmill/commit/e29a0dc5dfed4bb62b894e059c9506873d32a62c))
* widen variable.value without breaking live servers' cached queries ([#11737](https://github.com/windmill-labs/windmill/issues/11737)) ([89e6bdb](https://github.com/windmill-labs/windmill/commit/89e6bdbcf50e065f6906e1ab93b3c4109aba3ab6))
## [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 -
@@ -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: () => {
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc"
}
@@ -0,0 +1,330 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email,\n NULL::boolean as viewer_data_access,\n job_perms.job_token_scopes\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "args: sqlx::types::Json<HashMap<String, Box<RawValue>>>",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "runnable_settings_handle",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "kind: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 10,
"name": "runnable_id: ScriptHash",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "canceled_reason",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "canceled_by",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 14,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 15,
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 17,
"name": "script_lang: ScriptLang",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang",
"dbt"
]
}
}
}
},
{
"ordinal": 18,
"name": "same_worker",
"type_info": "Bool"
},
{
"ordinal": 19,
"name": "pre_run_error",
"type_info": "Text"
},
{
"ordinal": 20,
"name": "concurrent_limit",
"type_info": "Int4"
},
{
"ordinal": 21,
"name": "concurrency_time_window_s",
"type_info": "Int4"
},
{
"ordinal": 22,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
},
{
"ordinal": 23,
"name": "root_job",
"type_info": "Uuid"
},
{
"ordinal": 24,
"name": "timeout",
"type_info": "Int4"
},
{
"ordinal": 25,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 26,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 27,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 28,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 29,
"name": "preprocessed",
"type_info": "Bool"
},
{
"ordinal": 30,
"name": "script_entrypoint_override",
"type_info": "Varchar"
},
{
"ordinal": 31,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 32,
"name": "trigger_kind: TriggerKindLabel",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google",
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
]
}
}
}
},
{
"ordinal": 33,
"name": "visible_to_owner",
"type_info": "Bool"
},
{
"ordinal": 34,
"name": "permissioned_as_end_user_email",
"type_info": "Text"
},
{
"ordinal": 35,
"name": "viewer_data_access",
"type_info": "Bool"
},
{
"ordinal": 36,
"name": "job_token_scopes",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
true,
false,
true,
true,
false,
true,
false,
true,
true,
true,
false,
false,
true,
false,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
null,
null,
true
]
},
"hash": "9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096"
}
@@ -0,0 +1,337 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n v2_job_queue.scheduled_for,\n v2_job.runnable_path,\n v2_job.kind as \"kind: JobKind\",\n v2_job.runnable_id as \"runnable_id: ScriptHash\",\n v2_job_queue.canceled_reason,\n v2_job_queue.canceled_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job_status.flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email,\n NULL::boolean as viewer_data_access,\n job_perms.job_token_scopes",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "args: sqlx::types::Json<HashMap<String, Box<RawValue>>>",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "runnable_settings_handle",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "kind: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 10,
"name": "runnable_id: ScriptHash",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "canceled_reason",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "canceled_by",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 14,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 15,
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 17,
"name": "script_lang: ScriptLang",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang",
"dbt"
]
}
}
}
},
{
"ordinal": 18,
"name": "same_worker",
"type_info": "Bool"
},
{
"ordinal": 19,
"name": "pre_run_error",
"type_info": "Text"
},
{
"ordinal": 20,
"name": "concurrent_limit",
"type_info": "Int4"
},
{
"ordinal": 21,
"name": "concurrency_time_window_s",
"type_info": "Int4"
},
{
"ordinal": 22,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
},
{
"ordinal": 23,
"name": "root_job",
"type_info": "Uuid"
},
{
"ordinal": 24,
"name": "timeout",
"type_info": "Int4"
},
{
"ordinal": 25,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 26,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 27,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 28,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 29,
"name": "preprocessed",
"type_info": "Bool"
},
{
"ordinal": 30,
"name": "script_entrypoint_override",
"type_info": "Varchar"
},
{
"ordinal": 31,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 32,
"name": "trigger_kind: TriggerKindLabel",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google",
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
]
}
}
}
},
{
"ordinal": 33,
"name": "visible_to_owner",
"type_info": "Bool"
},
{
"ordinal": 34,
"name": "permissioned_as_end_user_email",
"type_info": "Text"
},
{
"ordinal": 35,
"name": "viewer_data_access",
"type_info": "Bool"
},
{
"ordinal": 36,
"name": "job_token_scopes",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Uuid",
"TextArray",
"Jsonb",
"TextArray",
"Jsonb",
"TextArray",
"Text",
"Jsonb"
]
},
"nullable": [
false,
false,
true,
true,
false,
true,
true,
false,
true,
false,
true,
true,
true,
false,
false,
true,
false,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
null,
null,
true
]
},
"hash": "fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220"
}
@@ -0,0 +1,71 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email,\n job_token_scopes, viewer_data_access\n FROM job_perms WHERE job_id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "is_operator",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "groups",
"type_info": "TextArray"
},
{
"ordinal": 5,
"name": "folders",
"type_info": "JsonbArray"
},
{
"ordinal": 6,
"name": "end_user_email",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "job_token_scopes",
"type_info": "TextArray"
},
{
"ordinal": 8,
"name": "viewer_data_access",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
true,
false
]
},
"hash": "fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd"
}
+189 -180
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.833.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.833.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
ac721c18c37bb0fd41aae17e8b4439c09168511f
8979bc9f0e8a1154c66b121d290a4f571a848fb1
@@ -0,0 +1 @@
ALTER TABLE job_perms DROP COLUMN IF EXISTS viewer_data_access;
@@ -0,0 +1,4 @@
-- Set by the server on a job pushed for an app runnable whose policy says it reads data as its
-- viewer: data table connections that name no role and object storage permission rules then go by
-- end_user_email instead of the job's own identity.
ALTER TABLE job_perms ADD COLUMN IF NOT EXISTS viewer_data_access BOOLEAN NOT NULL DEFAULT false;
+85
View File
@@ -445,5 +445,90 @@
"mailboxes.read"
],
"req_body_auth": true
},
"ms_outlook": {
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"],
"scope_options": [
{ "value": "offline_access", "label": "Stay connected (refresh token)" },
{ "value": "User.Read", "label": "Read your profile" },
{ "value": "Mail.Send", "label": "Send mail" },
{ "value": "Mail.Read", "label": "Read and search mail" },
{ "value": "Mail.ReadWrite", "label": "Read, draft, move and modify mail" },
{ "value": "MailboxSettings.Read", "label": "Read mailbox settings and categories" },
{ "value": "Contacts.Read", "label": "Read contacts" },
{ "value": "Contacts.ReadWrite", "label": "Read, create and edit contacts" },
{ "value": "People.Read", "label": "Search relevant people" },
{ "value": "Calendars.Read", "label": "Read calendars and events" },
{ "value": "Calendars.ReadWrite", "label": "Read, create, edit and delete events" },
{ "value": "Calendars.Read.Shared", "label": "Read shared calendars and find meeting times" },
{ "value": "Mail.Send.Shared", "label": "Send mail from shared mailboxes" },
{ "value": "Mail.Read.Shared", "label": "Read mail in shared mailboxes" },
{ "value": "Mail.ReadWrite.Shared", "label": "Read and modify mail in shared mailboxes" }
],
"connect_config_template": {
"display_name": "Outlook",
"label": "Microsoft Entra tenant",
"placeholder": "common, organizations, or your tenant id",
"help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints",
"auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token",
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"]
}
},
"hubspot": {
"auth_url": "https://app.hubspot.com/oauth/authorize",
"token_url": "https://api.hubapi.com/oauth/v1/token",
"scopes": [
"oauth",
"crm.objects.contacts.read",
"crm.objects.contacts.write",
"crm.objects.companies.read",
"crm.objects.companies.write",
"crm.objects.deals.read",
"crm.objects.deals.write",
"crm.objects.owners.read"
],
"scope_options": [
{ "value": "oauth", "label": "Basic OAuth access (always required by HubSpot)" },
{ "value": "crm.objects.contacts.read", "label": "Read contacts, and notes, tasks, meetings and calls" },
{ "value": "crm.objects.contacts.write", "label": "Create and edit contacts, and notes, tasks, meetings and calls" },
{ "value": "crm.objects.companies.read", "label": "Read companies" },
{ "value": "crm.objects.companies.write", "label": "Create and edit companies" },
{ "value": "crm.objects.deals.read", "label": "Read deals and deal pipelines" },
{ "value": "crm.objects.deals.write", "label": "Create and edit deals" },
{ "value": "crm.objects.owners.read", "label": "Read owners (users assignable to records)" },
{ "value": "tickets", "label": "Read and edit tickets and ticket pipelines" },
{ "value": "crm.objects.line_items.read", "label": "Read line items" },
{ "value": "crm.objects.line_items.write", "label": "Create and edit line items" },
{ "value": "e-commerce", "label": "Read and edit products and line items" },
{ "value": "crm.objects.quotes.read", "label": "Read quotes" },
{ "value": "crm.objects.quotes.write", "label": "Create and edit quotes" },
{ "value": "crm.objects.leads.read", "label": "Read leads" },
{ "value": "crm.objects.leads.write", "label": "Create and edit leads" },
{ "value": "crm.objects.custom.read", "label": "Read custom object records" },
{ "value": "crm.objects.custom.write", "label": "Create and edit custom object records" },
{ "value": "crm.schemas.custom.read", "label": "Read custom object schemas" },
{ "value": "crm.lists.read", "label": "Read lists (segments)" },
{ "value": "crm.lists.write", "label": "Edit lists and their members" },
{ "value": "sales-email-read", "label": "Read the content of logged one-to-one emails" },
{ "value": "forms", "label": "Read and edit forms and their submissions" },
{ "value": "files", "label": "Read and upload files in the file manager" },
{ "value": "content", "label": "Read and edit marketing emails, blog posts, pages and templates" },
{ "value": "crm.objects.marketing_events.read", "label": "Read marketing events" },
{ "value": "marketing.campaigns.read", "label": "Read marketing campaigns (Marketing Hub Professional)" },
{ "value": "communication_preferences.read_write", "label": "Read and change email subscription preferences" },
{ "value": "automation", "label": "Read, create and enroll into workflows (Professional)" },
{ "value": "automation.sequences.read", "label": "Read sequences and sequence enrollments (Sales Hub Professional)" },
{ "value": "automation.sequences.enrollments.write", "label": "Enroll contacts in sequences (Sales Hub Professional)" },
{ "value": "conversations.read", "label": "Read inboxes, threads and messages" },
{ "value": "conversations.write", "label": "Send messages and update threads" },
{ "value": "scheduler.meetings.meeting-link.read", "label": "Read meeting links and their availability" }
],
"extra_params": {
"optional_scope": "tickets crm.objects.line_items.read crm.objects.line_items.write e-commerce crm.objects.quotes.read crm.objects.quotes.write crm.objects.leads.read crm.objects.leads.write crm.objects.custom.read crm.objects.custom.write crm.schemas.custom.read crm.lists.read crm.lists.write sales-email-read forms files content crm.objects.marketing_events.read marketing.campaigns.read communication_preferences.read_write automation automation.sequences.read automation.sequences.enrollments.write conversations.read conversations.write scheduler.meetings.meeting-link.read"
},
"req_body_auth": true
}
}
+24 -24
View File
@@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -6430,7 +6430,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6442,7 +6442,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"convert_case",
"serde",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6487,7 +6487,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6499,7 +6499,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6511,7 +6511,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6533,7 +6533,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6545,7 +6545,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6556,7 +6556,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6579,7 +6579,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6591,7 +6591,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6605,7 +6605,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6622,7 +6622,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6635,7 +6635,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"serde",
@@ -6647,7 +6647,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6665,7 +6665,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6681,7 +6681,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6698,7 +6698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6730,7 +6730,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6744,7 +6744,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.830.0"
version = "1.833.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.830.0"
version = "1.833.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+3
View File
@@ -153,6 +153,9 @@ pub async fn connect(
pool_options = pool_options.idle_timeout(Duration::from_secs(60));
}
pool_options
.before_acquire(|_, meta| {
Box::pin(async move { Ok(!windmill_common::db::connection_is_retired(meta.age)) })
})
.after_connect(move |conn, _| {
if worker_mode {
Box::pin(async move {
+5 -3
View File
@@ -4469,12 +4469,14 @@ pub async fn monitor_db(
// Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM.
// Every ~5 min until one pass has gone through every workspace; spawned so a large instance
// does not hold up the join below.
// does not hold up the join below. The column widening it starts with is tried every
// iteration: a v2 value too long for the narrow column fails to save until it is done.
let legacy_secret_upgrade_f = async {
if server_mode
&& iteration.is_some()
&& iteration.as_ref().unwrap().should_run(30)
&& windmill_common::variables::legacy_secret_upgrade_pending()
&& (windmill_common::variables::variable_value_widen_pending()
|| (iteration.as_ref().unwrap().should_run(30)
&& windmill_common::variables::legacy_secret_upgrade_pending()))
{
if let Some(db) = conn.as_sql() {
let db = db.clone();
+1 -1
View File
@@ -132,7 +132,7 @@ input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_t
FK: (workspace_id) -> workspace(id)
instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char)
job_logs: job_id(uuid), workspace_id(char), created_at(ts), logs(text), log_offset(int), log_file_index(text[])
job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[])
job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[]), viewer_data_access(bool)
job_resolution: job_id(uuid), workspace_id(char), resolved_at(ts), resolved_by(char), note(text), automatic(bool)
job_result_stream: job_id(uuid), workspace_id(text), stream(text)
job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int)
+160
View File
@@ -1580,3 +1580,163 @@ async fn test_backups_fall_back_to_the_instance_storage(db: Pool<Postgres>) -> a
Ok(())
}
/// Runs its closure when dropped, so process-wide state is put back even when an assertion
/// fails.
struct OnDrop<F: FnMut()>(F);
impl<F: FnMut()> Drop for OnDrop<F> {
fn drop(&mut self) {
(self.0)()
}
}
/// Once every worker reads AES-256-GCM and this server has been up long enough for any server
/// of the previous release to be gone, objects are written in it: a session backed up as CBC
/// before the switch still restores whole with the GCM pieces added after it, a head at its
/// cap and a push token still read back under the larger ciphertext, and relocated GCM
/// ciphertext does not decrypt for another user.
#[sqlx::test(fixtures("base"))]
async fn test_backups_switch_to_gcm_once_every_worker_reads_it(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::min_version::{
MIN_VERSION, MIN_VERSION_SUPPORTS_AEAD_SECRETS, MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES,
};
use windmill_common::workspace_crypt::V2_MAGIC;
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let storage_dir = tempfile::tempdir()?;
configure_primary_lfs(&db, &storage_dir.path().to_string_lossy()).await?;
let before = MIN_VERSION.load_full();
let _restore = OnDrop(move || MIN_VERSION.store(before.clone()));
let _uptime = OnDrop(|| windmill_common::db::set_process_uptime_offset(Default::default()));
let is_gcm = |b: &[u8]| b.starts_with(&V2_MAGIC);
let object = |path: &str| -> Vec<u8> {
std::fs::read(user_root(storage_dir.path(), "test@windmill.dev").join(path)).unwrap()
};
MIN_VERSION.store(std::sync::Arc::new(
MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES.version().clone(),
));
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{
"id": "s1",
"whole": true,
"head": { "id": "s1", "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" },
"chats": [{ "id": "c1", "record": { "id": "c1", "title": "BEFORE" } }]
}]
}),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let cbc_chat = object("sessions/s1/chats/c1.json");
assert!(!cbc_chat.is_empty() && !is_gcm(&cbc_chat));
MIN_VERSION.store(std::sync::Arc::new(
MIN_VERSION_SUPPORTS_AEAD_SECRETS.version().clone(),
));
// Every worker reads it, but this server was only just started: still CBC.
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{ "id": "s1", "chats": [{ "id": "c0", "record": { "id": "c0", "title": "FRESH" } }] }]
}),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let fresh_chat = object("sessions/s1/chats/c0.json");
assert!(!fresh_chat.is_empty() && !is_gcm(&fresh_chat));
windmill_common::db::set_process_uptime_offset(
windmill_common::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME,
);
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{
"id": "s1",
"chats": [{ "id": "c2", "record": { "id": "c2", "title": "AFTER" } }],
"images": [{ "chat_id": "c2", "id": "i1", "data_url": "data:image/png;base64,AAAA" }]
}]
}),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
assert_eq!(
object("sessions/s1/chats/c1.json"),
cbc_chat,
"not rewritten"
);
assert!(is_gcm(&object("sessions/s1/chats/c2.json")));
assert!(is_gcm(&object("images/s1/c2/i1")));
let pulled = pull(&base, "SECRET_TOKEN", &["s1"]).await?;
let mut titles: Vec<String> = pulled["sessions"][0]["chats"]
.as_array()
.unwrap()
.iter()
.map(|c| c["record"]["title"].as_str().unwrap().to_string())
.collect();
titles.sort();
assert_eq!(titles, ["AFTER", "BEFORE", "FRESH"], "{pulled}");
assert_eq!(
pulled["sessions"][0]["images"][0]["data_url"],
"data:image/png;base64,AAAA"
);
// A head at its cap, pushed in two parts: the second reads back the push token.
let mut head = json!({ "id": "s2", "workspace_id": "test-workspace", "createdAt": 2, "chatId": "c", "pad": "" });
let pad = 1024 * 1024 - head.to_string().len();
head["pad"] = json!("x".repeat(pad));
assert_eq!(head.to_string().len(), 1024 * 1024);
for part in [
json!({ "id": "s2", "whole": true, "push": "p2", "opens": true, "partial": true, "head": head }),
json!({ "id": "s2", "whole": true, "push": "p2", "chats": [{ "id": "c", "record": { "id": "c" } }] }),
] {
let resp = push(
&base,
"SECRET_TOKEN",
json!({ "owner": "test@windmill.dev", "sessions": [part] }),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let pushed: Value = resp.json().await?;
assert_eq!(pushed["results"], json!([{ "id": "s2" }]), "{pushed}");
}
assert!(is_gcm(&object("sessions/s2/head.json")));
let pulled = pull(&base, "SECRET_TOKEN", &["s2"]).await?;
assert_eq!(
pulled["sessions"][0]["head"]["pad"].as_str().map(str::len),
Some(pad)
);
assert_eq!(pulled["sessions"][0]["chats"][0]["id"], "c", "{pulled}");
let first = user_root(storage_dir.path(), "test@windmill.dev");
copy_dir(
&first,
&first
.parent()
.unwrap()
.join(calculate_hash("test2@windmill.dev")),
)?;
assert_eq!(
pull(&base, "SECRET_TOKEN_2", &["s1", "s2"]).await?["sessions"],
json!([]),
"relocated ciphertext must not decrypt for another user"
);
Ok(())
}
+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(())
}
+86
View File
@@ -509,3 +509,89 @@ async fn test_raw_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()
Ok(())
}
/// Connecting to data tables as the viewer is honored on inline runnables only: a path runnable
/// is someone else's script, which must not read with the viewer's roles because the app's
/// policy entry for it says so.
#[sqlx::test(fixtures("base", "end_user_email"))]
async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let app_path = "f/test/viewer_data_access_app";
let script_path = "script/f/test/get_end_user_email";
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/apps/create"
)),
SAME_WS_TOKEN,
)
.json(&json!({
"path": app_path,
"summary": "",
"value": { "type": "app", "grid": [], "subgrids": {}, "hiddenInlineScripts": [] },
"policy": {
"execution_mode": "anonymous",
"on_behalf_of": null,
"on_behalf_of_email": null,
"triggerables_v2": {
"rawscript/894d2f85ea2411e777e7d5787366c9fec4c1a7a5f83482748f1d129196c9f54f": {
"static_inputs": {}, "one_of_inputs": {}, "viewer_data_access": true
},
(script_path): {
"static_inputs": {}, "one_of_inputs": {}, "viewer_data_access": true
}
}
}
}))
.send()
.await?;
anyhow::ensure!(resp.status().is_success(), "create app: {}", resp.text().await?);
let execute = |payload: serde_json::Value| async move {
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/apps_u/execute_component/{app_path}"
)),
SAME_WS_TOKEN,
)
.json(&payload)
.send()
.await?;
anyhow::ensure!(resp.status().is_success(), "execute: {}", resp.text().await?);
Ok::<_, anyhow::Error>(resp.text().await?.trim_matches('"').parse::<uuid::Uuid>()?)
};
let as_viewer = |job_id: uuid::Uuid| {
let db = db.clone();
async move {
sqlx::query_scalar::<_, bool>(
"SELECT viewer_data_access FROM job_perms WHERE job_id = $1",
)
.bind(job_id)
.fetch_one(&db)
.await
}
};
let inline = execute(json!({
"args": {},
"component": "get_email",
"raw_code": {
"language": "bun",
"content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }"
}
}))
.await?;
assert!(as_viewer(inline).await?);
let path = execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?;
assert!(
!as_viewer(path).await?,
"a path runnable connected as the viewer because its policy entry said so"
);
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'
);
+112
View File
@@ -0,0 +1,112 @@
//! A job whose `job_perms.viewer_data_access` is set reads workspace object storage as its viewer:
//! the advanced permission rules are checked against the viewer, not the job's own identity, and
//! such a job with no viewer is refused rather than checked as itself.
//!
//! Advanced S3 permissions are an enterprise feature.
#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))]
use serde_json::json;
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_test_utils::*;
/// A job of `test-user-3`, run for `viewer`, reading data as that viewer or not.
async fn job_token(
db: &Pool<Postgres>,
viewer: Option<&str>,
viewer_data_access: bool,
) -> anyhow::Result<String> {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag)
VALUES ($1, 'test-workspace', 'test-user-3', 'u/test-user-3', 'test3@windmill.dev',
'script', 'deno', 'u/test-user-3/app_runnable', 'deno')",
)
.bind(id)
.execute(db)
.await?;
sqlx::query(
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups,
workspace_id, end_user_email, viewer_data_access)
VALUES ($1, 'test3@windmill.dev', 'test-user-3', false, false, '{}', '{}',
'test-workspace', $2, $3)",
)
.bind(id)
.bind(viewer)
.bind(viewer_data_access)
.execute(db)
.await?;
Ok(windmill_common::auth::create_token_for_owner(
db,
"test-workspace",
"u/test-user-3",
"ephemeral-script",
300,
"test3@windmill.dev",
&id,
None,
None,
)
.await?)
}
#[sqlx::test(fixtures("base"))]
async fn a_job_reading_data_as_its_viewer_gets_the_viewers_storage_paths(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let storage_dir = tempfile::tempdir()?;
for user in ["test-user-2", "test-user-3"] {
let dir = storage_dir.path().join("u").join(user);
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("file.txt"), user)?;
}
sqlx::query("UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2")
.bind(json!({
"type": "FilesystemStorage",
"root_path": storage_dir.path().to_string_lossy(),
"public_resource": null,
"advanced_permissions": [{ "pattern": "u/{username}/**", "allow": "read" }]
}))
.bind("test-workspace")
.execute(&db)
.await?;
let read = |token: String, user: &'static str| async move {
let resp = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/job_helpers/download_s3_file"
))
.query(&[("file_key", format!("u/{user}/file.txt"))])
.bearer_auth(token)
.send()
.await?;
Ok::<_, anyhow::Error>(resp.status().is_success())
};
let own = job_token(&db, Some("test2@windmill.dev"), false).await?;
assert!(read(own.clone(), "test-user-3").await?);
assert!(
!read(own, "test-user-2").await?,
"a job not reading as its viewer reached the viewer's paths"
);
let as_viewer = job_token(&db, Some("test2@windmill.dev"), true).await?;
assert!(read(as_viewer.clone(), "test-user-2").await?);
assert!(
!read(as_viewer, "test-user-3").await?,
"a job reading as its viewer kept its own identity's paths"
);
let no_viewer = job_token(&db, None, true).await?;
assert!(
!read(no_viewer, "test-user-3").await?,
"a job reading as a viewer it does not have was checked as itself"
);
Ok(())
}
+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(())
}
+29
View File
@@ -0,0 +1,29 @@
//! Every s3_proxy response carries `nosniff` and the sandbox CSP, error responses included,
//! since the presigned route serves workspace objects from the app origin without a login.
#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))]
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
#[sqlx::test(fixtures("base"))]
async fn test_s3_proxy_sets_content_security_headers(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/s3_proxy/_default_/x.html?exp=9999999999&sig=00"
))
.send()
.await?;
assert!(resp.status().is_client_error(), "status: {}", resp.status());
assert_eq!(resp.headers()["x-content-type-options"], "nosniff");
assert_eq!(
resp.headers()["content-security-policy"],
"sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"
);
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);
}
}
@@ -1850,10 +1850,12 @@ async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database(
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers(
async fn a_connection_naming_no_role_holds_the_default_one_and_its_identitys(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess};
use windmill_common::workspaces::{
get_datatable_resource_from_db, DatatableAccess, DatatableConnectAs,
};
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
@@ -1869,20 +1871,25 @@ async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers(
JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member
WHERE r.rolname = $1 AND m.rolname LIKE 'wm\\_set\\_%'";
// The job is the app publisher's in every case: only the viewer the server recorded differs.
let connect = |role: Option<&'static str>, viewer: Option<&'static str>| {
let connect_job = |owner: &'static str, role: Option<&'static str>, connect_as| {
let (permissioned_as, email) = match owner {
"test-user" => ("u/test-user", "test@windmill.dev"),
"test-user-2" => ("u/test-user-2", "test2@windmill.dev"),
_ => ("u/test-user-3", "test3@windmill.dev"),
};
get_datatable_resource_from_db(
&db,
"test-workspace",
"main",
role,
DatatableAccess::PermissionedAs {
permissioned_as: "u/test-user",
email: "test@windmill.dev",
end_user_email: viewer,
},
DatatableAccess::PermissionedAs { permissioned_as, email, connect_as },
)
};
// An app published by the workspace admin `test-user`, run by `viewer`, whose runnable
// connects as its viewer.
let connect = |role: Option<&'static str>, viewer: Option<&'static str>| {
connect_job("test-user", role, DatatableConnectAs::Viewer(viewer))
};
let set_permissions = |default_role: String, roles: Value| {
sqlx::query(
"UPDATE workspace_settings
@@ -1953,11 +1960,25 @@ async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers(
expected
);
// An admin may use every role, the one owning the tables included. As a viewer they add
// what names them, which here is nothing; nor does a job with no viewer recorded.
for viewer in [Some("test@windmill.dev"), None] {
assert_eq!(connect(None, viewer).await?["user"], names[0].as_str());
}
// A runnable that does not connect as its viewer holds its own identity's roles, whoever
// views it: here the publisher, an admin, who may use every role but adds only what
// names them, which is nothing.
assert_eq!(
connect_job("test-user", None, DatatableConnectAs::Job).await?["user"],
names[0].as_str(),
"the viewer's roles reached a job that does not connect as its viewer"
);
// A job with no viewer, a schedule say, holds its owner's roles.
assert_eq!(
connect_job("test-user-2", None, DatatableConnectAs::Job).await?,
all
);
assert_eq!(
connect(None, Some("test@windmill.dev")).await?["user"],
names[0].as_str()
);
// Connecting as a viewer it does not have is refused rather than made as the job.
assert!(connect(None, None).await.is_err());
// A named role is that role alone, whoever is viewing.
let named: &'static str = Box::leak(names[1].clone().into_boxed_str());
assert_eq!(
@@ -1978,29 +1999,28 @@ async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers(
two,
"no default changes nothing for a viewer the roles name"
);
assert_eq!(
connect_job("test-user-3", None, DatatableConnectAs::Job).await?,
two
);
assert_eq!(
connect_job("test-user", None, DatatableConnectAs::Job).await?["user"],
names[0].as_str(),
"the role open to everyone names the admin too"
);
assert!(connect(None, None).await.is_err());
// The same holds for a default the job's own identity is not a tenant of.
let mut closed = roles.clone();
closed[ids[0].as_str()] = json!({"tenants": []});
set_permissions(ids[0].clone(), closed).await?;
let as_member = |viewer: Option<&'static str>| {
get_datatable_resource_from_db(
&db,
"test-workspace",
"main",
None,
DatatableAccess::PermissionedAs {
permissioned_as: "u/test-user-3",
email: "test3@windmill.dev",
end_user_email: viewer,
},
)
};
assert_eq!(
as_member(Some("test3@windmill.dev")).await?["user"],
connect_job("test-user-3", None, DatatableConnectAs::Job).await?["user"],
names[1].as_str()
);
assert_eq!(
connect(None, Some("test3@windmill.dev")).await?["user"],
names[1].as_str()
);
assert!(as_member(None).await.is_err());
set_permissions(ids[0].clone(), roles).await?;
// A set login nobody connects with any more is dropped, a recent one is kept, and a
@@ -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<()> {
@@ -266,6 +266,22 @@ pub async fn run_end_user_email(
}
}
/// [`run_end_user_email`] of an inline run of the caller's own code, and whether its data table
/// connections are made as that end user: they are when the calling job's are, since the code is
/// that job's own.
pub async fn run_inline_preview_end_user(
db: &DB,
w_id: &str,
authed: &ApiAuthed,
) -> error::Result<(Option<String>, bool)> {
match authed.job_id {
Some(job_id) => Ok(windmill_common::auth::get_job_perms(db, &job_id, w_id)
.await?
.map_or((None, false), |p| (p.end_user_email, p.viewer_data_access))),
None => Ok((Some(authed.email.clone()), false)),
}
}
#[cfg(feature = "enterprise")]
pub async fn check_license_key_valid() -> error::Result<()> {
use windmill_common::ee_oss::LICENSE_KEY_VALID;
+10
View File
@@ -132,6 +132,15 @@ async fn get_instance_ui(
))
}
/// Whether email notifications can go out, for the workspace admins who configure email
/// handlers and cannot read `smtp_settings` itself (it holds the password). Asks the same
/// resolver the senders use, so SMTP set through `SMTP_*` env vars counts too.
async fn get_smtp_configured(Extension(db): Extension<DB>, _authed: ApiAuthed) -> JsonResult<bool> {
Ok(Json(
SMTP_ENABLED && windmill_common::server::is_smtp_configured(&db).await?,
))
}
pub fn global_service() -> Router {
#[warn(unused_mut)]
let r = Router::new()
@@ -152,6 +161,7 @@ pub fn global_service() -> Router {
)
.route("/instance_config/yaml", get(get_instance_config_yaml))
.route("/test_smtp", post(test_email))
.route("/smtp_configured", get(get_smtp_configured))
.route("/test_license_key", post(test_license_key))
.route("/send_stats", post(send_stats))
.route("/get_stats", get(get_stats))
+19 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.830.0
version: 1.833.0
title: Windmill API
contact:
@@ -1971,6 +1971,24 @@ paths:
application/json:
schema: {}
/settings/smtp_configured:
get:
summary: whether instance SMTP is configured
description: |
Readable by any authenticated user. True when this build can send email
and SMTP is set, either in the smtp_settings instance setting or through
the SMTP_* environment variables.
operationId: getSmtpConfigured
tags:
- setting
responses:
"200":
description: whether email notifications can be sent
content:
application/json:
schema:
type: boolean
/settings/test_smtp:
post:
summary: test smtp
+46 -6
View File
@@ -24,7 +24,6 @@ use axum::{
Extension, Json, Router,
};
use futures::{StreamExt, TryStreamExt};
use magic_crypt::MagicCryptTrait;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::sync::Arc;
@@ -33,9 +32,11 @@ use windmill_api_workspaces::ai_session_backups::{
fallback_store, generation_prefix, primary_store, storage_id, MAX_OBJECT_BYTES,
};
use windmill_api_workspaces::workspaces::sessions_retention_days;
use windmill_common::db::{process_uptime, PREVIOUS_RELEASE_ROLLOUT_UPTIME};
use windmill_common::error::{Error, JsonResult, Result};
use windmill_common::utils::calculate_hash;
use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key, WorkspaceCrypt};
use windmill_common::workspace_crypt::{CipherFormat, MAX_BYTES_OVERHEAD};
use windmill_object_store::object_store_reexports::{
ObjectStore, ObjectStoreError, Path as ObjectPath, PutPayload,
};
@@ -47,9 +48,9 @@ const PULL_BODY_LIMIT: usize = 64 * 1024;
/// A pull answer larger than this hands the remaining ids back as `deferred`.
const PULL_RESPONSE_BUDGET: usize = 32 * 1024 * 1024;
const MAX_HEAD_BYTES: usize = 1024 * 1024;
/// What the cipher adds to a plaintext at most (a block of padding): an object stored at a
/// cap is that much larger than the cap when read back.
const CIPHER_PADDING: usize = 16;
/// What the cipher adds to a plaintext at most: an object stored at a cap is that much larger
/// than the cap when read back.
const CIPHER_PADDING: usize = MAX_BYTES_OVERHEAD;
/// The browser bounds an image to a 1568 px edge and re-encodes past 700 KB; this is
/// well above what that produces.
const MAX_IMAGE_BYTES: usize = 4 * 1024 * 1024;
@@ -120,6 +121,15 @@ pub fn workspaced_service() -> Router {
)
}
fn backup_format(current: CipherFormat, uptime: std::time::Duration) -> CipherFormat {
// A previous-release server reads only CBC here: GCM waits for it to be rolled out.
if current == CipherFormat::V2 && uptime >= PREVIOUS_RELEASE_ROLLOUT_UPTIME {
CipherFormat::V2
} else {
CipherFormat::LegacyCbc
}
}
/// What reading an object yields. `Gone`: not there (deleted since the listing, or never
/// pushed). `Grown`: larger than expected, so replaced since the listing (or planted), and
/// left unread; a pull answers with a page ending before it rather than without it.
@@ -214,7 +224,8 @@ impl Backend {
}
fn seal(&self, plaintext: &[u8]) -> Vec<u8> {
self.mc.encrypt_bytes_to_bytes(plaintext)
let format = backup_format(CipherFormat::current(), process_uptime());
self.mc.encrypt_bytes_in(plaintext, format)
}
/// Bytes written.
@@ -255,7 +266,7 @@ impl Backend {
// apart beyond the cipher's padding check, which a wrong key passes now and then.
match self
.mc
.decrypt_bytes_to_bytes(&bytes)
.decrypt_bytes(&bytes)
.ok()
.and_then(|plaintext| String::from_utf8(plaintext).ok())
{
@@ -1632,3 +1643,32 @@ async fn push(
results,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backups_are_written_in_gcm_only_once_the_previous_release_is_rolled_out() {
let just_short = PREVIOUS_RELEASE_ROLLOUT_UPTIME - std::time::Duration::from_secs(1);
for (current, uptime, expected) in [
(
CipherFormat::V2,
PREVIOUS_RELEASE_ROLLOUT_UPTIME,
CipherFormat::V2,
),
(CipherFormat::V2, just_short, CipherFormat::LegacyCbc),
(
CipherFormat::LegacyCbc,
PREVIOUS_RELEASE_ROLLOUT_UPTIME * 4,
CipherFormat::LegacyCbc,
),
] {
assert_eq!(
backup_format(current, uptime),
expected,
"{current:?} {uptime:?}"
);
}
}
}
+69 -28
View File
@@ -481,6 +481,12 @@ pub struct PolicyTriggerableInputs {
sensitive_inputs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tag: Option<String>,
/// The runnable reads data as the app's viewer rather than as the app's identity: data table
/// connections that name no role, and object storage advanced permission rules. Honored for
/// inline runnables only: their code is deployed with the app, so it is the publisher who
/// chose to read with the viewer's rights.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
viewer_data_access: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -1009,7 +1015,6 @@ fn raw_app_wrapper_html(secret: &str) -> String {
<html>
<head>
<meta charset="UTF-8" />
<title>App</title>
<link rel="stylesheet" href="./__SECRET__.css" />
<script>
(function () {
@@ -1061,6 +1066,17 @@ fn raw_app_wrapper_html(secret: &str) -> String {
history.pushState = function () { _ps.apply(this, arguments); notifyParent(); };
history.replaceState = function () { _rs.apply(this, arguments); notifyParent(); };
// The parent shows the app's document.title in its tab. This document has no
// <title> of its own: document.title reads the first one, so a placeholder would
// shadow the one a framework (React 19, react-helmet) appends.
var lastTitle = '';
new MutationObserver(function () {
var t = document.title;
if (t === lastTitle) return;
lastTitle = t;
try { window.parent.postMessage({ type: 'windmill:title', title: t }, '*'); } catch (_) {}
}).observe(document.head, { childList: true, subtree: true, characterData: true });
// ctx handshake: the embedding parent hands us the user context (and any
// persisted storage) before we evaluate the bundle, so `window.ctx` is set
// synchronously when the bundle runs. The bundle <script> is injected only
@@ -4501,6 +4517,7 @@ async fn execute_component(
// on the inline script is honored. In any other case we must read the
// tag from the deployed policy and ignore the request body.
let is_preview = payload.force_viewer_static_fields.is_some();
let is_inline = payload.raw_code.is_some();
// Preview mode runs request-supplied code as a `Viewer`-mode job (the
// app-editor equivalent of `/jobs/run/preview`), so it enforces the same
@@ -4595,6 +4612,8 @@ async fn execute_component(
delete_after_secs: force_viewer_delete_after_secs,
sensitive_inputs: force_viewer_sensitive_inputs.unwrap_or_default(),
tag: None,
// A preview runs as the caller, whose roles these are either way.
viewer_data_access: false,
},
None,
),
@@ -4734,34 +4753,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
@@ -4947,6 +4980,15 @@ async fn execute_component(
)
.await?;
if policy_triggerables.viewer_data_access && is_inline {
sqlx::query!(
"UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1",
uuid
)
.execute(&mut *tx)
.await?;
}
// Apply runnable query parameters if provided
if let Some(ref run_query) = payload.run_query_params {
if let Some(flow_path) = flow_path.as_deref() {
@@ -6028,11 +6070,10 @@ async fn app_load_table_count(
let file_query = app_s3_file_query(file_key.clone(), inner.storage.clone(), sig);
let job_authed =
app_s3_on_behalf_and_provenance(&db, &path, &w_id, &opt_authed, &file_query).await?;
let resp =
crate::job_helpers_oss::load_table_count_internal(
job_authed, &db, None, &w_id, file_key, inner,
)
.await?;
let resp = crate::job_helpers_oss::load_table_count_internal(
job_authed, &db, None, &w_id, file_key, inner,
)
.await?;
Ok(Json(resp).into_response())
}
@@ -4,4 +4,4 @@
//
// Prepended to the raw-app bundle job (see apps_raw_bundle.rs), which calls
// `__wmillAppPolicy.updateRawAppPolicy` from its own module scope.
var __wmillAppPolicy=(()=>{var g=Object.create;var i=Object.defineProperty;var b=Object.getOwnPropertyDescriptor;var m=Object.getOwnPropertyNames;var A=Object.getPrototypeOf,S=Object.prototype.hasOwnProperty;var x=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,p)=>(typeof require<"u"?require:t)[p]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var f=(e,t)=>{for(var p in t)i(e,p,{get:t[p],enumerable:!0})},s=(e,t,p,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of m(t))!S.call(e,r)&&r!==p&&i(e,r,{get:()=>t[r],enumerable:!(a=b(t,r))||a.enumerable});return e};var h=(e,t,p)=>(p=e!=null?g(A(e)):{},s(t||!e||!e.__esModule?i(p,"default",{value:e,enumerable:!0}):p,e)),R=e=>s(i({},"__esModule",{value:!0}),e);var v={};f(v,{updateRawAppPolicy:()=>d});function u(e){return Object.fromEntries(Object.entries(e??{}).filter(([t,p])=>p.type=="static").map(([t,p])=>[t,p.value]))}async function c(e){try{let t=new TextEncoder().encode(e),p=await crypto.subtle.digest("SHA-256",t);return Array.from(new Uint8Array(p)).map(n=>n.toString(16).padStart(2,"0")).join("")}catch{let{Sha256:t}=await import("@aws-crypto/sha256-js"),p=new t;return p.update(e??""),Array.from(await p.digest()).map(n=>n.toString(16).padStart(2,"0")).join("")}}function l(e){return e?.type=="runnableByPath"||e?.type=="path"}function y(e){return e?.type=="runnableByName"||e?.type=="inline"}async function d(e,t){let p=(await Promise.all(Object.entries(e).map(async([n,o])=>await _(n,o,o?.fields??{})))).filter(n=>n!=null),a=Object.fromEntries(p);return{...t,triggerables_v2:a}}function I(e,t){let p={};typeof e.delete_after_secs=="number"&&e.delete_after_secs>=0&&(p.delete_after_secs=e.delete_after_secs);let a=Object.entries(t).map(([r,n])=>n.sensitive?r:void 0).filter(Boolean);return a.length>0&&(p.sensitive_inputs=a),e.inlineScript?.tag&&(p.tag=e.inlineScript.tag),p}async function _(e,t,p){let a=u(p),r=Object.entries(p).map(([n,o])=>o.allowUserResources?n:void 0).filter(Boolean);if(y(t)){let n=await c(t.inlineScript?.content);return[`${e}:rawscript/${n}`,{static_inputs:a,one_of_inputs:{},allow_user_resources:r,...I(t,p)}]}else if(l(t)){let n=t.runType!=="hubscript"?t.runType:"script";return[`${e}:${n}/${t.path}`,{static_inputs:a,one_of_inputs:{},allow_user_resources:r,...I(t,p)}]}}return R(v);})();
var __wmillAppPolicy=(()=>{var g=Object.create;var i=Object.defineProperty;var b=Object.getOwnPropertyDescriptor;var m=Object.getOwnPropertyNames;var A=Object.getPrototypeOf,S=Object.prototype.hasOwnProperty;var x=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,p)=>(typeof require<"u"?require:t)[p]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var f=(e,t)=>{for(var p in t)i(e,p,{get:t[p],enumerable:!0})},c=(e,t,p,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of m(t))!S.call(e,r)&&r!==p&&i(e,r,{get:()=>t[r],enumerable:!(a=b(t,r))||a.enumerable});return e};var h=(e,t,p)=>(p=e!=null?g(A(e)):{},c(t||!e||!e.__esModule?i(p,"default",{value:e,enumerable:!0}):p,e)),R=e=>c(i({},"__esModule",{value:!0}),e);var v={};f(v,{updateRawAppPolicy:()=>I});function u(e){return Object.fromEntries(Object.entries(e??{}).filter(([t,p])=>p.type=="static").map(([t,p])=>[t,p.value]))}async function l(e){try{let t=new TextEncoder().encode(e),p=await crypto.subtle.digest("SHA-256",t);return Array.from(new Uint8Array(p)).map(n=>n.toString(16).padStart(2,"0")).join("")}catch{let{Sha256:t}=await import("@aws-crypto/sha256-js"),p=new t;return p.update(e??""),Array.from(await p.digest()).map(n=>n.toString(16).padStart(2,"0")).join("")}}function y(e){return e?.type=="runnableByPath"||e?.type=="path"}function s(e){return e?.type=="runnableByName"||e?.type=="inline"}async function I(e,t){let p=(await Promise.all(Object.entries(e).map(async([n,o])=>await _(n,o,o?.fields??{})))).filter(n=>n!=null),a=Object.fromEntries(p);return{...t,triggerables_v2:a}}function d(e,t){let p={};typeof e.delete_after_secs=="number"&&e.delete_after_secs>=0&&(p.delete_after_secs=e.delete_after_secs);let a=Object.entries(t).map(([r,n])=>n.sensitive?r:void 0).filter(Boolean);return a.length>0&&(p.sensitive_inputs=a),e.inlineScript?.tag&&(p.tag=e.inlineScript.tag),e.viewer_data_access===!0&&s(e)&&(p.viewer_data_access=!0),p}async function _(e,t,p){let a=u(p),r=Object.entries(p).map(([n,o])=>o.allowUserResources?n:void 0).filter(Boolean);if(s(t)){let n=await l(t.inlineScript?.content);return[`${e}:rawscript/${n}`,{static_inputs:a,one_of_inputs:{},allow_user_resources:r,...d(t,p)}]}else if(y(t)){let n=t.runType!=="hubscript"?t.runType:"script";return[`${e}:${n}/${t.path}`,{static_inputs:a,one_of_inputs:{},allow_user_resources:r,...d(t,p)}]}}return R(v);})();
+57
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)]
@@ -111,6 +113,12 @@ lazy_static::lazy_static! {
(20260909163047, include_str!(
"../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
// The widening fails every query the previous release's still-running
// servers prepared on the column; `variables::widen_variable_value` does it
// once they are gone.
(20261012213357, include_str!(
"../../migrations/20261012213357_widen_variable_value.up.sql"
).replace("ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR(15040);", "")),
].into_iter().collect();
}
@@ -298,6 +306,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 +386,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<()>,
+4 -2
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
@@ -9069,7 +9069,8 @@ async fn run_inline_preview_script(
if let Some(job_id) = job_id {
register_potential_assets_on_inline_execution(job_id, &w_id, &preview);
}
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let (end_user_email, viewer_data_access) =
run_inline_preview_end_user(&db, &w_id, &authed).await?;
let utils = get_worker_internal_server_inline_utils()?;
let result = utils.run_inline_preview_script.as_ref()(RunInlinePreviewScriptFnParams {
content: preview.content,
@@ -9081,6 +9082,7 @@ async fn run_inline_preview_script(
permissioned_as: username_to_permissioned_as(&authed.username),
permissioned_as_email: authed.email.clone(),
end_user_email,
viewer_data_access,
lang: preview.language,
job_dir: "".to_string(),
worker_name: "".to_string(),
+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()));
+5 -1
View File
@@ -366,6 +366,10 @@ pub struct JobPerms {
/// token.
#[serde(default)]
pub job_token_scopes: Option<Vec<String>>,
/// Whether the job reads data as `end_user_email`: its data table connections that name no
/// role, and the object storage advanced permission rules it is checked against.
#[serde(default)]
pub viewer_data_access: bool,
}
impl From<JobPerms> for Authed {
@@ -659,7 +663,7 @@ pub async fn get_job_perms<'a, E: sqlx::PgExecutor<'a>>(
sqlx::query_as!(
JobPerms,
"SELECT email, username, is_admin, is_operator, groups, folders, end_user_email,
job_token_scopes
job_token_scopes, viewer_data_access
FROM job_perms WHERE job_id = $1 AND workspace_id = $2",
job_id,
w_id
+59
View File
@@ -290,3 +290,62 @@ impl<'b> DbExecutor<'b> for &'b mut PgConnection {
&mut **self
}
}
lazy_static::lazy_static! {
static ref PROCESS_START: std::time::Instant = std::time::Instant::now();
}
/// Milliseconds after [`PROCESS_START`] before which every pooled connection is retired;
/// 0 retires none.
static RETIRE_CONNECTIONS_OPENED_BEFORE_MS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
/// Postgres refuses a statement prepared before its result columns changed type (even a
/// varchar length) with this error, on every execution, for as long as the connection lives.
/// The routine is matched as well as the message, which `lc_messages` may translate.
pub fn is_stale_cached_plan(e: &sqlx::Error) -> bool {
let sqlx::Error::Database(d) = e else {
return false;
};
d.code().as_deref() == Some("0A000")
&& (d
.try_downcast_ref::<sqlx::postgres::PgDatabaseError>()
.is_some_and(|pg| pg.routine() == Some("RevalidateCachedQuery"))
|| d.message()
.contains("cached plan must not change result type"))
}
/// Retire every pooled connection opened up to now, so a schema change that poisoned their
/// prepared statements costs one failed query instead of failing until they expire.
pub fn retire_connections_opened_before_now() {
let now = PROCESS_START.elapsed().as_millis() as u64 + 1;
RETIRE_CONNECTIONS_OPENED_BEFORE_MS.fetch_max(now, std::sync::atomic::Ordering::Relaxed);
}
/// For the pool's `before_acquire`: whether a connection of this age must be closed.
pub fn connection_is_retired(age: std::time::Duration) -> bool {
let cutoff = RETIRE_CONNECTIONS_OPENED_BEFORE_MS.load(std::sync::atomic::Ordering::Relaxed);
cutoff != 0 && (PROCESS_START.elapsed().saturating_sub(age).as_millis() as u64) < cutoff
}
/// Servers report no version, so the worker version gate alone cannot see a server of the
/// previous release; its rollout is assumed done once this server has been up this long.
pub const PREVIOUS_RELEASE_ROLLOUT_UPTIME: std::time::Duration =
std::time::Duration::from_secs(30 * 60);
static PROCESS_UPTIME_OFFSET_SECS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
/// Time since this process first used its database pool.
pub fn process_uptime() -> std::time::Duration {
PROCESS_START.elapsed()
+ std::time::Duration::from_secs(
PROCESS_UPTIME_OFFSET_SECS.load(std::sync::atomic::Ordering::Relaxed),
)
}
/// For tests of what waits on [`process_uptime`]: adds `offset` to it.
#[doc(hidden)]
pub fn set_process_uptime_offset(offset: std::time::Duration) {
PROCESS_UPTIME_OFFSET_SECS.store(offset.as_secs(), std::sync::atomic::Ordering::Relaxed);
}
+4
View File
@@ -153,6 +153,10 @@ impl From<anyhow::Error> for Error {
impl From<sqlx::Error> for Error {
#[track_caller]
fn from(e: sqlx::Error) -> Self {
if crate::db::is_stale_cached_plan(&e) {
tracing::warn!("a schema change invalidated cached statements, reopening the database connections: {e}");
crate::db::retire_connections_opened_before_now();
}
Self::SqlErr { error: e, location: prettify_location(std::panic::Location::caller()) }
}
}
@@ -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",
];
+3
View File
@@ -476,6 +476,9 @@ pub struct RunInlinePreviewScriptFnParams {
pub permissioned_as_email: String,
/// The caller's recorded end user: `job_perms.end_user_email` of the calling job.
pub end_user_email: Option<String>,
/// `job_perms.viewer_data_access` of the calling job: the code is the caller's own, so its
/// data table connections are made the way the caller's are.
pub viewer_data_access: bool,
pub base_internal_url: String,
pub worker_name: String,
pub conn: crate::worker::Connection,
@@ -27,6 +27,8 @@ pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted
// stored secret variables. Reading it ships one release earlier (1.831), so a rollback to
// that release still reads every secret; below it, secrets written after the switch stay
// unreadable until rolled forward. Only worker versions are seen here, not servers.
// Also gates widening `variable.value`, which needs every process to retire the connections
// the widening poisons (`db::retire_connections_opened_before_now`): that also ships in 1.832.
pub const MIN_VERSION_SUPPORTS_AEAD_SECRETS: VC = vc(1, 832, 0, "AES-GCM secret encryption");
pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing");
pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage");
+17 -7
View File
@@ -31,9 +31,24 @@ pub async fn load_smtp_config(db: &DB) -> error::Result<Option<Smtp>> {
Ok(parse_smtp_config(value))
}
/// Whether SMTP is set, without the "not configured" warning: any signed-in user can ask, and
/// "no" is an ordinary answer there rather than something to log.
pub async fn is_smtp_configured(db: &DB) -> error::Result<bool> {
let value = crate::global_settings::load_value_from_global_settings(db, SMTP_SETTING).await?;
Ok(resolve_smtp_config(value).is_some())
}
/// The half of [`load_smtp_config`] after the read, so a batched settings pass can parse a
/// value it already fetched.
pub fn parse_smtp_config(value: Option<serde_json::Value>) -> Option<Smtp> {
let smtp = resolve_smtp_config(value);
if smtp.is_none() {
tracing::warn!("SMTP not configured");
}
smtp
}
fn resolve_smtp_config(value: Option<serde_json::Value>) -> Option<Smtp> {
let config: SmtpConfigOpt = value
.map(|x| serde_json::from_value(x).ok())
.flatten()
@@ -57,7 +72,7 @@ pub fn parse_smtp_config(value: Option<serde_json::Value>) -> Option<Smtp> {
} else {
None
};
let smtp = config_smtp.or(
config_smtp.or(
if let (Some(host), username, password) = (
std::env::var("SMTP_HOST").ok(),
std::env::var("SMTP_USERNAME").ok(),
@@ -86,12 +101,7 @@ pub fn parse_smtp_config(value: Option<serde_json::Value>) -> Option<Smtp> {
} else {
None
},
);
if smtp.is_none() {
tracing::warn!("SMTP not configured");
}
smtp
)
}
impl Default for SmtpConfigOpt {
+69 -3
View File
@@ -400,7 +400,61 @@ pub fn legacy_secret_upgrade_pending() -> bool {
&& !LEGACY_SECRET_UPGRADE_DONE.load(Ordering::Relaxed)
}
/// Re-encrypt the secret variables still in the legacy format in v2, workspace by workspace.
/// AES-256-GCM ciphertext is 32 bytes (magic, nonce, tag) longer than legacy CBC before base64,
/// so the longest CBC value that fits 15000 chars is exactly 15040 chars once re-encrypted.
const VARIABLE_VALUE_WIDTH: i32 = 15040;
static VARIABLE_VALUE_WIDENED: AtomicBool = AtomicBool::new(false);
/// Whether this process has seen `variable.value` wide enough for every v2 value. Only servers
/// look, so it stays false in a worker.
pub fn variable_value_widened() -> bool {
VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed)
}
/// Whether `variable.value` may still have to be widened to fit v2 values.
pub fn variable_value_widen_pending() -> bool {
CipherFormat::current() == CipherFormat::V2 && !VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed)
}
/// Widen `variable.value` to [`VARIABLE_VALUE_WIDTH`], or `false` when it is too early to.
/// Not a migration: changing a column's type, even its length, fails every statement a live
/// process prepared on it until its connection closes. It waits for v2 writes, gated on a
/// worker version that retires such connections on the first failure, so that no process too
/// old to recover is still running.
async fn widen_variable_value(db: &DB) -> error::Result<bool> {
if VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) {
return Ok(true);
}
// varchar's typmod is its length + 4, -1 when unbounded.
let typmod: i32 = sqlx::query_scalar(
"SELECT atttypmod FROM pg_attribute WHERE attrelid = 'variable'::regclass AND attname = 'value'",
)
.fetch_one(db)
.await?;
if typmod >= 0 && typmod - 4 < VARIABLE_VALUE_WIDTH {
if crate::db::process_uptime() < crate::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME {
return Ok(false);
}
tracing::info!("widening variable.value to VARCHAR({VARIABLE_VALUE_WIDTH})");
let mut tx = db.begin().await?;
// Queued behind a long transaction, the ALTER would block every variable read meanwhile.
sqlx::query("SET LOCAL lock_timeout = '3s'")
.execute(&mut *tx)
.await?;
sqlx::query(&format!(
"ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR({VARIABLE_VALUE_WIDTH})"
))
.execute(&mut *tx)
.await?;
tx.commit().await?;
crate::db::retire_connections_opened_before_now();
}
VARIABLE_VALUE_WIDENED.store(true, Ordering::Relaxed);
Ok(true)
}
/// Widen `variable.value` for v2 values, then re-encrypt the secret variables still in the
/// legacy format in v2, workspace by workspace.
/// Idempotent, and emits no git-sync event: the plaintexts do not change.
pub async fn upgrade_legacy_secret_variables(db: &DB) {
struct Running;
@@ -409,14 +463,26 @@ pub async fn upgrade_legacy_secret_variables(db: &DB) {
LEGACY_SECRET_UPGRADE_RUNNING.store(false, Ordering::Release);
}
}
if !legacy_secret_upgrade_pending()
if !(variable_value_widen_pending() || legacy_secret_upgrade_pending())
|| LEGACY_SECRET_UPGRADE_RUNNING.swap(true, Ordering::AcqRel)
{
return;
}
// Cleared on drop, so a pass that panics does not stop every later one in this process.
let _running = Running;
upgrade_legacy_secret_variables_pass(db).await;
match widen_variable_value(db).await {
Ok(true) => {}
Ok(false) => return,
Err(e) => {
tracing::warn!(
"secret encryption upgrade: widening variable.value failed, retrying later: {e:#}"
);
return;
}
}
if legacy_secret_upgrade_pending() {
upgrade_legacy_secret_variables_pass(db).await;
}
}
async fn upgrade_legacy_secret_variables_pass(db: &DB) {
+15 -1
View File
@@ -814,7 +814,8 @@ fn format_pull_query(peek: String) -> String {
j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, j.priority, j.raw_code, j.raw_lock, j.raw_flow,
j.script_entrypoint_override, j.preprocessed, p.job_token_scopes, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path,
COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin,
p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email
p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email,
p.viewer_data_access
FROM q, j
LEFT JOIN v2_job_status f USING (id)
LEFT JOIN job_perms p ON p.job_id = j.id
@@ -2891,6 +2892,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 +2915,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 +2931,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::*;
+57 -4
View File
@@ -30,6 +30,10 @@ pub const V2_MAGIC: [u8; 4] = *b"wmA2";
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
const BLOCK: usize = 16;
const NARROW_VARIABLE_VALUE_WIDTH: usize = 15000;
/// The most `encrypt_bytes_in` adds to a plaintext, in either format.
pub const MAX_BYTES_OVERHEAD: usize = V2_MAGIC.len() + NONCE_LEN + BLOCK + TAG_LEN;
const AEAD_KEY_INFO: &[u8] = b"windmill workspace secret aes-256-gcm v1";
@@ -77,8 +81,8 @@ pub struct WorkspaceCrypt {
aead: Aes256Gcm,
}
/// Code that drives `magic_crypt` directly on this cipher (app link secrets, AI session
/// objects) keeps the legacy format it always had.
/// Code that drives `magic_crypt` directly on this cipher (app link secrets) keeps the legacy
/// format it always had.
impl std::ops::Deref for WorkspaceCrypt {
type Target = MagicCrypt256;
fn deref(&self) -> &MagicCrypt256 {
@@ -102,7 +106,23 @@ impl WorkspaceCrypt {
}
pub fn encrypt_str(&self, plaintext: &str) -> String {
self.encrypt_str_in(plaintext, CipherFormat::current())
self.encrypt_str_fitting(
plaintext,
CipherFormat::current(),
crate::variables::variable_value_widened(),
)
}
/// A v2 value is up to 44 chars longer than CBC: until `variable.value` is known widened
/// for it, one past the narrow width is written in CBC, which fits.
fn encrypt_str_fitting(&self, plaintext: &str, format: CipherFormat, widened: bool) -> String {
if format == CipherFormat::V2 {
let v2 = self.encrypt_str_in(plaintext, CipherFormat::V2);
if widened || v2.len() <= NARROW_VARIABLE_VALUE_WIDTH {
return v2;
}
}
self.encrypt_str_in(plaintext, CipherFormat::LegacyCbc)
}
pub fn encrypt_str_in(&self, plaintext: &str, format: CipherFormat) -> String {
@@ -136,7 +156,14 @@ impl WorkspaceCrypt {
(self.decrypt_str(&upgraded).ok()? == plaintext).then_some(upgraded)
}
fn decrypt_bytes(&self, bytes: &[u8]) -> Result<Vec<u8>, DecryptFailure> {
pub fn encrypt_bytes_in(&self, plaintext: &[u8], format: CipherFormat) -> Vec<u8> {
match format {
CipherFormat::V2 => self.seal(plaintext),
CipherFormat::LegacyCbc => self.legacy.encrypt_bytes_to_bytes(plaintext),
}
}
pub fn decrypt_bytes(&self, bytes: &[u8]) -> Result<Vec<u8>, DecryptFailure> {
match self.open(bytes) {
Some(plaintext) => Ok(plaintext),
// Also for a value with the magic: a CBC ciphertext starts with it one time in 2^32.
@@ -242,6 +269,32 @@ mod tests {
);
}
#[test]
fn bytes_round_trip_within_the_overhead_in_both_formats() {
let wc = WorkspaceCrypt::new(KEY);
for len in 0..64 {
let plain = vec![b'x'; len];
for format in [CipherFormat::V2, CipherFormat::LegacyCbc] {
let sealed = wc.encrypt_bytes_in(&plain, format);
assert_eq!(wc.open(&sealed).is_some(), format == CipherFormat::V2);
assert!(sealed.len() <= len + MAX_BYTES_OVERHEAD, "len {len}");
assert_eq!(wc.decrypt_bytes(&sealed).unwrap(), plain);
}
}
}
#[test]
fn v2_too_long_for_a_narrow_column_is_written_in_cbc() {
let wc = WorkspaceCrypt::new(KEY);
// The longest plaintext whose CBC value fits 15000 chars.
let long = "x".repeat(11231);
let narrow = wc.encrypt_str_fitting(&long, CipherFormat::V2, false);
assert!(!wc.is_v2(&narrow) && narrow.len() <= NARROW_VARIABLE_VALUE_WIDTH);
assert_eq!(wc.decrypt_str(&narrow).unwrap(), long);
assert!(wc.is_v2(&wc.encrypt_str_fitting(&long, CipherFormat::V2, true)));
assert!(wc.is_v2(&wc.encrypt_str_fitting("short", CipherFormat::V2, false)));
}
#[test]
fn v2_passes_the_block_multiple_ciphertext_shape_check() {
let wc = WorkspaceCrypt::new(KEY);
+30 -11
View File
@@ -2193,19 +2193,38 @@ pub enum DatatableAccess<'a> {
PermissionedAs {
permissioned_as: &'a str,
email: &'a str,
/// The job's viewer: MUST be what the server recorded in `job_perms.end_user_email`,
/// never an argument or anything else the job or its caller can set.
end_user_email: Option<&'a str>,
connect_as: DatatableConnectAs<'a>,
},
/// A job identified by id; its owner is read from `v2_job` and its viewer from `job_perms`.
/// For agent workers and anything else that authenticates as infrastructure rather than as
/// the job's user.
/// A job identified by id; its owner is read from `v2_job` and how it connects from
/// `job_perms`. For agent workers and anything else that authenticates as infrastructure
/// rather than as the job's user.
Job(uuid::Uuid),
/// No identity established. Unpermissioned data tables resolve as before; permissioned ones
/// are refused, so a caller predating this feature fails closed.
NoIdentity,
}
/// Whose roles a job's connection that names no role holds.
#[derive(Clone, Copy, Debug)]
pub enum DatatableConnectAs<'a> {
/// The job's own identity, `permissioned_as`.
Job,
/// The job's viewer, for a job whose `job_perms.viewer_data_access` is set. MUST be what
/// the server recorded in `job_perms.end_user_email`, never anything the job or its caller
/// can set; `None` when it recorded none, which refuses the connection.
Viewer(Option<&'a str>),
}
impl<'a> DatatableConnectAs<'a> {
pub fn from_job_perms(as_viewer: bool, end_user_email: Option<&'a str>) -> Self {
if as_viewer {
Self::Viewer(end_user_email)
} else {
Self::Job
}
}
}
/// Does one tenant list cover this identity? Admins of the governing workspace pass everything —
/// they can edit the tenant lists anyway, so refusing them would only be theatre.
pub fn can_use_datatable_role(
@@ -2249,11 +2268,11 @@ pub async fn can_use_datatable_role_in_governing_workspace(
/// back to admin.
///
/// A default role other than `admin` is a floor rather than the whole connection: a resolution
/// naming no role holds it when the job's own identity may use it, plus the roles whose tenant
/// lists name the job's viewer, as one login that is a member of each. With no role at all it is
/// refused. Being an admin adds none of the viewer's and `admin` is never among them, so a viewer
/// cannot be handed the connection that owns the tables and bypasses row-level security. A named
/// role is that role alone.
/// naming no role holds it when its identity may use it, plus the roles whose tenant lists name
/// that identity, as one login that is a member of each. The identity is the job's own unless
/// it connects as its viewer ([`DatatableConnectAs`]). With no role at all it is refused. Being
/// an admin adds no role and `admin` is never among them, so nobody is handed the connection that
/// owns the tables and bypasses row-level security. A named role is that role alone.
pub async fn get_datatable_resource_from_db(
db: &DB,
w_id: &str,
+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]
+8 -1
View File
@@ -3732,6 +3732,9 @@ pub struct MiniPulledJob {
pub trigger_kind: Option<TriggerKindLabel>,
pub visible_to_owner: bool,
pub permissioned_as_end_user_email: Option<String>,
/// `job_perms.viewer_data_access`: `None` where the job was read without it.
#[serde(default)]
pub viewer_data_access: Option<bool>,
pub runnable_settings_handle: Option<i64>,
/// The job's effective token scopes (`job_perms.job_token_scopes`), minted into its token.
/// No `sqlx(default)`: a query that forgets the column must fail rather than mint an
@@ -3786,6 +3789,7 @@ impl MiniPulledJob {
trigger_kind: None,
visible_to_owner: false,
permissioned_as_end_user_email: None,
viewer_data_access: None,
runnable_settings_handle: None,
concurrent_limit: None,
concurrency_time_window_s: None,
@@ -4103,6 +4107,7 @@ impl PulledJob {
folders,
end_user_email: self.job.permissioned_as_end_user_email.clone(),
job_token_scopes: self.job.job_token_scopes.clone(),
viewer_data_access: self.job.viewer_data_access.unwrap_or(false),
}),
_ => None,
};
@@ -4210,6 +4215,7 @@ pub async fn get_mini_pulled_job<'c>(
trigger_kind as \"trigger_kind: TriggerKindLabel\",
visible_to_owner,
NULL as permissioned_as_end_user_email,
NULL::boolean as viewer_data_access,
job_perms.job_token_scopes
FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id
LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1",
@@ -8741,7 +8747,8 @@ pub async fn get_same_worker_job(
v2_job.raw_flow,
COALESCE(pj.runnable_path, v2_job.args->>'_FLOW_PATH') as parent_runnable_path,
p.email as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin,
p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email
p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email,
p.viewer_data_access
FROM v2_job_queue
INNER JOIN v2_job ON v2_job.id = v2_job_queue.id
LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id
@@ -3457,6 +3457,7 @@ mod debounce {
trigger_kind: None,
visible_to_owner: false,
permissioned_as_end_user_email: None,
viewer_data_access: None,
runnable_settings_handle: rs_handle,
job_token_scopes: None,
};
@@ -3699,6 +3700,7 @@ mod debounce {
trigger_kind: None,
visible_to_owner: false,
permissioned_as_end_user_email: None,
viewer_data_access: None,
runnable_settings_handle: rs_handle,
job_token_scopes: None,
};
+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:") {
+52 -22
View File
@@ -1726,20 +1726,26 @@ pub async fn cached_result_path(
hasher.update(b"wac_step:");
hasher.update(step_key.as_bytes());
}
// What a run returns can depend on who it ran for (`WM_END_USER_EMAIL`, a data table used
// as the viewer), so a result cached for one viewer is never served to another. Read from
// `job_perms`: a flow job re-fetched between steps does not carry it.
let end_user: Option<Option<String>> = sqlx::query_scalar(
"SELECT end_user_email FROM job_perms WHERE job_id = $1 AND workspace_id = $2",
// What a run returns depends on whose roles its data table connections hold, its own
// identity's or its viewer's, and `WM_END_USER_EMAIL` names the viewer: a result cached for
// one of either, or in one of the two modes, is never served to another. The arguments follow
// undelimited, so every field here is fixed-width or length-prefixed and always present: an
// optional marker would read the same as an argument whose name starts with it.
hasher.update(b"permissioned_as:");
hasher.update((job.permissioned_as.len() as u64).to_le_bytes());
hasher.update(job.permissioned_as.as_bytes());
// Read from `job_perms`: a flow job re-fetched between steps does not carry them.
let perms: Option<(Option<String>, bool)> = sqlx::query_as(
"SELECT end_user_email, viewer_data_access FROM job_perms
WHERE job_id = $1 AND workspace_id = $2",
)
.bind(job.id)
.bind(&job.workspace_id)
.fetch_optional(db)
.await?;
// Length-prefixed: the arguments follow undelimited, and the end of one viewer's address
// must not read as the start of another run's first argument name.
if let Some(end_user) = end_user.flatten() {
hasher.update(b"end_user:");
let (end_user, as_viewer) = perms.unwrap_or_default();
hasher.update([as_viewer as u8, end_user.is_some() as u8]);
if let Some(end_user) = end_user {
hasher.update((end_user.len() as u64).to_le_bytes());
hasher.update(end_user.as_bytes());
}
@@ -2487,46 +2493,58 @@ mod tests {
trigger_kind: None,
visible_to_owner: false,
permissioned_as_end_user_email: None,
viewer_data_access: None,
runnable_settings_handle: None,
job_token_scopes: None,
}
}
// A cache hit returns before the job runs, so the key is all that keeps a result computed
// for one viewer (a data table used as the viewer, `WM_END_USER_EMAIL`) from another.
// for one identity or viewer (whose data table roles it read with, `WM_END_USER_EMAIL`)
// from another.
#[sqlx::test(migrations = "../migrations")]
async fn a_cached_result_is_keyed_by_the_recorded_viewer(pool: sqlx::PgPool) {
async fn a_cached_result_is_keyed_by_the_identity_and_the_recorded_viewer(pool: sqlx::PgPool) {
let client = test_client();
// The last two differ only in where the viewer's address ends and the first argument
// name starts.
let mut paths = vec![];
for (viewer, arg) in [
(Some("a@windmill.dev"), "mode"),
(Some("a@windmill.dev"), "mode"),
(Some("b@windmill.dev"), "mode"),
(None, "mode"),
(Some("a@windmill.co"), "mode"),
(Some("a@windmill.com"), "ode"),
for (owner, viewer, as_viewer, arg) in [
("u/publisher", Some("a@windmill.dev"), false, "mode"),
("u/publisher", Some("a@windmill.dev"), false, "mode"),
("u/publisher", Some("b@windmill.dev"), false, "mode"),
("u/publisher", None, false, "mode"),
("u/publisher", Some("a@windmill.co"), false, "mode"),
("u/publisher", Some("a@windmill.com"), false, "ode"),
("u/other", None, false, "mode"),
("u/publisher", Some("a@windmill.dev"), true, "mode"),
("u/publisher", None, false, "viewer_data_access;mode"),
("u/publisher", None, true, "mode"),
] {
let args = HashMap::from([(arg.to_string(), to_raw_value(&"all"))]);
let job = MiniPulledJob {
id: uuid::Uuid::new_v4(),
args: Some(sqlx::types::Json(args)),
permissioned_as: owner.to_string(),
..test_job()
};
sqlx::query(
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator,
workspace_id, groups, folders, end_user_email)
workspace_id, groups, folders, end_user_email, viewer_data_access)
VALUES ($1, 'publisher@windmill.dev', 'publisher', false, false, 'test', '{}',
'{}', $2)",
'{}', $2, $3)",
)
.bind(job.id)
.bind(viewer)
.bind(as_viewer)
.execute(&pool)
.await
.unwrap();
paths.push(cached_result_path(&pool, &client, &job, None).await.unwrap());
paths.push(
cached_result_path(&pool, &client, &job, None)
.await
.unwrap(),
);
}
assert_eq!(
paths[0], paths[1],
@@ -2535,7 +2553,19 @@ mod tests {
assert_ne!(paths[0], paths[2], "two viewers share a cached result");
assert_ne!(paths[0], paths[3]);
assert_ne!(paths[2], paths[3]);
assert_ne!(paths[4], paths[5], "an address runs into the argument names");
assert_ne!(
paths[4], paths[5],
"an address runs into the argument names"
);
assert_ne!(paths[3], paths[6], "two owners share a cached result");
assert_ne!(
paths[0], paths[7],
"a result read with the app's roles is served once the runnable connects as its viewer"
);
assert_ne!(
paths[8], paths[9],
"an argument name reads as the mode of another run"
);
}
#[tokio::test]
@@ -14,7 +14,7 @@ use windmill_common::utils::sanitize_string_from_password;
use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy};
use windmill_common::workspaces::{
get_datatable_resource_from_db, get_ducklake_from_db_unchecked,
strip_fork_reserved_attach_args, DatatableAccess, DucklakeCatalogResourceType,
strip_fork_reserved_attach_args, DatatableAccess, DatatableConnectAs, DucklakeCatalogResourceType,
};
use windmill_common::PgDatabase;
use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE;
@@ -2758,7 +2758,10 @@ async fn transform_attach_datatable(
DatatableAccess::PermissionedAs {
permissioned_as: &job.permissioned_as,
email: &job.permissioned_as_email,
end_user_email: job.permissioned_as_end_user_email.as_deref(),
connect_as: DatatableConnectAs::from_job_perms(
job.viewer_data_access.unwrap_or(false),
job.permissioned_as_end_user_email.as_deref(),
),
},
)
.await?
+5 -2
View File
@@ -26,7 +26,7 @@ use windmill_common::worker::{
to_raw_value, Connection, SqlAnnotations, SqlResultCollectionStrategy, CLOUD_HOSTED,
};
use windmill_common::workspaces::{
get_datatable_resource_from_db, parse_datatable_ref, parse_datatable_ref_for, DatatableAccess,
get_datatable_resource_from_db, parse_datatable_ref, parse_datatable_ref_for, DatatableAccess, DatatableConnectAs,
};
use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB};
use windmill_parser::{Arg, Typ};
@@ -1229,7 +1229,10 @@ pub async fn do_postgresql(
DatatableAccess::PermissionedAs {
permissioned_as: &job.permissioned_as,
email: &job.permissioned_as_email,
end_user_email: job.permissioned_as_end_user_email.as_deref(),
connect_as: DatatableConnectAs::from_job_perms(
job.viewer_data_access.unwrap_or(false),
job.permissioned_as_end_user_email.as_deref(),
),
},
)
.await?
+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)
+2 -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
@@ -7266,6 +7266,7 @@ pub fn init_worker_internal_server_inline_utils(
Some(params.lang),
);
job.permissioned_as_end_user_email = params.end_user_email;
job.viewer_data_access = Some(params.viewer_data_access);
Box::pin(async move {
let mut mem_peak: i32 = -1;
let mut canceled_by: Option<CanceledBy> = None;
@@ -2440,6 +2440,7 @@ async fn advance_flow_status(
v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",
v2_job.visible_to_owner,
NULL as permissioned_as_end_user_email,
NULL::boolean as viewer_data_access,
job_perms.job_token_scopes",
flow,
&step_path as &[&str],
+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.833.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.833.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/chat-sdk",
"version": "1.830.0",
"version": "1.833.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.833.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+21
View File
@@ -569,6 +569,26 @@ export async function pushRawApp(
);
}
/**
* `@windmill-labs/shared-utils` up to 1.0.13 derives the policy without `viewer_data_access`,
* which would silently deploy an inline runnable that asks to connect as its viewer as one
* connecting as the app. The server honors the flag on inline runnables only.
*/
export function carryViewerDataAccess(
runnables: Record<string, any> | undefined,
policy: Policy | undefined,
) {
const triggerables = policy?.triggerables_v2 as Record<string, any> | undefined;
if (!runnables || !triggerables) return;
for (const [id, runnable] of Object.entries(runnables)) {
if (runnable?.viewer_data_access !== true) continue;
if (runnable.type !== "inline" && runnable.type !== "runnableByName") continue;
for (const [key, triggerable] of Object.entries(triggerables)) {
if (key.startsWith(`${id}:rawscript/`)) triggerable.viewer_data_access = true;
}
}
}
export async function generatingPolicy(
app: any,
path: string,
@@ -579,6 +599,7 @@ export async function generatingPolicy(
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updateRawAppPolicy(app.runnables, base);
carryViewerDataAccess(app.runnables, app.policy);
finalizeDerivedPolicy(app.policy, executionMode, isolation, true);
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
+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.833.0";
+11 -2
View File
@@ -6522,6 +6522,8 @@ fields:
value: "default_user"
\`\`\`
\`viewer_data_access: true\` makes an inline runnable read data tables and object storage as the person using the app instead of as the app's identity (see "Reading data as the viewer").
### Referencing existing scripts
To use an existing Windmill script instead of inline code:
@@ -6558,7 +6560,7 @@ data:
- \`<datatable>/<table>\` — Specific table in public schema
- \`<datatable>/<schema>:<table>\` — Table in specific schema
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of whoever is using the app unless that default is \`admin\`.
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of the app's identity, or of whoever is using the app for a runnable with \`viewer_data_access: true\`, unless that default is \`admin\`.
## SQL Migrations (sql_to_apply/)
@@ -6679,6 +6681,8 @@ Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! }
**Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`.
**Set the browser tab title with \`document.title = '...'\`** (or a \`<title>\` the framework renders into \`<head>\`). The app has no \`index.html\` of its own, so a \`<title>\` written there is never served; the deployed app's page shows whatever \`document.title\` holds, and follows it when it changes.
**Always begin every React file (\`.tsx\`/\`.jsx\`) that uses JSX with \`import React from 'react'\`.** esbuild uses the classic JSX transform, so \`React\` must be in scope wherever JSX appears — a missing import compiles fine but throws \`React is not defined\` at runtime, leaving a blank screen.
### Generated bindings (\`wmill.d.ts\` / \`wmill.ts\`)
@@ -6853,6 +6857,11 @@ frontend_api: [jobs:run, users:read]
A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise.
## Reading data as the viewer
A backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.
Set it with \`viewer_data_access: true\` in the runnable's \`backend/<id>.yaml\`.
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
@@ -6864,7 +6873,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables.
5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`.
6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no \`data.roles\` entry.
6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see "Reading data as the viewer"). Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no \`data.roles\` entry. Data table roles are an Enterprise Edition feature, self-hosted only.
\`wmill datatable list\` lists the workspace's data tables. Create or change tables with a migration in \`sql_to_apply/\` (see "SQL Migrations" above), then add them to \`data.tables\` in \`raw_app.yaml\`.
+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 && \
+55 -44
View File
@@ -1,20 +1,23 @@
# Data table connections and the viewer's roles
# Data table connections and whose roles they hold
A data table under roles (Enterprise Edition) resolves a connection that names a role to that
role alone. A connection that names none is resolved from the data table's default role:
role alone. A connection that names none is resolved from the data table's default role and the
roles of one **identity**:
| Default role | A connection naming no role holds |
|---|---|
| `admin` (until changed) | `admin`, as before viewer roles existed |
| another role | that role when the job's own identity may use it, plus **every role the job's viewer is a tenant of** |
| none (`NO_DEFAULT_DATATABLE_ROLE`) | the viewer's roles only |
| `admin` (until changed) | `admin`, as before identity roles existed |
| another role | that role when the identity may use it, plus **every role the identity is a tenant of** |
| none (`NO_DEFAULT_DATATABLE_ROLE`) | the identity's roles only |
A connection left with no role at all is refused. Postgres row-level security can then answer
per viewer from a backend runnable that runs as the app's publisher, with nothing written in the
runnable.
The identity is the job's own, `permissioned_as`, like everything else a job reaches: the
publisher for a publisher-mode app, the owner of a schedule or trigger, whoever ran a script. It
is the job's **viewer** only for an inline runnable of a full-code app whose policy entry sets
`viewer_data_access` (`DatatableConnectAs::Viewer`). Postgres row-level security then answers per
viewer from a runnable that runs as the publisher, with nothing written in the code.
Policies test membership, which is true for a role's own login too, so one policy serves a named
connection and an unnamed one:
A connection left with no role at all is refused. Policies test membership, which is true for a
role's own login too, so one policy serves a named connection and an unnamed one:
```sql
CREATE POLICY emea ON orders FOR SELECT
@@ -27,67 +30,75 @@ A policy names its role as text, so renaming the role breaks it.
| Where | What |
|---|---|
| `windmill-common/src/workspaces.rs` | `DatatableAccess`, `get_datatable_resource_from_db` |
| `windmill-common/src/datatable_roles_ee.rs` | `resolve_datatable_role_connection`, `viewer_role_ids`, `ensure_role_set_login`, `retire_role_set_logins_of`, `sweep_unused_role_set_logins` |
| `windmill-api-jobs/src/execution.rs` | `run_end_user_email`, where a run's viewer is recorded |
| `windmill-common/src/workspaces.rs` | `DatatableAccess`, `DatatableConnectAs`, `get_datatable_resource_from_db` |
| `windmill-common/src/datatable_roles_ee.rs` | `resolve_datatable_role_connection`, `resolve_unnamed_login`, `governing_identity`, `ensure_role_set_login`, `retire_role_set_logins_of`, `sweep_unused_role_set_logins` |
| `windmill-api/src/apps.rs` | `execute_component`, which sets `job_perms.viewer_data_access` for a flagged inline runnable |
| `windmill-api-jobs/src/execution.rs` | `run_end_user_email`, where a run's viewer is recorded, and `run_inline_preview_end_user` |
| `frontend/.../raw_apps/ViewerDataAccessPopup.svelte` | the runnable setting, shown when the code reads a data table or object storage |
| `frontend/.../workspaceSettings/DataTablePermissionsButton.svelte` | the roles drawer, which warns while the default is `admin` |
## What holds it together
- **A default of `admin` is not a floor.** `admin` owns the tables and bypasses row-level
security, so nothing could be added to it, and it is the default until someone changes it.
Such a data table resolves exactly as it did before viewer roles existed. Nothing ever sets a
Such a data table resolves exactly as it did before identity roles existed. Nothing ever sets a
default back to `admin`: removing or deleting the default role leaves none.
- **The default role is tenant-checked against the job's own identity**, the publisher for a
publisher-mode app, and held only when that passes. The viewer's roles are added either way.
- **One identity decides both the default and the added roles.** The default is held when that
identity may use it, and the roles added are those whose tenants name that same identity. A
job never mixes its own identity's default with its viewer's roles.
- **Connecting as the viewer is the publisher's choice, made per runnable and stored in the
policy.** It is honored on inline runnables only, whose code is deployed with the app: a path
runnable is someone else's script, which would read with the viewer's roles without its author
choosing to. The server stamps `job_perms.viewer_data_access` in the transaction that pushes
the job. Nested jobs do not inherit it; an inline preview run with the job's token
(`run_inline/preview`, what `wmill.datatable()` uses) does, since that code is the job's own.
- **A job that connects as its viewer and has none is refused**, never resolved as the job: that
would undo what the runnable asked for. An anonymous app viewer is the usual case.
- **No role at all is a refusal, never an empty answer.** There is no login that would read
nothing: what a role sees is up to each table's grants and policies. A job without a recorded
viewer (anonymous app viewer, schedule, trigger, a tool job started by an AI agent step), or
whose viewer is not a member of the governing workspace, therefore needs a default it may use
or a named role.
nothing: what a role sees is up to each table's grants and policies.
- **A set login owns what it creates.** Postgres gives a new object to `current_user`, and
default privileges go by the creating role, so a table created over a connection that holds
several roles belongs to that set's login: no other role or viewer reaches it, and it passes to
`custom_instance_user` when the login is swept. Unnamed connections are for rows. To create an
object, name a role or run `SET ROLE <role>` first, which a set login may do for any role it
several roles belongs to that set's login: no other role or identity reaches it, and it passes
to `custom_instance_user` when the login is swept. Unnamed connections are for rows. To create
an object, name a role or run `SET ROLE <role>` first, which a set login may do for any role it
holds. Migrations are not affected: one that declares no role runs as `admin`.
- **The viewer is `job_perms.end_user_email`, and nothing else.** The server records it when the
run starts: the session behind an app request, the caller of a direct run or preview, and for a
job started with another job's token, that job's viewer. No argument reaches it.
- **A cached result belongs to one viewer.** `cached_result_path` hashes the run's recorded viewer
into the key, since a cache hit returns before any connection is resolved. Runs with no viewer
recorded keep sharing one entry.
- **Only a tenant list naming the viewer counts.** A workspace admin may *use* every role,
`admin` included. The viewer's roles are those whose tenants name them (user, group, folder,
`*`), and never `admin`.
- **A cached result belongs to one identity, one viewer and one mode.** `cached_result_path`
hashes the job's `permissioned_as`, its recorded viewer and `viewer_data_access` into the key,
since a cache hit returns before any connection is resolved. Turning the setting on changes no
code, so without the mode a result read with the app's roles would be served to the viewer.
- **Only a tenant list naming the identity counts.** A workspace admin may *use* every role,
`admin` included. The roles added are those whose tenants name the identity (user, group,
folder, `*`), and never `admin`.
- **One role is that role's login. Several are a login per distinct set.** `wm_set_<hash>` is a
member of exactly those roles, with `INHERIT`; the hash is over their catalog ids, so a rename
moves nothing and a change in someone's groups resolves them to another login instead of
editing one. Its privileges are its memberships, so `SET ROLE` only narrows and `RESET ROLE`
comes back to it. The prefix is reserved in `validate_role_name`.
- **Resolving stays cheap where no viewer could add anything.** The viewer is only looked up when
some role other than the default and `admin` has tenants on that data table.
- **A `datatable_role_set` row with a password names a login that can authenticate with it.** The
row is written after the login exists and removed, in its own commit, before the login is
dropped or made `NOLOGIN`. A login without a row is taken back with a new password on next use,
and so is one whose row has an empty password: that is what a sweep leaves when it could
neither drop a login nor let it back in, so the next sweep still finds it.
- **Set logins are bounded by use, not by the number of role combinations.** One exists only for
a set some viewer actually connected with, so there are at most as many as (viewer, data table)
pairs. `last_used_at` is recorded at most once a day, and `sweep_unused_role_set_logins`, run
from the monitor loop, drops those unused for 30 days; a dropped one is made again on demand.
The sweep goes by this instance's rows, never by the cluster's `wm_set_*` roles: a cluster can
serve several Windmill instances.
a set some identity actually connected with, so there are at most as many as (identity, data
table) pairs. `last_used_at` is recorded at most once a day, and `sweep_unused_role_set_logins`,
run from the monitor loop, drops those unused for 30 days; a dropped one is made again on
demand. The sweep goes by this instance's rows, never by the cluster's `wm_set_*` roles: a
cluster can serve several Windmill instances.
- **Disabling or dropping a role retires every set login holding it.** `NOLOGIN` on a role does
not reach a login that is a member of it.
- **A worker or server that predates this connects as the default role alone**, so a mixed fleet
shows a viewer fewer rows, never more. It refuses a data table with no default role, whose
marker it reads as a role the data table lacks.
- **Mixed fleets.** A worker or server older than v1.831 connects as the default role alone, and
refuses a data table with no default role, whose marker it reads as a role the data table
lacks. One from v1.831 or v1.832 adds the recorded viewer's roles to every job that has one,
flagged or not, and the job's own roles to none.
## Trust
A job that names no role runs its author's code with the viewer's reach into the data table, the
same trust as an app in viewer execution mode or a script someone else wrote and you run. Whoever
can deploy a publisher-mode app, or a script that others run, can therefore read through the
non-`admin` roles of the people who use it. Naming a role in the code is how an author pins the
connection to one role instead.
A job that names no role reads with its own identity's roles, the same trust as the resources and
variables it reaches: every viewer of a publisher-mode app sees what the publisher's roles see.
An inline runnable that connects as its viewer runs its publisher's code with the viewer's reach
into the data table, the same trust as an app in viewer execution mode. Naming a role in the code
pins the connection to that one role either way.
+2 -2
View File
@@ -111,8 +111,8 @@ rule added for deploys must hold for drafts too. Where the two differ, `write` d
fills in an omitted sandbox or mode, since it stores the policy it checked, while a draft skips
`triggerables_v2` and must not name another user's run identity. Every frontend writer of a
builder draft must conform the policy first with `conformBuilderAppPolicy`, since a policy loaded
from someone else's app breaks these rules: the editor and the AI chat do. The AI Sessions raw-app
editor, open to operators only behind a per-browser opt-in, does not, so its autosaves are refused.
from someone else's app breaks these rules: the raw-app editor component does, which covers both
the standalone editor and the AI Sessions one, and so does the AI chat.
The raw-app editor previews an app isolated only when its policy has `sandbox`, and otherwise runs
the bundle same-origin with the session of whoever opens it. A builder's draft is therefore
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.830.0",
"version": "1.833.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.830.0",
"version": "1.833.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.830.0",
"version": "1.833.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -25,7 +25,7 @@
import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry'
import { createEventDispatcher, onDestroy, tick } from 'svelte'
import Path from './Path.svelte'
import { ListRow, RadioCard, Skeleton } from './common'
import { Badge, ListRow, RadioCard, Skeleton } from './common'
import { useListHighlight } from './common/listRow/listHighlight.svelte'
import ApiConnectForm from './ApiConnectForm.svelte'
import SearchItems from './SearchItems.svelte'
@@ -322,6 +322,15 @@
(registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false))
)
/** The provider also has a browser sign-in, unavailable here only because the
* instance holds no OAuth client for it */
let signInNeedsInstanceClient = $derived.by(
() =>
(registryEntry()?.grant_types?.includes('authorization_code') ?? false) &&
connectClient != '' &&
!(connects?.includes(connectClient) ?? false)
)
/** Clear CC inputs and scopes so a previous selection never leaks into a new one */
function resetClientCredentialsState() {
supportsClientCredentials = false
@@ -1399,11 +1408,7 @@
</div>
{#if apiTokenApps[resourceType].img}
<div class="mt-4 w-full overflow-hidden">
<img
class="m-auto max-h-60"
alt="connect"
src={apiTokenApps[resourceType].img}
/>
<img class="m-auto max-h-60" alt="connect" src={apiTokenApps[resourceType].img} />
</div>
{/if}
{/if}
@@ -1514,14 +1519,14 @@
{#if supportsClientCredentials}
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis mb-1">Authentication</h3>
{#if ccOnly || ccBringYourOwn}
{#if (ccOnly || ccBringYourOwn) && !signInNeedsInstanceClient}
<div class="text-xs text-secondary font-normal mb-2">
{#if useSharedInstanceCreds}
{resourceType} connects server-to-server using the credentials configured for this
instance. The token is acquired and refreshed automatically.
instance.
{:else}
{resourceType} connects server-to-server. Enter a client ID and secret; the token is
acquired and refreshed automatically.
{resourceType} connects server-to-server with the client credentials grant. Enter a
client ID and secret.
{/if}
</div>
{:else}
@@ -1534,17 +1539,24 @@
>
<RadioCard
label={`Sign in through ${resourceType}`}
description="Opens a browser window to log in and authorize. Connects as you."
description={signInNeedsInstanceClient
? `Authorization code grant. Needs an OAuth client for ${resourceType} in the instance settings, added by a superadmin.`
: 'Authorization code grant. Opens a browser window to log in and authorize. Connects as you.'}
selected={!useClientCredentials}
onSelect={selectAuthCodeGrant}
disabled={signInNeedsInstanceClient}
badge={signInNeedsInstanceClient ? setupNeededBadge : undefined}
/>
{#snippet setupNeededBadge()}
<Badge verySmall class="px-2">Instance setup needed</Badge>
{/snippet}
<RadioCard
label={useSharedInstanceCreds
? 'Use the configured instance credentials'
: 'Use a client ID and secret'}
description={useSharedInstanceCreds
? "Runs server-to-server with this instance's credentials. No input needed."
: 'Runs server-to-server. Best for automation or service accounts.'}
? "Client credentials grant. Runs server-to-server with this instance's credentials. No input needed."
: 'Client credentials grant. Runs server-to-server. Best for automation or service accounts.'}
selected={useClientCredentials}
onSelect={() => enableClientCredentials()}
/>
@@ -97,7 +97,8 @@
'docusign',
'salesforce',
'outreach',
'notion'
'notion',
'hubspot'
]
// Providers whose registry entry (`backend/oauth_connect.json`) carries a
// `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown
@@ -1,6 +1,4 @@
<script lang="ts" module>
import type { ErrorHandler } from '$lib/gen/types.gen'
export const errorHandlerArgs = [
'path',
'workspace_id',
@@ -19,17 +17,6 @@
]
export const slackErrorHandlerHubPathEnding = '/workspace-or-schedule-error-handler-slack'
/** The stored `<script|flow>/<path>` form of a handler. `kind` is the Custom tab's Script/Flow
* choice and survives switching to a built-in tab, whose handlers are all scripts: stored as
* `flow/...` they never run. */
export function handlerFullPath(
selected: ErrorHandler,
kind: 'flow' | 'script',
path: string
): string {
return `${selected === 'custom' ? kind : 'script'}/${path}`
}
</script>
<script lang="ts">
@@ -67,34 +54,22 @@
RotateCw,
XCircle
} from 'lucide-svelte'
import { hubPaths } from '$lib/hub'
import { isCloudHosted } from '$lib/cloud'
import { untrack } from 'svelte'
import { syncSlackBotArg } from './handlerSlackArg'
import SmtpConfigurationStatus from './common/smtp/SmtpConfigurationStatus.svelte'
import { SettingService } from '$lib/gen'
import { isSmtpSettingsValid } from './instanceSettings/SmtpSettings.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { builtinHubPath, type HandlerDraft, type HandlerMode } from './handlerDraft.svelte'
const operatingWorkspace = useOperatingWorkspace()
const slackRecoveryHandler = hubPaths.slackRecoveryHandler
const slackHandlerScriptPath = hubPaths.slackErrorHandler
const slackSuccessHandler = hubPaths.slackSuccessHandler
const teamsRecoveryHandler = hubPaths.teamsRecoveryHandler
const teamsHandlerScriptPath = hubPaths.teamsErrorHandler
const teamsSuccessHandler = hubPaths.teamsSuccessHandler
interface Props {
errorOrRecovery: 'error' | 'recovery' | 'success'
errorOrRecovery: HandlerMode
isEditable: boolean
toggleText?: string
showScriptHelpText?: boolean
handlerSelected: ErrorHandler
handlerPath: string | undefined
handlerExtraArgs: Record<string, any>
/** Owned by the caller, which restores it on Discard; every tab edits its own slot. */
draft: HandlerDraft
customScriptTemplate: string
customHandlerKind?: 'flow' | 'script'
customTabTooltip?: import('svelte').Snippet
noMargin?: boolean
/** Workspace for handler lookup / settings / test jobs. Defaults to the operating
@@ -110,11 +85,8 @@
isEditable,
toggleText = 'Enable',
showScriptHelpText = false,
handlerSelected = $bindable('custom'),
handlerPath = $bindable(),
handlerExtraArgs = $bindable(),
draft = $bindable(),
customScriptTemplate,
customHandlerKind = $bindable('script'),
customTabTooltip,
noMargin = false,
workspace = undefined,
@@ -142,8 +114,6 @@
let connectionTestJob: { uuid: string; is_success: boolean; in_progress: boolean } | undefined =
$state()
const EMAIL_RECIPIENTS_KEY = 'email_recipients'
const CHANNEL_KEY = 'channel'
async function loadSlackResources() {
const settings = await WorkspaceService.getPublicSettings({ workspace: effectiveWorkspace! })
@@ -171,11 +141,7 @@
async function loadSmtpConfiguration() {
try {
const smtpSettings = (await SettingService.getGlobal({ key: 'smtp_settings' })) as Record<
string,
any
> | null
hasSmtpConfig = smtpSettings ? isSmtpSettingsValid(smtpSettings) : false
hasSmtpConfig = await SettingService.getSmtpConfigured()
} catch (error) {
hasSmtpConfig = false
}
@@ -190,7 +156,7 @@
let submitted_job = await testJobFunction({
workspace: effectiveWorkspace!,
requestBody: {
hub_script_path: handlerPath,
hub_script_path: draft[platform].path,
channel: channel,
test_msg: `This is a notification to test the connection between ${platform} and Windmill workspace '${effectiveWorkspace!}'`
}
@@ -236,7 +202,11 @@
await sendMessage(channel, 'teams')
}
async function loadHandlerScriptArgs(p: string, defaultArgs: string[] = []) {
async function loadHandlerScriptArgs(
p: string,
kind: 'script' | 'flow',
defaultArgs: string[] = []
) {
try {
let schema: Schema | undefined = emptySchema()
if (p.startsWith('hub/')) {
@@ -251,7 +221,7 @@
}
} else {
let scriptOrFlow: Script | Flow =
customHandlerKind === 'script'
kind === 'script'
? await ScriptService.getScriptByPath({ workspace: effectiveWorkspace!, path: p })
: await FlowService.getFlowByPath({ workspace: effectiveWorkspace!, path: p })
schema = scriptOrFlow.schema as Schema
@@ -269,47 +239,6 @@
}
}
function isSlackHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
if (errorOrRecovery == 'error') {
return scriptPath.startsWith('hub/') && scriptPath.endsWith(slackErrorHandlerHubPathEnding)
} else if (errorOrRecovery == 'recovery') {
return (
scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-recovery-handler-slack')
)
} else {
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-success-handler-slack')
}
}
function isTeamsHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
if (errorOrRecovery == 'error') {
return (
scriptPath.startsWith('hub/') &&
scriptPath.endsWith('/workspace-or-schedule-error-handler-teams')
)
} else if (errorOrRecovery == 'recovery') {
return (
scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-recovery-handler-teams')
)
} else {
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-success-handler-teams')
}
}
function isEmailHandler(scriptPath: string | undefined) {
if (!scriptPath) {
return false
}
return scriptPath.startsWith('hub/') && scriptPath.endsWith('/workspace-or-error-handler-email')
}
$effect(() => {
if (effectiveWorkspace) {
loadSlackResources()
@@ -318,82 +247,25 @@
loadSmtpConfiguration()
})
let builtinArgs: Record<string, any> | undefined
$effect(() => {
const isSlackBuiltin = handlerSelected === 'slack' && isSlackHandler(handlerPath)
const args = handlerExtraArgs
// A replaced args object (Discard, reload) holds the stored config, not what the built-in set.
untrack(() => syncSlackBotArg(args, isSlackBuiltin, args === builtinArgs))
builtinArgs = isSlackBuiltin ? args : undefined
})
let lastHandlerSelected: ErrorHandler | undefined = $state(undefined)
let handlerCache = $state({
slack: undefined as string | undefined,
teams: undefined as string | undefined,
email: undefined as string[] | undefined
})
let handlerPathCache: Partial<Record<ErrorHandler, string | undefined>> = $state({})
$effect(() => {
if (lastHandlerSelected !== handlerSelected && lastHandlerSelected !== undefined) {
if (lastHandlerSelected != 'custom') {
const key = lastHandlerSelected === 'email' ? EMAIL_RECIPIENTS_KEY : CHANNEL_KEY
handlerCache[lastHandlerSelected] = handlerExtraArgs[key]
}
handlerPathCache[lastHandlerSelected] = handlerPath
if (handlerSelected === 'custom') {
delete handlerExtraArgs[CHANNEL_KEY]
delete handlerExtraArgs[EMAIL_RECIPIENTS_KEY]
handlerPath = handlerPathCache['custom']
} else if (handlerSelected === 'email') {
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = handlerCache[handlerSelected] ?? []
delete handlerExtraArgs[CHANNEL_KEY]
} else {
handlerExtraArgs[CHANNEL_KEY] = handlerCache[handlerSelected] ?? ''
delete handlerExtraArgs[EMAIL_RECIPIENTS_KEY]
handlerPath = handlerPathCache[handlerSelected]
}
}
lastHandlerSelected = handlerSelected
})
$effect(() => {
handlerPath &&
!isSlackHandler(handlerPath) &&
!isTeamsHandler(handlerPath) &&
!isEmailHandler(handlerPath) &&
loadHandlerScriptArgs(handlerPath, errorHandlerArgs).then(
const { path, kind } = draft.custom
path &&
loadHandlerScriptArgs(path, kind, errorHandlerArgs).then(
(schema) => (customHandlerSchema = schema)
)
})
$effect(() => {
handlerPath &&
isSlackHandler(handlerPath) &&
loadHandlerScriptArgs(handlerPath, [...errorHandlerArgs, 'slack']).then(
const path = draft.slack.path
path &&
loadHandlerScriptArgs(path, 'script', [...errorHandlerArgs, 'slack']).then(
(schema) => (slackHandlerSchema = schema)
)
})
$effect(() => {
if (handlerSelected === 'email') {
handlerPath = hubPaths.emailErrorHandler
}
})
// The instance channels are reached by having no workspace handler at all, so the tab
// owns an empty path rather than a handler script.
$effect(() => {
if (handlerSelected === 'instance_alerts') {
handlerPath = undefined
}
})
</script>
<div class={classNames('space-y-2', noMargin ? '' : 'mt-2')}>
<ToggleButtonGroup bind:selected={handlerSelected} disabled={!isEditable}>
<ToggleButtonGroup bind:selected={draft.selected} disabled={!isEditable}>
{#snippet children({ item })}
<ToggleButton label="Slack" value="slack" {item} disabled={!isEditable} />
<ToggleButton label="Teams" value="teams" {item} disabled={!isEditable} />
@@ -418,21 +290,21 @@
</ToggleButtonGroup>
<div class="flex flex-col gap-6 p-4 rounded-md shadow-sm bg-surface-tertiary">
{#if handlerSelected === 'custom'}
{#if draft.selected === 'custom'}
<div class="flex flex-col gap-1">
<div class="flex flex-row">
<ScriptPicker
disabled={!isEditable || !$enterpriseLicense}
kinds={['script', 'failure']}
allowFlow={true}
bind:scriptPath={handlerPath}
bind:itemKind={customHandlerKind}
bind:scriptPath={draft.custom.path}
bind:itemKind={draft.custom.kind}
allowRefresh={isEditable}
workspace={effectiveWorkspace}
clearable
/>
{#if !handlerPath}
{#if !draft.custom.path}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
@@ -456,7 +328,7 @@
</div>
{/if}
</div>
{#if handlerPath}
{#if draft.custom.path}
<div>
<p class="font-semibold text-xs mb-1">Extra arguments</p>
{#await import('$lib/components/SchemaForm.svelte')}
@@ -465,7 +337,7 @@
<Module.default
disabled={!isEditable}
schema={customHandlerSchema}
bind:args={handlerExtraArgs}
bind:args={draft.custom.args}
shouldHideNoInputs
className="text-xs"
/>
@@ -475,7 +347,7 @@
{/if}
</div>
{/if}
{:else if handlerSelected === 'slack'}
{:else if draft.selected === 'slack'}
<!-- Slack Connection Status -->
<SlackConnectionStatus
isConnected={workspaceConnectedToSlack}
@@ -487,23 +359,15 @@
{#if workspaceConnectedToSlack}
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isSlackHandler(handlerPath)}
checked={!!draft.slack.path}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = slackHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = slackRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = slackSuccessHandler
} else {
handlerPath = undefined
}
on:change={(e) => {
draft.slack.path = e.detail ? builtinHubPath(errorOrRecovery, 'slack') : undefined
}}
/>
{/if}
{#if workspaceConnectedToSlack && isSlackHandler(handlerPath)}
{#if workspaceConnectedToSlack && draft.slack.path}
<div class="flex flex-col gap-2">
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
@@ -515,18 +379,18 @@
schemaFieldTooltip={{
channel: 'Slack channel name without the "#" - example: "windmill-alerts"'
}}
bind:args={handlerExtraArgs}
bind:args={draft.slack.args}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{#if $enterpriseLicense && isSlackHandler(handlerPath)}
{#if $enterpriseLicense}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
disabled={emptyString(draft.slack.args['channel'])}
wrapperClasses="w-fit"
variant="default"
on:click={() => sendSlackMessage(handlerExtraArgs['channel'])}
on:click={() => sendSlackMessage(draft.slack.args['channel'])}
unifiedSize="md">Send test message</Button
>
{#if connectionTestJob !== undefined}
@@ -559,7 +423,7 @@
{:else if workspaceConnectedToSlack == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{:else if handlerSelected === 'teams'}
{:else if draft.selected === 'teams'}
<!-- Teams Connection Status -->
<TeamsConnectionStatus
isConnected={workspaceConnectedToTeams}
@@ -571,18 +435,10 @@
{#if workspaceConnectedToTeams}
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isTeamsHandler(handlerPath)}
checked={!!draft.teams.path}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = teamsHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = teamsRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = teamsSuccessHandler
} else {
handlerPath = undefined
}
on:change={(e) => {
draft.teams.path = e.detail ? builtinHubPath(errorOrRecovery, 'teams') : undefined
}}
/>
{/if}
@@ -604,26 +460,26 @@
placeholder="Search Teams channels"
workspace={effectiveWorkspace}
teamId={teams_team_guid}
selectedChannel={handlerExtraArgs['channel']
selectedChannel={draft.teams.args['channel']
? {
channel_id: handlerExtraArgs['channel'],
channel_name: handlerExtraArgs['channel_name']
channel_id: draft.teams.args['channel'],
channel_name: draft.teams.args['channel_name']
}
: undefined}
onSelectedChannelChange={(channel) => {
handlerExtraArgs['channel'] = channel?.channel_id
handlerExtraArgs['channel_name'] = channel?.channel_name
draft.teams.args['channel'] = channel?.channel_id
draft.teams.args['channel_name'] = channel?.channel_name
}}
onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)}
/>
</div>
</div>
{#if $enterpriseLicense && isTeamsHandler(handlerPath) && workspaceConnectedToTeams}
{#if $enterpriseLicense && draft.teams.path && workspaceConnectedToTeams}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
disabled={emptyString(draft.teams.args['channel'])}
btnClasses="w-32 text-center whitespace-nowrap"
variant="default"
on:click={() => sendTeamsMessage(handlerExtraArgs['channel'] ?? '')}
on:click={() => sendTeamsMessage(draft.teams.args['channel'] ?? '')}
size="xs">Send test message</Button
>
{#if connectionTestJob !== undefined}
@@ -649,7 +505,7 @@
{:else if workspaceConnectedToTeams == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{:else if handlerSelected === 'email'}
{:else if draft.selected === 'email'}
{#if isCloudHosted()}
<Alert type="info" title="Email notifications are not available in Cloud">
Email notifications for trigger failures are only available in self-hosted Windmill
@@ -662,8 +518,8 @@
<MultiSelect
items={[] as { label: string; value: string }[]}
bind:value={
() => handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [],
(recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients)
() => draft.email.args.email_recipients ?? [],
(recipients) => (draft.email.args.email_recipients = recipients)
}
placeholder="Enter email addresses..."
onCreateItem={(email) => {
@@ -672,23 +528,24 @@
sendUserToast('Invalid email format', true)
return
}
const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? []
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email]
draft.email.args.email_recipients = [
...(draft.email.args.email_recipients ?? []),
email
]
}}
class="w-full"
/>
{#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0}
{#if draft.email.args.email_recipients?.length > 0}
<span class="text-xs text-secondary">
{handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[
EMAIL_RECIPIENTS_KEY
]?.length === 1
{draft.email.args.email_recipients.length} email{draft.email.args.email_recipients
.length === 1
? ''
: 's'} configured
</span>
{/if}
</div>
{/if}
{:else if handlerSelected === 'instance_alerts'}
{:else if draft.selected === 'instance_alerts'}
<div class="flex flex-col gap-2">
<span class="text-xs text-secondary">
Failed jobs are reported to the Slack, Teams and email channels configured at the instance
+10 -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.
@@ -1588,6 +1583,7 @@
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'flow',
path: pathSnapshot ?? $pathStore,
summary: flowStore.val.summary,
summaryContent: flowSummary,
// Gated like the summary's own editor: a host that allows neither field keeps the
// band's segment, which copies the path.
@@ -1597,6 +1593,7 @@
: undefined
}}
actions={flowHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="none"
/>
+261 -117
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 BreadcrumbSegment from '$lib/components/BreadcrumbSegment.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, kindKey } 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. */
@@ -145,6 +160,9 @@ not this component) — so the lead can run the whole line.
// The summary is the band's, not the breadcrumb's (see PageHeaderBar), so the path reads as the
// last segment only for an item that has no summary to follow it.
const itemIsLast = $derived(!!item && !item.summaryContent && !item.summary)
/** Only the summary's text names the item: a control offering to add one does not, and the path
* then stays at every stage. */
const summaryNamesItem = $derived(!!item?.summary?.trim())
// No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the
// fork's own name inside, so the family's name is not painted as if it were the fork.
@@ -156,15 +174,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 +202,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()}
@@ -241,89 +264,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 || !summaryNamesItem}
{#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">
@@ -334,19 +397,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. -->
@@ -361,8 +433,8 @@ not this component) — so the lead can run the whole line.
{@render pathLabel()}
</button>
{/if}
{#if afterName}{@render afterName()}{/if}
{#if pathCopied}
{@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"
@@ -386,15 +458,21 @@ not this component) — so the lead can run the whole line.
</span>
{/if}
{:else if section?.kind}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={twMerge(SEGMENT, LAST)}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
onPick={() => {}}
/>
{#if measure}
<span class={twMerge('inline-flex items-center px-1', SEGMENT, LAST)}>
{@render sectionKindIcon()}<span>{KIND_LABEL_LOWER[section.kind]}</span>
</span>
{:else}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={twMerge(SEGMENT, LAST)}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
onPick={() => {}}
/>
{/if}
{:else if section}
{#if section.parent}
<!-- Gives width back with the workspace part, before the section's own name does. -->
@@ -405,30 +483,96 @@ 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. -->
<span
class={twMerge(
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
section.content ? '' : 'truncate'
)}
aria-current="page"
>
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
</span>
{#if afterName}{@render afterName()}{/if}
{#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 ? '' : 'shrink-0'
)}
aria-current={measure ? undefined : 'page'}
bind:clientWidth={
() => sectionContentWidth, (w) => !measure && section.content && (sectionContentWidth = w)
}
>
<!-- 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>
{/if}
{@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. -->
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current="page"
aria-current={measure ? undefined : 'page'}
>
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
{#if afterName}{@render afterName()}{/if}
{@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}
+280 -124
View File
@@ -16,6 +16,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,
@@ -33,15 +36,99 @@ 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))
/** A summary control can be a placeholder offering to add one; the text is what names the item. */
const summaryNamesItem = $derived(!!item?.summary?.trim())
// 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 actionWidthsByVersion = $state<Record<string, (number | undefined)[]>>({})
let actionsEpoch = $state(0)
const actionsVersion = $derived(content?.actionsVersion ?? '')
const actionWidths = $derived(actionWidthsByVersion[actionsVersion] ?? [])
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,
summaryNamesItem,
natural: {
family: trailMeasures.family,
fork: trailMeasures.fork,
summary: hasSummary ? (item?.summaryContent ? summaryContentWidth : summaryTextWidth) : 0,
name: trailMeasures.name
}
})
})
const stage = $derived(BAR_STAGES[fit.stage])
/** The level alone: `fit` is a new object on every recompute, and the attachment re-measuring
* on each one would feed itself. */
const fold = $derived(fit.fold)
onDestroy(pageHeader.provideActionFold(() => fold))
// Re-attached whenever the fold level, the page or the actions' version changes, so the first
// reading there is taken even when the row comes out exactly as wide as it was before.
// The page's actions can change on screen — a run's Cancel goes once the job completes — and
// the other levels most likely changed with them, so a change here drops their widths: kept, a
// stale wider one would hold the row folded for good. The bar measures them again by trying.
function recordActions(fold: number, width: number) {
const version = actionsVersion
const widths = actionWidthsByVersion[version] ?? []
const seen = widths[fold]
// Unchanged, nothing is written: a write recomputes `fit`, and a reading that always wrote
// kept the bar measuring every frame.
if (seen === width) return
const next = seen !== undefined ? [] : [...widths]
next[fold] = width
actionWidthsByVersion[version] = next
}
function measureActions(
fold: number,
_epoch: number,
_version: string
): Attachment<HTMLElement> {
return (node) => {
const ro = new ResizeObserver(() => recordActions(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
@@ -69,8 +156,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) {
actionWidthsByVersion = {}
actionsEpoch++
}
})
const separator = $derived(pageHeader.content?.separator ?? 'onScroll')
const showEdge = $derived(separator === 'always' || (separator === 'onScroll' && scrolledUnder))
@@ -86,142 +179,205 @@ 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}
<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 || !summaryNamesItem ? '·' : '/'}</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}
</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(fold, actionsEpoch, actionsVersion)}
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}
</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,11 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
actionsFlexible,
actionsFill,
actionFolds,
earlyActionFolds,
actionsVersion,
belowBar,
belowBarFold,
separator,
contexts,
actingWorkspaceId,
@@ -33,6 +38,11 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
actionsFlexible,
actionsFill,
actionFolds,
earlyActionFolds,
actionsVersion,
belowBar,
belowBarFold,
separator,
contexts,
actingWorkspaceId,
+43 -41
View File
@@ -35,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'
@@ -612,7 +611,12 @@
openedRunId = undefined
}
let resolutionNote = $state('')
const phone = $derived(!hosted && 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()}
@@ -624,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 {hosted ? 'flex-wrap justify-end' : ''}">
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}
@@ -806,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: $operatingWorkspace === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus={!hosted}
/>
<!-- 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)}
@@ -842,7 +840,7 @@
<!-- 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 || hosted ? 'hidden' : 'contents'}>
<div class={fold >= 2 || hosted ? 'hidden' : 'contents'}>
<OpenInSessionButton
source={{
page: () => RUNS_PATH + window.location.search,
@@ -923,14 +921,18 @@
</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. -->
{#if hosted}
<div class="flex px-4 pt-3 pb-2">
{@render headerActions()}
</div>
{:else}
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
<PageHeaderContent
afterName={runsHint}
actions={headerActions}
actionFolds={3}
belowBar={filterRow}
belowBarFold={3}
/>
{/if}
<!-- Graph -->
@@ -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
@@ -2316,6 +2311,7 @@
// route binds here and drops the `{#if}` around this editor in one flush, which
// invalidates the band before this component is torn down.
path: pathSnapshot ?? script?.path,
summary: script?.summary,
summaryContent: scriptSummary,
// Gated like the summary's own editor: a host that allows neither field keeps the
// band's segment, which copies the path.
@@ -2326,6 +2322,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
@@ -1010,10 +1002,12 @@
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'app',
path: pathSnapshot ?? (newEditedPath || $appPath || newPath || undefined),
summary: $summary,
summaryContent: appSummary,
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
@@ -17,6 +17,8 @@ export type TriggerableV2 = {
delete_after_secs?: number
sensitive_inputs?: string[]
tag?: string
/** Data table connections naming no role are made as the app's viewer. Inline runnables only. */
viewer_data_access?: boolean
}
export async function hash(message) {
@@ -6,7 +6,8 @@
import type { Preview } from '$lib/gen'
import { createEventDispatcher, getContext, onMount, untrack } from 'svelte'
import type { AppViewerContext } from '../../types'
import { Maximize2, Trash2 } from 'lucide-svelte'
import { Maximize2, Paintbrush, Trash2 } from 'lucide-svelte'
import { getModifierKey } from '$lib/utils'
import InlineScriptEditorDrawer from './InlineScriptEditorDrawer.svelte'
import { inferArgs, parseOutputs } from '$lib/infer'
import type { Schema } from '$lib/common'
@@ -305,18 +306,17 @@
{/if}
<Button
variant="default"
title={`Format (${getModifierKey()}S)`}
aria-label="Format"
variant="subtle"
unifiedSize="sm"
on:click={async () => {
editor?.format()
simpleEditor?.format()
}}
shortCut={{
key: 'S'
}}
>
Format
</Button>
endIcon={{ icon: Paintbrush }}
iconOnly
/>
<RunButton bind:runLoading {id} inlineScript={!transformer ? inlineScript : undefined} />
</div>
</div>
@@ -656,22 +656,13 @@
wsId={$workspaceStore}
itemKind="script"
canWrite
bind:errorHandlerSelected={config.advanced.errorHandlerSelected}
bind:errorHandlerPath={config.advanced.errorHandlerPath}
bind:errorHandleritemKind={config.advanced.errorHandleritemKind}
bind:errorHandlerExtraArgs={config.advanced.errorHandlerExtraArgs}
bind:errorHandler={config.advanced.errorHandler}
bind:wsErrorHandlerMuted={config.advanced.wsErrorHandlerMuted}
bind:failedTimes={config.advanced.failedTimes}
bind:failedExact={config.advanced.failedExact}
bind:recoveryHandlerSelected={config.advanced.recoveryHandlerSelected}
bind:recoveryHandlerPath={config.advanced.recoveryHandlerPath}
bind:recoveryHandlerItemKind={config.advanced.recoveryHandlerItemKind}
bind:recoveryHandlerExtraArgs={config.advanced.recoveryHandlerExtraArgs}
bind:recoveryHandler={config.advanced.recoveryHandler}
bind:recoveredTimes={config.advanced.recoveredTimes}
bind:successHandlerSelected={config.advanced.successHandlerSelected}
bind:successHandlerPath={config.advanced.successHandlerPath}
bind:successHandlerItemKind={config.advanced.successHandlerItemKind}
bind:successHandlerExtraArgs={config.advanced.successHandlerExtraArgs}
bind:successHandler={config.advanced.successHandler}
bind:retry={config.advanced.retry}
bind:dynamicSkipPath={config.advanced.dynamicSkipPath}
bind:tag={config.advanced.tag}
@@ -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,86 @@
<!--
@component
The Permissions section of the assistant settings modal: what the user's role lets them create
and edit in the chat's workspace, next to what the assistant can make for them from the chat.
The assistant acts with the user's own rights, but its write tools go through drafts, so for
operators the second column is narrower.
-->
<script lang="ts">
import { Badge, Section } from '$lib/components/common'
import { Check, Minus } from 'lucide-svelte'
import { userWorkspaces } from '$lib/stores'
import { useActingUser } from '$lib/actingUser.svelte'
import { capitalize } from '$lib/utils/capitalize'
import { OPERATOR_SETTINGS_LOCATION, permissionRows, roleName } from './assistantPermissions'
let { ws }: { ws: string } = $props()
const acting = useActingUser(() => ws)
let user = $derived(acting.current)
let operatorSettings = $derived($userWorkspaces.find((w) => w.id === ws)?.operator_settings)
let role = $derived(user ? roleName(user) : undefined)
let rows = $derived(user ? permissionRows(user, operatorSettings) : [])
let chatNarrower = $derived(rows.some((r) => r.user && !r.assistant))
</script>
<Section
label="Permissions"
description="What you can create and edit in this workspace, and what the assistant can make for you from this chat."
class="flex flex-col gap-4"
>
{#snippet action()}
{#if role}
<Badge color={role === 'operator' ? 'yellow' : 'gray'}>{capitalize(role)}</Badge>
{/if}
{/snippet}
{#if !user}
<p class="text-xs text-secondary">
{acting.resolved(ws)
? 'Your role in this workspace could not be read.'
: 'Checking your role…'}
</p>
{:else}
<div class="grid max-w-sm grid-cols-[1fr_auto_auto] gap-x-6 gap-y-1.5 text-xs">
<span class="text-2xs text-secondary">Create and edit</span>
<span class="text-2xs text-secondary text-center">You</span>
<span class="text-2xs text-secondary text-center">From this chat</span>
{#snippet cell(allowed: boolean)}
<span class="flex justify-center">
{#if allowed}
<Check size={14} class="text-primary" aria-hidden="true" />
{:else}
<Minus size={14} class="text-hint" aria-hidden="true" />
{/if}
<span class="sr-only">{allowed ? 'Allowed' : 'Not allowed'}</span>
</span>
{/snippet}
{#each rows as row (row.label)}
<span class="text-primary">{row.label}</span>
{@render cell(row.user)}
{@render cell(row.assistant)}
{/each}
</div>
<div class="flex flex-col gap-2 text-xs text-secondary">
{#if chatNarrower}
<p>
The assistant acts as you, with your rights, but it saves every change as a draft before
deploying it, and your role can't save drafts of everything you can edit. Make those on
their own pages instead.
</p>
{/if}
<p>Anyone in the workspace can run the scripts and flows they can see.</p>
<p>
Items in folders you can't read are hidden from you and from the assistant. Someone with
access to the folder can run them.
</p>
{#if role === 'operator'}
<p>
A workspace admin can give operators more rights in {OPERATOR_SETTINGS_LOCATION}.
</p>
{/if}
</div>
{/if}
</Section>
@@ -10,7 +10,13 @@ The trigger sits in the composer next to the model pill; `open(section)` is for
callers that already know which section they mean, such as the "+" menu's Manage entries.
-->
<script lang="ts" module>
export type AssistantSettingsSection = 'tools' | 'skills' | 'instructions' | 'mcp' | 'files'
export type AssistantSettingsSection =
| 'tools'
| 'skills'
| 'instructions'
| 'mcp'
| 'files'
| 'permissions'
</script>
<script lang="ts">
@@ -21,6 +27,7 @@ callers that already know which section they mean, such as the "+" menu's Manage
Paperclip,
Plug,
ScrollText,
ShieldCheck,
SlidersHorizontal
} from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
@@ -35,6 +42,7 @@ callers that already know which section they mean, such as the "+" menu's Manage
import AssistantInstructionsSection from './AssistantInstructionsSection.svelte'
import AssistantMcpSection from './AssistantMcpSection.svelte'
import AssistantFilesSection from './AssistantFilesSection.svelte'
import AssistantPermissionsSection from './AssistantPermissionsSection.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
// Prefilled title marks the issue as AI Sessions feedback for triage.
@@ -71,7 +79,8 @@ callers that already know which section they mean, such as the "+" menu's Manage
skills: skillsBusy,
instructions: instructionsBusy,
mcp: mcpBusy,
files: filesBusy
files: filesBusy,
permissions: false
})
let blockingSection = $derived<AssistantSettingsSection | undefined>(
busyBySection[section]
@@ -121,7 +130,8 @@ callers that already know which section they mean, such as the "+" menu's Manage
{ id: 'mcp', label: 'MCP connections', icon: Plug, count: mcpCount },
// The count is what is readable rather than what is attached: a locked folder under
// a heading about what the assistant can use would say the opposite of the truth.
{ id: 'files', label: 'Files & folders', icon: Paperclip, count: fileCount }
{ id: 'files', label: 'Files & folders', icon: Paperclip, count: fileCount },
{ id: 'permissions', label: 'Permissions', icon: ShieldCheck }
])
/** Opens on `target`, or back on whichever section was last read. */
@@ -267,6 +277,11 @@ callers that already know which section they mean, such as the "+" menu's Manage
<div class="{section === 'files' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2">
<AssistantFilesSection bind:count={fileCount} bind:blocksClose={filesBusy} />
</div>
<div
class="{section === 'permissions' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2"
>
<AssistantPermissionsSection {ws} />
</div>
</div>
</div>
</Modal2>
@@ -37,6 +37,8 @@ export interface BackendRunnable {
inlineScript?: InlineScript
// For workspace scripts/flows and hub scripts
path?: string
// Inline only: data table connections naming no role hold the viewer's roles
viewerDataAccess?: boolean
}
export interface AppFiles {
@@ -327,6 +329,12 @@ const getSetBackendRunnableSchema = memo(() =>
.optional()
.describe(
'Required when type is "script", "flow", or "hubscript". The fully qualified path to the workspace script/flow or hub script.'
),
viewerDataAccess: z
.boolean()
.optional()
.describe(
"Inline only. When true, the runnable reads data as the person viewing the app instead of as the app's identity: data table connections that name no role hold the viewer's roles, and object storage permission rules are checked against the viewer, so row-level security and per-user storage paths answer per viewer. Resources and variables stay the app's. Anonymous viewers are refused. Omit to keep the current setting."
)
})
)
@@ -710,7 +718,8 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
...args,
staticInputs: args.staticInputs === null ? undefined : args.staticInputs,
inlineScript: args.inlineScript === null ? undefined : args.inlineScript,
path: args.path === null ? undefined : args.path
path: args.path === null ? undefined : args.path,
viewerDataAccess: args.viewerDataAccess === null ? undefined : args.viewerDataAccess
}
const parsedArgs = getSetBackendRunnableSchema().parse(cleanedArgs)
@@ -723,7 +732,10 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
type: parsedArgs.type,
...(parsedArgs.staticInputs && { staticInputs: parsedArgs.staticInputs }),
...(parsedArgs.inlineScript && { inlineScript: parsedArgs.inlineScript }),
...(parsedArgs.path && { path: parsedArgs.path })
...(parsedArgs.path && { path: parsedArgs.path }),
...(parsedArgs.viewerDataAccess !== undefined && {
viewerDataAccess: parsedArgs.viewerDataAccess
})
}
toolCallbacks.setToolStatus(toolId, {
@@ -0,0 +1,68 @@
import type { OperatorSettings } from '$lib/gen'
import type { UserExt } from '$lib/stores'
import { roleCanAuthor, type EditKind } from '$lib/editRights'
import { capabilitiesForRole } from './global/sessionAccess'
import type { SessionAccess } from './sessionCapabilities'
/** Where a workspace admin changes what operators may do. Named in the prompt and the
* permissions section alike, so the two send the user to the same place. */
export const OPERATOR_SETTINGS_LOCATION = 'Workspace settings → Users → Operator settings'
type PermissionRow = {
kind: EditKind
label: string
/** The draft capability the kind's write tool declares in `requires`, plus the schedule or
* trigger write gate where the kind has one. */
assistant: (access: SessionAccess) => boolean
}
const ROWS: PermissionRow[] = [
{ kind: 'script', label: 'Scripts', assistant: (a) => a.has('write_draft') },
{ kind: 'flow', label: 'Flows', assistant: (a) => a.has('write_flow_draft') },
{ kind: 'raw_app', label: 'Apps', assistant: (a) => a.has('write_app_draft') },
{
kind: 'schedule',
label: 'Schedules',
assistant: (a) => a.has('write_draft') && a.has('manage_schedules')
},
{
kind: 'trigger',
label: 'Triggers',
assistant: (a) => a.has('write_draft') && a.has('manage_triggers')
},
{ kind: 'resource', label: 'Resources', assistant: (a) => a.has('write_draft') },
{ kind: 'variable', label: 'Variables', assistant: (a) => a.has('write_draft') }
]
export type RoleName = 'admin' | 'developer' | 'operator'
export function roleName(
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'>
): RoleName {
if (user.is_admin || user.is_super_admin) return 'admin'
return user.operator ? 'operator' : 'developer'
}
/**
* Per item kind, whether the user's role lets them create and edit it themselves, and whether
* the chat can for them. Both act with the user's rights, but the user's column follows the
* item handlers (`roleCanAuthor`) and the chat's the draft endpoint every write tool goes
* through (`capabilitiesForRole`), which refuses operators more than the handlers do.
*/
export function permissionRows(
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'>,
operatorSettings: OperatorSettings | null | undefined
): { label: string; user: boolean; assistant: boolean }[] {
const access = capabilitiesForRole({
isAdmin: !!user.is_admin || !!user.is_super_admin,
operator: !!user.operator,
// Deploy rules gate deploying, not the drafts these rows are about.
deployRulesPass: true,
operatorSettings
})
return ROWS.map((row) => ({
label: row.label,
user: roleCanAuthor(row.kind, user, operatorSettings),
assistant: row.assistant(access)
}))
}

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