mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-15 08:02:53 +00:00
Merge remote-tracking branch 'origin/main' into operator-trigger-drafts
# Conflicts: # frontend/src/lib/components/copilot/chat/global/core.ts
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.831.0"
|
||||
".": "1.833.0"
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+6
-4
@@ -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 -
|
||||
|
||||
+14
@@ -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"
|
||||
}
|
||||
+330
@@ -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"
|
||||
}
|
||||
+337
@@ -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"
|
||||
}
|
||||
+71
@@ -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"
|
||||
}
|
||||
Generated
+90
-90
@@ -957,7 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f9ef7a68684657ce9da341c934c73b63df7a3585a61e366d84c84ed5d3d167"
|
||||
dependencies = [
|
||||
"attribute-derive-macro",
|
||||
"manyhow 0.14.0",
|
||||
"manyhow 0.14.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
@@ -971,7 +971,7 @@ checksum = "300d426e5e73549125bb8e4649bdd5508f82c94ddf898fb0517246e71dfb8e98"
|
||||
dependencies = [
|
||||
"collection_literals",
|
||||
"interpolator",
|
||||
"manyhow 0.14.0",
|
||||
"manyhow 0.14.1",
|
||||
"proc-macro-utils",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -8036,11 +8036,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "manyhow"
|
||||
version = "0.14.0"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c69ea6e469ca785c846b25dd69eac9e9c916b51d8ada1f212866787ee1da12d"
|
||||
checksum = "f1704b073316272a6785b70599f91f73b49022c2e290b065591ee6b9109ed8c7"
|
||||
dependencies = [
|
||||
"manyhow-macros 0.14.0",
|
||||
"manyhow-macros 0.14.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
@@ -8059,9 +8059,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "manyhow-macros"
|
||||
version = "0.14.0"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22fc5c1b2c4000056dae3619fea36e79ce79dabfee4907d2dcad6612b189e370"
|
||||
checksum = "31601db71f1f5fb6f831a18aa3052d13a62528ee4f2cba7e8c9a5e258d953725"
|
||||
dependencies = [
|
||||
"proc-macro-utils",
|
||||
"proc-macro2",
|
||||
@@ -15486,7 +15486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15576,7 +15576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -15611,7 +15611,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15624,7 +15624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15767,7 +15767,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15790,7 +15790,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15807,7 +15807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -15833,7 +15833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15843,7 +15843,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15860,7 +15860,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -15882,7 +15882,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -15905,7 +15905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15921,7 +15921,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15943,7 +15943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15965,7 +15965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -15979,7 +15979,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16015,7 +16015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16041,7 +16041,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -16069,7 +16069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16091,7 +16091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -16111,7 +16111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16153,7 +16153,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16182,7 +16182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16194,7 +16194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
@@ -16218,7 +16218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -16232,7 +16232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -16271,7 +16271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16286,7 +16286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -16305,7 +16305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -16421,7 +16421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -16441,7 +16441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16458,7 +16458,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16485,7 +16485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16502,7 +16502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16518,7 +16518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16540,7 +16540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16571,7 +16571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -16596,7 +16596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -16632,7 +16632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16651,7 +16651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16660,7 +16660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16672,7 +16672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16684,7 +16684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16696,7 +16696,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16708,7 +16708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16720,7 +16720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16731,7 +16731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16742,7 +16742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16754,7 +16754,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ruff_python_ast",
|
||||
@@ -16765,7 +16765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16788,7 +16788,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16800,7 +16800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16814,7 +16814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16831,7 +16831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16844,7 +16844,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16856,7 +16856,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16874,7 +16874,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16890,7 +16890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ruff_python_ast",
|
||||
@@ -16907,7 +16907,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16921,7 +16921,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16960,7 +16960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16998,7 +16998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -17009,7 +17009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17044,7 +17044,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17068,7 +17068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17101,7 +17101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17128,7 +17128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17161,7 +17161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17181,7 +17181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17215,7 +17215,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17252,7 +17252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17275,7 +17275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17299,7 +17299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -17323,7 +17323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17358,7 +17358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17386,7 +17386,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17411,7 +17411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -17430,7 +17430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -17549,7 +17549,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -18275,18 +18275,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.62"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
|
||||
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.62"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
|
||||
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.831.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.831.0"
|
||||
version = "1.833.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
118785eaa78675089b9a676d24e5d0a2e03f3d9a
|
||||
0ffb633473c5465513576afc6f54d18f93c7f1e1
|
||||
|
||||
@@ -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;
|
||||
@@ -446,7 +446,7 @@
|
||||
],
|
||||
"req_body_auth": true
|
||||
},
|
||||
"outlook": {
|
||||
"ms_outlook": {
|
||||
"req_body_auth": true,
|
||||
"scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"],
|
||||
"scope_options": [
|
||||
|
||||
+24
-24
@@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -6430,7 +6430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6442,7 +6442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6451,7 +6451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6463,7 +6463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6475,7 +6475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6487,7 +6487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6499,7 +6499,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6511,7 +6511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6522,7 +6522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6533,7 +6533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6545,7 +6545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ruff_python_ast",
|
||||
@@ -6556,7 +6556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6579,7 +6579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6591,7 +6591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6605,7 +6605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6622,7 +6622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6635,7 +6635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6647,7 +6647,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6665,7 +6665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6681,7 +6681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ruff_python_ast",
|
||||
@@ -6698,7 +6698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6730,7 +6730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6744,7 +6744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.831.0"
|
||||
version = "1.833.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.831.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
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
@@ -4961,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() {
|
||||
@@ -6042,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);})();
|
||||
|
||||
@@ -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<()>,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2180,19 +2180,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(
|
||||
@@ -2236,11 +2255,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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.831.0";
|
||||
export const VERSION = "v1.833.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/chat-sdk",
|
||||
"version": "1.831.0",
|
||||
"version": "1.833.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/chat-sdk",
|
||||
"version": "1.831.0",
|
||||
"version": "1.833.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "^4.0.102",
|
||||
|
||||
@@ -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.831.0",
|
||||
"version": "1.833.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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.831.0";
|
||||
export const VERSION = "1.833.0";
|
||||
|
||||
Generated
+11
-2
@@ -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\`.
|
||||
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.831.0",
|
||||
"version": "1.833.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.831.0",
|
||||
"version": "1.833.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.831.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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,145 +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
|
||||
<div
|
||||
class={twMerge(
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{#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
|
||||
onmouseenter={() => navHandleSlot.open()}
|
||||
onmouseleave={() => navHandleSlot.scheduleClose()}
|
||||
>
|
||||
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
|
||||
<!-- 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}
|
||||
<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 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.
|
||||
<!-- 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.
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
|
||||
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,
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+8
-8
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
@@ -231,6 +231,8 @@ import {
|
||||
type SessionTool,
|
||||
type SessionToolPolicy
|
||||
} from '../sessionCapabilities'
|
||||
import { OPERATOR_SETTINGS_LOCATION } from '../assistantPermissions'
|
||||
import { capitalize } from '$lib/utils/capitalize'
|
||||
import {
|
||||
buildRunsUrl,
|
||||
buildSchedulesUrl,
|
||||
@@ -1054,6 +1056,12 @@ const backendRunnableSchema = z
|
||||
.optional()
|
||||
.describe(
|
||||
'Required when type is "script", "flow", or "hubscript". Workspace path of the runnable.'
|
||||
),
|
||||
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."
|
||||
)
|
||||
})
|
||||
.describe(
|
||||
@@ -1425,6 +1433,9 @@ function buildFolderGuidance(
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const listJoin = (items: (string | false)[]) =>
|
||||
new Intl.ListFormat('en', { type: 'conjunction' }).format(items.filter((i): i is string => !!i))
|
||||
|
||||
const buildGlobalSystemPrompt = (
|
||||
username: string,
|
||||
previewTools: boolean,
|
||||
@@ -1516,7 +1527,10 @@ The current user's workspace username is "${username}".${instanceLine}
|
||||
${
|
||||
canWriteDraft
|
||||
? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.'
|
||||
: `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${refusedKinds.slice(0, -1).join(', ')} or ${refusedKinds.at(-1)} here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when(
|
||||
: `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${refusedKinds.slice(0, -1).join(', ')} or ${refusedKinds.at(-1)} here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin, who grants operators more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor. ${capitalize(listJoin(['resources', 'variables', canManageSchedules && !canDraftSchedules && 'schedules', canManageTriggers && !canDraftTriggers && 'triggers']))} they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when(
|
||||
canManageSchedules,
|
||||
' To make something recur, they can schedule a deployed script or flow themselves; only when none does the job is there something to ask an admin for, and it is that script.'
|
||||
)} When you decline something, give the reason stated here and keep to it.${when(
|
||||
buildsFlows,
|
||||
'\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.'
|
||||
)}${when(
|
||||
@@ -1556,6 +1570,7 @@ Rules:${when(
|
||||
- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.`
|
||||
)}
|
||||
- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.
|
||||
- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed.
|
||||
- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when(
|
||||
canWriteAnyDraft,
|
||||
`
|
||||
@@ -1872,6 +1887,7 @@ function convertPersistedToBackendRunnable(
|
||||
content: persisted.inlineScript.content ?? ''
|
||||
}
|
||||
}
|
||||
if (persisted.viewer_data_access === true) out.viewerDataAccess = true
|
||||
} else if (persisted.type === 'path' || persisted.type === 'runnableByPath') {
|
||||
if (persisted.runType === 'flow') out.type = 'flow'
|
||||
else if (persisted.runType === 'hubscript') out.type = 'hubscript'
|
||||
@@ -1909,7 +1925,14 @@ function buildPersistedRunnable(
|
||||
// `type` (isRunnableByName / isRunnableByPath), so a leftover does not change which code
|
||||
// runs — it contradicts it: a runnable reported back as inline still names a flow in
|
||||
// `path`, and that is what the model reads on its next turn.
|
||||
const { runType: _runType, path: _path, inlineScript: _inlineScript, ...carried } = existing ?? {}
|
||||
const {
|
||||
runType: _runType,
|
||||
path: _path,
|
||||
inlineScript: _inlineScript,
|
||||
viewer_data_access: carriedAsViewer,
|
||||
...carried
|
||||
}: Partial<PersistedRunnable> = existing ?? {}
|
||||
const viewerDataAccess = input.viewerDataAccess ?? carriedAsViewer === true
|
||||
|
||||
// `schema` describes the item a path runnable points at, so it only survives while that
|
||||
// target is unchanged. Carried across a retarget it types `backend.<key>(args)` from the
|
||||
@@ -1931,6 +1954,7 @@ function buildPersistedRunnable(
|
||||
content: input.inlineScript.content,
|
||||
language: input.inlineScript.language
|
||||
},
|
||||
...(viewerDataAccess && { viewer_data_access: true }),
|
||||
fields
|
||||
}
|
||||
}
|
||||
@@ -2748,6 +2772,11 @@ export function getSessionContextPromptSection(
|
||||
}. Scripts, flows, apps, resources and variables must be promoted from the session's deploy panel (fork or pull request); do not offer to deploy or delete them directly.`
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
'- Sessions are saved: the sidebar lists them under AI Sessions, and the user can reopen any of them.',
|
||||
'- The composer has a permission mode picker: Ask permission, Auto-accept edits, Yolo (bypass permissions: tool confirmations are accepted without asking) and Plan (read-only). Some modes are hidden where they do not apply.',
|
||||
"- The sliders button next to the model picker opens Assistant settings. Its Permissions section shows what the user's role lets them change and what you can change for them; point them there when they ask what they are allowed to do."
|
||||
)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -289,4 +289,25 @@ describe('session tool policies', () => {
|
||||
expect(full).toBe(ungated)
|
||||
}
|
||||
})
|
||||
|
||||
// The operator's own rights outrun the chat's tools: what they schedule in the UI must not
|
||||
// be sent to an admin.
|
||||
it("sends an operator to the UI for what their role allows and the chat's tools do not", () => {
|
||||
const prompt = (operatorSettings: { manage_schedules?: boolean }): string =>
|
||||
prepareGlobalSystemMessage(undefined, {
|
||||
user: { username: 'op' },
|
||||
access: capabilitiesForRole({
|
||||
isAdmin: false,
|
||||
operator: true,
|
||||
deployRulesPass: true,
|
||||
operatorSettings
|
||||
})
|
||||
}).content as string
|
||||
|
||||
// Schedules and triggers are not sent to the UI: the chat drafts them itself.
|
||||
expect(prompt({})).toContain('Resources and variables they can still edit')
|
||||
expect(prompt({})).toContain('lets them create and edit schedules and triggers')
|
||||
expect(prompt({})).toContain('schedule a deployed script or flow themselves')
|
||||
expect(prompt({ manage_schedules: false })).not.toContain('schedule a deployed script')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,14 +17,16 @@
|
||||
|
||||
type MainButton = {
|
||||
label: string
|
||||
/** Shown under the label once the button has collapsed into a menu. */
|
||||
/** Shown under the label once the button is a row of a menu. */
|
||||
description?: string
|
||||
buttonProps: ButtonProps
|
||||
/** Where the button lands below the `lg` breakpoint, where the bar cannot hold every
|
||||
* button beside the summary: the ellipsis menu, or the dropdown of the enabled bar button
|
||||
* labelled `dropdownOf` (the menu when there is none). Unset keeps it in the bar at every
|
||||
* width. */
|
||||
narrow?: 'menu' | { dropdownOf: string }
|
||||
/** A secondary action: a bar button while the bar has room for every one, then, from the
|
||||
* first fold, in the ellipsis menu or in the dropdown of the enabled bar button labelled
|
||||
* `dropdownOf` (the menu when there is none). Unset makes it a main button, which goes to
|
||||
* the menu only on the folds after that. */
|
||||
placement?: 'menu' | { dropdownOf: string }
|
||||
/** In its `placement` at every width, never a bar button of its own. */
|
||||
alwaysPlaced?: boolean
|
||||
}
|
||||
|
||||
type ButtonProps = any
|
||||
@@ -70,6 +72,9 @@
|
||||
wide?: boolean
|
||||
/** Controls ahead of the menu, such as the way an agent's page runs it. */
|
||||
leading_actions?: import('svelte').Snippet
|
||||
/** Marks that belong to the item's name, such as its favorite star: drawn right after the
|
||||
* summary rather than among the badges and buttons at the far end. */
|
||||
name_marks?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -89,7 +94,8 @@
|
||||
trigger_badges,
|
||||
ownsPageHeader = false,
|
||||
wide = true,
|
||||
leading_actions
|
||||
leading_actions,
|
||||
name_marks
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -98,26 +104,50 @@
|
||||
// no error handler and says so itself.
|
||||
const renameKind = $derived(itemKind ?? errorHandlerKind)
|
||||
|
||||
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
|
||||
// own box is narrow while the window is not — a session's preview panel — and says so through
|
||||
// `wide`. The band knows its own width, which the host cannot see: these buttons share that row
|
||||
// with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured
|
||||
// (0) counts as wide, because the bar measures itself on mount and starting narrow would pop
|
||||
// the buttons out of the menu a frame later.
|
||||
// Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row
|
||||
// has to fold before that rather than at the ~1150 the two parts add up to on paper.
|
||||
const COLLAPSE_BELOW = 1250
|
||||
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
|
||||
const wideRow = $derived(wide && roomInBar)
|
||||
// The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below):
|
||||
// first the secondary ones go to their `placement`, then every bar button but the last, which
|
||||
// is what the page is for (Edit), then that one too. Outside the band — a session's preview
|
||||
// panel — no bar measures the row, but the host knows when its own box is narrow while the
|
||||
// window is not and says so through `wide`: the secondary buttons fold there, as at the first
|
||||
// fold, and the error handler's toggle goes to the menu.
|
||||
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : wide ? 0 : 1)
|
||||
const wideRow = $derived(wide && fold <= 1)
|
||||
|
||||
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
const rowButtons = $derived(mainButtons.filter((b) => !b.placement))
|
||||
const barButtons = $derived(
|
||||
fold === 0
|
||||
? mainButtons.filter((b) => !b.alwaysPlaced)
|
||||
: fold === 1
|
||||
? rowButtons
|
||||
: fold === 2
|
||||
? rowButtons.slice(-1)
|
||||
: []
|
||||
)
|
||||
|
||||
function dropdownHost(btn: MainButton): MainButton | undefined {
|
||||
if (typeof btn.narrow !== 'object') return undefined
|
||||
const label = btn.narrow.dropdownOf
|
||||
if (typeof btn.placement !== 'object' || barButtons.includes(btn)) return undefined
|
||||
const label = btn.placement.dropdownOf
|
||||
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
|
||||
}
|
||||
|
||||
/** What the menu holds of the buttons: the folded bar buttons, each followed by what its
|
||||
* dropdown would have held, then the secondary actions with no bar button to hang from. */
|
||||
const menuButtons = $derived.by(() => {
|
||||
const folded = rowButtons.filter((b) => !barButtons.includes(b))
|
||||
const homeless = mainButtons.filter(
|
||||
(b) => b.placement && !barButtons.includes(b) && !dropdownHost(b)
|
||||
)
|
||||
const childOf = (host: MainButton) =>
|
||||
homeless.filter(
|
||||
(b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label
|
||||
)
|
||||
const placed = new Set(folded.flatMap(childOf))
|
||||
return [
|
||||
...folded.flatMap((host) => [host, ...childOf(host)]),
|
||||
...homeless.filter((b) => !placed.has(b))
|
||||
]
|
||||
})
|
||||
|
||||
async function toggleErrorHandler() {
|
||||
if (!errorHandlerKind || !scriptOrFlowPath) return
|
||||
const next = await toggleWorkspaceErrorHandler(
|
||||
@@ -130,7 +160,7 @@
|
||||
}
|
||||
|
||||
const allMenuItems: Item[] = $derived([
|
||||
...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
|
||||
...menuButtons.map((b) => ({
|
||||
displayName: b.label,
|
||||
description: b.description,
|
||||
icon: b.buttonProps.startIcon,
|
||||
@@ -155,12 +185,11 @@
|
||||
action: item.onclick,
|
||||
disabled: item.disabled,
|
||||
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
|
||||
separatorTop: i === 0 && !wideRow
|
||||
separatorTop: i === 0 && (menuButtons.length > 0 || (!wideRow && !!errorHandlerKind))
|
||||
}))
|
||||
])
|
||||
|
||||
function dropdownItemsOf(host: MainButton) {
|
||||
if (wideRow) return undefined
|
||||
const items = mainButtons
|
||||
.filter((b) => dropdownHost(b) === host)
|
||||
.map((b) => ({
|
||||
@@ -176,15 +205,18 @@
|
||||
</script>
|
||||
|
||||
{#snippet summaryContent()}
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={renameKind}
|
||||
compact
|
||||
/>
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={renameKind}
|
||||
compact
|
||||
/>
|
||||
{#if name_marks}<span class="flex items-center shrink-0">{@render name_marks()}</span>{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- The same editor as the summary's, hung off the band's path segment so it opens under the path.
|
||||
@@ -272,12 +304,17 @@
|
||||
item={{
|
||||
kind: errorHandlerKind,
|
||||
path,
|
||||
// Read-only, the summary control falls back to the path when there is no summary, so the
|
||||
// path is what names the item there and the trail's own copy can go.
|
||||
summary: onSaved ? summary : summary || path,
|
||||
summaryContent,
|
||||
// Only where this page can save one: without `onSaved` the editor behind it has nothing
|
||||
// to write, and the band keeps its own segment, which copies the path.
|
||||
pathTrigger: onSaved ? pathTrigger : undefined
|
||||
}}
|
||||
{actions}
|
||||
actionFolds={3}
|
||||
earlyActionFolds={1}
|
||||
contexts={headerContexts}
|
||||
separator="always"
|
||||
/>
|
||||
@@ -301,6 +338,7 @@
|
||||
kind={renameKind}
|
||||
/>
|
||||
</div>
|
||||
{@render name_marks?.()}
|
||||
{@render badges('md')}
|
||||
</div>
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new flow from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: operatingHref(`/flows/add?template=${flow.path}`),
|
||||
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
|
||||
@@ -390,7 +390,7 @@
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
description: editInForkDescription('flow', workspace, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path, workspace),
|
||||
onClick: (e: Event | undefined) => editInFork(e, flow.path),
|
||||
@@ -418,7 +418,7 @@
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
@@ -435,7 +435,8 @@
|
||||
if (!actingUser?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
alwaysPlaced: true,
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
@@ -682,6 +683,12 @@
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet name_marks()}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace && flow && workspace === $workspaceStore}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
@@ -698,12 +705,6 @@
|
||||
small={false}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && flow}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace === $workspaceStore}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
{/if}
|
||||
<OnBehalfOfBadge
|
||||
onBehalfOf={flow?.on_behalf_of}
|
||||
onBehalfOfEmail={flow?.on_behalf_of_email}
|
||||
|
||||
@@ -532,7 +532,7 @@
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new script from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: operatingHref(`/scripts/add?template=${script.path}`),
|
||||
onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`),
|
||||
@@ -553,7 +553,7 @@
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
description: editInForkDescription('script', workspace, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('script', script.path, workspace),
|
||||
onClick: (e: Event | undefined) => editInFork(e, script.path),
|
||||
@@ -586,7 +586,7 @@
|
||||
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => {
|
||||
versionsDrawerOpen = !versionsDrawerOpen
|
||||
@@ -602,7 +602,8 @@
|
||||
if (!actingUser?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
alwaysPlaced: true,
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromScript(script.path, script.summary, script.schema)
|
||||
@@ -870,6 +871,12 @@
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet name_marks()}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace && script && workspace === $workspaceStore}
|
||||
<Star kind="script" path={script.path} summary={script.summary} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
@@ -887,12 +894,6 @@
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && script}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace === $workspaceStore}
|
||||
<Star kind="script" path={script.path} summary={script.summary} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if script?.auto_kind === 'wac'}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
<PageHeaderContent
|
||||
item={{
|
||||
path: pathSnapshot ?? shownPath,
|
||||
summary: draft?.state?.description,
|
||||
summaryContent: agentSummary,
|
||||
// Only where the rename behind it can be saved: a reader who cannot write this agent
|
||||
// keeps the segment that copies the path, rather than an editor with nothing to edit.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ErrorHandler } from '$lib/gen'
|
||||
import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts'
|
||||
import hubPaths from '$lib/hubPaths.json'
|
||||
|
||||
export type HandlerMode = 'error' | 'recovery' | 'success'
|
||||
type BuiltinDestination = 'slack' | 'teams' | 'email'
|
||||
|
||||
export type HandlerSlot = { path?: string; args: Record<string, any> }
|
||||
export type HandlerDraft = {
|
||||
selected: ErrorHandler
|
||||
slack: HandlerSlot
|
||||
teams: HandlerSlot
|
||||
email: HandlerSlot
|
||||
custom: HandlerSlot & { kind: 'script' | 'flow' }
|
||||
}
|
||||
type StoredHandler = { path?: string; kind: 'script' | 'flow'; args: Record<string, any> }
|
||||
|
||||
const SLACK_BOT_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH
|
||||
|
||||
// Suffixes rather than full paths, so a handler stored under an older hub id is still recognised.
|
||||
// Email uses one script for every mode.
|
||||
const EMAIL_SUFFIX = '/workspace-or-error-handler-email'
|
||||
const BUILTIN_SUFFIXES: Record<HandlerMode, Record<'slack' | 'teams', string>> = {
|
||||
error: {
|
||||
slack: '/workspace-or-schedule-error-handler-slack',
|
||||
teams: '/workspace-or-schedule-error-handler-teams'
|
||||
},
|
||||
recovery: {
|
||||
slack: '/schedule-recovery-handler-slack',
|
||||
teams: '/schedule-recovery-handler-teams'
|
||||
},
|
||||
success: {
|
||||
slack: '/schedule-success-handler-slack',
|
||||
teams: '/schedule-success-handler-teams'
|
||||
}
|
||||
}
|
||||
|
||||
// The only settings each built-in hub script takes besides the ones the backend injects.
|
||||
const BUILTIN_KEYS: Record<BuiltinDestination, string[]> = {
|
||||
slack: ['channel'],
|
||||
teams: ['channel', 'channel_name'],
|
||||
email: ['email_recipients']
|
||||
}
|
||||
|
||||
export function builtinHubPath(mode: HandlerMode, destination: 'slack' | 'teams'): string {
|
||||
const key =
|
||||
`${destination}${mode[0].toUpperCase()}${mode.slice(1)}Handler` as keyof typeof hubPaths
|
||||
return hubPaths[key]
|
||||
}
|
||||
|
||||
export function handlerTypeForPath(mode: HandlerMode, path: string | undefined): ErrorHandler {
|
||||
if (path?.startsWith('hub/')) {
|
||||
if (path.endsWith(EMAIL_SUFFIX)) return 'email'
|
||||
for (const [destination, suffix] of Object.entries(BUILTIN_SUFFIXES[mode])) {
|
||||
if (path.endsWith(suffix)) return destination as BuiltinDestination
|
||||
}
|
||||
}
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
function emptyDraft(selected: ErrorHandler): HandlerDraft {
|
||||
return {
|
||||
selected,
|
||||
slack: { args: {} },
|
||||
teams: { args: {} },
|
||||
email: { path: hubPaths.emailErrorHandler, args: {} },
|
||||
custom: { kind: 'script', args: {} }
|
||||
}
|
||||
}
|
||||
|
||||
function pick(args: Record<string, any>, keys: string[]): Record<string, any> {
|
||||
return Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
|
||||
}
|
||||
|
||||
/** Puts a stored handler into the slot of its destination; every other slot starts empty. */
|
||||
export function handlerDraftFromStored(
|
||||
mode: HandlerMode,
|
||||
path: string | undefined,
|
||||
kind: 'script' | 'flow' = 'script',
|
||||
args: Record<string, any> | null | undefined = {},
|
||||
opts: { asCustom?: boolean; emptySelected?: ErrorHandler } = {}
|
||||
): HandlerDraft {
|
||||
if (!path) return emptyDraft(opts.emptySelected ?? 'custom')
|
||||
const destination = opts.asCustom ? 'custom' : handlerTypeForPath(mode, path)
|
||||
const draft = emptyDraft(destination)
|
||||
if (destination === 'custom') {
|
||||
draft.custom = { path, kind, args: { ...(args ?? {}) } }
|
||||
} else if (destination !== 'instance_alerts') {
|
||||
draft[destination] = { path, args: pick(args ?? {}, BUILTIN_KEYS[destination]) }
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
/** What a save stores: the selected destination only. */
|
||||
export function handlerDraftToStored(draft: HandlerDraft): StoredHandler {
|
||||
const none: StoredHandler = { kind: 'script', args: {} }
|
||||
switch (draft.selected) {
|
||||
case 'custom': {
|
||||
const { path, kind, args } = draft.custom
|
||||
return path ? { path, kind, args: $state.snapshot(args) } : none
|
||||
}
|
||||
case 'slack':
|
||||
case 'teams':
|
||||
case 'email': {
|
||||
const { path, args } = draft[draft.selected]
|
||||
if (!path) return none
|
||||
const stored = { path, kind: 'script' as const, args: $state.snapshot(args) }
|
||||
if (draft.selected === 'slack') stored.args.slack = SLACK_BOT_ARG
|
||||
return stored
|
||||
}
|
||||
default:
|
||||
return none
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneHandlerDraft(draft: HandlerDraft): HandlerDraft {
|
||||
return $state.snapshot(draft) as HandlerDraft
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
handlerDraftFromStored,
|
||||
handlerDraftToStored,
|
||||
handlerTypeForPath
|
||||
} from './handlerDraft.svelte'
|
||||
|
||||
const SLACK_BOT = '$res:f/slack_bot/bot_token'
|
||||
|
||||
describe('handler draft', () => {
|
||||
it('round-trips every destination unchanged', () => {
|
||||
const stored = [
|
||||
{
|
||||
path: 'hub/28794/workspace-or-schedule-error-handler-slack',
|
||||
kind: 'script' as const,
|
||||
args: { channel: 'alerts', slack: SLACK_BOT }
|
||||
},
|
||||
{
|
||||
path: 'hub/19742/workspace-or-schedule-error-handler-teams',
|
||||
kind: 'script' as const,
|
||||
args: { channel: 'c1', channel_name: 'General' }
|
||||
},
|
||||
{
|
||||
path: 'hub/19795/workspace-or-error-handler-email',
|
||||
kind: 'script' as const,
|
||||
args: { email_recipients: ['ops@example.com'] }
|
||||
},
|
||||
{
|
||||
path: 'f/handlers/on_error',
|
||||
kind: 'flow' as const,
|
||||
args: { slack: '$res:u/me/my_slack', channel: 'own' }
|
||||
}
|
||||
]
|
||||
for (const s of stored) {
|
||||
const draft = handlerDraftFromStored('error', s.path, s.kind, s.args)
|
||||
expect(handlerDraftToStored(draft)).toEqual(s)
|
||||
}
|
||||
})
|
||||
|
||||
it("saves only the selected destination's settings", () => {
|
||||
const draft = handlerDraftFromStored('error', 'u/me/handler', 'script', {
|
||||
webhook_url: 'https://hooks.example/custom'
|
||||
})
|
||||
draft.selected = 'email'
|
||||
draft.email.args.email_recipients = ['ops@example.com']
|
||||
expect(handlerDraftToStored(draft)).toEqual({
|
||||
path: 'hub/19795/workspace-or-error-handler-email',
|
||||
kind: 'script',
|
||||
args: { email_recipients: ['ops@example.com'] }
|
||||
})
|
||||
})
|
||||
|
||||
it('recognises built-in handlers per mode', () => {
|
||||
const recoverySlack = 'hub/28791/slack/schedule-recovery-handler-slack'
|
||||
expect(handlerTypeForPath('recovery', recoverySlack)).toBe('slack')
|
||||
expect(handlerTypeForPath('error', recoverySlack)).toBe('custom')
|
||||
})
|
||||
|
||||
it('stores no handler when the destination has none', () => {
|
||||
const draft = handlerDraftFromStored('error', undefined)
|
||||
expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} })
|
||||
draft.selected = 'slack'
|
||||
expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} })
|
||||
draft.selected = 'instance_alerts'
|
||||
expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} })
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { syncSlackBotArg } from './handlerSlackArg'
|
||||
|
||||
const AUTO = '$res:f/slack_bot/bot_token'
|
||||
|
||||
describe('syncSlackBotArg', () => {
|
||||
it("leaves a custom handler's slack value alone", () => {
|
||||
const args = { slack: '$res:u/me/my_slack' }
|
||||
syncSlackBotArg(args, false, false)
|
||||
syncSlackBotArg(args, false, true)
|
||||
expect(args).toEqual({ slack: '$res:u/me/my_slack' })
|
||||
})
|
||||
|
||||
it('sets the bot token for the built-in and drops it when leaving', () => {
|
||||
const args: Record<string, any> = {}
|
||||
syncSlackBotArg(args, true, false)
|
||||
expect(args.slack).toBe(AUTO)
|
||||
syncSlackBotArg(args, false, true)
|
||||
expect(args).toStrictEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts'
|
||||
|
||||
const AUTO_SLACK_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH
|
||||
|
||||
// `slack` belongs to the Slack built-in only while it holds the value the picker set; any other
|
||||
// value is a custom handler's own argument and must survive handler switches and reloads.
|
||||
export function syncSlackBotArg(
|
||||
args: Record<string, any>,
|
||||
isSlackBuiltin: boolean,
|
||||
wasSlackBuiltin: boolean
|
||||
) {
|
||||
if (isSlackBuiltin) {
|
||||
args.slack = AUTO_SLACK_ARG
|
||||
} else if (wasSlackBuiltin && args.slack === AUTO_SLACK_ARG) {
|
||||
delete args.slack
|
||||
}
|
||||
}
|
||||
@@ -446,7 +446,7 @@ export const APP_TO_ICON_COMPONENT = {
|
||||
quickbooks: QuickbooksIcon,
|
||||
ms_teams_webhook: MsTeamsIcon,
|
||||
teams: MsTeamsIcon,
|
||||
outlook: OutlookIcon,
|
||||
ms_outlook: OutlookIcon,
|
||||
mailgun: MailgunIcon,
|
||||
ipinfo: IpinfoIcon,
|
||||
gworkspace: GoogleIcon,
|
||||
|
||||
@@ -838,17 +838,8 @@ export const settings: Record<string, Setting[]> = {
|
||||
description: 'Default python version for newly deployed scripts',
|
||||
key: 'instance_python_version',
|
||||
fieldType: 'select_python',
|
||||
// To change latest stable version:
|
||||
// 1. Change placeholder in instanceSettings.ts
|
||||
// 2. Change LATEST_STABLE_PY in dockerfile
|
||||
// 3. Change #[default] annotation for PyVersion in backend
|
||||
placeholder: '3.10,3.11,3.12,3.13,3.14,3.15',
|
||||
select_items: [
|
||||
{
|
||||
label: 'Latest Stable',
|
||||
value: 'default',
|
||||
tooltip: 'python-3.12'
|
||||
},
|
||||
{
|
||||
label: '3.10'
|
||||
},
|
||||
|
||||
@@ -52,6 +52,26 @@ export type PageHeaderContent = {
|
||||
* controls share one shape that opens into the page below. The bar gives up its right padding
|
||||
* to it, since the surface is what meets the edge. */
|
||||
actionsFill?: boolean
|
||||
/** How many levels past the full row these actions can fold through, each a narrower row than
|
||||
* the last. The bar picks the level from the room its breadcrumb leaves once it has dropped
|
||||
* all it can, and the page reads it back as `pageHeader.actionFold`. Without it the actions
|
||||
* never fold and the breadcrumb gives up width around them. */
|
||||
actionFolds?: number
|
||||
/** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons
|
||||
* that are there only while the bar has room for everything. */
|
||||
earlyActionFolds?: number
|
||||
/** Changes whenever the actions may have changed width at a level that is not on screen — a
|
||||
* run's Cancel, folded into a menu, going once the job completes. The bar keeps what it
|
||||
* measured per version, so a new one is measured afresh rather than judged by stale widths. */
|
||||
actionsVersion?: string
|
||||
/** A second row of the band, under the breadcrumb and the actions: where a control too wide
|
||||
* for one row goes at a fold that has no room for it beside them, rather than being squeezed
|
||||
* until it is useless. The band is taller while it is there. */
|
||||
belowBar?: Snippet
|
||||
/** The fold from which the bar shows `belowBar`. The bar decides rather than the page passing
|
||||
* the row in only at that fold: the fold is derived from this registration, so a registration
|
||||
* that read it would be a cycle. */
|
||||
belowBarFold?: number
|
||||
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
|
||||
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
|
||||
* which at the far end of the bar would be a journey away from what it edits. */
|
||||
@@ -91,10 +111,9 @@ let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
|
||||
let nextId = 0
|
||||
|
||||
/**
|
||||
* Below this bar width a page keeps one button — the thing the page is for — and folds the rest
|
||||
* into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet
|
||||
* in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the
|
||||
* line sits above the tablet rather than between the two.
|
||||
* A phone's bar: a tighter gutter, and the width below which controls that cannot fold through
|
||||
* `actionFolds` — a flexible filter row, a banner — slim down. A phone in portrait is ~390px of
|
||||
* bar and a tablet in portrait ~720, which is why the line sits above the tablet.
|
||||
*/
|
||||
export const PHONE_BAR = 800
|
||||
|
||||
@@ -112,6 +131,9 @@ export const BAR_HEIGHT_PX = 48
|
||||
// share that row with the breadcrumb, so it is the width they crowd against — not the window's,
|
||||
// which the sidebar and a page's side panel both take from.
|
||||
let barWidth = $state(0)
|
||||
// The bar owns the fold level: it is derived from the bar's own measurements, so the bar hands
|
||||
// over a getter rather than writing a copy that an effect would have to keep in step.
|
||||
let actionFoldSource = $state<(() => number) | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* One registration's content, or undefined while it cannot answer.
|
||||
@@ -151,6 +173,11 @@ export const pageHeader = {
|
||||
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
|
||||
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
|
||||
if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill
|
||||
if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds
|
||||
if (c.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds
|
||||
if (c.actionsVersion !== undefined) merged.actionsVersion = c.actionsVersion
|
||||
if (c.belowBar !== undefined) merged.belowBar = c.belowBar
|
||||
if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold
|
||||
if (c.separator !== undefined) merged.separator = c.separator
|
||||
}
|
||||
return merged
|
||||
@@ -176,6 +203,17 @@ export const pageHeader = {
|
||||
setBarWidth(w: number) {
|
||||
barWidth = w
|
||||
},
|
||||
/** How far the page's actions are folded: 0 for the full row, up to its `actionFolds`. */
|
||||
get actionFold(): number {
|
||||
return actionFoldSource?.() ?? 0
|
||||
},
|
||||
/** Called by the bar once; returns the release to call when the bar goes away. */
|
||||
provideActionFold(get: () => number): () => void {
|
||||
actionFoldSource = get
|
||||
return () => {
|
||||
if (actionFoldSource === get) actionFoldSource = undefined
|
||||
}
|
||||
},
|
||||
/** Registers on mount and returns the id to release on destroy. */
|
||||
register(get: () => PageHeaderContent): number {
|
||||
const id = nextId++
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BAR_STAGES, fitBar, type BarMeasure } from './pageHeaderStages'
|
||||
|
||||
// Each stage 50px narrower than the last, so the bar width alone says which one fits.
|
||||
const trail = BAR_STAGES.map((_, i) => 600 - i * 50)
|
||||
const base: BarMeasure = {
|
||||
barWidth: 2000,
|
||||
chrome: 0,
|
||||
trail,
|
||||
summary: BAR_STAGES.map(() => 0),
|
||||
actions: [300, 200, 100],
|
||||
folds: 2,
|
||||
natural: { family: 0, fork: 0, summary: 0, name: 0 }
|
||||
}
|
||||
|
||||
describe('fitBar', () => {
|
||||
it('picks the first stage that fits beside the full row of actions', () => {
|
||||
expect(fitBar({ ...base, barWidth: 900 }).stage).toBe(0)
|
||||
expect(fitBar({ ...base, barWidth: 800 })).toMatchObject({ stage: 2, fold: 0 })
|
||||
})
|
||||
|
||||
it('folds the actions only once the breadcrumb is at its last stage, and keeps it there', () => {
|
||||
// 350 + 300 overflows a 600px bar; 350 + 200 fits. Stage 0 would fit beside the folded row
|
||||
// too, but the parts a narrowing bar dropped do not come back when the buttons fold.
|
||||
expect(fitBar({ ...base, barWidth: 600 })).toMatchObject({ stage: 5, fold: 1 })
|
||||
expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 })
|
||||
})
|
||||
|
||||
it('spends early folds before the breadcrumb drops anything', () => {
|
||||
// 600 + 200 fits an 800px bar at the first fold; without early folds stage 2 would have
|
||||
// dropped parts beside the full row instead.
|
||||
expect(fitBar({ ...base, barWidth: 800, earlyFolds: 1 })).toMatchObject({ stage: 0, fold: 1 })
|
||||
// Past that, the breadcrumb gives way at the early fold before the later folds start.
|
||||
expect(fitBar({ ...base, barWidth: 700, earlyFolds: 1 })).toMatchObject({ stage: 2, fold: 1 })
|
||||
})
|
||||
|
||||
it('tries a level it has not seen at the width of the next one folded further', () => {
|
||||
// Only the second fold has been measured: the levels before it are assumed no wider, so the
|
||||
// bar tries the full row first and goes on to the first fold once it has seen it overflow.
|
||||
const unseen = { ...base, barWidth: 600, actions: [undefined, undefined, 100] }
|
||||
expect(fitBar(unseen).fold).toBe(0)
|
||||
expect(fitBar({ ...unseen, actions: [300, undefined, 100] }).fold).toBe(1)
|
||||
})
|
||||
|
||||
it("takes the squeeze from the path's name before the summary", () => {
|
||||
const fit = fitBar({
|
||||
...base,
|
||||
barWidth: 360,
|
||||
actions: [0],
|
||||
folds: 0,
|
||||
trail: trail.map((w) => w - 400),
|
||||
summary: BAR_STAGES.map(() => 100),
|
||||
natural: { family: 0, fork: 0, summary: 300, name: 300 }
|
||||
})
|
||||
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 100, so 180
|
||||
// has to go: all 140 the name has above its floor, then 40 from the summary.
|
||||
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 260 }, scopesGive: false })
|
||||
})
|
||||
|
||||
it('keeps the summary and drops the path when only one of them fits', () => {
|
||||
const natural = { family: 0, fork: 0, summary: 300, name: 300 }
|
||||
// Stage 2 is the first without the path: the name is no longer a part the bar sizes.
|
||||
const fit = fitBar({ ...base, barWidth: 520, natural, actions: [0], folds: 0 })
|
||||
expect(fit.stage).toBe(2)
|
||||
expect(fit.caps.name).toBeUndefined()
|
||||
// A placeholder offering to add a summary names nothing: the path stays a part the bar sizes.
|
||||
const placeholder = fitBar({
|
||||
...base,
|
||||
barWidth: 520,
|
||||
natural,
|
||||
actions: [0],
|
||||
folds: 0,
|
||||
summaryNamesItem: false
|
||||
})
|
||||
expect(placeholder.caps.name).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* How the page header gives up width. Whole parts drop in a fixed order, and the bar shows the
|
||||
* first stage whose parts, each held at its floor width, fit beside the page's actions. Which
|
||||
* stage that is depends on the names in the bar rather than on a pixel breakpoint. Past the last
|
||||
* stage the page's actions fold, one level at a time; past their last level the item's name and
|
||||
* its summary truncate, then the folders above it.
|
||||
*/
|
||||
|
||||
/** What the breadcrumb itself drops. */
|
||||
export type TrailStage = {
|
||||
/** Inside a fork, the family's name before the fork's. Outside one it is the workspace's own
|
||||
* name and stays until `wsButton`. */
|
||||
familyName: boolean
|
||||
/** The item's path. Dropped only for an item that has a summary: the summary is what a
|
||||
* person calls the thing, while its path is where it is filed. Without one, the path is the
|
||||
* item's only name and stays. */
|
||||
path: boolean
|
||||
forkName: boolean
|
||||
/** The workspace part as its disc alone, still leading home, beside its picker's chevron. */
|
||||
wsButton: boolean
|
||||
/** The folders between the first one and the name; collapsed, they read as `…`. */
|
||||
middleLevels: boolean
|
||||
}
|
||||
|
||||
export type BarStage = TrailStage & { label: string }
|
||||
|
||||
export const BAR_STAGES: BarStage[] = [
|
||||
{
|
||||
label: 'Full',
|
||||
familyName: true,
|
||||
path: true,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Family name dropped',
|
||||
familyName: false,
|
||||
path: true,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Path dropped for the summary',
|
||||
familyName: false,
|
||||
path: false,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Fork name dropped',
|
||||
familyName: false,
|
||||
path: false,
|
||||
forkName: false,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Workspace as its disc',
|
||||
familyName: false,
|
||||
path: false,
|
||||
forkName: false,
|
||||
wsButton: true,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Middle folders collapsed',
|
||||
familyName: false,
|
||||
path: false,
|
||||
forkName: false,
|
||||
wsButton: true,
|
||||
middleLevels: false
|
||||
}
|
||||
]
|
||||
|
||||
export type Part = 'family' | 'fork' | 'summary' | 'name'
|
||||
|
||||
/** The trail at each stage with its names at their floors, and each name's own width. */
|
||||
export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number }
|
||||
|
||||
/** The width a part may truncate to before the next stage drops something whole. The summary's
|
||||
* is wide: below it the path goes rather than the summary losing most of what it says. */
|
||||
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 200, name: 160 }
|
||||
/** Past the last stage and fold, the item's name and then its summary keep this much before the
|
||||
* folders give any. */
|
||||
export const SQUEEZED_FLOOR = 40
|
||||
|
||||
/** Middle folders collapsed to one `…`; the first folder and the name always stay. */
|
||||
export function shownPathLevels(levels: string[], stage: TrailStage): string[] {
|
||||
const scopes = levels.slice(0, -1)
|
||||
if (stage.middleLevels || scopes.length <= 1) return levels
|
||||
return [scopes[0], '…', levels[levels.length - 1]]
|
||||
}
|
||||
|
||||
/** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */
|
||||
export function partVisible(
|
||||
part: Part,
|
||||
stage: BarStage,
|
||||
natural: Record<Part, number>,
|
||||
summaryNamesItem = true
|
||||
): boolean {
|
||||
if (natural[part] <= 0) return false
|
||||
switch (part) {
|
||||
case 'summary':
|
||||
return true
|
||||
case 'family':
|
||||
// Outside a fork the family's name is the workspace's: it goes only with the whole part.
|
||||
return !stage.wsButton && (stage.familyName || natural.fork <= 0)
|
||||
case 'fork':
|
||||
return !stage.wsButton && stage.forkName
|
||||
case 'name':
|
||||
return stage.path || natural.summary <= 0 || !summaryNamesItem
|
||||
}
|
||||
}
|
||||
|
||||
export type BarMeasure = {
|
||||
barWidth: number
|
||||
/** The bar's padding and the sidebar handle: what is there at every stage. */
|
||||
chrome: number
|
||||
/** Per stage: the breadcrumb with each name at its floor. */
|
||||
trail: number[]
|
||||
/** Per stage: the summary at its floor with the separator before it, 0 where there is none. */
|
||||
summary: number[]
|
||||
/** Per fold level: the actions with the gap before them, undefined for a level not yet seen. */
|
||||
actions: (number | undefined)[]
|
||||
/** How many levels past the full row the page's actions can fold through. */
|
||||
folds: number
|
||||
/** How many of those levels fold before the breadcrumb drops anything: buttons that are there
|
||||
* only while there is room for everything. */
|
||||
earlyFolds?: number
|
||||
natural: Record<Part, number>
|
||||
/** The summary is the item's own text, not a placeholder offering to add one: only then does it
|
||||
* name the item well enough for the path to go. */
|
||||
summaryNamesItem?: boolean
|
||||
}
|
||||
|
||||
export type BarFit = {
|
||||
stage: number
|
||||
fold: number
|
||||
/** Max widths for the parts that give width, in the order they give it. */
|
||||
caps: Partial<Record<Part, number>>
|
||||
/** Even the name at its squeezed floor does not fit: the folders above it truncate too. */
|
||||
scopesGive: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex shrinks every part in proportion, so however lopsided the factors, the name gives a few
|
||||
* pixels while the summary still has room, and a few pixels are an ellipsis. The parts are rigid
|
||||
* instead and the deficit is taken from them in a strict order, each down to its floor.
|
||||
*/
|
||||
export function fitBar(m: BarMeasure): BarFit {
|
||||
// A level not yet seen is assumed as narrow as the next one folded further: trying it is how it
|
||||
// gets measured, and once it has been, the bar never picks it again unless it fits.
|
||||
const actionsAt = (fold: number) => {
|
||||
for (let f = fold; f < m.actions.length; f++)
|
||||
if (m.actions[f] !== undefined) return m.actions[f]!
|
||||
return 0
|
||||
}
|
||||
const last = BAR_STAGES.length - 1
|
||||
const floorWidth = (s: number, f: number) =>
|
||||
m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f)
|
||||
|
||||
const early = Math.min(m.earlyFolds ?? 0, m.folds)
|
||||
let stage = last
|
||||
let fold = m.folds
|
||||
choose: {
|
||||
for (let f = 0; f < early; f++) {
|
||||
if (floorWidth(0, f) <= m.barWidth) {
|
||||
stage = 0
|
||||
fold = f
|
||||
break choose
|
||||
}
|
||||
}
|
||||
for (let s = 0; s <= last; s++) {
|
||||
if (floorWidth(s, early) <= m.barWidth) {
|
||||
stage = s
|
||||
fold = early
|
||||
break choose
|
||||
}
|
||||
}
|
||||
// The other levels fold only once the breadcrumb has given all it can, so a narrowing bar
|
||||
// only ever loses things: the parts it dropped do not come back when the buttons fold.
|
||||
for (let f = early + 1; f <= m.folds; f++) {
|
||||
if (floorWidth(last, f) <= m.barWidth) {
|
||||
fold = f
|
||||
break choose
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const at = BAR_STAGES[stage]
|
||||
// The summary gives last: it is what a person calls the item, so the path's name goes first.
|
||||
const parts = (['family', 'fork', 'name', 'summary'] as Part[]).filter((p) =>
|
||||
partVisible(p, at, m.natural, m.summaryNamesItem ?? true)
|
||||
)
|
||||
const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p]))
|
||||
// The stage was measured with its parts at their floors; this is its width with them whole.
|
||||
let deficit = floorWidth(stage, fold) + parts.reduce((s, p) => s + slack(p), 0) - m.barWidth
|
||||
const caps: Partial<Record<Part, number>> = {}
|
||||
const take = (p: Part, floor: number) => {
|
||||
if (deficit <= 0 || !parts.includes(p)) return
|
||||
const current = caps[p] ?? m.natural[p]
|
||||
const give = Math.min(deficit, Math.max(0, current - Math.min(m.natural[p], floor)))
|
||||
if (give > 0) caps[p] = current - give
|
||||
deficit -= give
|
||||
}
|
||||
for (const p of parts) take(p, FLOOR[p])
|
||||
take('name', SQUEEZED_FLOOR)
|
||||
take('summary', SQUEEZED_FLOOR)
|
||||
return { stage, fold, caps, scopesGive: deficit > 0 }
|
||||
}
|
||||
@@ -897,6 +897,7 @@ addEventListener('message', function (e) {
|
||||
content: runnable.inlineScript.content ?? ''
|
||||
}
|
||||
}
|
||||
if (runnable.viewer_data_access) backendRunnable.viewerDataAccess = true
|
||||
} else if (isRunnableByPath(runnable)) {
|
||||
// Determine type based on runType
|
||||
if (runnable.runType === 'flow') {
|
||||
@@ -1200,6 +1201,9 @@ addEventListener('message', function (e) {
|
||||
content: runnable.inlineScript.content,
|
||||
language: runnable.inlineScript.language
|
||||
},
|
||||
...((runnable.viewerDataAccess ?? runnables[key]?.viewer_data_access) && {
|
||||
viewer_data_access: true
|
||||
}),
|
||||
fields: runnable.staticInputs
|
||||
? Object.fromEntries(
|
||||
Object.entries(runnable.staticInputs).map(([k, v]) => [
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -318,20 +318,15 @@
|
||||
|
||||
// Top-bar responsive collapse — container width, not viewport.
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
|
||||
// the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff,
|
||||
// Jobs, Export, AI and Deploy — is ~560px.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1350 : 720)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
|
||||
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
|
||||
// in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
|
||||
)
|
||||
// The second fold keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
|
||||
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
|
||||
// in the menu, Jobs from `compactTopbar` (true at the second fold too) and the export always.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
// Top-bar button size + bar height. Condensed (session preview) uses the
|
||||
// smallest well-supported unified size (`sm`) so the bar is thinner.
|
||||
@@ -914,8 +909,8 @@
|
||||
</a>
|
||||
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
|
||||
it can be fetched (S3, GitHub raw, your docs site) and link
|
||||
<span class="font-mono">/replay?src=<url></span> to have it load itself. Windmill
|
||||
keeps no copy.
|
||||
<span class="font-mono">/replay?src=<url></span> to have it load itself. Windmill keeps
|
||||
no copy.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
@@ -956,10 +951,12 @@
|
||||
// rename popover is open so the trail holds still as the user types.
|
||||
kind: 'app',
|
||||
path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined),
|
||||
summary,
|
||||
summaryContent: appSummary,
|
||||
pathTrigger: rawAppPathTrigger
|
||||
}}
|
||||
actions={rawAppHeaderActions}
|
||||
actionFolds={2}
|
||||
contexts={headerContexts}
|
||||
separator="none"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import type { Preview, ScriptLang } from '$lib/gen'
|
||||
import { createEventDispatcher, onDestroy, onMount, untrack } from 'svelte'
|
||||
import { Trash2, Bug, Terminal } from 'lucide-svelte'
|
||||
import { Trash2, Bug, Terminal, Paintbrush } from 'lucide-svelte'
|
||||
import { getModifierKey } from '$lib/utils'
|
||||
import { inferArgs, inferAssets } from '$lib/infer'
|
||||
import type { Schema } from '$lib/common'
|
||||
import Editor from '$lib/components/Editor.svelte'
|
||||
@@ -17,6 +18,7 @@
|
||||
import CacheTtlPopup from '../apps/editor/inlineScriptsPanel/CacheTtlPopup.svelte'
|
||||
import TagPopup from '../apps/editor/inlineScriptsPanel/TagPopup.svelte'
|
||||
import DeleteAfterUsePopup from './DeleteAfterUsePopup.svelte'
|
||||
import ViewerDataAccessPopup from './ViewerDataAccessPopup.svelte'
|
||||
import { computeFields } from '../apps/editor/inlineScriptsPanel/utils'
|
||||
import EditorBar, { EDITOR_BAR_HELPERS_INLINE_THRESHOLD } from '../EditorBar.svelte'
|
||||
import { LanguageIcon } from '../common/languageIcons'
|
||||
@@ -52,6 +54,7 @@
|
||||
editor?: any | undefined
|
||||
lastDeployedCode?: string | undefined
|
||||
delete_after_secs?: number | undefined
|
||||
viewer_data_access?: boolean | undefined
|
||||
/** Called when code is selected in the editor */
|
||||
onSelectionChange?: (
|
||||
selection: {
|
||||
@@ -74,9 +77,17 @@
|
||||
editor = $bindable(undefined),
|
||||
lastDeployedCode,
|
||||
onSelectionChange,
|
||||
delete_after_secs = $bindable()
|
||||
delete_after_secs = $bindable(),
|
||||
viewer_data_access = $bindable()
|
||||
}: Props = $props()
|
||||
|
||||
// The asset parser misses a chained `wmill.datatable('x')`...``, so the source is matched too.
|
||||
const readsData = $derived(
|
||||
(inlineScript?.assets?.some((a) => ['datatable', 'ducklake', 's3object'].includes(a.kind)) ??
|
||||
false) ||
|
||||
/datatable\s*\(|datatable:\/\/|s3:\/\/|s3_?file|S3Object/i.test(inlineScript?.content ?? '')
|
||||
)
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
let opWs = $derived($operatingWorkspace)
|
||||
|
||||
@@ -620,6 +631,9 @@
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex w-full flex-row gap-2 items-center justify-end">
|
||||
{#if readsData || viewer_data_access}
|
||||
<ViewerDataAccessPopup bind:viewer_data_access />
|
||||
{/if}
|
||||
{#if inlineScript}
|
||||
<TagPopup bind:tag={inlineScript.tag} />
|
||||
<CacheTtlPopup bind:cache_ttl={inlineScript.cache_ttl} />
|
||||
@@ -627,14 +641,16 @@
|
||||
<DeleteAfterUsePopup bind:delete_after_secs />
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
title={`Format (${getModifierKey()}S)`}
|
||||
aria-label="Format"
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
on:click={async () => {
|
||||
editor?.format()
|
||||
}}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
endIcon={{ icon: Paintbrush }}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
type RunnableWithInlineScript = RunnableWithFields & {
|
||||
inlineScript?: InlineScript & { language: ScriptLang }
|
||||
delete_after_secs?: number
|
||||
viewer_data_access?: boolean
|
||||
}
|
||||
export type Runnable = RunnableWithInlineScript | undefined
|
||||
interface Props {
|
||||
@@ -224,6 +225,7 @@
|
||||
bind:name={runnable.name}
|
||||
bind:fields={runnable.fields}
|
||||
bind:delete_after_secs={runnable.delete_after_secs}
|
||||
bind:viewer_data_access={runnable.viewer_data_access}
|
||||
onRun={testPreview}
|
||||
on:delete
|
||||
path={appPath}
|
||||
|
||||
@@ -27,9 +27,10 @@
|
||||
* (publish flow) needs it to read the app's DOM, which is only possible on
|
||||
* the unsandboxed path. */
|
||||
oniframe?: (iframe: HTMLIFrameElement | undefined) => void
|
||||
/** Mirror the app's own route into the page URL's hash, and start from it. Off where
|
||||
* the app is embedded in a page that is not its own — an AI session preview tab —
|
||||
* since there the hash belongs to that page and to whichever app was opened first. */
|
||||
/** Mirror the app's own route into the page URL's hash, and start from it, and its
|
||||
* `document.title` into the page's. Off where the app is embedded in a page that is
|
||||
* not its own — an AI session preview tab — since there the hash and title belong to
|
||||
* that page and to whichever app was opened first. */
|
||||
syncHashToUrl?: boolean
|
||||
}
|
||||
|
||||
@@ -241,6 +242,19 @@
|
||||
}
|
||||
})
|
||||
|
||||
// The page's own title, put back when the app clears its title or unmounts. Only
|
||||
// while the tab still shows the app's: anything else is the page's newer title.
|
||||
let pageTitle = ''
|
||||
let appTitle: string | undefined = undefined
|
||||
function setAppTitle(title: string) {
|
||||
if (document.title !== appTitle) pageTitle = document.title
|
||||
appTitle = title.trim().slice(0, 200) || pageTitle
|
||||
document.title = appTitle
|
||||
}
|
||||
onMount(() => () => {
|
||||
if (appTitle !== undefined && document.title === appTitle) document.title = pageTitle
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
const data = event.data
|
||||
@@ -287,6 +301,8 @@
|
||||
if (syncHashToUrl && window.location.hash !== newHash) {
|
||||
history.replaceState(null, '', newHash || window.location.pathname)
|
||||
}
|
||||
} else if (data?.type === 'windmill:title') {
|
||||
if (syncHashToUrl) setAppTitle(typeof data.title === 'string' ? data.title : '')
|
||||
} else if (runnerFailed) {
|
||||
refuseRequest(event)
|
||||
} else if (!runner) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { AppWindow, UserRound } from 'lucide-svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { autoPlacement } from '@floating-ui/core'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
viewer_data_access: boolean | undefined
|
||||
}
|
||||
|
||||
let { viewer_data_access = $bindable() }: Props = $props()
|
||||
|
||||
// It only changes what data table roles and object storage permission rules decide, both
|
||||
// Enterprise Edition.
|
||||
const unavailable = $derived(
|
||||
!$enterpriseLicense
|
||||
? 'Data table roles and object storage permission rules are Enterprise Edition features.'
|
||||
: undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
floatingConfig={{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'bottom-end', 'top-start', 'top-end', 'top', 'bottom']
|
||||
})
|
||||
]
|
||||
}}
|
||||
closeButton
|
||||
contentClasses="block text-primary p-4 max-w-sm"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
nonCaptureEvent={true}
|
||||
variant={viewer_data_access ? 'accent-secondary' : 'default'}
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: viewer_data_access ? UserRound : AppWindow }}
|
||||
iconOnly
|
||||
title={(viewer_data_access
|
||||
? 'Reads data tables and object storage as the viewer'
|
||||
: 'Reads data tables and object storage as the app') +
|
||||
(unavailable ? ' (Enterprise Edition)' : '')}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<Toggle
|
||||
checked={viewer_data_access ?? false}
|
||||
disabled={unavailable !== undefined && !viewer_data_access}
|
||||
eeOnly
|
||||
on:change={() => {
|
||||
viewer_data_access = viewer_data_access ? undefined : true
|
||||
}}
|
||||
options={{
|
||||
right: 'Read data as the viewer',
|
||||
rightTooltip:
|
||||
'Data table roles map who may use a data table to Postgres roles, so grants and row-level security decide what a connection sees. Object storage permission rules decide which paths an identity may list, read and write.',
|
||||
rightDocumentationLink:
|
||||
'https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables'
|
||||
}}
|
||||
/>
|
||||
<div class="mt-3 flex flex-col gap-2 text-xs text-secondary">
|
||||
{#if unavailable}
|
||||
<p class="text-primary">{unavailable}</p>
|
||||
{/if}
|
||||
<p>
|
||||
By default this runnable reads as the identity the app runs as, the publisher in publisher
|
||||
mode, for every viewer: a data table connection that names no role holds that identity's
|
||||
roles, and object storage permission rules are checked against it.
|
||||
</p>
|
||||
<p>
|
||||
Turn this on to read as the person viewing the app instead, so row-level security and
|
||||
per-user storage paths answer per viewer. Anonymous viewers are refused. Resources,
|
||||
variables and everything else stay the app's.
|
||||
</p>
|
||||
<p>
|
||||
A data table connection that names a role is that role alone either way, and one whose
|
||||
default role is admin stays admin, which bypasses row-level security.
|
||||
{#if isCloudHosted()}Data table roles are only available on self-hosted instances.{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -29,14 +29,15 @@ export async function updateRawAppPolicy(
|
||||
type RunnableWithInlineScript = RunnableWithFields & {
|
||||
inlineScript?: InlineScript & { language: ScriptLang }
|
||||
delete_after_secs?: number
|
||||
viewer_data_access?: boolean
|
||||
}
|
||||
export type Runnable = RunnableWithInlineScript | undefined
|
||||
|
||||
function extraFields(
|
||||
runnable: RunnableWithInlineScript,
|
||||
fields: Record<string, any>
|
||||
): Partial<Pick<TriggerableV2, 'delete_after_secs' | 'sensitive_inputs' | 'tag'>> {
|
||||
const out: Partial<Pick<TriggerableV2, 'delete_after_secs' | 'sensitive_inputs' | 'tag'>> = {}
|
||||
): Partial<TriggerableV2> {
|
||||
const out: Partial<TriggerableV2> = {}
|
||||
if (typeof runnable.delete_after_secs === 'number' && runnable.delete_after_secs >= 0)
|
||||
out.delete_after_secs = runnable.delete_after_secs
|
||||
const sensitive_inputs = Object.entries(fields)
|
||||
@@ -44,6 +45,9 @@ function extraFields(
|
||||
.filter(Boolean) as string[]
|
||||
if (sensitive_inputs.length > 0) out.sensitive_inputs = sensitive_inputs
|
||||
if (runnable.inlineScript?.tag) out.tag = runnable.inlineScript.tag
|
||||
// The server honors it on inline runnables only, whose code is deployed with the app.
|
||||
if (runnable.viewer_data_access === true && isRunnableByName(runnable))
|
||||
out.viewer_data_access = true
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user