Compare commits

..
Author SHA1 Message Date
hugocasa 245bbd5d27 feat: dispatch ai agent tools through the worker queue 2026-09-18 17:36:21 +02:00
Ruben Fiszelandrubenfiszel 01b38320ea chore(main): release 1.815.0 (#11212)
* chore(main): release 1.815.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-09-18 15:28:25 +02:00
hugocasaandClaude Opus 5 37e493ae66 feat: add an instance setting to refuse a token in MCP URLs (#11162)
* feat: add an instance setting to refuse a token in MCP URLs

MCP clients are commonly configured with the token in the URL
(`/api/mcp/w/{workspace}/mcp?token=...`). A URL-borne credential ends up in
browser history, proxy logs and referrers, so an instance can now turn that
channel off with the `mcp_disable_token_query_param` global setting and leave
the Authorization header as the only way in, which sends MCP clients through
the OAuth flow the endpoints already advertise.

The rejection is a middleware on both the workspaced and the gateway MCP
mounts, layered outside everything that reads a token and inside the
WWW-Authenticate layer, so the 401 carries the resource pointer a client needs
to start OAuth discovery.

Off by default. With it on, the token drawer and the home connect drawer stop
offering to mint a token for an MCP URL and hand over the bare URL instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read the MCP URL policy when a URL is asked for, and drop the all-workspaces option

Two review findings on the token drawer:

The policy was read once per page load and cached for the browser session, so a
superadmin turning the setting on left every open tab handing out `?token=` URLs
the server now refuses. Both entry points now read it when the user actually asks
for an MCP URL: when MCP mode is entered, and when the connect drawer opens.

The workspace picker offered "All workspaces / Multi-workspace", but the gateway's
consent screen binds the token it issues to the one workspace picked there, so
OAuth has no multi-workspace grant to hand out. That entry is now token-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: don't guess the MCP URL policy, and say where the switch lands on restart

Review findings:

The comment on the settings load claimed `MODE=mcp` as the target deployment, but
that mode joins no monitor loop, so the startup pass is its only read and a change
lands on restart. That is true of every global setting there, `base_url` included;
the comment now says so, and the setting description tells an operator running
dedicated MCP servers what to expect.

A failed settings probe resolved to "tokens allowed", so with the switch on the
drawer would mint a non-expiring token and hand over a URL the server refuses for
as long as it exists. The probe now propagates its error and the panel reports it
with a retry, creating nothing until the answer is known.

The test passed a valid token, so it could not tell a rejection before
authentication from one after it. It now also sends a token that was never valid
and asserts the middleware's own message, which fails if the layer moves inward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: withhold the MCP URL until a workspace is picked

With no persisted workspace the store starts undefined, so opening the drawer from
/user/workspaces before choosing one rendered a copyable
`/api/mcp/w/undefined/mcp`. It reads like a real URL and a client pointed at it
would never connect. The panel now asks for a workspace instead, matching the guard
the token branch already has on its generate button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: drop the coverage-status note from the MCP switch test

It documented what the test does not reach rather than a constraint the next
reader could break; that belongs in the PR, not the module doc. The layer-order
rationale, which is what a future edit would break, stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: fall back to the bare MCP URL instead of alerting on a failed read

When the setting read fails, show the bare URL rather than an error with a retry.
It works whichever way the setting is, so no alert is needed, and it still never
mints a token for a URL the server may refuse. The connect drawer's wording falls
back the same way so the blurb matches the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: move the MCP URL token setting to Core

It sat in the Auth/OAuth/SAML list, which the settings sidebar shows under SSO,
suggesting a dependency on SSO that does not exist: MCP OAuth has Windmill act as
the authorization server, and any login method, password included, completes it.
It is an instance-wide credential policy, so it now lives with the other ones in
Core, kept out of quick setup like its neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:24:00 +02:00
f0d66a42eb fix: re-encrypt git sync secrets on workspace key rotation (#11218)
* fix: re-encrypt git sync credentials and webhook secrets on workspace key rotation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: point ee-repo-ref at the git sync key rotation companion

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: update ee-repo-ref to bc3ef08c8e4233508c023e6ee847a3cd0b8be43b

This commit updates the EE repository reference after PR #814 was merged in windmill-ee-private.

Previous ee-repo-ref: 8121eac421c5d026f36e2edec140f3c79b23d2cd

New ee-repo-ref: bc3ef08c8e4233508c023e6ee847a3cd0b8be43b

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-18 15:21:07 +02:00
hugocasaandClaude Opus 5 9d335de87a feat: add a workspace toggle that adds its admins and developers to new forks (#11215)
* feat: add a workspace toggle that adds its admins and developers to new forks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: add members copied into a fork as manual members, not instance-group ones

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: keep the fork members copy comment at the query and drop the raw spacer

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:15:35 +02:00
hugocasaandClaude Opus 5 53afecd458 fix: register the job token with the sensitive log masking system (#10943)
* fix(worker): register the job token with the log masking system

The masking system covered secrets fetched through `get_value_internal` and
`$encrypted:` args, but not the job's own token, so a script that echoed
`$WM_TOKEN` wrote it verbatim into logs that are persisted to the database
and, when configured, to object storage.

`run_worker` now registers the token for the job it just pulled, alongside
the existing `register_running_job` call, so it is redacted like any other
registered secret.

That makes every job carry at least one registered value, where before the
per-batch mask snapshot was skipped entirely for the majority of jobs that
touched no secret. Cache the compiled Aho-Corasick automaton per job and
invalidate it when a new secret is registered, so a chatty job no longer
rebuilds it once per log batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(nativets): mask secrets in the in-process log path

NativeTS hands `console.log` output to a task that drains a channel into
`append_logs`, so it never reaches the masking in `handle_child::write_lines`
and a script logging `$WM_TOKEN` persisted the raw JWT.

That drain can still be flushing after the job is unregistered, so a plain
per-line `snapshot` would leave the tail unmasked. `JobMasker` keeps the last
masks it saw for exactly that window, and refreshes while the job is alive so
secrets fetched mid-run are covered too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(nativets): seed the job masker at construction

A `JobMasker` that only looked up its masks on the first `mask` call had the
same hole at the head of the log that its retention closes at the tail: if the
drain task's first productive poll landed after the job was unregistered, the
registry was already gone and every line was written raw.

`new` now takes the snapshot, and its callers construct it from the job's own
execution while the job is still registered.

Also cover the nativets sink with an integration test, gated on `deno_core` the
way the CI test build is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(nativets): mask on the producing side of the log channel

Masking as the drain task wrote to `append_logs` left two holes, because that
task is detached and outlives the job: a secret registered mid-run could still
be queued when the job was unregistered and would then be written raw, and the
`windmill:job_log` tracing emission that EE forwards job logs on never went
through the mask at all.

Mask where the line is produced instead. That loop is joined before the job
completes, so the job's secrets are always still registered, and one call now
covers both the tracing mirror and the channel. The result stream keeps reading
the raw text, the way `handle_child` keeps its raw `line` for results.

`JobMasker` is no longer load-bearing for the post-unregistration window, so it
is documented for what it now does: keep the security notice to once per set of
secrets for a sink that masks line by line.

Also drop the nativets test's tag override — `DEFAULT_TAGS` does advertise
`nativets`, so the comment justifying it was wrong — and pin the automaton
cache invalidation, whose failure mode is an unmasked secret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(worker): hold the log-masking lifecycle at the job boundary

Registering the job around the poller's call left every other way of running a
job uncovered: the interactive worker shell and inline AI agent tools both call
`handle_queued_job` directly, and a script logging `$WM_TOKEN` from either
persisted the live credential. Register from inside `handle_queued_job` instead,
under a drop guard, so each path is covered by construction rather than by
remembering to add a call. Nothing is lost by unregistering earlier: the writes
that follow go through `append_logs`, which never consulted the registry.

In nativets, decide the stream/log routing before masking. `MaskSnapshot`'s
notice is one-shot, so a secret-bearing `WM_STREAM:` chunk used to spend it on
text that is then discarded, leaving later redactions in `job_logs` unexplained.

Restore the masker's post-unregistration test: the memory-limit path never joins
the producing loop, so that fallback is still load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* docs: correct the nativets masking comments

The producer loop is not joined on the memory-limit path, so it does not
"always" run while the job is registered — say normally, which is what
`JobMasker`'s fallback is there for. Name the reason a stream chunk stays raw
everywhere it goes, including the tracing mirror: it is result data that no log
sink persists, so masking it would be masking a result. State the masker test's
invariant without asserting a mechanism behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(masking): keep the security notice on a line of its own

`mask` appended the notice as a newline plus the notice text, which
assumes the caller hands it a bare log line. nativets hands it a chunk
that already ends in a newline, and its sink concatenates chunks
verbatim, so the notice arrived after a blank line and the next log line
was welded onto the end of it.

Emit the notice as its own line for either shape. `handle_child` is
unaffected: its input never ends in a newline, so it keeps the original
path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:15:02 +02:00
321 changed files with 3873 additions and 4108 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.814.0"
".": "1.815.0"
}
+34
View File
@@ -1,5 +1,39 @@
# Changelog
## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18)
### Features
* add a workspace toggle that adds its admins and developers to new forks ([#11215](https://github.com/windmill-labs/windmill/issues/11215)) ([9d335de](https://github.com/windmill-labs/windmill/commit/9d335de87a4dbaa51038d55afe8d980761dcdfaf))
* add an instance setting to refuse a token in MCP URLs ([#11162](https://github.com/windmill-labs/windmill/issues/11162)) ([37e493a](https://github.com/windmill-labs/windmill/commit/37e493ae66ed5c000ecac492d60fc0fdf4bda71f))
* **ai-chat:** render get_run through the run tools' card ([#11204](https://github.com/windmill-labs/windmill/issues/11204)) ([6f9c4dc](https://github.com/windmill-labs/windmill/commit/6f9c4dc29455d13b0e64af05c2d6aa8bd5ff4fd6))
* **ai-chat:** tell the chat which kind of app it is looking at ([#11208](https://github.com/windmill-labs/windmill/issues/11208)) ([c8c06d8](https://github.com/windmill-labs/windmill/commit/c8c06d8f79774abf109192e71a8b6fc37c7937ba))
* attach files to a flow chat message ([#11185](https://github.com/windmill-labs/windmill/issues/11185)) ([c4c9677](https://github.com/windmill-labs/windmill/commit/c4c9677982b75c63d98ebf85b1904e0c341ba957))
* **auth:** 2 h login links and a click-to-sign-in page for emailed ones ([#11203](https://github.com/windmill-labs/windmill/issues/11203)) ([5639187](https://github.com/windmill-labs/windmill/commit/5639187fec6d517a72e82df49d63d7438301127c))
* cap user token expiration with an instance setting ([#11159](https://github.com/windmill-labs/windmill/issues/11159)) ([9320312](https://github.com/windmill-labs/windmill/commit/9320312eac56f944c4d31504601293ab4e816ccc))
* flow chat job-backed detail, smooth streaming and answer chrome ([#11186](https://github.com/windmill-labs/windmill/issues/11186)) ([e2a91ca](https://github.com/windmill-labs/windmill/commit/e2a91ca2b141a0bee64ddfafe3cbf3ff86b6fb35))
* put a data table's connection under Postgres roles ([#11020](https://github.com/windmill-labs/windmill/issues/11020)) ([0e807fb](https://github.com/windmill-labs/windmill/commit/0e807fb1dd80d7536ec144cd49445abc7961e504))
* render an AI agent result as its answer, not as raw JSON ([#11051](https://github.com/windmill-labs/windmill/issues/11051)) ([a089928](https://github.com/windmill-labs/windmill/commit/a08992834d45d0211336f4fc32c3421646ca47c5))
* support $flow_expr[...] dynamic tags on flow steps ([#11170](https://github.com/windmill-labs/windmill/issues/11170)) ([48f0025](https://github.com/windmill-labs/windmill/commit/48f00259c5e7361d3553dbcb809461e1cde96f8f))
### Bug Fixes
* **frontend:** inline only the package version, not the whole package.json ([#11191](https://github.com/windmill-labs/windmill/issues/11191)) ([813e486](https://github.com/windmill-labs/windmill/commit/813e486e166ac6215364817a7732b66f8dc1d463))
* keep instance groups when editing auto-invite ([#11217](https://github.com/windmill-labs/windmill/issues/11217)) ([df61dea](https://github.com/windmill-labs/windmill/commit/df61dea5fa8b18d1e0044dc0db6702b053d5119f))
* key the large root font size on screen width, not window width ([#11216](https://github.com/windmill-labs/windmill/issues/11216)) ([3b4e13d](https://github.com/windmill-labs/windmill/commit/3b4e13d1c564c6195e30b55b0671f7533e3ce408))
* re-encrypt git sync secrets on workspace key rotation ([#11218](https://github.com/windmill-labs/windmill/issues/11218)) ([f0d66a4](https://github.com/windmill-labs/windmill/commit/f0d66a42eba27502767423af0c08b2ca1f16e8df))
* re-point cloned fork identities that name nobody in the fork ([#11161](https://github.com/windmill-labs/windmill/issues/11161)) ([9690c44](https://github.com/windmill-labs/windmill/commit/9690c4462cf264a5577b87d07d465b5442b4e09d))
* register the job token with the sensitive log masking system ([#10943](https://github.com/windmill-labs/windmill/issues/10943)) ([53afecd](https://github.com/windmill-labs/windmill/commit/53afecd4588247bc1812d3e68a30db1f3c3b2724))
* show the New menu's description panel only on hover ([#11199](https://github.com/windmill-labs/windmill/issues/11199)) ([ecd0a6c](https://github.com/windmill-labs/windmill/commit/ecd0a6c77bc3a057b8072dbb0aca731e8bd3d882))
* stop picker listing a draft twice when drafts share a friendly path ([#11214](https://github.com/windmill-labs/windmill/issues/11214)) ([72507d5](https://github.com/windmill-labs/windmill/commit/72507d52a021de8101794ee8f4a3392be1ba20c7))
### Performance Improvements
* count completed jobs on the v2_job_completed index with a timeout ([#11211](https://github.com/windmill-labs/windmill/issues/11211)) ([d1a2536](https://github.com/windmill-labs/windmill/commit/d1a25360b070994a31fb033707dbfde1fb553939))
## [1.814.0](https://github.com/windmill-labs/windmill/compare/v1.813.0...v1.814.0) (2026-09-17)
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE descendants AS (\n SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id\n WHERE j.workspace_id = $2\n ) SELECT d.id AS \"id!\" FROM descendants d JOIN v2_job_queue q ON q.id = d.id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "0c2bf6925de6dd4d9f8d47d7fdd9e7801129570106b5acd49c0f708398606771"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "started_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "451f303f4a24d848ba4ccdc2441ca7cb85bdfc66110b6534260f8642f91f006a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations,\n add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a"
"hash": "5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, status = 'success' AS \"success!\", result AS \"result: Json<Box<RawValue>>\"\n FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "success!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "result: Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": [
false,
null,
true
]
},
"hash": "789c3f6d29f46fcb17a22fe97405a1754ccb8ddeb08c23795dfe14e9f332e9dc"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -177,6 +177,11 @@
"ordinal": 34,
"name": "guest_jwt_jwks_url",
"type_info": "Text"
},
{
"ordinal": 35,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
}
],
"parameters": {
@@ -219,8 +224,9 @@
false,
false,
true,
true
true,
false
]
},
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
"hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET worker = $1 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Uuid"
]
},
"nullable": []
},
"hash": "e459c277c0bc27293d71972d12d26d32b476a43ed391c09d7678215195c4283a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -45,16 +45,21 @@
},
{
"ordinal": 8,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"ordinal": 10,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"ordinal": 11,
"name": "datatable",
"type_info": "Jsonb"
}
@@ -73,10 +78,11 @@
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
"hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30"
}
+85 -85
View File
@@ -2311,9 +2311,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.6"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -5055,9 +5055,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "fixedbitset"
@@ -5497,9 +5497,9 @@ dependencies = [
[[package]]
name = "generator"
version = "0.8.9"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
dependencies = [
"cc",
"cfg-if",
@@ -14793,7 +14793,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14881,7 +14881,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14915,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14928,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"argon2",
@@ -15068,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15091,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15108,7 +15108,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15144,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15161,7 +15161,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15183,7 +15183,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15206,7 +15206,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15222,7 +15222,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15244,7 +15244,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15266,7 +15266,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15280,7 +15280,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15315,7 +15315,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15340,7 +15340,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15368,7 +15368,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15390,7 +15390,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15410,7 +15410,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15448,7 +15448,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15477,7 +15477,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"lazy_static",
"serde",
@@ -15489,7 +15489,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15513,7 +15513,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15527,7 +15527,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15562,7 +15562,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15576,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15595,7 +15595,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15702,7 +15702,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"chrono",
"futures",
@@ -15722,7 +15722,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"regex",
"serde",
@@ -15739,7 +15739,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15766,7 +15766,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"futures",
@@ -15783,7 +15783,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15799,7 +15799,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15820,7 +15820,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15851,7 +15851,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15876,7 +15876,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15911,7 +15911,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"futures",
@@ -15929,7 +15929,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15938,7 +15938,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15950,7 +15950,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15962,7 +15962,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15974,7 +15974,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15986,7 +15986,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15998,7 +15998,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16009,7 +16009,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16020,7 +16020,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16032,7 +16032,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16043,7 +16043,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16065,7 +16065,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16077,7 +16077,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16108,7 +16108,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16121,7 +16121,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde",
@@ -16133,7 +16133,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16151,7 +16151,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16167,7 +16167,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16183,7 +16183,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16197,7 +16197,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16236,7 +16236,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"const_format",
@@ -16276,7 +16276,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16287,7 +16287,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16322,7 +16322,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16346,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16379,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16406,7 +16406,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16439,7 +16439,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16459,7 +16459,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16493,7 +16493,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16529,7 +16529,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16552,7 +16552,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16576,7 +16576,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16600,7 +16600,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16635,7 +16635,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16663,7 +16663,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16688,7 +16688,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16707,7 +16707,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16825,7 +16825,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.814.0"
version = "1.815.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.814.0"
version = "1.815.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
f2fced19fcae81de7f6dac545010ce404c052e1b
bc3ef08c8e4233508c023e6ee847a3cd0b8be43b
@@ -0,0 +1 @@
ALTER TABLE workspace_settings DROP COLUMN add_admins_and_developers_to_forks;
@@ -0,0 +1 @@
ALTER TABLE workspace_settings ADD COLUMN add_admins_and_developers_to_forks BOOLEAN NOT NULL DEFAULT false;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.814.0"
version = "1.815.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+7 -2
View File
@@ -52,7 +52,8 @@ use windmill_common::{
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
NO_DEFAULT_MAVEN_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
@@ -126,7 +127,8 @@ use windmill_worker::{
use crate::monitor::{
initial_load, load_concurrency_key_max_queued, load_disable_password_login,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
load_mcp_disable_token_query_param, load_metrics_debug_enabled,
load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides,
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces,
load_workspace_fairness_duration_secs, load_workspace_fairness_enabled,
@@ -2164,6 +2166,9 @@ async fn process_notify_event(
DISABLE_PASSWORD_LOGIN_SETTING => {
load_disable_password_login(db).await;
}
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => {
load_mcp_disable_token_query_param(db).await;
}
EXPOSE_METRICS_SETTING => {
tracing::info!("Metrics setting changed, restarting");
spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
+27
View File
@@ -65,6 +65,7 @@ use windmill_common::{
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING,
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING,
@@ -288,6 +289,15 @@ pub async fn initial_load(
);
if let Some(db) = conn.as_sql() {
// Outside the `server_mode` block below: a `MODE=mcp` process serves the MCP routes
// with `server_mode` false and would otherwise never read this at all. That mode
// joins no monitor loop, so there — as for every global setting, `base_url`
// included — this pass is the only read, and a change lands on restart.
pass.setting(
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
false,
|v| async move { apply_mcp_disable_token_query_param(v) },
);
pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move {
apply_tag_per_workspace_enabled(v)
});
@@ -1617,6 +1627,23 @@ pub fn apply_disable_password_login(value: Option<serde_json::Value>) {
};
}
pub async fn load_mcp_disable_token_query_param(db: &DB) {
match load_value_from_global_settings(db, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING).await {
Ok(v) => apply_mcp_disable_token_query_param(v),
Err(e) => tracing::error!("Error loading mcp_disable_token_query_param setting: {e:#}"),
};
}
pub fn apply_mcp_disable_token_query_param(value: Option<serde_json::Value>) {
match value {
Some(serde_json::Value::Bool(t)) => {
MCP_DISABLE_TOKEN_QUERY_PARAM.store(t, Ordering::Relaxed)
}
None => MCP_DISABLE_TOKEN_QUERY_PARAM.store(false, Ordering::Relaxed),
_ => (),
};
}
struct LogFile {
file_path: String,
hostname: String,
+1 -1
View File
@@ -234,7 +234,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
FK: (workspace_id) -> workspace(id)
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool)
FK: (workspace_id) -> workspace(id)
zombie_job_counter: job_id(uuid), counter(int)
FK: (job_id) -> v2_job(id)
+181
View File
@@ -0,0 +1,181 @@
use axum::{
extract::State,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use std::{sync::Arc, time::Duration};
use tokio::sync::Notify;
use windmill_common::{flows::FlowValue, jobs::JobPayload};
use windmill_test_utils::{
completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind,
};
async fn model(Json(body): Json<Value>) -> ([(&'static str, &'static str); 1], String) {
let finished = body["messages"]
.as_array()
.unwrap()
.iter()
.any(|m| m["role"] == "tool");
let delta = if finished {
json!({"role":"assistant", "content":"done"})
} else {
json!({"role":"assistant", "tool_calls": (0..3).map(|i| json!({
"index":i, "id":format!("call_{i}"), "type":"function",
"function":{"name":format!("tool_{i}"), "arguments":"{}"}
})).collect::<Vec<_>>()})
};
let event = json!({"choices":[{"index":0, "delta":delta, "finish_reason":null}]});
let end = json!({"choices":[{"index":0, "delta":{}, "finish_reason":if finished {"stop"} else {"tool_calls"}}]});
(
[("content-type", "text/event-stream")],
format!("data: {event}\n\ndata: {end}\n\ndata: [DONE]\n\n"),
)
}
async fn run_batch(db: Pool<Postgres>, parallel: bool, limited: bool) -> anyhow::Result<()> {
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
let server = ApiServer::start(db.clone()).await?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let base = format!("http://{}", listener.local_addr()?);
let gate = Arc::new(Notify::new());
let router = Router::new()
.route("/v1/chat/completions", post(model))
.route(
"/first",
get(|State(gate): State<Arc<Notify>>| async move {
gate.notified().await;
"ok"
}),
)
.route(
"/second",
get(|State(gate): State<Arc<Notify>>| async move {
gate.notify_one();
"ok"
}),
)
.with_state(gate);
let stub = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let tools: Vec<Value> = (0..3).map(|i| {
let wait = if parallel && !limited && i < 2 {
format!("await fetch('{base}/{}');", if i == 0 {"first"} else {"second"})
} else { String::new() };
let finish = if i == 2 { "throw new Error('expected tool failure');".to_string() }
else { format!("return {i};") };
json!({"id":format!("t{i}"),"summary":format!("tool_{i}"),"value":{
"type":"rawscript", "language":"bun", "input_transforms":{},
"tag": if parallel { "bun" } else { "unserved-tool-tag" },
"concurrent_limit": if limited { Some(1) } else { None },
"custom_concurrency_key": if limited { Some("agent-tool-test") } else { None },
"content":format!("export async function main() {{ {wait} await Bun.sleep({}); {finish} }}", if i == 0 {200} else {0})
}})
}).collect();
let flow: FlowValue = serde_json::from_value(json!({"modules":[{"id":"agent","value":{
"type":"aiagent", "tools":tools, "input_transforms":{
"provider":{"type":"static","value":{"kind":"customai","model":"queue-test","resource":{"base_url":format!("{base}/v1")}}},
"user_message":{"type":"static","value":"run the tools"},
"max_iterations":{"type":"static","value":3}
}
}}]}))?;
let id = RunJob::from(JobPayload::RawFlow {
value: flow,
path: Some("u/test/agent_queue".into()),
restarted_from: None,
})
.push(&db)
.await;
let notifications = listen_for_completed_jobs(&db).await;
let wait = async {
if parallel {
in_test_worker(&db, notifications.find(&id), server.addr.port()).await;
} else {
notifications.find(&id).await;
}
};
tokio::time::timeout(
Duration::from_secs(45),
in_test_worker(&db, wait, server.addr.port()),
)
.await?;
let result = completed_job(id, &db).await;
assert!(result.success, "{:?}", result.result);
let result = result.json_result().expect("agent result");
let messages: Vec<_> = result["messages"]
.as_array()
.unwrap()
.iter()
.filter(|m| m["role"] == "tool")
.collect();
assert_eq!(messages.len(), 3);
for (index, message) in messages.iter().enumerate() {
assert_eq!(message["tool_call_id"], format!("call_{index}"));
}
assert_eq!(messages[0]["content"], "0");
assert_eq!(messages[1]["content"], "1");
assert!(messages[2]["content"]
.as_str()
.unwrap()
.contains("expected tool failure"));
let (parent_id, parent_worker): (uuid::Uuid, String) = sqlx::query_as(
"SELECT j.id, c.worker FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1"
).bind(id).fetch_one(&db).await?;
let children: Vec<(String, String, bool)> = sqlx::query_as(
"SELECT j.runnable_path, c.worker, c.status = 'success' FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1 ORDER BY j.runnable_path"
).bind(parent_id).fetch_all(&db).await?;
assert_eq!(children.len(), 3);
assert_eq!(
children[0].1, parent_worker,
"first tool must stay on the parent worker"
);
assert_eq!(
children.iter().map(|c| c.2).collect::<Vec<_>>(),
vec![true, true, false]
);
if limited {
let overlapping: i64 = sqlx::query_scalar(
"SELECT count(*) FROM v2_job j1 JOIN v2_job_completed c1 ON c1.id = j1.id
JOIN v2_job j2 ON j2.parent_job = j1.parent_job AND j2.id > j1.id
JOIN v2_job_completed c2 ON c2.id = j2.id
WHERE j1.parent_job = $1 AND c1.started_at < c2.completed_at AND c2.started_at < c1.completed_at"
).bind(parent_id).fetch_one(&db).await?;
assert_eq!(
overlapping, 0,
"the reserved first job must count toward the shared limit"
);
} else if parallel {
assert_ne!(
children[1].1, parent_worker,
"second tool must unblock the first from another worker"
);
} else {
assert!(children.iter().all(|child| child.1 == parent_worker));
}
stub.abort();
server.close().await?;
Ok(())
}
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn parent_drains_tools_without_another_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
run_batch(db, false, false).await
}
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn parent_keeps_first_tool_while_another_worker_runs_siblings(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
run_batch(db, true, false).await
}
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn reserved_tool_obeys_shared_concurrency_limit(db: Pool<Postgres>) -> anyhow::Result<()> {
run_batch(db, true, true).await
}
+108
View File
@@ -0,0 +1,108 @@
/*
* The job's own token (`$WM_TOKEN`) stays valid well past the job it was minted
* for, and job logs are persisted to `job_logs` and optionally to object storage,
* so a script that echoes the token would otherwise park a live credential in
* durable storage. `run_worker` registers the token with `sensitive_log_masks`
* for the job it pulled; this pins that the persisted log carries the masked form.
*/
use sqlx::{Pool, Postgres};
use windmill_common::{
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
};
use windmill_test_utils::*;
/// Prefix of a serialized job token: `jwt_` plus the base64 of a JWT header.
/// The masked form keeps only `jwt` + the last three characters, so it never matches.
const RAW_TOKEN_PREFIX: &str = "jwt_ey";
#[sqlx::test(fixtures("base"))]
async fn test_job_token_masked_in_persisted_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "echo \"running with --token $WM_TOKEN\"".to_string(),
path: None,
lock: None,
language: ScriptLang::Bash,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.run_until_complete(&db, false, port)
.await;
assert!(job.success, "job should have succeeded");
let logs =
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(job.id)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!logs.contains(RAW_TOKEN_PREFIX),
"an unmasked job token reached the persisted logs: {logs}"
);
assert!(
logs.contains("secret value was masked"),
"expected the masking notice in logs: {logs}"
);
Ok(())
}
/// nativets runs V8 in-process and persists `console.log` output through its own
/// channel, so it is masked by a different mechanism than the bash case above and
/// needs its own guard.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_job_token_masked_in_nativets_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "export async function main() {\n console.log('running with --token ' + process.env.WM_TOKEN);\n return 'ok';\n}".to_string(),
path: None,
lock: None,
language: ScriptLang::Nativets,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.run_until_complete(&db, false, port)
.await;
assert!(job.success, "job should have succeeded");
let logs =
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(job.id)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!logs.contains(RAW_TOKEN_PREFIX),
"an unmasked job token reached the persisted logs: {logs}"
);
assert!(
logs.contains("secret value was masked"),
"expected the masking notice in logs: {logs}"
);
Ok(())
}
@@ -0,0 +1,75 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// With `add_admins_and_developers_to_forks` on, a fork starts with the parent's admins and
/// developers at their parent role, even when a developer forks it; operators are left out. The
/// copies are manual members: a parent membership that came from an instance group must not carry
/// that provenance into a fork that does not configure the group.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_adds_parent_admins_and_developers(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!(
"http://localhost:{}/api/w/test-workspace/workspaces",
server.addr.port()
);
let client = reqwest::Client::new();
sqlx::query(
"UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, added_via)
VALUES ('test-workspace', 'test4@windmill.dev', 'test-user-4', false,
'{\"source\": \"instance_group\", \"group\": \"devs\"}')",
)
.execute(&db)
.await?;
let resp = client
.post(format!(
"{base_url}/edit_add_admins_and_developers_to_forks"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "add_admins_and_developers_to_forks": true }))
.send()
.await?;
assert!(
resp.status().is_success(),
"enabling the setting: {}",
resp.text().await?
);
let resp = client
.post(format!("{base_url}/create_fork"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({ "id": "wm-fork-team", "name": "Team fork" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"creating the fork: {}",
resp.text().await?
);
let members: Vec<(String, bool, bool)> = sqlx::query_as(
"SELECT username, is_admin, added_via IS NULL FROM usr
WHERE workspace_id = 'wm-fork-team' ORDER BY username",
)
.fetch_all(&db)
.await?;
assert_eq!(
members,
vec![
("test-user".to_string(), true, true),
("test-user-2".to_string(), false, true),
("test-user-4".to_string(), false, true),
]
);
Ok(())
}
@@ -0,0 +1,102 @@
//! The `mcp_disable_token_query_param` switch closes the URL-borne credential path.
//!
//! The rejection is a middleware layered between the `WWW-Authenticate` decorator and
//! everything that reads a token, on both the workspaced and the gateway mount. Each half of
//! that sandwich is pinned: the `WWW-Authenticate` header on the refusal catches the layer
//! being moved outward (a client would lose the pointer that starts OAuth discovery), and
//! refusing a token that was never valid catches it being moved inward past authentication
//! (the URL-borne token would be hashed and looked up before anything refused it).
#![cfg(feature = "mcp")]
use std::sync::atomic::Ordering;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM;
use windmill_test_utils::*;
/// Workspace-less with an `mcp:` scope, which is what the gateway mount requires; the
/// workspaced mount takes its workspace from the path, so one token reaches both.
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
)
.execute(db)
.await?;
Ok(())
}
/// A token that is not in `token` at all. Authentication would refuse it on its own, so a
/// refusal carrying the middleware's own wording is evidence nothing looked it up first.
const BOGUS_TOKEN: &str = "NOT_A_REAL_TOKEN";
async fn tools_list(url: &str) -> anyhow::Result<reqwest::Response> {
Ok(reqwest::Client::new()
.post(url)
.header("Accept", "application/json, text/event-stream")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
.send()
.await?)
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mcp_token_query_param_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
insert_mcp_token(&db).await?;
let server = ApiServer::start_mcp(db.clone()).await?;
let port = server.addr.port();
let workspaced =
format!("http://localhost:{port}/api/mcp/w/test-workspace/mcp?token=MCP_TOKEN");
let gateway = format!("http://localhost:{port}/api/mcp/gateway?token=MCP_TOKEN");
assert_eq!(
tools_list(&workspaced).await?.status(),
200,
"a URL-borne token is the documented default and must keep working while the switch is off"
);
assert_eq!(tools_list(&gateway).await?.status(), 200);
MCP_DISABLE_TOKEN_QUERY_PARAM.store(true, Ordering::Relaxed);
for url in [&workspaced, &gateway] {
let resp = tools_list(url).await?;
assert_eq!(
resp.status(),
401,
"{url} still admitted a token in the URL"
);
// What sends the client into the OAuth flow rather than leaving it stuck on a 401.
assert!(
resp.headers().contains_key("www-authenticate"),
"{url} rejected without pointing at the authorization server"
);
}
// Refused before authentication, not after: an invalid token gets the middleware's own
// message rather than the generic 401 that looking it up would produce.
let resp = tools_list(&format!(
"http://localhost:{port}/api/mcp/w/test-workspace/mcp?token={BOGUS_TOKEN}"
))
.await?;
assert_eq!(resp.status(), 401);
assert!(
resp.text().await?.contains("does not accept a token in the MCP URL"),
"an invalid URL token was answered by authentication, so the token was read before \
the switch refused it"
);
// The header stays open: it is the channel the OAuth flow itself hands tokens over on.
let resp = reqwest::Client::new()
.post(format!("http://localhost:{port}/api/mcp/gateway"))
.header("Accept", "application/json, text/event-stream")
.header("Authorization", "Bearer MCP_TOKEN")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
.send()
.await?;
assert_eq!(resp.status(), 200);
Ok(())
}
@@ -275,6 +275,67 @@ async fn test_encryption_key_rotation_dispatches_batched_git_sync(
Ok(())
}
/// Stored repository tokens and webhook secrets are encrypted under the
/// workspace key but never synced, so a rotation has to carry them over even
/// when the caller skips re-encrypting variables.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_encryption_key_rotation_reencrypts_git_sync_secrets(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::variables::{build_crypt, crypt_from_key_with_suffix, decrypt, encrypt};
initialize_tracing().await;
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script_git_secrets";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
let mc = build_crypt(&db, "test-workspace").await?;
sqlx::query(
r#"
UPDATE workspace_settings SET
git_credentials = jsonb_build_array(jsonb_build_object(
'token', $1::text, 'repo_identity', 'https://gitlab.example.com/grp/proj')),
git_sync = jsonb_set(git_sync, '{repositories,0,auto_pull}', jsonb_build_object(
'enabled', true, 'mode', 'webhook', 'webhook_id', 1, 'webhook_secret', $2::text))
WHERE workspace_id = 'test-workspace'
"#,
)
.bind(encrypt(&mc, "stored-token"))
.bind(encrypt(&mc, "hook-secret"))
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let new_key = "c".repeat(64);
let resp = authed(client().post(format!("{base}/encryption_key")))
.json(&json!({"new_key": new_key, "skip_reencrypt": true}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"set_encryption_key failed: {}",
resp.text().await?
);
let (token, secret): (String, String) = sqlx::query_as(
"SELECT git_credentials->0->>'token', git_sync#>>'{repositories,0,auto_pull,webhook_secret}'
FROM workspace_settings WHERE workspace_id = 'test-workspace'",
)
.fetch_one(&db)
.await?;
let new_mc = crypt_from_key_with_suffix(&new_key, "");
assert_eq!(decrypt(&new_mc, token)?, "stored-token");
assert_eq!(decrypt(&new_mc, secret)?, "hook-secret");
Ok(())
}
/// Regression test for the non-debouncing fallback: a workspace whose sync
/// script predates hub version 28103 must still receive git-sync jobs for the
/// encryption_key entry and every re-encrypted secret. Before the fallback was
+9 -4
View File
@@ -64,10 +64,11 @@ use windmill_common::{
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
MAX_TOKEN_EXPIRATION_DAYS_SETTING, RETENTION_PERIOD_SECS_OVERRIDES_SETTING,
RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING,
WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING,
MAX_TOKEN_EXPIRATION_DAYS_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING,
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
WS_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -1364,6 +1365,10 @@ pub async fn get_global_setting(
&& key != INSTANCE_BANNER_SETTING
// The token form reads it to stop offering expirations the server would shorten.
&& key != MAX_TOKEN_EXPIRATION_DAYS_SETTING
// Whoever is wiring up an MCP client reads it to know whether a URL-borne token
// would be refused, and they are usually not a superadmin. Not a secret: pointing
// any MCP client at the instance discovers the same answer.
&& key != MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING
{
require_super_admin(&db, &authed).await?;
}
+146 -17
View File
@@ -38,7 +38,7 @@ use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
crypt_from_key_with_suffix, decrypt, encrypt, WORKSPACE_CRYPT_CACHE,
};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
use windmill_common::workspaces::GitRepositorySettings;
@@ -155,6 +155,10 @@ pub fn workspaced_service() -> Router {
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
.route("/edit_default_app", post(edit_default_app))
.route("/edit_guest_access", post(edit_guest_access))
.route(
"/edit_add_admins_and_developers_to_forks",
post(edit_add_admins_and_developers_to_forks),
)
.route("/edit_guest_jwt_key", post(edit_guest_jwt_key))
.route("/guest_usage", get(get_guest_usage))
.route("/default_app", get(get_default_app))
@@ -338,6 +342,7 @@ pub struct WorkspaceSettings {
pub guest_jwt_public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guest_jwt_jwks_url: Option<String>,
pub add_admins_and_developers_to_forks: bool,
}
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
@@ -363,6 +368,8 @@ pub struct WorkspacePublicSettings {
/// Not sensitive, and the app editor needs it to say whether the guest rung is
/// live -- an app can be set to `guest` while the workspace has guests off.
pub guest_access_enabled: bool,
/// Read by the fork dialog, which tells the forker who else the fork will include.
pub add_admins_and_developers_to_forks: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deploy_ui: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1126,7 +1133,8 @@ async fn get_settings(
error_handler_fallback_to_instance_alerts,
guest_access_enabled,
guest_jwt_public_key,
guest_jwt_jwks_url
guest_jwt_jwks_url,
add_admins_and_developers_to_forks
FROM
workspace_settings
WHERE
@@ -1168,6 +1176,7 @@ async fn get_public_settings(
teams_team_guid,
mute_critical_alerts,
guest_access_enabled,
add_admins_and_developers_to_forks,
deploy_ui,
large_file_storage,
datatable
@@ -5052,6 +5061,47 @@ async fn edit_guest_access(
))
}
#[derive(Deserialize)]
struct EditAddAdminsAndDevelopersToForks {
add_admins_and_developers_to_forks: bool,
}
async fn edit_add_admins_and_developers_to_forks(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(EditAddAdminsAndDevelopersToForks { add_admins_and_developers_to_forks }): Json<
EditAddAdminsAndDevelopersToForks,
>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2",
add_admins_and_developers_to_forks,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.edit_add_admins_and_developers_to_forks",
ActionKind::Update,
&w_id,
Some(&add_admins_and_developers_to_forks.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(format!(
"Adding admins and developers to new forks set to {add_admins_and_developers_to_forks} for workspace {w_id}"
))
}
#[derive(Deserialize)]
struct EditGuestJwtKey {
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the
@@ -5640,9 +5690,6 @@ async fn set_encryption_key(
));
}
// Build the previous cipher before the transaction (reads from cache/pool)
let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?;
let mut tx = db.begin().await?;
// Under the row's lock, so two rotations racing serialize and each sees the key the
@@ -5676,17 +5723,14 @@ async fn set_encryption_key(
None
};
// From the keys read and written under the lock, never from `build_crypt`: its
// cache can still hold a key an earlier rotation replaced, and the git-sync
// secrets below are skipped rather than failed when they do not decrypt.
let previous_encryption_key = crypt_from_key_with_suffix(&previous_key, "");
let new_encryption_key = crypt_from_key_with_suffix(&request.new_key, "");
let mut reencrypted_secret_paths: Vec<String> = Vec::new();
if !request.skip_reencrypt.unwrap_or(false) {
// Build the new cipher directly from the key string, since the transaction
// hasn't committed yet and build_crypt() would read the old key from the pool.
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", request.new_key, salt)
} else {
request.new_key.clone()
};
let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256);
let mut truncated_new_key = request.new_key.clone();
truncated_new_key.truncate(8);
tracing::warn!(
@@ -5726,6 +5770,14 @@ async fn set_encryption_key(
}
}
reencrypt_git_sync_secrets(
&mut tx,
&w_id,
&previous_encryption_key,
&new_encryption_key,
)
.await?;
tx.commit().await?;
// Invalidate the cache only after the transaction has committed
@@ -5763,6 +5815,64 @@ async fn set_encryption_key(
return Ok(());
}
/// Move the git-sync secrets the server keeps under the workspace key (stored
/// repository tokens, webhook secrets) to the new key. They are never synced, so
/// unlike variables they are still under the old key when the caller skips
/// re-encryption.
async fn reencrypt_git_sync_secrets(
conn: &mut sqlx::PgConnection,
w_id: &str,
old: &magic_crypt::MagicCrypt256,
new: &magic_crypt::MagicCrypt256,
) -> Result<()> {
let Some((mut credentials, mut git_sync)) =
sqlx::query_as::<_, (serde_json::Value, Option<serde_json::Value>)>(
"SELECT git_credentials, git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
)
.bind(w_id)
.fetch_optional(&mut *conn)
.await?
else {
return Ok(());
};
let reencrypt = |value: &mut serde_json::Value| {
let Some(ciphertext) = value.as_str() else {
return;
};
match decrypt(old, ciphertext.to_string()) {
Ok(plain) => *value = serde_json::Value::String(encrypt(new, &plain)),
// Left by an earlier rotation and unrecoverable either way; failing here
// would block every later rotation of the workspace.
Err(e) => tracing::warn!(
"a git-sync secret of workspace {w_id} does not decrypt under its current key, leaving it as is: {e}"
),
}
};
for entry in credentials.as_array_mut().into_iter().flatten() {
if let Some(token) = entry.get_mut("token") {
reencrypt(token);
}
}
let repositories = git_sync
.as_mut()
.and_then(|g| g.get_mut("repositories"))
.and_then(|r| r.as_array_mut());
for repo in repositories.into_iter().flatten() {
if let Some(secret) = repo.pointer_mut("/auto_pull/webhook_secret") {
reencrypt(secret);
}
}
sqlx::query(
"UPDATE workspace_settings SET git_credentials = $2, git_sync = $3 WHERE workspace_id = $1",
)
.bind(w_id)
.bind(credentials)
.bind(git_sync)
.execute(&mut *conn)
.await?;
Ok(())
}
#[derive(Serialize)]
struct UsedTriggers {
pub websocket_used: bool,
@@ -6712,7 +6822,8 @@ async fn update_workspace_settings(
ducklake = source_ws.ducklake,
dbt_warehouses = source_ws.dbt_warehouses,
datatable = source_ws.datatable,
git_app_installations = source_ws.git_app_installations
git_app_installations = source_ws.git_app_installations,
add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks
FROM workspace_settings source_ws
WHERE source_ws.workspace_id = $1
AND workspace_settings.workspace_id = $2
@@ -6853,14 +6964,21 @@ async fn copy_workspace_members(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
admins_and_developers_only: bool,
) -> Result<()> {
// Admins and developers join as manual members: the fork does not inherit the source's
// instance-group config, so a copied `instance_group` provenance would let the fork's
// reconciliation delete them and their data.
sqlx::query!(
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,
CASE WHEN $3 THEN NULL ELSE added_via END
FROM usr WHERE workspace_id = $2
AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))
ON CONFLICT DO NOTHING",
target_workspace_id,
source_workspace_id,
admins_and_developers_only,
)
.execute(&mut **tx)
.await?;
@@ -8651,8 +8769,19 @@ async fn create_workspace_fork(
// intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before
// the explicit creator insert below so the creator (a parent member) is copied with full metadata
// (operator/role/is_service_account/added_via), not the bare row the insert alone would leave.
// Independently, the parent's admins can have every fork of it start with its admins and
// developers; the forker cannot opt out, since the point is that those admins can review it.
if nw.copy_members && nw.is_dev_workspace {
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?;
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, false).await?;
} else if sqlx::query_scalar!(
"SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
parent_workspace_id
)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(false)
{
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, true).await?;
}
// Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
+41 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.814.0
version: 1.815.0
title: Windmill API
contact:
@@ -4090,9 +4090,13 @@ paths:
guest_access_enabled:
type: boolean
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
add_admins_and_developers_to_forks:
type: boolean
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
required:
- workspace_id
- guest_access_enabled
- add_admins_and_developers_to_forks
/w/{workspace}/workspaces/get_settings:
get:
@@ -4183,6 +4187,9 @@ paths:
guest_jwt_jwks_url:
type: string
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
add_admins_and_developers_to_forks:
type: boolean
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
/w/{workspace}/workspaces/get_deploy_to:
get:
@@ -6313,6 +6320,39 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks:
post:
summary: choose whether new forks of this workspace start with its admins and developers
description: >-
When on, every fork created from this workspace gets the workspace's admins and
developers as members, with the role they hold here; operators, disabled users and
service accounts are left out. The setting is copied into each fork, so forks of a
fork follow it too. Off by default. Workspace-admin gated.
operationId: editAddAdminsAndDevelopersToForks
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Whether new forks start with this workspace's admins and developers
required: true
content:
application/json:
schema:
type: object
properties:
add_admins_and_developers_to_forks:
type: boolean
required:
- add_admins_and_developers_to_forks
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/edit_guest_jwt_key:
post:
summary: set the key guest JWTs are verified against for this workspace
+5 -3
View File
@@ -560,7 +560,7 @@ pub async fn run_server(
if server_mode || mcp_mode {
use mcp::{
add_www_authenticate_header, add_www_authenticate_header_gateway,
extract_workspace_from_token,
extract_workspace_from_token, reject_token_query_param,
};
let (mcp_router, mcp_cancellation_token) = setup_mcp_server(
db.clone(),
@@ -573,15 +573,17 @@ pub async fn run_server(
let workspaced_mcp_router = mcp_router
.clone()
.route_layer(from_extractor::<ApiAuthed>())
.layer(axum::middleware::from_fn(reject_token_query_param))
.layer(axum::middleware::from_fn(add_www_authenticate_header))
.layer(axum::middleware::from_fn(extract_and_store_workspace_id));
// Gateway MCP router — resolves workspace from token
let gateway_mcp_router = mcp_router
.route_layer(from_extractor::<ApiAuthed>())
.layer(axum::middleware::from_fn(extract_workspace_from_token))
.layer(axum::middleware::from_fn(reject_token_query_param))
.layer(axum::middleware::from_fn(
add_www_authenticate_header_gateway,
))
.layer(axum::middleware::from_fn(extract_workspace_from_token));
));
(
workspaced_mcp_router,
gateway_mcp_router,
+28 -2
View File
@@ -43,11 +43,14 @@ use axum::{
extract::{Extension, Path},
http::Request,
middleware::Next,
response::Response,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use windmill_common::{auth::hash_token, db::GatewayWorkspaceId, error::JsonResult};
use windmill_common::{
auth::hash_token, db::GatewayWorkspaceId, error::JsonResult,
global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM,
};
// McpAuth impl for ApiAuthed is in windmill-api-auth (same crate as the type)
@@ -446,6 +449,29 @@ pub async fn add_www_authenticate_header(
}
}
/// Middleware refusing a credential carried in the MCP URL once the instance sets
/// `mcp_disable_token_query_param`. Sits outside everything that reads the token, so neither
/// the gateway lookup nor `ApiAuthed` ever sees it, and inside the `WWW-Authenticate` layer,
/// whose header is what sends the client into the OAuth flow instead. Refused rather than
/// ignored: the URL leaked the token whether or not the request used it.
pub async fn reject_token_query_param(request: Request<axum::body::Body>, next: Next) -> Response {
let carries_token = MCP_DISABLE_TOKEN_QUERY_PARAM.load(std::sync::atomic::Ordering::Relaxed)
&& request
.uri()
.query()
.is_some_and(|q| url::form_urlencoded::parse(q.as_bytes()).any(|(k, _)| k == "token"));
if carries_token {
return (
axum::http::StatusCode::UNAUTHORIZED,
"This instance does not accept a token in the MCP URL. Remove the token query \
parameter and let your client sign in through OAuth, or send the token in an \
Authorization header.",
)
.into_response();
}
next.run(request).await
}
/// Extract the bearer token from either the `Authorization` header or the
/// `?token=` query parameter (MCP clients commonly pass it in the URL).
fn extract_gateway_token(request: &Request<axum::body::Body>) -> Option<String> {
+1 -1
View File
@@ -12,5 +12,5 @@ pub mod oauth_server;
pub use core::{
add_www_authenticate_header, add_www_authenticate_header_gateway,
extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service,
setup_mcp_server,
reject_token_query_param, setup_mcp_server,
};
@@ -102,6 +102,10 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation";
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
/// Refuse `?token=` on the MCP endpoints, leaving the `Authorization` header as the only way
/// in. A URL-borne credential ends up in browser history, proxy logs and referrers, so an
/// instance that cares sends MCP clients through the OAuth flow instead.
pub const MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING: &str = "mcp_disable_token_query_param";
/// Ceiling, in days, on how far ahead a token minted through `POST /users/tokens/create` or
/// `POST /users/tokens/impersonate` may expire; a request asking for more, or for no
/// expiration at all, is shortened to it rather than refused. On those routes only: server-side
@@ -407,6 +411,7 @@ use std::sync::atomic::AtomicBool;
lazy_static::lazy_static! {
pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false);
pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false);
/// Origins HTTP routes allow cross-origin when they configure none of their
/// own. Empty means unset, which keeps the historical `*`.
pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap<Vec<String>> =
@@ -10,7 +10,7 @@
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use std::sync::{Arc, RwLock};
use uuid::Uuid;
/// Minimum length for a secret to be registered for masking.
@@ -20,9 +20,27 @@ const MIN_SECRET_LENGTH: usize = 8;
const MASKED_NOTICE: &str =
"[windmill] secret value was masked for security reasons, use string transformations to display full value";
/// The secrets registered for one job, plus the automaton compiled from them.
#[derive(Default)]
struct JobMasks {
secrets: HashSet<String>,
/// Built on the first `snapshot` after a change and shared by every later
/// snapshot. Every job registers at least its own token, so without this
/// cache each log batch of each job would rebuild the automaton.
compiled: Option<Arc<CompiledMasks>>,
}
/// Aho-Corasick automaton for O(m) multi-pattern matching in a single pass,
/// regardless of the number of secrets registered, with the replacement
/// strings indexed to match the automaton's pattern order.
struct CompiledMasks {
ac: aho_corasick::AhoCorasick,
replacements: Vec<String>,
}
lazy_static::lazy_static! {
/// Map of job_id -> set of secret values that should be masked in that job's logs.
static ref SENSITIVE_MASKS: RwLock<HashMap<Uuid, HashSet<String>>> =
/// Map of job_id -> secret values that should be masked in that job's logs.
static ref SENSITIVE_MASKS: RwLock<HashMap<Uuid, JobMasks>> =
RwLock::new(HashMap::new());
/// Set of currently running job IDs on this worker process.
@@ -32,13 +50,8 @@ lazy_static::lazy_static! {
}
/// A lock-free snapshot of secrets for a job, taken once per log batch.
/// Uses Aho-Corasick for O(m) multi-pattern matching in a single pass,
/// regardless of the number of secrets registered.
pub struct MaskSnapshot {
/// Aho-Corasick automaton for fast matching.
ac: aho_corasick::AhoCorasick,
/// Replacement strings, indexed to match the automaton's pattern order.
replacements: Vec<String>,
compiled: Arc<CompiledMasks>,
/// Whether the security notice has already been appended for this snapshot.
/// Tracked locally to avoid a global write lock on every masked line.
notice_shown: std::cell::Cell<bool>,
@@ -53,34 +66,104 @@ impl MaskSnapshot {
}
// Single-pass check + replace using the pre-built automaton
if !self.ac.is_match(text) {
if !self.compiled.ac.is_match(text) {
return Cow::Borrowed(text);
}
let mut result = self.ac.replace_all(text, &self.replacements);
let mut result = self
.compiled
.ac
.replace_all(text, &self.compiled.replacements);
// Append the notice only once per snapshot (i.e. per batch)
// Append the notice only once per snapshot (i.e. per batch), as its own line.
// Callers pass either a bare line (`handle_child`) or a chunk that already ends
// in a newline (nativets), and the sinks concatenate what they get verbatim:
// assuming either shape welds the notice onto a neighbouring line.
if !self.notice_shown.get() {
self.notice_shown.set(true);
result.push('\n');
result.push_str(MASKED_NOTICE);
if result.ends_with('\n') {
result.push_str(MASKED_NOTICE);
result.push('\n');
} else {
result.push('\n');
result.push_str(MASKED_NOTICE);
}
}
Cow::Owned(result)
}
}
/// A masker for sinks that mask line by line rather than in batches, like nativets
/// masking each `console.log` chunk as V8 produces it. `snapshot` per line would
/// re-arm the security notice on every one; this keeps it to once per distinct set
/// of secrets while still picking up secrets registered mid-run.
///
/// Masks by job id alone — the caller is the one that knows the text it passes
/// belongs to that job.
pub struct JobMasker {
job_id: Uuid,
snapshot: Option<MaskSnapshot>,
}
impl JobMasker {
pub fn new(job_id: Uuid) -> Self {
JobMasker { job_id, snapshot: snapshot(&job_id) }
}
/// Mask every secret registered for the job. Returns `Cow::Borrowed` when no match.
/// Falls back to the masks it last saw once the job is unregistered, so a sink
/// still draining past the end of a run does not start emitting secrets.
pub fn mask<'a>(&mut self, text: &'a str) -> Cow<'a, str> {
if let Some(fresh) = snapshot(&self.job_id) {
// Replacing an equivalent snapshot would re-arm the notice, so only take
// one built from a secret set we have not seen.
let unchanged = self
.snapshot
.as_ref()
.is_some_and(|cur| Arc::ptr_eq(&cur.compiled, &fresh.compiled));
if !unchanged {
self.snapshot = Some(fresh);
}
}
match self.snapshot.as_ref() {
Some(snapshot) => snapshot.mask(text),
None => Cow::Borrowed(text),
}
}
}
/// Take a snapshot of the current secrets for a job. Returns `None` if no secrets
/// are registered (the caller can then skip masking entirely for the whole batch).
///
/// Call this once per log batch in `write_lines`, not per line.
pub fn snapshot(job_id: &Uuid) -> Option<MaskSnapshot> {
let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner());
let secrets = masks.get(job_id)?;
if secrets.is_empty() {
return None;
{
let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner());
let job = masks.get(job_id)?;
if job.secrets.is_empty() {
return None;
}
if let Some(compiled) = job.compiled.as_ref() {
return Some(MaskSnapshot {
compiled: compiled.clone(),
notice_shown: std::cell::Cell::new(false),
});
}
}
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
let job = masks.get_mut(job_id)?;
if job.secrets.is_empty() {
return None;
}
let compiled = job
.compiled
.get_or_insert_with(|| Arc::new(compile(&job.secrets)));
Some(MaskSnapshot { compiled: compiled.clone(), notice_shown: std::cell::Cell::new(false) })
}
fn compile(secrets: &HashSet<String>) -> CompiledMasks {
// Sort longest-first so longer secrets are matched before shorter substrings
let mut sorted: Vec<&String> = secrets.iter().collect();
sorted.sort_by(|a, b| b.len().cmp(&a.len()));
@@ -106,7 +189,7 @@ pub fn snapshot(job_id: &Uuid) -> Option<MaskSnapshot> {
.build(sorted.iter().map(|s| s.as_str()))
.expect("failed to build aho-corasick automaton");
Some(MaskSnapshot { ac, replacements, notice_shown: std::cell::Cell::new(false) })
CompiledMasks { ac, replacements }
}
/// Register a job as currently running. Call this before `handle_queued_job`.
@@ -148,20 +231,110 @@ pub fn register_secret_for_all_running_jobs(secret: &str) {
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
for job_id in job_ids {
if let Some(set) = masks.get_mut(&job_id) {
set.insert(secret.to_string());
if let Some(job) = masks.get_mut(&job_id) {
if job.secrets.insert(secret.to_string()) {
job.compiled = None;
}
}
}
}
/// Register a secret value for a specific job.
/// Used for `$encrypted:` args where we know the job ID.
/// Used for the job's own token and for `$encrypted:` args, where we know the job ID.
pub fn register_secret_for_job(job_id: Uuid, secret: &str) {
if secret.len() < MIN_SECRET_LENGTH {
return;
}
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
if let Some(set) = masks.get_mut(&job_id) {
set.insert(secret.to_string());
if let Some(job) = masks.get_mut(&job_id) {
if job.secrets.insert(secret.to_string()) {
job.compiled = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The compiled automaton is cached per job, so a secret registered after the
/// first snapshot only gets masked if the cache is invalidated.
#[test]
fn snapshot_rebuilds_after_a_new_secret_is_registered() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "firstsecretvalue");
let _ = snapshot(&job_id)
.expect("secret registered")
.mask("firstsecretvalue");
register_secret_for_job(job_id, "secondsecretvalue");
let snap = snapshot(&job_id).expect("secrets registered");
let masked = snap.mask("firstsecretvalue then secondsecretvalue");
assert!(!masked.contains("firstsecretvalue"), "{masked}");
assert!(!masked.contains("secondsecretvalue"), "{masked}");
unregister_running_job(job_id);
}
/// A line-by-line sink must not repeat the notice on every line, and must still
/// pick up a secret registered after the masker was built.
#[test]
fn job_masker_notices_once_per_secret_set() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "firstsecretvalue");
let mut masker = JobMasker::new(job_id);
let first = masker.mask("saw firstsecretvalue").into_owned();
assert!(!first.contains("firstsecretvalue"), "{first}");
assert!(first.contains(MASKED_NOTICE), "{first}");
let second = masker.mask("saw firstsecretvalue again").into_owned();
assert!(!second.contains("firstsecretvalue"), "{second}");
assert!(!second.contains(MASKED_NOTICE), "{second}");
register_secret_for_job(job_id, "secondsecretvalue");
let third = masker.mask("saw secondsecretvalue").into_owned();
assert!(!third.contains("secondsecretvalue"), "{third}");
unregister_running_job(job_id);
}
/// Unregistration must not turn masking off under a sink that is still emitting:
/// the masker keeps working off the masks it last saw rather than going quiet.
#[test]
fn job_masker_masks_after_the_job_is_unregistered() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "supersecretvalue");
let mut masker = JobMasker::new(job_id);
unregister_running_job(job_id);
let masked = masker.mask("logged supersecretvalue here");
assert!(!masked.contains("supersecretvalue"), "{masked}");
}
/// The notice has to end up on a line of its own for both shapes callers pass:
/// a bare line (`handle_child`) and a newline-terminated chunk (nativets). The
/// sinks concatenate what they are given verbatim, so getting this wrong welds
/// the notice onto whichever line follows it.
#[test]
fn notice_lands_on_its_own_line_for_both_caller_shapes() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "supersecretvalue");
let line = snapshot(&job_id)
.expect("secret registered")
.mask("tok supersecretvalue");
assert_eq!(line, format!("tok s*****e\n{MASKED_NOTICE}"));
let chunk = snapshot(&job_id)
.expect("secret registered")
.mask("tok supersecretvalue\n");
assert_eq!(chunk, format!("tok s*****e\n{MASKED_NOTICE}\n"));
unregister_running_job(job_id);
}
}
+13
View File
@@ -775,6 +775,19 @@ pub fn make_pull_query(tags: &[String]) -> String {
query
}
/// Claim a parent's tool jobs without tag filtering. The caller must supply only child IDs
/// it owns; this query is an internal scheduling primitive and does not authorize job access.
pub fn make_tool_job_pull_query(job_ids: &[uuid::Uuid]) -> String {
// pull() binds only the worker name. These literals come from typed UUIDs, never input SQL.
let ids = job_ids.iter().map(|id| format!("'{id}'::uuid")).join(", ");
format_pull_query(format!(
"SELECT id FROM v2_job_queue
WHERE running = false AND id = ANY(ARRAY[{ids}]::uuid[]) AND scheduled_for <= now()
ORDER BY priority DESC NULLS LAST, scheduled_for
FOR UPDATE SKIP LOCKED LIMIT 1"
))
}
// Variant of `make_pull_query` that additionally excludes jobs whose workspace_id is in the
// overloaded-list bind parameter ($2::text[]). Built as a separate string (rather than reusing
// `make_pull_query` with an always-bound array) so the planner can keep using the same indexes
+39
View File
@@ -4399,6 +4399,45 @@ pub fn has_active_concurrency_limit(concurrent_limit: Option<i32>) -> bool {
concurrent_limit.is_some_and(|n| n > 0)
}
/// Admit a job already owned by a worker without releasing its queue reservation.
/// The caller must maintain its heartbeat while waiting and complete it on failure.
pub async fn try_admit_owned_job(db: &DB, job: &MiniPulledJob) -> error::Result<bool> {
#[cfg(all(feature = "private", feature = "enterprise"))]
{
let settings = windmill_common::runnable_settings::prefetch_cached_from_handle(
job.runnable_settings_handle,
db,
)
.await?
.1
.maybe_fallback(None, job.concurrent_limit, job.concurrency_time_window_s);
if has_active_concurrency_limit(settings.concurrent_limit)
&& !*DISABLE_CONCURRENCY_LIMIT
&& job.canceled_by.is_none()
{
let key = concurrency_key(db, &job.id).await?.ok_or_else(|| {
Error::internal_err(format!("No concurrency key found for job {}", job.id))
})?;
if !key.is_empty() {
return Ok(crate::jobs_ee::update_concurrency_counter(
db,
&job.id,
key,
serde_json::json!({ job.id.to_string(): {} }),
job.id.to_string(),
settings.concurrency_time_window_s.unwrap_or(0),
settings.concurrent_limit.unwrap_or_default(),
)
.await?
.0);
}
}
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
let _ = (db, job);
Ok(true)
}
pub async fn custom_concurrency_key(
db: &Pool<Postgres>,
job_id: &Uuid,
+19 -4
View File
@@ -838,6 +838,11 @@ pub async fn eval_fetch_timeout(
}
}
let w_id_for_tracing = w_id_for_tracing;
// nativets delivers logs in-process, so they never reach the masking in
// `handle_child::write_lines` and a `console.log` of `$WM_TOKEN` would be
// persisted verbatim. Mask here rather than in the detached task draining into
// `append_logs`: this loop normally runs while the job is still registered.
let mut masker = windmill_common::sensitive_log_masks::JobMasker::new(job_id);
let handle = tokio::spawn(async move {
let mut result_stream = String::new();
let mut is_stream = false;
@@ -845,10 +850,20 @@ pub async fn eval_fetch_timeout(
use windmill_common::result_stream::extract_stream_from_logs;
use windmill_common::tracing_init::{OTEL_JOB_LOGS, OTEL_PREFIX};
let stream = extract_stream_from_logs(&log.trim_end_matches("\n"));
// A stream chunk is result data, not a log line — it never reaches
// `job_logs`, and `merge_result_stream` can make it the job's result —
// so it stays raw wherever it goes, here and in the mirror below.
// Deliberately unlike `handle_child`, which streams the masked text.
// Routed before masking because the notice is one-shot: spent on a chunk
// no sink persists, a later redaction in `job_logs` would go unexplained.
let logged = stream.is_none().then(|| masker.mask(&log).into_owned());
// Mirror `process_streaming_log_lines` (EE) + the OTEL_JOB_LOGS
// hook from handle_child.rs, neither of which runs for nativets
// since nativets delivers logs in-process via the log channel.
for line in log.lines() {
for line in logged.as_deref().unwrap_or(&log).lines() {
tracing::info!(
target: "windmill:job_log",
job_id = ?job_id,
@@ -862,7 +877,7 @@ pub async fn eval_fetch_timeout(
}
}
if let Some(stream) = extract_stream_from_logs(&log.trim_end_matches("\n")) {
if let Some(stream) = stream {
if !is_stream {
is_stream = true;
if let Some(ref f) = stream_notifier_update {
@@ -874,8 +889,8 @@ pub async fn eval_fetch_timeout(
if let Err(e) = result_stream_sender.send(stream) {
tracing::error!("failed to send result stream: {e}");
}
} else {
if let Err(e) = append_logs_sender.send(log) {
} else if let Some(logged) = logged {
if let Err(e) = append_logs_sender.send(logged) {
tracing::error!("failed to send log: {e}");
}
}
+297 -281
View File
@@ -9,14 +9,12 @@ use crate::result_processor::handle_non_flow_job_error;
use crate::worker_flow::{
evaluate_input_transform, raw_script_to_payload, script_to_payload, JobPayloadWithTag,
};
use crate::{
create_job_dir, handle_queued_job, JobCompletedReceiver, JobCompletedSender, SendResult,
SendResultPayload,
};
use crate::{create_job_dir, handle_queued_job, JobCompletedSender};
use anyhow::Context;
use mappable_rc::Marc;
use serde_json::value::RawValue;
use std::{collections::HashMap, sync::Arc};
use sqlx::types::Json;
use std::{collections::HashMap, sync::Arc, time::Duration};
use uuid::Uuid;
use windmill_ai::{ai_types::OpenAIToolCall, query_builder::StreamEventSink, types::*};
use windmill_common::jobs::JobPayload;
@@ -36,11 +34,11 @@ use windmill_common::{
flow_conversations::{MessageExtras, MessageType},
flow_status::AgentAction,
flows::FlowModuleValue,
worker::{to_raw_value, Connection},
worker::{make_tool_job_pull_query, to_raw_value, Connection},
};
use windmill_queue::{
add_completed_job, add_completed_job_error, get_mini_pulled_job, push, MiniCompletedJob,
MiniPulledJob, PushArgs, PushIsolationLevel,
get_mini_pulled_job, pull, push, try_admit_owned_job, MiniCompletedJob, MiniPulledJob,
PushArgs, PushIsolationLevel,
};
/// Shared collection of abort handles for spawned tool tasks.
@@ -82,6 +80,7 @@ pub struct ToolExecutionContext<'a> {
// Abort handles for spawned tool tasks (used for force-cancel cleanup)
pub tool_abort_handles: ToolAbortHandles,
pub job_completed_tx: JobCompletedSender,
}
/// Execute all tool calls from an AI response
@@ -98,7 +97,8 @@ pub async fn execute_tool_calls(
let mut used_structured_output_tool = false;
let mut final_content = None;
for tool_call in tool_calls.iter() {
let mut calls = tool_calls.iter().peekable();
while let Some(tool_call) = calls.next() {
// Stream tool call progress
if let Some(stream_event_processor) = ctx.stream_event_processor {
let event = StreamingEvent::ToolExecution {
@@ -150,15 +150,24 @@ pub async fn execute_tool_calls(
)
.await?;
} else if tool.module.is_some() {
execute_windmill_tool(
&mut ctx,
tool_call,
tool,
actions,
&mut messages,
final_events_str,
)
.await?;
let mut batch = vec![(tool_call, tool)];
while let Some(next_call) = calls.peek() {
if structured_output_tool_name.as_deref()
== Some(next_call.function.name.as_str())
{
break;
}
let Some(next_tool) = tools.iter().find(|t| {
t.def.function.name == next_call.function.name
&& t.mcp_source.is_none()
&& t.module.is_some()
}) else {
break;
};
batch.push((calls.next().unwrap(), next_tool));
}
execute_windmill_tools(&mut ctx, &batch, actions, &mut messages, final_events_str)
.await?;
} else {
return Err(Error::internal_err(format!(
"Tool type not supported: {}",
@@ -313,14 +322,13 @@ async fn execute_mcp_tool_call(
}
/// Execute a Windmill tool (script or flow)
async fn execute_windmill_tool(
ctx: &mut ToolExecutionContext<'_>,
async fn enqueue_windmill_tool(
ctx: &ToolExecutionContext<'_>,
tool_call: &OpenAIToolCall,
tool: &Tool,
actions: &mut Vec<AgentAction>,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
reserved: bool,
) -> Result<Uuid, Error> {
// Regular Windmill tools must have a module
let tool_module = tool.module.as_ref().ok_or_else(|| {
Error::internal_err(format!("Tool {} has no module", tool_call.function.name))
@@ -402,8 +410,6 @@ async fn execute_windmill_tool(
tool_call_args.insert(key.clone(), result);
}
let is_ai_agent_tool = matches!(tool_value, FlowModuleValue::AIAgent { .. });
let job_payload = match tool_value {
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
script_to_payload(
@@ -473,8 +479,6 @@ async fn execute_windmill_tool(
));
}
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
// tool jobs are pushed with the parent agent job's tag and executed inline on the
// same worker, so a tag override on a nested agent tool does not apply here
JobPayloadWithTag {
payload: JobPayload::AIAgent { path },
tag: None,
@@ -532,44 +536,52 @@ async fn execute_windmill_tool(
false,
None,
ctx.job.visible_to_owner,
Some(ctx.job.tag.clone()),
job_payload.tag,
job_payload.timeout,
None,
job_priority,
job_perms.as_ref(),
true,
reserved,
None,
None,
None,
)
.await?;
let mut tx = tx;
if reserved {
// Running ownership reserves the first child; its normal tag allows zombie recovery.
sqlx::query!(
"UPDATE v2_job_queue SET worker = $1 WHERE id = $2",
ctx.worker_name,
uuid,
)
.execute(&mut *tx)
.await?;
sqlx::query!("UPDATE v2_job_runtime SET ping = now() WHERE id = $1", uuid)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(uuid)
}
let tool_job = get_mini_pulled_job(ctx.db, &uuid).await?;
let Some(tool_job) = tool_job else {
return Err(Error::internal_err("Tool job not found".to_string()));
};
let tool_job = Arc::new(tool_job);
let (inner_job_completed_tx, inner_job_completed_rx) = JobCompletedSender::new(ctx.conn, 1);
let inner_job_completed_rx = inner_job_completed_rx.expect(
"inner_job_completed_tx should be set as agent jobs are not supported on agent workers",
);
fn spawn_local_tool(
ctx: &ToolExecutionContext<'_>,
tool_job: MiniPulledJob,
reserved: bool,
) -> tokio::task::JoinHandle<Result<OccupancyMetrics, Error>> {
let mut tool_job = Arc::new(tool_job);
// Spawn handle_queued_job on separate task to prevent tokio stack overflow
// Clone everything needed for the spawned task
let tool_job_spawn = tool_job.clone();
let db = ctx.db.clone();
let conn_spawn = ctx.conn.clone();
let client_spawn = ctx.client.clone();
let hostname_spawn = ctx.hostname.to_string();
let worker_name_spawn = ctx.worker_name.to_string();
let worker_dir_spawn = ctx.worker_dir.to_string();
let base_internal_url_spawn = ctx.base_internal_url.to_string();
let inner_job_completed_tx_spawn = inner_job_completed_tx.clone();
let job_completed_tx = ctx.job_completed_tx.clone();
let mut occupancy_metrics_spawn = ctx.occupancy_metrics.clone();
let mut killpill_rx_spawn = ctx.killpill_rx.resubscribe();
@@ -578,147 +590,266 @@ async fn execute_windmill_tool(
#[cfg(feature = "benchmark")]
let mut bench_spawn = windmill_common::bench::BenchmarkIter::new();
let job_dir = create_job_dir(&worker_dir_spawn, tool_job_spawn.id).await;
let result = async {
if reserved {
loop {
let queued = get_mini_pulled_job(&db, &tool_job.id)
.await?
.ok_or_else(|| Error::AlreadyCompleted("Tool job already completed".to_string()))?;
tool_job = Arc::new(queued);
sqlx::query!(
"UPDATE v2_job_runtime SET ping = now() WHERE id = $1",
tool_job.id,
)
.execute(&db)
.await?;
if try_admit_owned_job(&db, &tool_job).await? {
let started_at = sqlx::query_scalar!(
"UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at",
tool_job.id,
)
.fetch_optional(&db)
.await?
.flatten();
Arc::make_mut(&mut tool_job).started_at = started_at;
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
let perms =
windmill_common::auth::get_job_perms(&db, &tool_job.id, &tool_job.workspace_id).await?;
let token = windmill_queue::create_token(&db, &tool_job, perms).await;
let client_spawn = AuthedClient::new(
base_internal_url_spawn.clone(),
tool_job.workspace_id.clone(),
token,
None,
);
let job_dir = create_job_dir(&worker_dir_spawn, tool_job.id).await;
let result = handle_queued_job(
tool_job_spawn,
None,
None,
None,
None,
&conn_spawn,
&client_spawn,
&hostname_spawn,
&worker_name_spawn,
&worker_dir_spawn,
&job_dir,
None,
&base_internal_url_spawn,
inner_job_completed_tx_spawn,
&mut occupancy_metrics_spawn,
&mut killpill_rx_spawn,
None,
None,
#[cfg(feature = "benchmark")]
&mut bench_spawn,
)
handle_queued_job(
tool_job.clone(),
None,
None,
None,
None,
&conn_spawn,
&client_spawn,
&hostname_spawn,
&worker_name_spawn,
&worker_dir_spawn,
&job_dir,
None,
&base_internal_url_spawn,
job_completed_tx,
&mut occupancy_metrics_spawn,
&mut killpill_rx_spawn,
None,
None,
#[cfg(feature = "benchmark")]
&mut bench_spawn,
)
.await
}
.await;
// Return both result and updated metrics
(result, occupancy_metrics_spawn)
match result {
Err(err) => {
let err_string = format!("{}: {}", err.name(), err);
handle_non_flow_job_error(
&db,
&MiniCompletedJob::from(tool_job),
0,
None,
err_string,
windmill_common::worker::error_to_value(&err),
&worker_name_spawn,
)
.await?;
}
Ok(_) => {}
}
Ok(occupancy_metrics_spawn)
});
// Register abort handle so the task can be killed on force-cancel
let abort_handle = join_handle.abort_handle();
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
ctx.tool_abort_handles.lock().unwrap().push(abort_handle);
// Await the spawned task
let (handle_result, updated_occupancy) = join_handle.await.map_err(|e| {
if e.is_cancelled() {
Error::ExecutionErr("Tool execution task was cancelled".to_string())
} else {
Error::internal_err(format!("Tool execution task failed: {}", e))
}
})?;
// Merge occupancy metrics back
ctx.occupancy_metrics.total_duration_of_running_jobs =
updated_occupancy.total_duration_of_running_jobs;
match handle_result {
Err(err) => {
handle_tool_execution_error(
ctx,
tool_call,
tool_module,
&MiniCompletedJob::from(tool_job),
job_id,
err,
messages,
final_events_str,
)
.await?;
}
Ok(outcome) => {
handle_tool_execution_success(
ctx,
tool_call,
tool_module,
job_id,
outcome.is_success(),
is_ai_agent_tool,
inner_job_completed_rx,
messages,
final_events_str,
)
.await?;
}
}
Ok(())
join_handle
}
/// Handle tool execution error
async fn handle_tool_execution_error(
async fn execute_windmill_tools(
ctx: &mut ToolExecutionContext<'_>,
tool_call: &OpenAIToolCall,
tool_module: &windmill_common::flows::FlowModule,
tool_job: &MiniCompletedJob,
job_id: Uuid,
err: Error,
batch: &[(&OpenAIToolCall, &Tool)],
actions: &mut Vec<AgentAction>,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
let err_string = format!("{}: {}", err.name(), err.to_string());
let err_json = windmill_common::worker::error_to_value(&err);
let _ = handle_non_flow_job_error(
ctx.db,
tool_job,
0,
None,
err_string.clone(),
err_json,
ctx.worker_name,
)
.await;
let error_message = format!("Error running tool: {}", err_string);
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(error_message.clone())),
tool_call_id: Some(tool_call.id.clone()),
agent_action: Some(AgentAction::ToolCall {
job_id,
function_name: tool_call.function.name.clone(),
module_id: tool_module.id.clone(),
}),
..Default::default()
});
// Stream tool result (error case)
if let Some(stream_event_processor) = ctx.stream_event_processor {
let tool_result_event = StreamingEvent::ToolResult {
call_id: tool_call.id.clone(),
function_name: tool_call.function.name.clone(),
result: error_message.clone(),
success: false,
};
stream_event_processor
.send(tool_result_event, final_events_str)
.await?;
let mut job_ids = Vec::with_capacity(batch.len());
let mut local = None;
for (index, (call, tool)) in batch.iter().enumerate() {
if index > 0 {
if let Some(processor) = ctx.stream_event_processor {
processor
.send(
StreamingEvent::ToolExecution {
call_id: call.id.clone(),
function_name: call.function.name.clone(),
},
final_events_str,
)
.await?;
}
}
job_ids.push(enqueue_windmill_tool(ctx, call, tool, actions, index == 0).await?);
if index == 0 {
let job = get_mini_pulled_job(ctx.db, &job_ids[0])
.await?
.ok_or_else(|| Error::internal_err("Reserved tool job not found".to_string()))?;
local = Some(spawn_local_tool(ctx, job, true));
}
}
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
let mut results: HashMap<Uuid, (bool, String)> = HashMap::new();
let mut next_result = 0;
let mut poll = tokio::time::interval(Duration::from_millis(100));
poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
while next_result < batch.len() || local.is_some() {
tokio::select! {
result = async { local.as_mut().unwrap().await }, if local.is_some() => {
local = None;
let metrics = result.map_err(|e| Error::internal_err(format!("Tool task failed: {e}")))??;
ctx.occupancy_metrics.total_duration_of_running_jobs = metrics.total_duration_of_running_jobs;
ctx.tool_abort_handles.lock().unwrap().retain(|handle| !handle.is_finished());
}
_ = poll.tick() => {}
}
let pending: Vec<Uuid> = job_ids[next_result..]
.iter()
.filter(|id| !results.contains_key(id))
.copied()
.collect();
if !pending.is_empty() {
let completed = sqlx::query!(
"SELECT id, status = 'success' AS \"success!\", result AS \"result: Json<Box<RawValue>>\"
FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)",
ctx.job.workspace_id,
&pending,
).fetch_all(ctx.db).await?;
for completed in completed {
let index = job_ids
.iter()
.position(|id| *id == completed.id)
.ok_or_else(|| Error::internal_err("Unexpected tool completion".to_string()))?;
let (call, tool) = batch[index];
let result = completed
.result
.map(|value| value.0)
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
let is_agent = tool.module.as_ref().is_some_and(|module| {
matches!(module.get_value(), Ok(FlowModuleValue::AIAgent { .. }))
});
let content = if is_agent && completed.success {
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
} else {
result.get().to_string()
};
if let Some(processor) = ctx.stream_event_processor {
processor
.send(
StreamingEvent::ToolResult {
call_id: call.id.clone(),
function_name: call.function.name.clone(),
result: content.clone(),
success: completed.success,
},
final_events_str,
)
.await?;
}
results.insert(completed.id, (completed.success, content));
}
}
// Transcript rows, model messages, and positional action statuses share call order.
while next_result < batch.len() {
let job_id = job_ids[next_result];
let Some((success, content)) = results.remove(&job_id) else {
break;
};
let (call, tool) = batch[next_result];
let module = tool
.module
.as_ref()
.ok_or_else(|| Error::internal_err("Windmill tool has no module".to_string()))?;
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(content.clone())),
tool_call_id: Some(call.id.clone()),
agent_action: Some(AgentAction::ToolCall {
job_id,
function_name: call.function.name.clone(),
module_id: module.id.clone(),
}),
..Default::default()
});
if let Some(parent) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent, success).await?;
}
let (content, extras) = windmill_tool_row(call, success, &content);
add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await;
next_result += 1;
}
if local.is_none() && next_result < batch.len() {
let pending: Vec<Uuid> = job_ids[next_result..]
.iter()
.filter(|id| !results.contains_key(id))
.copied()
.collect();
if !pending.is_empty() {
local = claim_local_tool(ctx, &pending).await?;
}
}
}
let (content, extras) = windmill_tool_row(tool_call, false, &error_message);
add_tool_message_to_chat(ctx, Some(job_id), &content, false, Some(extras)).await;
Ok(())
}
async fn claim_local_tool(
ctx: &ToolExecutionContext<'_>,
pending: &[Uuid],
) -> Result<Option<tokio::task::JoinHandle<Result<OccupancyMetrics, Error>>>, Error> {
let query = (String::new(), make_tool_job_pull_query(pending));
#[cfg(feature = "benchmark")]
let mut bench = windmill_common::bench::BenchmarkIter::new();
let mut pulled = pull(
ctx.db,
false,
ctx.worker_name,
Some(&query),
#[cfg(feature = "benchmark")]
&mut bench,
)
.await?;
if let Err(err) = pulled.maybe_apply_debouncing(ctx.db).await {
pulled.error_while_preprocessing = Some(err.to_string());
}
match pulled.to_pulled_job() {
Ok(job) => Ok(job.map(|job| spawn_local_tool(ctx, job.job, false))),
Err(
windmill_queue::PulledJobResultToJobErr::MissingConcurrencyKey(job)
| windmill_queue::PulledJobResultToJobErr::ErrorWhilePreprocessing(job),
) => {
ctx.job_completed_tx.send_job(job, true).await?;
Ok(None)
}
}
}
/// Extract the `output` field of an `AIAgentResult` envelope, serialized back to JSON.
/// Returns `None` if the result is not a JSON object carrying an `output` field.
fn extract_ai_agent_output(result: &RawValue) -> Option<String> {
@@ -728,119 +859,6 @@ fn extract_ai_agent_output(result: &RawValue) -> Option<String> {
.map(|output| output.get().to_string())
}
/// Handle tool execution success
async fn handle_tool_execution_success(
ctx: &mut ToolExecutionContext<'_>,
tool_call: &OpenAIToolCall,
tool_module: &windmill_common::flows::FlowModule,
job_id: Uuid,
success: bool,
is_ai_agent_tool: bool,
inner_job_completed_rx: JobCompletedReceiver,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok();
let (result, job_success) = if let Some(SendResult {
result: SendResultPayload::JobCompleted(ref jc),
..
}) = send_result
{
let result = jc.result.clone();
// Write tool completion to the DB inline instead of forwarding through
// the parent channel. Forwarding would deadlock for nested agents: the
// sub-tool result would fill the parent's bounded(1) channel, leaving
// no room for the agent's own completion from process_result.
if jc.success {
add_completed_job(
ctx.db,
&jc.job,
true,
false,
sqlx::types::Json(&*jc.result),
jc.result_columns.clone(),
jc.mem_peak,
jc.canceled_by.clone(),
false,
jc.duration,
jc.from_cache.unwrap_or(false),
)
.await
.map_err(|e| Error::internal_err(format!("Failed to add completed job: {e}")))?;
} else {
let error_value: serde_json::Value =
serde_json::from_str(jc.result.get()).unwrap_or_else(|_| {
serde_json::json!({ "message": format!("Non serializable error: {}", jc.result.get()) })
});
add_completed_job_error(
ctx.db,
&jc.job,
jc.mem_peak,
jc.canceled_by.clone(),
error_value,
ctx.worker_name,
false,
jc.duration,
)
.await
.map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?;
}
(result, jc.success)
} else {
return Err(Error::internal_err(
"Tool job completed but no result".to_string(),
));
};
// A nested agent returns the whole `AIAgentResult` envelope: on top of `output` it carries
// the child's entire message history, stream log and token usage. Feeding that back would
// grow the caller's context by the child's full transcript on every call, so the caller only
// sees `output`. The envelope stays intact in the tool job's completed row.
let tool_result = if is_ai_agent_tool && job_success {
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
} else {
result.get().to_string()
};
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(tool_result.clone())),
tool_call_id: Some(tool_call.id.clone()),
agent_action: Some(AgentAction::ToolCall {
job_id,
function_name: tool_call.function.name.clone(),
module_id: tool_module.id.clone(),
}),
..Default::default()
});
let (content, extras) = windmill_tool_row(tool_call, success, &tool_result);
// The job ran; whether it ran successfully is `success`, and the row stored below is
// worded from it. The stream has to carry the same value, or the card the reader watches
// and the row that replaces it describe the same call differently.
if let Some(stream_event_processor) = ctx.stream_event_processor {
let tool_result_event = StreamingEvent::ToolResult {
call_id: tool_call.id.clone(),
function_name: tool_call.function.name.clone(),
result: tool_result,
success,
};
stream_event_processor
.send(tool_result_event, final_events_str)
.await?;
}
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?;
}
add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await;
Ok(())
}
/// A Windmill tool's conversation row: worded from the tool, carrying the model's call and
/// the exact text the model got back, the same text agent memory keeps for that tool
/// message, so a card needs no job fetch. The call is the model's arguments, not the job's
@@ -905,9 +923,7 @@ async fn add_tool_message_to_chat(
.or(ctx.job.flow_step_id.as_deref());
let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id);
// Awaited, not spawned: `created_seq` is the transcript's order, so a round's rows
// must commit in the order of its calls. Calls run one after another; running them
// in parallel would need their rows written in call order all the same.
// created_seq defines transcript order, so these writes must stay sequential.
if let Err(e) = add_message_to_conversation(
ctx.db,
&memory_id,
+44 -37
View File
@@ -504,6 +504,7 @@ pub async fn handle_ai_agent_job(
hostname: &str,
killpill_rx: &mut tokio::sync::broadcast::Receiver<()>,
has_stream: &mut bool,
job_completed_tx: crate::JobCompletedSender,
) -> Result<Box<RawValue>, Error> {
// build_args_map returns None if no $res:/$var: transforms needed, in which case use original args
let local_args = match build_args_map(job, client, conn).await? {
@@ -1010,6 +1011,7 @@ pub async fn handle_ai_agent_job(
omit_output_from_conversation,
cancel_rx,
tool_abort_handles.clone(),
job_completed_tx,
);
let mut occupancy_opt = Some(occupancy_metrics);
@@ -1028,13 +1030,10 @@ pub async fn handle_ai_agent_job(
cancel_tx,
CANCEL_GRACE_PERIOD,
)
.await?
.await
};
// agent_fut and update_job are now dropped — borrows on mcp_clients and canceled_by released
// Cleanup MCP clients
cleanup_mcp_clients(mcp_clients).await;
let format_cancel_info = |cb: &Option<CanceledBy>| {
cb.as_ref()
.map_or(("unknown".to_string(), "unknown".to_string()), |x| {
@@ -1045,38 +1044,42 @@ pub async fn handle_ai_agent_job(
})
};
match outcome {
GracefulPollOutcome::Ok(result) => Ok(result),
GracefulPollOutcome::Timeout(ms) => {
tracing::error!("AI agent timeout after {}s", ms / 1000);
Err(Error::ExecutionErr(format!(
"AI agent timeout after (>{}s)",
ms / 1000
)))
}
GracefulPollOutcome::Cancelled { canceled_by: cb } => {
let (by, reason) = format_cancel_info(&cb);
Err(Error::ExecutionErr(format!(
"Job cancelled by {by} (reason: {reason})"
)))
}
GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => {
let (by, reason) = format_cancel_info(&cb);
// Abort any still-running spawned tool tasks
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
for handle in tool_abort_handles.lock().unwrap().drain(..) {
handle.abort();
let result = match outcome {
Err(error) => Err(error),
Ok(outcome) => match outcome {
GracefulPollOutcome::Ok(result) => Ok(result),
GracefulPollOutcome::Timeout(ms) => {
tracing::error!("AI agent timeout after {}s", ms / 1000);
Err(Error::ExecutionErr(format!(
"AI agent timeout after (>{}s)",
ms / 1000
)))
}
// Hard timeout: clean up orphaned jobs still stuck in v2_job_queue
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, cb).await;
Err(Error::ExecutionErr(format!(
"Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)"
)))
}
GracefulPollOutcome::AlreadyCompleted => {
Err(Error::AlreadyCompleted("Job already completed".to_string()))
GracefulPollOutcome::Cancelled { canceled_by: cb } => {
let (by, reason) = format_cancel_info(&cb);
Err(Error::ExecutionErr(format!(
"Job cancelled by {by} (reason: {reason})"
)))
}
GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => {
let (by, reason) = format_cancel_info(&cb);
Err(Error::ExecutionErr(format!(
"Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)"
)))
}
GracefulPollOutcome::AlreadyCompleted => {
Err(Error::AlreadyCompleted("Job already completed".to_string()))
}
},
};
if result.is_err() {
for handle in tool_abort_handles.lock().unwrap().drain(..) {
handle.abort();
}
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, canceled_by.clone()).await;
}
cleanup_mcp_clients(mcp_clients).await;
result
}
/// OpenAI rejects a `prompt_cache_key` over 64 characters
@@ -1129,6 +1132,7 @@ pub async fn run_agent(
// abort handles for spawned tool tasks
tool_abort_handles: ToolAbortHandles,
job_completed_tx: crate::JobCompletedSender,
) -> error::Result<Box<RawValue>> {
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
let credentials = args.provider.to_provider_credentials(db).await?;
@@ -1859,6 +1863,7 @@ pub async fn run_agent(
previous_result: &previous_result,
id_context: &id_context,
tool_abort_handles: tool_abort_handles.clone(),
job_completed_tx: job_completed_tx.clone(),
};
let (tool_messages, tool_content, tool_used_structured_output) =
@@ -2758,11 +2763,13 @@ async fn cleanup_orphaned_tool_jobs(
)
});
// Find direct child jobs still in v2_job_queue (agent tool jobs are always direct children)
let orphaned_ids: Vec<Uuid> = match sqlx::query_scalar!(
r#"SELECT j.id FROM v2_job j
JOIN v2_job_queue q ON q.id = j.id
WHERE j.parent_job = $1 AND j.workspace_id = $2"#,
r#"WITH RECURSIVE descendants AS (
SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2
UNION ALL
SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id
WHERE j.workspace_id = $2
) SELECT d.id AS "id!" FROM descendants d JOIN v2_job_queue q ON q.id = d.id"#,
parent_job_id,
w_id,
)
+27 -4
View File
@@ -3859,8 +3859,6 @@ pub async fn run_worker(
let arc_job = Arc::new(job);
windmill_common::sensitive_log_masks::register_running_job(arc_job.id);
let span = create_span_with_name(&arc_job, &worker_name, Some(hostname), "job");
let log_ctx = log_context_for_job(&arc_job, &worker_name, Some(hostname));
@@ -3976,8 +3974,6 @@ pub async fn run_worker(
_ => {}
}
windmill_common::sensitive_log_masks::unregister_running_job(job_id);
#[cfg(feature = "prometheus")]
if let Some(duration) = _timer.map(|x| x.stop_and_record()) {
register_metric(
@@ -4512,6 +4508,30 @@ async fn detect_and_store_runtime_assets_from_job_args(
}
}
/// Holds a job's entry in the log-masking registry for as long as it executes, so
/// that secrets it fetches can be registered against it, and masks the job's own
/// token from the start: `$WM_TOKEN` stays valid well past the run, and a script
/// that echoes it would otherwise leave a live credential in the persisted logs.
///
/// Lives here rather than at the call sites so that every way of running a job —
/// the poller, the interactive worker shell, an inline AI agent tool — is covered
/// by construction.
struct RunningJobMasks(Uuid);
impl RunningJobMasks {
fn register(job_id: Uuid, token: &str) -> Self {
windmill_common::sensitive_log_masks::register_running_job(job_id);
windmill_common::sensitive_log_masks::register_secret_for_job(job_id, token);
RunningJobMasks(job_id)
}
}
impl Drop for RunningJobMasks {
fn drop(&mut self) {
windmill_common::sensitive_log_masks::unregister_running_job(self.0);
}
}
pub async fn handle_queued_job(
job: Arc<MiniPulledJob>,
raw_code: Option<String>,
@@ -4533,6 +4553,8 @@ pub async fn handle_queued_job(
flow_runners: Option<Arc<FlowRunners>>,
#[cfg(feature = "benchmark")] _bench: &mut BenchmarkIter,
) -> windmill_common::error::Result<JobOutcome> {
let _masks = RunningJobMasks::register(job.id, &client.token);
if job.canceled_by.is_some() {
return Err(Error::JsonErr(canceled_job_to_result(&job)));
}
@@ -4951,6 +4973,7 @@ pub async fn handle_queued_job(
hostname,
killpill_rx,
&mut has_stream,
job_completed_tx.clone(),
))
.await
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.814.0";
export const VERSION = "v1.815.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-chat",
"version": "1.814.0",
"version": "1.815.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-chat",
"version": "1.814.0",
"version": "1.815.0",
"license": "Apache-2.0",
"devDependencies": {
"@ai-sdk/react": "^4.0.102",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-chat",
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
"version": "1.814.0",
"version": "1.815.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.814.0";
export const VERSION = "1.815.0";
+59 -4
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.814.0",
"version": "1.815.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.814.0",
"version": "1.815.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -1733,6 +1733,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1749,6 +1750,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1765,6 +1767,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1781,6 +1784,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1797,6 +1801,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1813,6 +1818,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1829,6 +1835,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1845,6 +1852,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1861,6 +1869,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1877,6 +1886,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1893,6 +1903,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1909,6 +1920,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1925,6 +1937,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1941,6 +1954,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7534,7 +7548,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -8240,6 +8254,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8260,6 +8275,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8280,6 +8296,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8300,6 +8317,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8320,6 +8338,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8340,6 +8359,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8360,6 +8380,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8380,6 +8401,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8400,6 +8422,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8420,6 +8443,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8440,6 +8464,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -13187,6 +13212,21 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -13397,6 +13437,21 @@
"svelte": "^4.2.19 || ^5.1.0"
}
},
"node_modules/svelte/node_modules/@typescript-eslint/types": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz",
"integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/svelte/node_modules/esrap": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz",
@@ -13983,7 +14038,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.814.0",
"version": "1.815.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",
@@ -3,13 +3,11 @@
import Select from './select/Select.svelte'
import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib'
import type { AIProvider, ProviderConfig } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import ResourcePicker from './ResourcePicker.svelte'
import Toggle from './Toggle.svelte'
import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage'
import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
value: ProviderConfig | undefined
@@ -28,7 +26,7 @@
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace ?? '')
let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '')
let value = $derived.by(() => {
if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined
@@ -2,6 +2,7 @@
import { ExternalLink, Globe } from 'lucide-svelte'
import { JobService, type Job } from '$lib/gen'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { msToReadableTimeShort } from '$lib/utils'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
@@ -9,9 +10,6 @@
import GfmMarkdown from './GfmMarkdown.svelte'
import type { AgentTraceEntry } from './agentTrace'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
entries: AgentTraceEntry[]
@@ -42,7 +40,7 @@
try {
jobs.set(
jobId,
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $operatingWorkspace! })
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $workspaceStore! })
)
} catch {
// A tool job can be gone (retention) or unreadable. The row still has its
@@ -123,7 +121,7 @@
{:else if entry.jobId}
<a
class="text-2xs text-accent inline-flex items-center gap-1 w-fit hover:underline"
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $operatingWorkspace}"
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $workspaceStore}"
target="_blank"
rel="noreferrer"
>
@@ -1,6 +1,7 @@
<script lang="ts">
import { OauthService, type ResourceType } from '$lib/gen'
import FilesetEditor from './FilesetEditor.svelte'
import { workspaceStore } from '$lib/stores'
import { emptySchema, emptyString } from '$lib/utils'
import SchemaForm from './SchemaForm.svelte'
import Toggle from './Toggle.svelte'
@@ -19,9 +20,6 @@
import { base } from '$lib/base'
import { isDataTableWizardEnabled } from './workspaceSettings/utils.svelte'
import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
resourceType: string
@@ -154,7 +152,7 @@
}
}
$effect(() => {
$operatingWorkspace && untrack(() => loadSchema())
$workspaceStore && untrack(() => loadSchema())
})
$effect(() => {
notFound && rawCode && untrack(() => parseJson())
@@ -1,7 +1,7 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { userStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import LabelsInput from './LabelsInput.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import {
@@ -53,9 +53,6 @@
import Label from './Label.svelte'
import ResourcePathHint from './ResourcePathHint.svelte'
import SchemaForm from './SchemaForm.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
step?: number
@@ -87,7 +84,7 @@
fillPath = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace!)
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let isValid = $state(true)
+3 -5
View File
@@ -1,14 +1,12 @@
<script lang="ts">
import { ResourceService, VariableService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { copyToClipboard, truncate } from '$lib/utils'
import { ClipboardCopy, Expand } from 'lucide-svelte'
import Drawer from './common/drawer/Drawer.svelte'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import Tooltip from './Tooltip.svelte'
import { Button, DrawerContent } from './common'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
value: any
@@ -24,14 +22,14 @@
async function getResource(path: string) {
jsonViewerContent = await ResourceService.getResourceValue({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
path
})
}
async function getVariable(path: string) {
jsonViewerContent = await VariableService.getVariableValue({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
path
})
}
+2 -4
View File
@@ -41,14 +41,12 @@
import { safeSelectItems } from './select/utils.svelte'
import S3ArgInput from './common/fileUpload/S3ArgInput.svelte'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { getJsonSchemaFromResource } from './schema/jsonSchemaResource.svelte'
import AIProviderPicker from './AIProviderPicker.svelte'
import TextInput from './text_input/TextInput.svelte'
import FileInput from './common/fileInput/FileInput.svelte'
import { randomUUID } from '$lib/utils/uuid'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
label?: string
@@ -825,7 +823,7 @@
/>
{/await}
{:else if inputCat == 'object' && format?.startsWith('jsonschema-')}
{#await getJsonSchemaFromResource(format.substring('jsonschema-'.length), workspace ?? $operatingWorkspace ?? '')}
{#await getJsonSchemaFromResource(format.substring('jsonschema-'.length), workspace ?? $workspaceStore ?? '')}
<Loader2 class="animate-spin" />
{:then schema}
{#if !schema || !schema.properties}
@@ -1,11 +1,9 @@
<script lang="ts">
import { JobService, type FlowValue } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { tryEvery } from '$lib/utils'
import { Check, LoaderCircle, Server, X, Cpu } from 'lucide-svelte'
import Button from './common/button/Button.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface CredentialsCheckResult {
available: boolean
@@ -30,7 +28,7 @@
apiResult = null
try {
const response = await fetch(`/api/w/${$operatingWorkspace}/ai/check_bedrock_credentials`)
const response = await fetch(`/api/w/${$workspaceStore}/ai/check_bedrock_credentials`)
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
@@ -80,7 +78,7 @@
}
const job = await JobService.runFlowPreview({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
requestBody: {
value: flowValue as unknown as FlowValue,
args: {}
@@ -90,7 +88,7 @@
tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
id: job
})
@@ -132,7 +130,7 @@
workerStatus = 'error'
try {
await JobService.cancelQueuedJob({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
id: job,
requestBody: {
reason: 'Timeout checking Bedrock credentials'
@@ -1,11 +1,9 @@
<script lang="ts">
import Select from './select/Select.svelte'
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { RefreshCcw } from 'lucide-svelte'
import { Button } from './common'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface ChannelItem {
channel_id?: string
@@ -23,8 +21,8 @@
showRefreshButton?: boolean
onError?: (error: Error) => void
onSelectedChannelChange?: (channel: ChannelItem | undefined) => void
/** Workspace to list Teams channels from; defaults to the operating workspace (see
* `useOperatingWorkspace`). */
/** Workspace to list Teams channels from; defaults to the nav
* `$workspaceStore`. A forked session passes its acting workspace. */
workspace?: string
}
@@ -42,7 +40,7 @@
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace)
let effectiveWorkspace = $derived(workspace ?? $workspaceStore)
let isFetching = $state(false)
let loadedChannels = $state<ChannelItem[]>([])
@@ -1,9 +1,7 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import ClipboardPanel from './details/ClipboardPanel.svelte'
import Section from './Section.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
let url = $derived(`${window.location.protocol}//${window.location.hostname}/`)
</script>
@@ -22,7 +20,7 @@
<span class="font-medium">Setup the wmill cli for this workspace & remote:</span>
<div class="mt-1">
<ClipboardPanel
content={`wmill workspace add ${$operatingWorkspace} ${$operatingWorkspace} ${url}`}
content={`wmill workspace add ${$workspaceStore} ${$workspaceStore} ${url}`}
/>
</div>
</li>
+2 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { superadmin, type DBSchema } from '$lib/stores'
import { superadmin, userStore, type DBSchema } from '$lib/stores'
import {
ChevronDownIcon,
EditIcon,
@@ -33,10 +33,6 @@
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Star from './Star.svelte'
import type { Asset } from '$lib/gen'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
/** Represents a selected table with its schema */
export interface SelectedTable {
@@ -648,7 +644,7 @@
Import schema from database
</span>
</button>
{#if !!actingUser?.is_admin || !!$superadmin}
{#if !!$userStore?.is_admin || !!$superadmin}
<button
onclick={() => onImport('schema_and_data')}
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
@@ -1,5 +1,5 @@
<script lang="ts">
import { dbSchemas, type DBSchema } from '$lib/stores'
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import { sortArray } from '$lib/utils'
import { Loader2, RefreshCcw } from 'lucide-svelte'
import Alert from './common/alert/Alert.svelte'
@@ -29,9 +29,6 @@
import { createAsyncConfirmationModal } from './common/confirmationModal/asyncConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { outOfOrderRunMessage } from './workspaceSettings/datatableMigrationUtils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
input?: DbInput
@@ -47,8 +44,9 @@
/** Tables that are already added and should show as disabled */
disabledTables?: SelectedTable[]
onImport?: (mode: 'schema_and_data' | 'schema_only') => void
/** Workspace the datatable/schema lookups run against. Defaults to the operating
* workspace (see `useOperatingWorkspace`). */
/** Workspace the datatable/schema lookups run against. Defaults to the
* navigation `$workspaceStore`; pass the acting workspace when embedded in
* a session preview whose workspace differs from the top nav. */
workspace?: string
/** Worker tag every job of this manager runs on, overriding the database
* language's native tag. Bound so the hints below can offer to set it. */
@@ -70,7 +68,7 @@
workerTag = $bindable()
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[schemaCacheKey(input)])
+6 -8
View File
@@ -66,6 +66,7 @@
</script>
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { createGrid, type GridApi, type IDatasource } from 'ag-grid-community'
import { transformColumnDefs } from './apps/components/display/table/utils'
@@ -81,9 +82,6 @@
import 'ag-grid-community/styles/ag-theme-alpine.css'
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
import { untrack } from 'svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
type Props = {
dbTableOps: IDbTableOps
@@ -103,7 +101,7 @@
let datasource: IDatasource = {
getRows: async function (params) {
if (!$operatingWorkspace) return params.failCallback()
if (!$workspaceStore) return params.failCallback()
let lastRow = rowCount && rowCount <= params.endRow ? rowCount : -1
const items = await dbTableOps.getRows({
@@ -134,7 +132,7 @@
minWidth: 150,
editable: true,
onCellValueChanged: (e) => {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
const colDef = e.colDef as unknown as { field: string; datatype: string }
dbTableOps
.onUpdate?.(
@@ -171,7 +169,7 @@
let prevUpdateKey: any = undefined
$effect(() => {
if (!$operatingWorkspace || !api) return
if (!$workspaceStore || !api) return
const key = { quicksearch, colDefs: dbTableOps.colDefs, refreshCount, rowFilter }
if (deepEqual(key, prevUpdateKey)) return
prevUpdateKey = key
@@ -193,7 +191,7 @@
),
...(dbTableOps.onDelete && {
onDelete: (values) => {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
dbTableOps
.onDelete?.({ values })
.then(() => {
@@ -251,7 +249,7 @@
columnDefs={dbTableOps.colDefs ?? []}
dbType={dbTableOps.dbType}
onInsert={(values) => {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
dbTableOps.onInsert?.({ values }).then((result) => {
refresh?.()
sendUserToast('Row inserted')
@@ -1,12 +1,10 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import Select from './select/Select.svelte'
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
value?: string | undefined
@@ -31,7 +29,7 @@
}: Props = $props()
let datatables = usePromise(() =>
WorkspaceService.listDataTables({ workspace: $operatingWorkspace ?? '' }).then((d) =>
WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' }).then((d) =>
d.map((d) => d.name)
)
)
@@ -1,13 +1,10 @@
<script lang="ts">
import { userStore } from '$lib/stores'
import { SettingsIcon } from 'lucide-svelte'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import DefaultScriptsInner from './DefaultScriptsInner.svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
placement?: 'left' | 'right'
@@ -20,7 +17,7 @@
let drawer: Drawer | undefined = $state()
</script>
{#if actingUser?.is_admin || actingUser?.is_super_admin}
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Drawer bind:this={drawer} {placement}>
<DrawerContent title="Edit Default Scripts" on:close={drawer?.closeDrawer}>
<DefaultScriptsInner />
@@ -1,13 +1,10 @@
<script lang="ts">
import { WorkspaceService, type Script, type WorkspaceDefaultScripts } from '$lib/gen'
import { defaultScripts } from '$lib/stores'
import { defaultScripts, workspaceStore } from '$lib/stores'
import { flip } from 'svelte/animate'
import Toggle from './Toggle.svelte'
import { defaultScriptLanguages } from '$lib/scripts'
import Alert from './common/alert/Alert.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
small?: boolean
@@ -32,7 +29,7 @@
}
defaultScripts.update((s) => ({ ...s, order: norder }))
await WorkspaceService.editDefaultScripts({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
requestBody: $defaultScripts
})
}
@@ -1,12 +1,10 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import Select from './select/Select.svelte'
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
value?: string | undefined
@@ -31,8 +29,9 @@
}: Props = $props()
let ducklakes = usePromise(() =>
WorkspaceService.listDucklakes({ workspace: $operatingWorkspace ?? '' })
WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' })
)
</script>
<div class={className}>
@@ -47,6 +46,9 @@
{onClear}
/>
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'ducklake', path: value })}
<ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'ducklake', path: value }} />
<ExploreAssetButton
class="mt-1 w-fit"
asset={{ kind: 'ducklake', path: value }}
/>
{/if}
</div>
@@ -4,6 +4,7 @@
const bubble = createBubbler()
import type { Schema } from '$lib/common'
import { VariableService, type ScriptLang } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
@@ -35,9 +36,6 @@
import Section from '$lib/components/Section.svelte'
import Editor from './Editor.svelte'
import AddPropertyV2 from './schema/AddPropertyV2.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
// export let openEditTab: () => void = () => {}
const dispatch = createEventDispatcher()
@@ -130,7 +128,7 @@
workspace = undefined
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
$effect.pre(() => {
if (args == undefined) {
+10 -12
View File
@@ -42,6 +42,7 @@
import { editorConfig, registerWebviewPaste, updateOptions } from '$lib/editorUtils'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { createHash as randomHash } from '$lib/editorLangUtils'
import { workspaceStore } from '$lib/stores'
import DdlMigrationGuard from './DdlMigrationGuard.svelte'
import {
type Preview,
@@ -119,9 +120,6 @@
import { rawAppLintStore, type MonacoLintError } from './raw_apps/lintStore'
import { MarkerSeverity } from 'monaco-editor'
import { resource, useDebounce, watch } from 'runed'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
// import EditorTheme from './EditorTheme.svelte'
let divEl: HTMLDivElement | null = $state(null)
@@ -686,7 +684,7 @@
// via a short-TTL cache — macros are late-bound, so mild staleness is fine.
async function addWorkspaceMacroCompletions() {
workspaceMacroCompletor?.dispose()
const workspace = $operatingWorkspace
const workspace = $workspaceStore
if (!workspace) return
let macros: Awaited<ReturnType<typeof listWorkspaceMacrosCached>> = []
try {
@@ -741,7 +739,7 @@
provideCompletionItems: async function (model, position) {
// Read the store per request, not at registration — the provider
// outlives a workspace switch.
const workspace = $operatingWorkspace
const workspace = $workspaceStore
if (!workspace) return { suggestions: [] }
const before = model.getLineContent(position.lineNumber).slice(0, position.column - 1)
if (!/^\s*(\/\/|--|#)\s*(column|data_test|on|materialize)\b/.test(before)) {
@@ -782,7 +780,7 @@
$dbSchemas[resourcePath] = await getDbSchemas(
lang === 'graphql' ? 'graphql' : (scriptLang ?? ''),
resourcePath,
$operatingWorkspace,
$workspaceStore,
(e) => console.error(`error getting ${lang} (${scriptLang}) db schema`, e),
{ customTag }
)
@@ -1780,9 +1778,9 @@
let customTsTypesData = resource([() => lang], async () => {
if (lang !== 'typescript') return undefined
let datatables = (
await WorkspaceService.listDataTables({ workspace: $operatingWorkspace ?? '' })
await WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' })
).map((d) => d.name)
let ducklakes = await WorkspaceService.listDucklakes({ workspace: $operatingWorkspace ?? '' })
let ducklakes = await WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' })
return { datatables, ducklakes }
})
function setTypescriptCustomTypes() {
@@ -1824,7 +1822,7 @@
scriptLang === 'nativets')
) {
const resourceTypes = await ResourceService.listResourceType({
workspace: $operatingWorkspace ?? ''
workspace: $workspaceStore ?? ''
})
const namespace = formatResourceTypes(
@@ -2025,7 +2023,7 @@
$lspTokenStore = newToken
token = newToken
}
let root = hostname + '/api/scripts_u/tokened_raw/' + $operatingWorkspace + '/' + token
let root = hostname + '/api/scripts_u/tokened_raw/' + $workspaceStore + '/' + token
return root
}
@@ -2276,10 +2274,10 @@
<svelte:window onkeydown={onKeyDown} />
<EditorTheme />
{#if datatableForMigrations && $operatingWorkspace}
{#if datatableForMigrations && $workspaceStore}
<DdlMigrationGuard
bind:this={ddlGuard}
workspace={$operatingWorkspace}
workspace={$workspaceStore}
datatable={datatableForMigrations}
/>
{/if}
+5 -6
View File
@@ -22,6 +22,7 @@
<script lang="ts">
import { ResourceService, VariableService, WorkspaceService, type Script } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
import type Editor from './Editor.svelte'
import ItemPicker from './ItemPicker.svelte'
@@ -75,9 +76,6 @@
import FlowInlineScriptAiButton from './copilot/FlowInlineScriptAIButton.svelte'
import GitRepoPopoverPicker from './GitRepoPopoverPicker.svelte'
import { insertDelegateToGitRepoInCode } from '$lib/ansibleUtils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
lang: SupportedLanguage | 'bunnative' | undefined
@@ -123,8 +121,9 @@
right?: import('svelte').Snippet
openAiChat?: boolean
moduleId?: string
// Workspace to scope variable/resource/data-table lookups to. Defaults to the
// operating workspace (see `useOperatingWorkspace`).
// Workspace to scope variable/resource/data-table lookups to. Defaults to
// the nav `$workspaceStore`; an AI-session live editor passes the session's
// acting workspace (a fork) so the helper pickers hit the right workspace.
workspace?: string
}
@@ -154,7 +153,7 @@
workspace = undefined
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
let contextualVariablePicker: ItemPicker | undefined = $state()
let variablePicker: ItemPicker | undefined = $state()
@@ -15,15 +15,7 @@
} from '$lib/components/workspacePicker'
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import { isOwner } from '$lib/utils'
import { userStore } from '$lib/stores'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
import { userStore, workspaceStore } from '$lib/stores'
interface Props {
summary?: string
@@ -55,8 +47,9 @@
* dropped, leaving only the summary. Used by the condensed session-
* preview top bar to save vertical room. */
hidePath?: boolean
/** Workspace whose items the breadcrumb picker lists; defaults to the operating
* workspace (see `useOperatingWorkspace`). */
/** Workspace whose items the breadcrumb picker lists. Session live
* editors pass their acting workspace so the picker isn't scoped to the
* navigation workspace; falls back to $workspaceStore in the picker. */
workspaceId?: string
}
@@ -123,7 +116,7 @@
// Treat an empty path as ownable so the pen popover lets a user pick the
// path for a brand-new item. `Path.reset()` then synthesizes a default
// under their own user/folder scope.
let own = $derived(!path || isOwner(path, actingUser, $operatingWorkspace))
let own = $derived(!path || isOwner(path, $userStore, $workspaceStore))
// Virtual entry for the picker: surfaces the currently-edited item at its
// live path (which may differ from `savedPath` mid-rename, so the picker
@@ -31,7 +31,7 @@
import type { Schema, SupportedLanguage } from '$lib/common'
import { base } from '$lib/base'
import { enterpriseLicense } from '$lib/stores'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import MsTeamsIcon from '$lib/components/icons/MSTeamsIcon.svelte'
import { classNames, emptySchema, emptyString, sendUserToast, tryEvery } from '$lib/utils'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
@@ -61,9 +61,6 @@
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'
const operatingWorkspace = useOperatingWorkspace()
const slackRecoveryHandler = hubPaths.slackRecoveryHandler
const slackHandlerScriptPath = hubPaths.slackErrorHandler
@@ -84,8 +81,9 @@
customHandlerKind?: 'flow' | 'script'
customTabTooltip?: import('svelte').Snippet
noMargin?: boolean
/** Workspace for handler lookup / settings / test jobs. Defaults to the operating
* workspace (see `useOperatingWorkspace`). */
/** Workspace for handler lookup / settings / test jobs. Defaults to the
* nav `$workspaceStore`; a trigger editor in a forked session passes its
* acting workspace so the handler is resolved and saved there. */
workspace?: string
/** Offer the instance critical alert channels as a destination. Workspace-level
* error handling only: schedules and triggers have no such setting. */
@@ -108,12 +106,13 @@
showInstanceAlerts = false
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace)
// Carry the workspace onto the "create from template" route, so the handler script is
// created where this handler is saved. `customScriptTemplate` already has a query string.
let effectiveWorkspace = $derived(workspace ?? $workspaceStore)
// Carry the acting workspace onto the "create from template" route when an
// explicit override is set, so a forked session creates the handler script
// there. `customScriptTemplate` already has a query string (`?hub=…`).
let templateHref = $derived(
effectiveWorkspace
? `${customScriptTemplate}&workspace=${encodeURIComponent(effectiveWorkspace)}`
workspace
? `${customScriptTemplate}&workspace=${encodeURIComponent(workspace)}`
: customScriptTemplate
)
@@ -33,18 +33,15 @@
import { Button, ButtonType } from '$lib/components/common'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import { VolumeService } from '$lib/gen'
import { globalDbManagerDrawer, globalS3FilePickerExplorer } from '$lib/stores'
import {
globalDbManagerDrawer,
globalS3FilePickerExplorer,
userStore,
workspaceStore
} from '$lib/stores'
import { isS3Uri } from '$lib/utils'
import { Database, File, HardDriveIcon } from 'lucide-svelte'
import DucklakeIcon from './icons/DucklakeIcon.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const {
asset,
@@ -72,7 +69,7 @@
} = $props()
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
const assetUri = $derived(formatAsset(asset))
// Contexts with a select/upload flow pass their own picker; everything else
// (e.g. the resources list) falls back to the global read-only explorer.
@@ -83,7 +80,7 @@
</script>
<Button
disabled={actingUser?.operator || disabled}
disabled={$userStore?.operator || disabled}
unifiedSize={'md'}
variant={buttonVariant}
wrapperClasses={className}
+12 -14
View File
@@ -17,7 +17,13 @@
linkedAgentToolsVersion,
migrateLinkedAgentToolsScope
} from '$lib/components/flows/linkedAgentToolsStore.svelte'
import { enterpriseLicense, userStore, userWorkspaces, usedTriggerKinds } from '$lib/stores'
import {
enterpriseLicense,
userStore,
userWorkspaces,
workspaceStore,
usedTriggerKinds
} from '$lib/stores'
import {
generateRandomString,
orderedJsonStringify,
@@ -105,14 +111,6 @@
import { UserDraft } from '$lib/userDraft.svelte'
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let {
initialPath = $bindable(''),
@@ -160,9 +158,9 @@
// and the AutosaveIndicator all target it. Falls back to the global store, so
// the full-page editor is unchanged; the sessions preview overrides it to the
// session's (forked) workspace, so an embedded editor acts on the session's
// fork rather than the navigation workspace (`workspaceStore`, which stays put).
// fork rather than the navigation workspace ($workspaceStore, which stays put).
// indicatorPath is the matching draft path.
const opWorkspace = $derived(autosaveWorkspace ?? $operatingWorkspace)
const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath)
let initialPathStore = writable(initialPath)
@@ -609,7 +607,7 @@
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!actingUser?.is_admin || !!actingUser?.is_super_admin,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
$pathStore,
true
@@ -620,7 +618,7 @@
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!actingUser?.is_admin || !!actingUser?.is_super_admin,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
initialPath
)
@@ -1396,7 +1394,7 @@
<AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} />
{#key renderCount}
{#if !actingUser?.operator}
{#if !$userStore?.operator}
{#if $pathStore}
<FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} />
{/if}
@@ -10,12 +10,10 @@
import FlowGraphViewerStep from './FlowGraphViewerStep.svelte'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { dfs } from './flows/dfs'
import { workspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
import { publishLinkedAgentTools } from './flows/flowState'
import { linkedToolsScope } from './flows/linkedAgentToolsStore.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
flow: {
@@ -47,7 +45,7 @@
noGraph = false,
triggerNode = false,
stepDetail = $bindable(undefined),
workspace = $operatingWorkspace,
workspace = $workspaceStore,
minHeight = 400,
noBorder = false,
hideDefaultInputs = false,
@@ -12,7 +12,7 @@
import SchemaViewer from './SchemaViewer.svelte'
import { scriptPathToHref } from '$lib/scripts'
import { cleanExpr, copyToClipboard } from '$lib/utils'
import { hubBaseUrlStore } from '$lib/stores'
import { hubBaseUrlStore, workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import FlowModuleScript from './flows/content/FlowModuleScript.svelte'
@@ -20,9 +20,6 @@
import HighlightTheme from './HighlightTheme.svelte'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
import FlowGraphViewerStepHeader from './FlowGraphViewerStepHeader.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
schema?: any | undefined
@@ -44,7 +41,7 @@
workspace = undefined,
onBack = undefined
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
let codeViewer: Drawer | undefined = $state()
</script>
@@ -3,11 +3,9 @@
import { createEventDispatcher, untrack } from 'svelte'
import PopoverV2 from '$lib/components/meltComponents/Popover.svelte'
import HistoricInputs from './HistoricInputs.svelte'
import { workspaceStore } from '$lib/stores'
import { JobService } from '$lib/gen'
import { Button } from './common'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
path: string
@@ -29,7 +27,7 @@
async function loadInitial() {
loading = true
let jobs = await JobService.listJobs({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
scriptPathExact: path,
jobKinds: ['flow', 'flowpreview'].join(','),
perPage: 1
@@ -45,7 +43,7 @@
}
$effect(() => {
if ($operatingWorkspace && !newFlow) {
if ($workspaceStore && !newFlow) {
untrack(() => loadInitial())
}
})
@@ -11,6 +11,7 @@
Keyboard
} from 'lucide-svelte'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import LogViewer from './LogViewer.svelte'
import FlowLogViewer from './FlowLogViewer.svelte'
@@ -25,9 +26,6 @@
import { Tooltip } from './meltComponents'
import FlowTimelineBar from './FlowTimelineBar.svelte'
import { getActiveReplay } from './recording/replay.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
type RootJobData = Partial<Job>
@@ -98,7 +96,7 @@
function getJobLink(jobId: string | undefined): string {
if (!jobId) return ''
return `${base}/run/${jobId}?workspace=${workspaceId ?? $operatingWorkspace}`
return `${base}/run/${jobId}?workspace=${workspaceId ?? $workspaceStore}`
}
function getStatusColor(status: FlowStatusModule['type'] | undefined): string {
@@ -1,5 +1,6 @@
<script lang="ts">
import { type Job, JobService, type FlowModule, type RestartedFrom } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import { createEventDispatcher, getContext } from 'svelte'
import type { FlowEditorContext } from './flows/types'
@@ -10,9 +11,6 @@
import FlowProgressBar from './flows/FlowProgressBar.svelte'
import { CornerDownLeft, Play, RefreshCw, X } from 'lucide-svelte'
import type { Schema } from '$lib/common'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
open: boolean
@@ -167,7 +165,7 @@
try {
jobId &&
(await JobService.cancelQueuedJob({
workspace: opWorkspace?.() ?? $operatingWorkspace ?? '',
workspace: opWorkspace?.() ?? $workspaceStore ?? '',
id: jobId,
requestBody: {}
}))
@@ -6,6 +6,7 @@
type OpenFlow,
type ScriptLang
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Badge, Button } from './common'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
@@ -43,9 +44,6 @@
import FlowRestartButton from './FlowRestartButton.svelte'
import { useNestedRestartState } from './useNestedRestartState.svelte'
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
previewMode: 'upTo' | 'whole'
@@ -140,7 +138,7 @@
opWorkspace
} = $state(getContext<FlowEditorContext>('FlowEditorContext'))
// Acting workspace when previewing inside an AI session; else the nav workspace.
let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace)
let opWs = $derived(opWorkspace?.() ?? $workspaceStore)
const dispatch = createEventDispatcher()
let renderCount: number = $state(0)
@@ -3,9 +3,7 @@
import Popover from './meltComponents/Popover.svelte'
import { Play, RefreshCw } from 'lucide-svelte'
import { FlowService, JobService, type FlowVersion } from '$lib/gen'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
import { workspaceStore } from '$lib/stores'
import { emptyString, sendUserToast } from '$lib/utils'
interface Props {
@@ -168,7 +166,7 @@
flow_version: flowVersion
}
let run = await JobService.restartFlowAtStep({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
id: jobId,
requestBody
})
@@ -180,7 +178,7 @@
loadingVersions = true
try {
flowVersions = await FlowService.getFlowHistory({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
path: flowPath
})
if (flowVersions.length > 0) {
@@ -4,11 +4,8 @@
import { setContext, untrack } from 'svelte'
import type { DurationStatus, FlowStatusViewerContext, GraphModuleState } from './graph'
import { isOwner as loadIsOwner, type StateStore } from '$lib/utils'
import { userStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import type { CompletedJob, FlowModule, FlowNote, FlowValue, Job } from '$lib/gen'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
jobId: string
@@ -101,7 +98,7 @@
})
function loadOwner(path: string) {
isOwner = loadIsOwner(path, $userStore!, workspaceId ?? $operatingWorkspace!)
isOwner = loadIsOwner(path, $userStore!, workspaceId ?? $workspaceStore!)
}
async function updateJobId() {
@@ -15,6 +15,7 @@
type FlowNote,
type FlowValue
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
import FlowJobResult from './FlowJobResult.svelte'
import WorkflowTimeline from './WorkflowTimeline.svelte'
@@ -68,9 +69,6 @@
releaseLinkedToolsScope,
retainLinkedToolsScope
} from './flows/linkedAgentToolsStore.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
let {
flowState: flowStateStore,
@@ -170,7 +168,7 @@
isSubflow = false,
reducedPolling = false,
wideResults = false,
workspace = $operatingWorkspace,
workspace = $workspaceStore,
prefix = undefined,
topModuleStates = undefined,
refreshGlobal,
@@ -244,7 +242,7 @@
resourceMetadataCache[asset.path] = undefined
if (!isReplay) {
ResourceService.getResource({
workspace: workspace ?? $operatingWorkspace!,
workspace: workspace ?? $workspaceStore!,
path: asset.path
})
.then((r) => (resourceMetadataCache[asset.path] = r))
@@ -280,7 +278,7 @@
// resolving in the navigation one finds nothing, or an unrelated resource sharing the path. The
// store scope stays keyed on `workspace` to match what FlowGraphV2 reads — the job id in the key
// already makes the bucket unique.
let agentFetchWorkspace = $derived(workspaceId ?? job?.workspace_id ?? $operatingWorkspace)
let agentFetchWorkspace = $derived(workspaceId ?? job?.workspace_id ?? $workspaceStore)
// Hold this scope for as long as the viewer is mounted, so the store's cap can't drop tools the
// run still needs (nothing would refetch them — the set of linked steps hasn't changed).
$effect(() => {
@@ -629,7 +627,7 @@
) {
if (!isReplay) {
JobService.getJob({
workspace: workspaceId ?? $operatingWorkspace ?? '',
workspace: workspaceId ?? $workspaceStore ?? '',
id: mod.job ?? '',
noLogs: true,
noCode: true
@@ -724,7 +722,7 @@
})
if (!isReplay) {
JobService.getStartedAtByIds({
workspace: workspaceId ?? $operatingWorkspace ?? '',
workspace: workspaceId ?? $workspaceStore ?? '',
requestBody: missingStartedAtIds
})
.then((jobs) => {
@@ -1571,7 +1569,7 @@
let storedJob = storedListJobs[j]
if (!storedJob && !isReplay) {
storedJob = await JobService.getJob({
workspace: workspaceId ?? $operatingWorkspace ?? '',
workspace: workspaceId ?? $workspaceStore ?? '',
id: loopJobId,
noLogs: true,
noCode: true
@@ -2137,7 +2135,7 @@
id={isReplay ? undefined : job.id}
workspace={isReplay
? undefined
: (job.workspace_id ?? $operatingWorkspace ?? 'no_w')}
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
args={job.args}
/>
{:else}
@@ -2204,7 +2202,7 @@
id={isReplay ? undefined : node.job_id}
workspace={isReplay
? undefined
: (job.workspace_id ?? $operatingWorkspace ?? 'no_w')}
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
args={node.args}
/>
</div>
@@ -7,14 +7,12 @@
import { Loader2 } from 'lucide-svelte'
import { cleanValueProperties, replaceFalseWithUndefined } from '$lib/utils'
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
import { workspaceStore } from '$lib/stores'
import { watch } from 'runed'
import HighlightTheme from './HighlightTheme.svelte'
import FlowViewerInner from './FlowViewerInner.svelte'
import FlowInputViewer from './FlowInputViewer.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface PreviousFlow {
summary: string
@@ -88,7 +86,7 @@
return
}
previousFlow = await FlowService.getFlowVersion({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
version
})
previousFlowCache[version] = previousFlow
+18 -16
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { userStore, workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
type Folder,
type FolderDefaultPermissionedAs,
@@ -90,12 +89,8 @@
workspace
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const targetWorkspace = $derived(workspace ?? $operatingWorkspace ?? '')
const aimedElsewhere = $derived(!!targetWorkspace && targetWorkspace !== $workspaceStore)
// The group editor and permission history act on the operating workspace and take no
// workspace of their own, so they can only follow a drawer aimed at that one.
const offOperating = $derived(!!targetWorkspace && targetWorkspace !== $operatingWorkspace)
const targetWorkspace = $derived(workspace ?? $workspaceStore ?? '')
const aimedElsewhere = $derived(!!workspace && workspace !== $workspaceStore)
// `$userStore` describes the workspace the app is *in*. Aimed at another one it answers
// the wrong question — a folder admin there would get read-only controls, and a
@@ -126,9 +121,9 @@
})
async function loadTargetUser(): Promise<void> {
if (!aimedElsewhere) return
if (!aimedElsewhere || !workspace) return
try {
targetUser = await UserService.whoami({ workspace: targetWorkspace })
targetUser = await UserService.whoami({ workspace })
} catch {
// Not a member, or the call failed: no membership means read-only controls,
// which is the safe reading — the write would be refused anyway.
@@ -556,7 +551,7 @@
let loadStarted = false
$effect.pre(() => {
if (loadStarted) return
if (targetWorkspace && $userStore) {
if ($workspaceStore && $userStore) {
loadStarted = true
untrack(() => {
load()
@@ -680,7 +675,10 @@
class="grow min-w-0"
>
{#snippet endSnippet({ item, close: closeSelect })}
{#if ownerKind == 'group' && !offOperating}
<!-- GroupEditor reads and writes `$workspaceStore` and takes no workspace of its
own, so it cannot follow a drawer aimed at another one: viewing a group
there would edit the same-named group in the active workspace. -->
{#if ownerKind == 'group' && !aimedElsewhere}
<Button
title="View group"
variant="subtle"
@@ -698,7 +696,7 @@
{/if}
{/snippet}
{#snippet bottomSnippet({ close: closeSelect })}
{#if ownerKind == 'group' && !offOperating}
{#if ownerKind == 'group' && !aimedElsewhere}
<Button
variant="subtle"
unifiedSize="sm"
@@ -840,9 +838,11 @@
</Cell>
<Cell last actions>
<div class="flex items-center justify-end">
<!-- The group editor decides on its own whether the group is editable here; a
member with no write on it still gets to see who is in it. -->
{#if ownerKindOf(perm.owner_name) === 'group' && !offOperating}
<!-- The group editor reads `$workspaceStore`, so it can only be opened for the
workspace the app is in — see the picker's own buttons. It decides on its
own whether the group is editable here; a member with no write on it still
gets to see who is in it. -->
{#if ownerKindOf(perm.owner_name) === 'group' && !aimedElsewhere}
<Button
title="Manage group"
variant="subtle"
@@ -1001,7 +1001,9 @@
</CollapseLink>
{/if}
{#if !isNew && !offOperating && reloadHistory > 0}
<!-- PermissionHistory fetches against `$workspaceStore`; aimed elsewhere it would show
another folder's history entirely. -->
{#if !isNew && !aimedElsewhere && reloadHistory > 0}
{#key reloadHistory}
<PermissionHistory
{name}
@@ -1,7 +1,6 @@
<script lang="ts">
import { FolderService, UserService, type User } from '$lib/gen'
import { workspaceStore, userStore } from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { isDemoWorkspaceRestricted } from '$lib/cloud'
import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte'
import { Button } from './common'
@@ -40,15 +39,14 @@
workspace
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const targetWorkspace = $derived(workspace ?? $operatingWorkspace ?? '')
const targetWorkspace = $derived(workspace ?? $workspaceStore ?? '')
// `$userStore` describes the workspace the app is *in*. When this picker is aimed
// somewhere else, those memberships answer the wrong question — and since a folder
// without write access renders disabled, a stale answer makes the real folders
// unpickable. Resolve the membership for the workspace actually being listed.
let targetUser: User | undefined = $state(undefined)
const aimedElsewhere = $derived(!!targetWorkspace && targetWorkspace !== $workspaceStore)
const aimedElsewhere = $derived(!!workspace && workspace !== $workspaceStore)
const membership = $derived(aimedElsewhere ? targetUser : ($userStore ?? undefined))
const restricted = $derived(
@@ -131,9 +129,9 @@
}
async function loadTargetUser(): Promise<void> {
if (!aimedElsewhere) return
if (!workspace || workspace === $workspaceStore) return
try {
targetUser = await UserService.whoami({ workspace: targetWorkspace })
targetUser = await UserService.whoami({ workspace })
} catch {
// Not a member, or the call failed: every folder stays read-only, which is
// the safe reading — the import would be refused anyway.
@@ -1,5 +1,5 @@
<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import { workspaceStore, enterpriseLicense, userStore } from '$lib/stores'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
import { Loader2, Github, RotateCw, Plus, Minus, Download, AlertTriangle } from 'lucide-svelte'
@@ -18,14 +18,6 @@
type GitHubAppState
} from '$lib/githubApp'
import RepositorySelector from './RepositorySelector.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
resourceType: string
@@ -78,14 +70,14 @@
let showGitHubApp = $derived(
resourceType === 'git_repository' &&
$operatingWorkspace &&
(actingUser?.is_admin || actingUser?.is_super_admin)
$workspaceStore &&
($userStore?.is_admin || $userStore?.is_super_admin)
)
// Load GitHub installations when conditions are met
$effect(() => {
if (showGitHubApp && $enterpriseLicense && $operatingWorkspace) {
loadGithubInstallations(githubState, $operatingWorkspace).catch((error) => {
if (showGitHubApp && $enterpriseLicense && $workspaceStore) {
loadGithubInstallations(githubState, $workspaceStore).catch((error) => {
console.error('Failed to load GitHub installations:', error)
})
}
@@ -121,11 +113,11 @@
}
async function handleDeleteInstallation(installationId: number) {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
try {
await deleteInstallation($operatingWorkspace, installationId, () =>
loadGithubInstallations(githubState, $operatingWorkspace!)
await deleteInstallation($workspaceStore, installationId, () =>
loadGithubInstallations(githubState, $workspaceStore!)
)
} catch (error) {
console.error('Failed to delete installation:', error)
@@ -133,11 +125,11 @@
}
async function handleAddInstallation(installationId: number, workspaceId: string) {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
try {
await addInstallationToWorkspace($operatingWorkspace, installationId, workspaceId, () =>
loadGithubInstallations(githubState, $operatingWorkspace!)
await addInstallationToWorkspace($workspaceStore, installationId, workspaceId, () =>
loadGithubInstallations(githubState, $workspaceStore!)
)
} catch (error) {
console.error('Failed to add installation:', error)
@@ -145,22 +137,22 @@
}
async function handleExportInstallation(installationId: number) {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
try {
await exportInstallation($operatingWorkspace, installationId)
await exportInstallation($workspaceStore, installationId)
} catch (error) {
console.error('Failed to export installation:', error)
}
}
async function handleImportInstallation() {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
try {
await importInstallation($operatingWorkspace, githubState.importJwt, () => {
await importInstallation($workspaceStore, githubState.importJwt, () => {
githubState.importJwt = ''
loadGithubInstallations(githubState, $operatingWorkspace!)
loadGithubInstallations(githubState, $workspaceStore!)
})
} catch (error) {
console.error('Failed to import installation:', error)
@@ -168,17 +160,17 @@
}
function handleRefreshInstallations() {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
loadGithubInstallations(githubState, $operatingWorkspace).catch((error) => {
loadGithubInstallations(githubState, $workspaceStore).catch((error) => {
console.error('Failed to refresh installations:', error)
})
}
function handleInstallClickWithPopover() {
if (!$operatingWorkspace) return
if (!$workspaceStore) return
handleInstallClick(githubState, $operatingWorkspace, () => {
handleInstallClick(githubState, $workspaceStore, () => {
githubAppPopover?.open()
})
}
@@ -305,9 +297,9 @@
target="_blank"
disabled={githubState.isCheckingInstallation}
on:click={() => {
if ($operatingWorkspace) {
startInstallationCheck(githubState, $operatingWorkspace, () =>
loadGithubInstallations(githubState, $operatingWorkspace!)
if ($workspaceStore) {
startInstallationCheck(githubState, $workspaceStore, () =>
loadGithubInstallations(githubState, $workspaceStore!)
)
}
}}
@@ -1,5 +1,5 @@
<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import { workspaceStore, userStore, enterpriseLicense } from '$lib/stores'
import { GitSyncService, type GitlabProject } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Popover from './meltComponents/Popover.svelte'
@@ -8,14 +8,6 @@
import TextInput from './text_input/TextInput.svelte'
import Select from './select/Select.svelte'
import { GitBranch, Gitlab, Loader2 } from 'lucide-svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
resourceType: string
@@ -38,7 +30,7 @@
onArgsUpdate
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
let baseUrl = $state('https://gitlab.com')
let token = $state('')
@@ -58,7 +50,7 @@
let show = $derived(
resourceType === 'git_repository' &&
!!ws &&
(actingUser?.is_admin || actingUser?.is_super_admin)
($userStore?.is_admin || $userStore?.is_super_admin)
)
// The project listing is served by an enterprise-only route, so on a build
// without it the form's first request would 404. The button still shows,
@@ -1,11 +1,9 @@
<script lang="ts">
import { ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Popover } from './meltComponents'
import { GitBranch, Loader2 } from 'lucide-svelte'
import { createEventDispatcher, untrack } from 'svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
isOpen?: boolean
@@ -22,12 +20,12 @@
let gitRepoResources = $state<{ path: string; description?: string }[]>([])
async function loadGitRepoResources() {
if (!$operatingWorkspace || loading) return
if (!$workspaceStore || loading) return
loading = true
try {
const resources = await ResourceService.listResource({
workspace: $operatingWorkspace,
workspace: $workspaceStore,
resourceType: 'git_repository'
})
@@ -41,7 +39,7 @@
}
$effect(() => {
if (isOpen && $operatingWorkspace) {
if (isOpen && $workspaceStore) {
untrack(() => loadGitRepoResources())
}
})
@@ -1,12 +1,10 @@
<script lang="ts">
import { HelpersService, ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button, Drawer, DrawerContent } from './common'
import { GitBranch, Loader2, FolderOpen } from 'lucide-svelte'
import Select from './select/Select.svelte'
import { createEventDispatcher } from 'svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
open: boolean
@@ -29,7 +27,7 @@
workspace: workspaceProp = undefined
}: Props = $props()
let ws = $derived(workspaceProp ?? $operatingWorkspace)
let ws = $derived(workspaceProp ?? $workspaceStore)
const dispatch = createEventDispatcher<{
selected: {
@@ -3,13 +3,11 @@
import { untrack } from 'svelte'
import { Alert, Button } from './common'
import S3FilePickerInner from './S3FilePickerInner.svelte'
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
import { Loader, Loader2, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte'
import { hubPaths } from '$lib/hub'
import { sleep } from '$lib/utils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const CLONE_MARKER_FILE = '.windmill_clone_complete'
const POLL_INTERVAL_MS = 1500
@@ -44,7 +42,7 @@
workspace: workspaceProp = undefined
}: Props = $props()
let ws = $derived(workspaceProp ?? $operatingWorkspace)
let ws = $derived(workspaceProp ?? $workspaceStore)
let commitHash = $derived(commitHashInput)
+13 -30
View File
@@ -6,7 +6,7 @@
type Group,
type InstanceGroup
} from '$lib/gen'
import { userStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { onMount, tick, untrack } from 'svelte'
import { Button } from './common'
import Skeleton from './common/skeleton/Skeleton.svelte'
@@ -34,14 +34,6 @@
type GroupDraft,
type GroupRole
} from '$lib/groupDraft'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const ROLE_TOOLTIPS = {
member:
@@ -82,18 +74,10 @@
}: Props = $props()
const restricted = $derived(
isDemoWorkspaceRestricted($operatingWorkspace, actingUser?.is_admin, actingUser?.is_super_admin)
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let groupPerms = $state<Record<string, boolean> | undefined>(undefined)
// A verdict that does not come from a loaded group: a new one is writable, one that failed
// to load is not.
let writeVerdict = $state<boolean | undefined>(undefined)
// Derived for the same reason as the trigger editors: the acting user's role can land after
// the group does, and a snapshot taken first would stay read-only.
const can_write = $derived(
writeVerdict ?? (groupPerms !== undefined && canWrite(name, groupPerms, actingUser))
)
let can_write = $state(false)
let group: Group | undefined
let instance_group: InstanceGroup | undefined = $state()
let usernames: string[] = $state([])
@@ -139,11 +123,11 @@
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
}
async function loadGroupNames(): Promise<void> {
groupNames = (await GroupService.listGroupNames({ workspace: $operatingWorkspace! })) ?? []
groupNames = (await GroupService.listGroupNames({ workspace: $workspaceStore! })) ?? []
}
async function loadInstanceGroup(): Promise<void> {
@@ -158,7 +142,7 @@
loadAside(loadUsernames)
if (isNew) {
loadAside(loadGroupNames)
writeVerdict = true
can_write = true
setDraft(emptyDraft())
loaded = true
} else {
@@ -175,9 +159,8 @@
const apply = (value: GroupDraft) =>
opts?.baselineOnly ? (baseline = structuredClone(value)) : setDraft(value)
try {
group = await GroupService.getGroup({ workspace: $operatingWorkspace!, name })
groupPerms = group.extra_perms ?? {}
writeVerdict = undefined
group = await GroupService.getGroup({ workspace: $workspaceStore!, name })
can_write = canWrite(name, group.extra_perms ?? {}, $userStore)
apply({
summary: group.summary ?? '',
members: Array.from(
@@ -196,7 +179,7 @@
sendUserToast(e?.body ?? String(e), true)
// Only the opening read decides this. Revoking it on a failed reconcile would
// disable Save against a draft that is still dirty, with nothing left to reload.
if (!opts?.baselineOnly) writeVerdict = false
if (!opts?.baselineOnly) can_write = false
} finally {
loaded = true
}
@@ -262,7 +245,7 @@
* membership goes through the endpoints that name who was added or promoted — which is
* what the permission history reads back. The diff itself is in `groupDraft.ts`. */
async function applyMemberChanges(next: GroupDraft['members'], prev: GroupDraft['members']) {
const workspace = $operatingWorkspace ?? ''
const workspace = $workspaceStore ?? ''
for (const call of groupMemberDiff(prev, next, $userStore?.username)) {
switch (call.kind) {
case 'addUser':
@@ -306,7 +289,7 @@
try {
if (created) {
await GroupService.createGroup({
workspace: $operatingWorkspace ?? '',
workspace: $workspaceStore ?? '',
requestBody: { name, summary: next.summary }
})
alreadyCreated = true
@@ -316,7 +299,7 @@
} else {
if (next.summary !== prev.summary) {
await GroupService.updateGroup({
workspace: $operatingWorkspace ?? '',
workspace: $workspaceStore ?? '',
name,
requestBody: { summary: next.summary }
})
@@ -352,7 +335,7 @@
let loadStarted = false
$effect.pre(() => {
if (loadStarted) return
if ($operatingWorkspace && $userStore) {
if ($workspaceStore && $userStore) {
loadStarted = true
untrack(() => {
load()
@@ -7,9 +7,7 @@
import { DataTable } from '$lib/components/table'
import HistoricList from './HistoricList.svelte'
import { Loader2 } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
import { workspaceStore } from '$lib/stores'
interface Props {
runnableId?: string | undefined
@@ -34,7 +32,7 @@
workspace = undefined
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
let historicList: HistoricList | undefined = $state(undefined)
const dispatch = createEventDispatcher()
@@ -1,11 +1,9 @@
<script lang="ts">
import { InputService, type RunnableType } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { onDestroy, untrack } from 'svelte'
import InfiniteList from './InfiniteList.svelte'
import JobSchemaPicker from './schema/JobSchemaPicker.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
runnableId: string | undefined
@@ -54,7 +52,7 @@
refreshInterval()
loadInputsPageFn = async (page: number, perPage: number) => {
const inputs = await InputService.getInputHistory({
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
runnableId,
runnableType,
page,
@@ -111,7 +109,7 @@
if (!id) return
const payloadData = await InputService.getArgsFromHistoryOrSavedInput({
jobOrInputId: id,
workspace: $operatingWorkspace!,
workspace: $workspaceStore!,
input,
allowLarge
})
@@ -124,11 +122,7 @@
}
$effect(() => {
$operatingWorkspace &&
runnableId &&
runnableType &&
infiniteList &&
untrack(() => initLoadInputs())
$workspaceStore && runnableId && runnableType && infiniteList && untrack(() => initLoadInputs())
})
</script>
@@ -1,13 +1,11 @@
<script lang="ts">
import { VariableService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { watch } from 'runed'
import { Plus } from 'lucide-svelte'
import { Button } from './common'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
/** The transforms being edited. A picked variable is written into `pickForField`'s. */
@@ -28,7 +26,7 @@
variableEditor = $bindable()
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
watch(
() => ws,
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
import { untrack } from 'svelte'
import StepInputsGen from './copilot/StepInputsGen.svelte'
@@ -11,9 +12,6 @@
import type ItemPicker from './ItemPicker.svelte'
import type VariableEditor from './VariableEditor.svelte'
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
schema: Schema | { properties?: Record<string, any> }
@@ -56,7 +54,7 @@
workspace
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? $workspaceStore)
let inputCheck: { [id: string]: boolean } = $state({})
+2 -4
View File
@@ -14,6 +14,7 @@
type WorkflowStatus,
type OpenFlow
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getViewToken } from '$lib/viewToken'
import { WM_LOGS_SKIPPED } from '$lib/consts'
import { getContext, onDestroy, tick, untrack } from 'svelte'
@@ -21,9 +22,6 @@
import { sendUserToast } from '$lib/toast'
import { DynamicInput, isScriptPreview } from '$lib/utils'
import { getActiveReplay, getReplayStartTime } from './recording/replay.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
// Will be set to number if job is not a flow
@@ -77,7 +75,7 @@
children
}: Props = $props()
let workspace = $derived(workspaceOverride ?? $operatingWorkspace)
let workspace = $derived(workspaceOverride ?? $workspaceStore)
let syncIteration: number = 0
let errorIteration = 0
@@ -1,11 +1,9 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { Alert, Skeleton } from './common'
import { Activity } from 'lucide-svelte'
import { JobService } from '$lib/gen'
import { msToReadableTime } from '$lib/utils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
// OTEL SpanKind enum values from opentelemetry-proto/opentelemetry/proto/trace/v1/trace.proto
const SpanKind = {
@@ -56,14 +54,14 @@
})
async function loadTraces() {
if (!$operatingWorkspace || !jobId) return
if (!$workspaceStore || !jobId) return
loading = true
error = null
try {
const response = await JobService.getJobOtelTraces({
workspace: $operatingWorkspace,
workspace: $workspaceStore,
id: jobId
})
traces = response as unknown as OtelSpan[]
@@ -125,18 +123,12 @@
function getKindLabel(kind: number): string {
switch (kind) {
case SpanKind.INTERNAL:
return 'Internal'
case SpanKind.SERVER:
return 'Server'
case SpanKind.CLIENT:
return 'Client'
case SpanKind.PRODUCER:
return 'Producer'
case SpanKind.CONSUMER:
return 'Consumer'
default:
return 'Unknown'
case SpanKind.INTERNAL: return 'Internal'
case SpanKind.SERVER: return 'Server'
case SpanKind.CLIENT: return 'Client'
case SpanKind.PRODUCER: return 'Producer'
case SpanKind.CONSUMER: return 'Consumer'
default: return 'Unknown'
}
}
@@ -190,15 +182,17 @@
<Activity size={48} class="mb-4 opacity-50" />
<p class="text-lg font-medium">No HTTP requests captured</p>
<p class="text-sm mt-2">
This job did not make any HTTP/HTTPS requests, or HTTP Request Tracing is not enabled in
instance settings.
This job did not make any HTTP/HTTPS requests, or HTTP Request Tracing is not enabled in instance settings.
</p>
</div>
{:else}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Traces ({traces.length} spans)</h3>
<button class="text-sm text-blue-600 hover:underline" onclick={loadTraces}>
<button
class="text-sm text-blue-600 hover:underline"
onclick={loadTraces}
>
Refresh
</button>
</div>
@@ -225,7 +219,10 @@
{@const statusCode = span.status?.code ?? 0}
<div class="hover:bg-surface-hover">
<button class="w-full px-4 py-2 text-left" onclick={() => toggleSpan(span.span_id)}>
<button
class="w-full px-4 py-2 text-left"
onclick={() => toggleSpan(span.span_id)}
>
<div class="grid grid-cols-12 gap-2 items-center">
<div class="col-span-4 flex items-center gap-2">
<span class="text-xs text-secondary">
@@ -296,9 +293,7 @@
{#each Object.entries(parsedAttrs) as [key, value]}
<div class="flex gap-2">
<span class="text-secondary font-medium shrink-0">{key}:</span>
<span class="font-mono break-all"
>{typeof value === 'object' ? JSON.stringify(value) : value}</span
>
<span class="font-mono break-all">{typeof value === 'object' ? JSON.stringify(value) : value}</span>
</div>
{/each}
</div>
@@ -2,9 +2,7 @@
import Badge from './common/badge/Badge.svelte'
import Button from './common/button/Button.svelte'
import { Plus, Tag, X } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
import { workspaceStore } from '$lib/stores'
interface Props {
labels: string[] | undefined
@@ -55,7 +53,7 @@
async function loadExistingLabels() {
try {
const resp = await fetch(`/api/w/${workspace ?? $operatingWorkspace}/labels/list`)
const resp = await fetch(`/api/w/${workspace ?? $workspaceStore}/labels/list`)
if (resp.ok) existingLabels = await resp.json()
} catch {}
}
@@ -1,5 +1,6 @@
<script lang="ts">
import { ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getContext, untrack } from 'svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
import { Button, Drawer, DrawerContent } from './common'
@@ -10,9 +11,6 @@
import IconedResourceType from './IconedResourceType.svelte'
import { addResourceTitle } from './resourceTypeDisplay'
import { loadResourceTypeDisplayName } from './displayNameLoaders'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
value: string | undefined
@@ -37,7 +35,7 @@
let open = $state(false)
let refreshCount = $state(0)
const appViewerContext = getContext<AppViewerContext>('AppViewerContext')
let ws = $derived(workspace ?? appViewerContext?.workspace ?? $operatingWorkspace)
let ws = $derived(workspace ?? appViewerContext?.workspace ?? $workspaceStore)
let collection = $state(value ? [{ value, label: value }] : [])
@@ -141,7 +139,7 @@
title="App connection"
class="w-full h-full"
src="{base}/embed_connect?resource_type={resourceType}&workspace={appViewerContext?.workspace ??
$operatingWorkspace}&express=false"
$workspaceStore}&express=false"
/> -->
</DrawerContent>
</Drawer>

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