Compare commits

...
Author SHA1 Message Date
GuilhemandClaude Fable 5.1 a9ec0aec3a feat: render the flow chat through the shared session chat components (#11175)
* feat: render the flow chat through the shared session chat components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: settle the flow chat queue on the turn's outcome and guard IME enter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep copilot free-tier banners and deploy-locked sends out of the flow chat

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:45:01 +02:00
hugocasaandClaude Opus 5 9d348f84c7 fix: skip expiry notifications for app embed and SDK tokens (#11169)
* fix: skip expiry notifications for app embed and SDK tokens

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

* refactor: share app token label prefixes between mint sites and the check

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

* fix: skip expiry alerts for impersonation and test-connection tokens

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:28:53 +02:00
hugocasaandClaude Opus 5 02e47de8b4 feat: allow git sync auto-pull, promotion and PRs on Pro licenses (#11173)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:28:40 +02:00
Guilhem 64dffe6106 feat: show flow step detail inside the graph tab on narrow detail layouts (#11168)
* feat: show flow step detail as a page inside the graph tab on narrow detail layouts

* fix: pad the detail step tab, wrapped header row and raise the tabbed layout breakpoint

* fix: keep the step header pinned and switch to triggers on every trigger node tap
2026-09-16 16:12:21 +02:00
GuilhemandClaude Fable 5.1 3d08197182 feat: badge chat-input flows on the home list (#11164)
* feat: badge chat-input flows on the home list

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a malformed draft value from aborting the runnables list

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: only a JSON boolean marks a draft flow as chat-enabled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 14:46:49 +02:00
Ruben Fiszelandrubenfiszel c3e11bd223 chore(main): release 1.813.0 (#11141)
* chore(main): release 1.813.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-09-16 12:12:22 +02:00
GuilhemandClaude Fable 5.1 6885226c26 collapse detail page header actions into menus below lg (#11163)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:05:22 +02:00
Ruben FiszelandClaude Opus 5 49d0310ecc fix(apps): re-check access in place after a password sign-in (#11166)
* fix(apps): re-check access in place after a password sign-in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: drop redundant comment in the password sign-in hand-back

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:03:32 +02:00
hugocasaandClaude Opus 5 54553b2add fix(cli): resolve lockgen imports through modules a push leaves alone (#11160)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:53:24 +02:00
hugocasaandClaude Opus 5 b51c0eabbe feat: stream reasoning summaries in AI agent Responses API steps (#11124)
* feat: stream reasoning summaries in AI agent Responses API steps

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

* fix: retry without a reasoning summary a strict gateway rejects

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

* fix: request the reasoning summary whether or not the step streams

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

* feat: return the OpenAI reasoning summary in the agent step's reasoning

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

* fix: retry without a reasoning summary a gateway rejects with 422

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:46:15 +02:00
73dc892f9c fix: walk the whole fork ancestry for app installations and fork conflicts (#11151)
* fix: walk the whole fork ancestry for app installations and fork conflicts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: describe the fork-conflict gate as ancestor-wide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: update ee-repo-ref to d252afcc80e77fcc4f9a2a346b80908c8605a6c0

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

Previous ee-repo-ref: 5f68c8c351ffc92feccffe69a857b60be376464e

New ee-repo-ref: d252afcc80e77fcc4f9a2a346b80908c8605a6c0

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-16 10:45:34 +02:00
hugocasa 57a99f66a8 feat: rename saved agents from the agent editor and flag broken links (#11147)
* feat: list flows that link a saved agent and flag broken agent links

* feat: rename saved agents from the agent editor and repoint the flow

* fix: show an unreadable linked agent as not accessible, not missing

* fix: address review nits on agent rename and missing-agent state

* fix: open content search above modals and keep Escape for it

* fix: register content search on the opener's overlay stack

* docs: scope the global search z-index comment to the bases it clears

* refactor: show linked agents' rename warning as for scripts and flows

* fix: keep the failed-lookup rename warning to resources
2026-09-16 10:45:01 +02:00
GuilhemandClaude Fable 5.1 b9b5988ebd fix: keep sidebar confirmation dialogs from being confined to the rail (#11158)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 10:42:47 +02:00
Ruben Fiszel e8078f2a96 fix: dispatch workflow-as-code tasks from a deployed flow's inline step (#11146)
* fix: dispatch workflow-as-code tasks from a deployed flow's inline step

* fix: give a workflow-as-code task its own result-cache key

* fix: key a cached workflow-as-code task on its name and arguments

* fix: hash a cached workflow-as-code task's arguments like any job's

* chore: regenerate system prompts for the task cache_ttl docs

* fix: key a cached workflow-as-code task on its step key, not its name

* fix: key a cached workflow-as-code task on a fingerprint of its code

* fix: keep the task() doc attached to task()

* fix: key a cached inline task on its step key and the workflow input

* docs: cache_ttl has no effect on a taskFlow target
2026-09-16 09:58:29 +02:00
Ruben FiszelandClaude Opus 5 a48ae656ae feat: delete a browser's copy of an AI session past its workspace retention (#11156)
* feat: delete a browser's copy of an AI session past its workspace retention

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: tell the AI session retention only to a member who can reach the workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep the retention sweep's design narrative in the docs, not the code

* fix: give the session retention its own route, leaving the status contract alone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: shorten the retention route comment to its constraints

* docs: name the two clocks in the retention setting, and the deploy window

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:57:43 +02:00
Ruben FiszelandClaude Fable 5.1 ee6d317e31 feat: retention for AI sessions on the object store and in the browser (#11152)
* feat: retention for AI sessions, swept on the object store and in the browser

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: make the retention sweeps retryable and safe against pushes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: spare other tabs' sessions, reclaim abandoned split pushes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: sweep under an exclusive session lock, keep the captured user

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: say a tab selecting a session mid-sweep is not held back

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: sweep local sessions only while no other tab has them loaded

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: one tab sweeps at a time, and keeps the switched user's hold

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: push the fallback session again before the rotation assertions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: keep retention server-side here, move the browser sweep out

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the retention setting no longer touches browser-local sessions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 09:27:49 +02:00
a9a9335a34 fix: keep the instance users table's actions and header in view (#11145)
* fix: keep the instance users table's actions and header in view

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pMjimm9tUMXtD8rkWB62w

* fix: lock only the User option for group-granted roles and keep pinned cells opaque

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pMjimm9tUMXtD8rkWB62w

* fix: close the instance settings drawer from the manage-in-workspace menu item

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pMjimm9tUMXtD8rkWB62w

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-16 00:26:56 +02:00
AlexRV12andClaude Opus 5 d0d5b295b9 docs: terminate datatable write examples with .execute() (#11148)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 00:26:16 +02:00
Ruben Fiszel 9a8a9c480c fix: stop reading an array job result as wm_failure or http response (#11154)
* fix: only read wm_failure and wm_labels from an object job result

* fix: serve an array sync result as json, not a composite response
2026-09-15 23:21:53 +02:00
Ruben FiszelandClaude Fable 5.1 f082fddf41 [ee] feat: fall back to instance storage for AI session backups (#11153)
* feat: instance object store as fallback for AI session backups

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: fence the instance store sweep by generation, name it by location

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin that an instance store location tells endpoints apart

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show the instance storage fallback setting on while it is unset

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: check the generation fence queries at compile time

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop the instance storage fallback once the plan is Pro

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 23:09:45 +02:00
Ruben FiszelandClaude Fable 5.1 1c17b3c8db test: pin unlisting on a failed multi-object ai session push (#11150)
* test: pin that a failed multi-object incremental push leaves the session unlisted

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: pick the newest backup generation in the ai sessions test helper

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:18:37 +00:00
176 changed files with 6237 additions and 1499 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.812.0"
".": "1.813.0"
}
+3 -2
View File
@@ -31,8 +31,9 @@ Open-source platform for internal tools, workflows, API integrations, background
`cargo run`; a normal build cannot start one at all.
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
scope, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`.
Read before designing anything that creates users, tokens or sessions.
scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and
that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates
users, tokens or sessions.
- **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with
`feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped
silently, so frontend-only instrumentation records nothing.
+26
View File
@@ -1,5 +1,31 @@
# Changelog
## [1.813.0](https://github.com/windmill-labs/windmill/compare/v1.812.0...v1.813.0) (2026-09-16)
### Features
* back AI sessions up to the workspace object storage ([#11116](https://github.com/windmill-labs/windmill/issues/11116)) ([796b6e5](https://github.com/windmill-labs/windmill/commit/796b6e5297d8cceb842ec097f33ec1c3115058bd))
* delete a browser's copy of an AI session past its workspace retention ([#11156](https://github.com/windmill-labs/windmill/issues/11156)) ([a48ae65](https://github.com/windmill-labs/windmill/commit/a48ae656ae59d600311f81ef357d08df5226515a))
* rename saved agents from the agent editor and flag broken links ([#11147](https://github.com/windmill-labs/windmill/issues/11147)) ([57a99f6](https://github.com/windmill-labs/windmill/commit/57a99f66a88f195cac8f583b59d69d627cc1ec1d))
* retention for AI sessions on the object store and in the browser ([#11152](https://github.com/windmill-labs/windmill/issues/11152)) ([ee6d317](https://github.com/windmill-labs/windmill/commit/ee6d317e318fa8a1506fb18d51c45b627070627a))
* return an ai agent step's thinking in its job result ([#11140](https://github.com/windmill-labs/windmill/issues/11140)) ([c4e878e](https://github.com/windmill-labs/windmill/commit/c4e878e8313a72a16bfbe81fbb3935ee7728ec6f))
* stream reasoning summaries in AI agent Responses API steps ([#11124](https://github.com/windmill-labs/windmill/issues/11124)) ([b51c0ea](https://github.com/windmill-labs/windmill/commit/b51c0eabbe774c78a9cbf824a3df6528d970b8f6))
### Bug Fixes
* **apps:** re-check access in place after a password sign-in ([#11166](https://github.com/windmill-labs/windmill/issues/11166)) ([49d0310](https://github.com/windmill-labs/windmill/commit/49d0310ecc08040b6c6fa4f402584543d679e2e8))
* **apps:** run-mode inline app component uses only pinned content ([#11135](https://github.com/windmill-labs/windmill/issues/11135)) ([781b5a5](https://github.com/windmill-labs/windmill/commit/781b5a57e81eb721d97d7b87e23dd84f23895400))
* **cli:** keep the workspace color when settings are synced from git ([#11144](https://github.com/windmill-labs/windmill/issues/11144)) ([129c045](https://github.com/windmill-labs/windmill/commit/129c04559548cd1bcf67758ec416fb2a48e7b928))
* **cli:** resolve lockgen imports through modules a push leaves alone ([#11160](https://github.com/windmill-labs/windmill/issues/11160)) ([54553b2](https://github.com/windmill-labs/windmill/commit/54553b2add6941395f03ca34ee24cf335a3b23c2))
* dispatch workflow-as-code tasks from a deployed flow's inline step ([#11146](https://github.com/windmill-labs/windmill/issues/11146)) ([e8078f2](https://github.com/windmill-labs/windmill/commit/e8078f2a963166b09849650424583f5dcfd28a84))
* keep sidebar confirmation dialogs from being confined to the rail ([#11158](https://github.com/windmill-labs/windmill/issues/11158)) ([b9b5988](https://github.com/windmill-labs/windmill/commit/b9b5988ebdf3edd75add445282977c516ef5ed51))
* keep the instance users table's actions and header in view ([#11145](https://github.com/windmill-labs/windmill/issues/11145)) ([a9a9335](https://github.com/windmill-labs/windmill/commit/a9a9335a34a13ffd8cd2699adc92087b679548ca))
* stop reading an array job result as wm_failure or http response ([#11154](https://github.com/windmill-labs/windmill/issues/11154)) ([9a8a9c4](https://github.com/windmill-labs/windmill/commit/9a8a9c480cf008761ec6ceaff694085a252a32ae))
* walk the whole fork ancestry for app installations and fork conflicts ([#11151](https://github.com/windmill-labs/windmill/issues/11151)) ([73dc892](https://github.com/windmill-labs/windmill/commit/73dc892f9c9c840a5f0fb12fcbb28bbe0f38795f))
* **worker:** bound cache transfers and import fetches in bun jobs ([#11138](https://github.com/windmill-labs/windmill/issues/11138)) ([31c4325](https://github.com/windmill-labs/windmill/commit/31c43255fdcc827c3fdf65e40238f8f3a83201cd))
## [1.812.0](https://github.com/windmill-labs/windmill/compare/v1.811.1...v1.812.0) (2026-09-15)
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3",
"query": "SELECT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "00c0ae12b19ba495f307f0ce6b4833947c5b3fe45826fc5468e326d171d95236"
"hash": "0c49b098051900b834cb791e37af2e38967680a77316ee9a53e67d70d7df9f63"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3",
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "dc5eeb7b7bf0b7217ef66eb950ab7e9cf578bba7bd1eec981526be4067bcb314"
"hash": "139e153d1ebe584e878d3b2569551892fa3899934e7e0e51ad23bfcd2d6d3d08"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path)\n SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path\n FROM workspace_runnable_dependencies\n WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "2aa87574b437f0e29991696564c4250441863ab47c345d9685f53c7e224b4887"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru\n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_agent AND wru.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "46e65196c2a4f07d171a22f1e45c6ac927bd0e6b0f383626e673225065ee90bf"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_settings.workspace_id AS \"id!\",\n workspace_settings.ai_config->'sessions_retention_days' AS retention\n FROM workspace_settings\n LEFT JOIN usr ON usr.workspace_id = workspace_settings.workspace_id AND usr.email = $2\n WHERE workspace_settings.workspace_id = ANY($1)\n AND ($3 OR (usr.email IS NOT NULL AND NOT usr.disabled))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "retention",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"TextArray",
"Text",
"Bool"
]
},
"nullable": [
false,
null
]
},
"hash": "8eee14066c86b4a4ef921576277c8a287bf1ff4b9d4301e3ea0efd8077936aff"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path)\n SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path\n FROM workspace_runnable_dependencies\n WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "a54e2334c365f90577f68ebefc8f3bee9b93ba0387f566ad7f502cbee818296e"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET ai_sessions_backup_generation = ai_sessions_backup_generation + 1 WHERE workspace_id = $1 AND large_file_storage IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "c2f3492c2d80f5c6d157d8c1dab7f9ed1c0f4b4d1f07789b40945cfb8a3a7b39"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n ))\n RETURNING token_prefix",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1"
"hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT large_file_storage IS NOT NULL AS \"has_storage!\" FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_storage!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "dfa82a3f291cdc8f05cc4af6114e67c90e86d35dd5f74b1366f802dbb951ef9a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ai_sessions_backup_generation FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "ai_sessions_backup_generation",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "ea7bc2e5f53144ca23f8b3dea71cb6a994e969d915d50afbf0cb8547e6decf11"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) VALUES ($1, $2, FALSE, TRUE, $3) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "fa8c36eda6d4cb64b4ac5979cc4eea76ab0226a514d25712a733e01add508b71"
}
+104 -93
View File
@@ -549,7 +549,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"synstructure 0.13.2",
]
[[package]]
@@ -561,7 +561,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"synstructure 0.13.2",
]
[[package]]
@@ -10572,9 +10572,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "3.3.0"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873b730df6f0a9b74b13eb514e0dca4c2db0d8b68b74af98a2e9bf3f9d436585"
checksum = "cd740c45d66ceb87e5579082abc27bd771665e464e9660a17a048c721b2a6025"
dependencies = [
"darling 0.24.1",
"proc-macro2",
@@ -12737,6 +12737,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
@@ -14783,7 +14794,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14871,7 +14882,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14904,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14917,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"argon2",
@@ -15057,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15080,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15097,7 +15108,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15123,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15133,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15150,7 +15161,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15172,7 +15183,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15195,7 +15206,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15211,7 +15222,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15233,7 +15244,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15255,7 +15266,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15269,7 +15280,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15304,7 +15315,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15329,7 +15340,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15357,7 +15368,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15379,7 +15390,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15399,7 +15410,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15437,7 +15448,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15465,7 +15476,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"lazy_static",
"serde",
@@ -15477,7 +15488,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15501,7 +15512,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15515,7 +15526,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15550,7 +15561,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15564,7 +15575,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15583,7 +15594,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15689,7 +15700,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"chrono",
"futures",
@@ -15709,7 +15720,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"regex",
"serde",
@@ -15726,7 +15737,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15753,7 +15764,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"futures",
@@ -15770,7 +15781,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15786,7 +15797,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15807,7 +15818,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15838,7 +15849,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15863,7 +15874,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15898,7 +15909,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"futures",
@@ -15916,7 +15927,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15925,7 +15936,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15937,7 +15948,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15949,7 +15960,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15961,7 +15972,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15973,7 +15984,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15985,7 +15996,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -15996,7 +16007,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16007,7 +16018,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16019,7 +16030,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16030,7 +16041,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16052,7 +16063,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16064,7 +16075,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16078,7 +16089,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16095,7 +16106,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16108,7 +16119,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde",
@@ -16120,7 +16131,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16138,7 +16149,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16154,7 +16165,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16170,7 +16181,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16184,7 +16195,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16223,7 +16234,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"const_format",
@@ -16263,7 +16274,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16274,7 +16285,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16309,7 +16320,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16333,7 +16344,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16366,7 +16377,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16393,7 +16404,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16426,7 +16437,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16446,7 +16457,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16480,7 +16491,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16516,7 +16527,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16539,7 +16550,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16563,7 +16574,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16587,7 +16598,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16622,7 +16633,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16650,7 +16661,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16675,7 +16686,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16694,7 +16705,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16812,7 +16823,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"bytes",
"futures",
@@ -17500,14 +17511,14 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"syn 3.0.5",
"synstructure 0.14.0",
]
[[package]]
@@ -17541,14 +17552,14 @@ dependencies = [
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"syn 3.0.5",
"synstructure 0.14.0",
]
[[package]]
@@ -17620,9 +17631,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.6.7"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.812.0"
version = "1.813.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.812.0"
version = "1.813.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
1c1dab33563c4907aff8b0da825fb66db60af82a
d252afcc80e77fcc4f9a2a346b80908c8605a6c0
@@ -0,0 +1,9 @@
DELETE FROM workspace_runnable_dependencies WHERE runnable_is_agent;
DROP INDEX flow_workspace_without_hash_unique_idx;
CREATE UNIQUE INDEX flow_workspace_without_hash_unique_idx
ON workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, workspace_id)
WHERE script_hash IS NULL;
ALTER TABLE workspace_runnable_dependencies DROP COLUMN runnable_is_agent;
@@ -0,0 +1,21 @@
-- A flow step linked to a saved agent (an `ai_agent` resource) is recorded next to the scripts and
-- subflows the flow runs, so renaming the agent can name the flows it would break. An agent row is
-- neither a script nor a flow: readers of script usages have to exclude it.
ALTER TABLE workspace_runnable_dependencies
ADD COLUMN runnable_is_agent BOOLEAN NOT NULL DEFAULT false;
-- A script step and a linked agent can share a path. Without the flag in the key, the second
-- insert's ON CONFLICT DO NOTHING would silently drop one of the two rows.
DROP INDEX flow_workspace_without_hash_unique_idx;
CREATE UNIQUE INDEX flow_workspace_without_hash_unique_idx
ON workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id)
WHERE script_hash IS NULL;
-- The worker only records a flow when it is next deployed, so seed the ones already linking an
-- agent from their current value.
INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id)
SELECT DISTINCT f.path, agent_ref #>> '{}', false, true, f.workspace_id
FROM flow f
CROSS JOIN LATERAL jsonb_path_query(f.value, 'lax $.** ? (@.type == "aiagent" && @.agent.type() == "string").agent') AS agent_ref
ON CONFLICT DO NOTHING;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.812.0"
version = "1.813.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.812.0"
version = "1.813.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+21 -15
View File
@@ -4380,6 +4380,23 @@ pub async fn monitor_db(
}
};
// Delete the AI session backups older than their workspace's retention. Every ~40 min
// (240 iterations at the default 10 s, the most a u8 `should_run` counts): the retention
// counts in days. Spawned for the same reason as the credential maintenance above, a
// sweep of many sessions outlasting the join's deadline; the sweep's own advisory lock
// keeps one server at a time at it.
let ai_session_retention_f = async {
#[cfg(feature = "parquet")]
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(240) {
if let Some(db) = conn.as_sql() {
let db = db.clone();
tokio::spawn(
async move { windmill_api::sweep_expired_ai_session_backups(&db).await },
);
}
}
};
// run every 2 iterations (~20s at the default LISTEN_NEW_EVENTS_INTERVAL_SEC).
// Enterprise feature: the active `// freshness` backstop lives in
// windmill-queue's `freshness_watchdog` (`private`); OSS gets a no-op stub.
@@ -4434,6 +4451,7 @@ pub async fn monitor_db(
cleanup_scheduled_job_deletions_f,
git_auto_pull_f,
git_credential_maintenance_f,
ai_session_retention_f,
pipeline_freshness_watchdog_f,
reconcile_unarmed_schedules_f,
);
@@ -4694,17 +4712,11 @@ const GIT_AUTO_PULL_LOCK_ID: i64 = 737_483_921;
/// Poll every git-sync repository with auto-pull enabled and enqueue a pull when
/// the tracked branch has new commits (repo → Windmill direction).
///
/// Runs on a single replica at a time (advisory lock) and only on
/// Enterprise-licensed instances. Detection is `git ls-remote`; GitHub-App
/// repositories are skipped here and sync via webhooks instead (phase 2).
/// Runs on a single replica at a time (advisory lock). Detection is
/// `git ls-remote`; GitHub-App repositories are skipped here and sync via
/// webhooks instead (phase 2).
#[cfg(feature = "private")]
pub async fn poll_git_auto_pull(db: &Pool<Postgres>) {
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
return;
}
let mut lock_conn = match db.acquire().await {
Ok(c) => c,
Err(e) => {
@@ -4774,12 +4786,6 @@ const GIT_CREDENTIAL_LOCK_ID: i64 = 737_483_923;
/// sync down on its expiry date.
#[cfg(all(feature = "enterprise", feature = "private"))]
async fn maintain_git_credentials(db: &Pool<Postgres>) {
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
return;
}
// Transaction-scoped advisory lock, as for the schedule reconcile above: a
// session lock on a pooled connection would ride back into the pool still
// held if the sweep died before unlocking, and wedge the pass on every
+1 -1
View File
@@ -232,7 +232,7 @@ workspace_key: workspace_id(char), kind(workspace_key_kind), key(char)
FK: (workspace_id) -> workspace(id)
workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_groups(text[]), bypass_users(text[]), created_at(ts)
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)
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)
FK: (workspace_id) -> workspace(id)
+501 -4
View File
@@ -34,6 +34,54 @@ async fn configure_primary_lfs(db: &Pool<Postgres>, root_path: &str) -> anyhow::
Ok(())
}
/// Configures the primary storage through the route, which is what sweeps the workspace's
/// backups out of the instance store.
async fn configure_primary_lfs_via_route(base: &str, root_path: &str) -> anyhow::Result<()> {
let resp = authed(
client().post(format!("{base}/workspaces/edit_large_file_storage_config")),
"SECRET_TOKEN",
)
.json(&json!({ "large_file_storage": {
"type": "FilesystemStorage",
"root_path": root_path,
"public_resource": false,
"advanced_permissions": null,
"secondary_storage": {}
}}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
/// The instance setting allowing the instance store to stand in for a workspace without
/// storage: `None` leaves it unset, which is on.
async fn set_instance_fallback(db: &Pool<Postgres>, on: Option<bool>) -> anyhow::Result<()> {
sqlx::query("DELETE FROM global_settings WHERE name = 'ai_sessions_instance_storage_fallback'")
.execute(db)
.await?;
if let Some(on) = on {
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ('ai_sessions_instance_storage_fallback', $1)",
)
.bind(json!(on))
.execute(db)
.await?;
}
Ok(())
}
/// Polls until nothing is under the directory, for a deletion that runs off the request.
async fn wait_until_empty(dir: &std::path::Path, what: &str) {
for _ in 0..100 {
if files_under(dir).is_empty() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
panic!("{what}: objects left under {}", dir.display());
}
async fn list(base: &str, token: &str) -> anyhow::Result<Value> {
let resp = authed(client().get(format!("{base}/ai/sessions/list")), token)
.send()
@@ -87,7 +135,8 @@ async fn rotate(base: &str, key: &str) -> anyhow::Result<()> {
}
/// The user's prefix on disk, `windmill_ai_sessions/{w_id}/g{generation}/{email hash}`,
/// under whichever generation is current.
/// under the newest generation: deleting an older generation's objects leaves its
/// directories behind, and `read_dir` order differs across filesystems.
fn user_root(storage_dir: &std::path::Path, email: &str) -> std::path::PathBuf {
let workspace = storage_dir.join("windmill_ai_sessions/test-workspace");
let hash = calculate_hash(email);
@@ -96,8 +145,18 @@ fn user_root(storage_dir: &std::path::Path, email: &str) -> std::path::PathBuf {
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path().join(&hash))
.find(|path| path.exists())
.filter_map(|entry| {
let generation: i64 = entry
.file_name()
.to_str()?
.strip_prefix('g')?
.parse()
.ok()?;
Some((generation, entry.path().join(&hash)))
})
.filter(|(_, path)| path.exists())
.max_by_key(|(generation, _)| *generation)
.map(|(_, path)| path)
.expect("the user has backups under the current key")
}
@@ -132,7 +191,9 @@ async fn test_backups_round_trip_encrypted_and_scoped_to_the_user(
server.addr.port()
);
// No storage configured: the browser is told to stop trying.
// No storage configured, and the instance store (another test of this process may
// have loaded one) not allowed to stand in: the browser is told to stop trying.
set_instance_fallback(&db, Some(false)).await?;
let listing = list(&base, "SECRET_TOKEN").await?;
assert_eq!(listing["enabled"], false);
assert_eq!(listing["sessions"], json!([]));
@@ -945,6 +1006,96 @@ async fn test_backups_round_trip_encrypted_and_scoped_to_the_user(
.await?;
assert_eq!(resp.status(), 200);
// An incremental part changing more than one object unlists the session before its
// writes, so one write failing after another landed leaves it absent rather than listed
// as a mix of old and new pieces. A directory planted at `artifacts.json` fails that write.
let s10_head =
json!({ "id": "s10", "workspace_id": "test-workspace", "createdAt": 10, "chatId": "c1" });
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{ "id": "s10", "whole": true, "head": s10_head, "chats": s9_chats(&["c1"]), "artifacts": { "items": ["a1"] } }]
}),
)
.await?;
assert_eq!(resp.status(), 200);
let s10_dir = user_root(storage_dir.path(), "test@windmill.dev").join("sessions/s10");
let artifacts_path = s10_dir.join("artifacts.json");
std::fs::remove_file(&artifacts_path)?;
std::fs::create_dir(&artifacts_path)?;
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{ "id": "s10", "chats": s9_chats(&["c2"]), "artifacts": { "items": ["a2"] } }]
}),
)
.await?;
assert_eq!(resp.status(), 200);
let answer: Value = resp.json().await?;
assert!(
answer["results"][0]["error"].is_string(),
"the artifacts write must fail: {answer}"
);
assert!(answer["results"][0]["needs_whole"].is_null());
assert!(
s10_dir.join("chats/c2.json").is_file(),
"the chat landed before the artifacts failed"
);
let s10_listed = |listing: Value| {
listing["sessions"]
.as_array()
.unwrap()
.iter()
.any(|s| s["id"] == "s10")
};
assert!(!s10_listed(list(&base, "SECRET_TOKEN").await?));
assert_eq!(
pull(&base, "SECRET_TOKEN", &["s10"]).await?["sessions"],
json!([])
);
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{ "id": "s10", "chats": s9_chats(&["c3"]) }]
}),
)
.await?;
assert_eq!(resp.status(), 200);
let answer: Value = resp.json().await?;
assert_eq!(answer["results"][0]["needs_whole"], true);
assert!(!s10_listed(list(&base, "SECRET_TOKEN").await?));
std::fs::remove_dir(&artifacts_path)?;
let resp = push(
&base,
"SECRET_TOKEN",
json!({
"owner": "test@windmill.dev",
"sessions": [{ "id": "s10", "whole": true, "head": s10_head, "chats": s9_chats(&["c1", "c2", "c3"]), "artifacts": { "items": ["a2"] } }]
}),
)
.await?;
assert_eq!(resp.status(), 200);
assert!(s10_listed(list(&base, "SECRET_TOKEN").await?));
let pulled = pull(&base, "SECRET_TOKEN", &["s10"]).await?;
assert_eq!(
pulled["sessions"][0]["artifacts"],
json!({ "items": ["a2"] })
);
assert_eq!(pulled_chats(pulled), vec!["c1", "c2", "c3"]);
let resp = push(
&base,
"SECRET_TOKEN",
json!({ "owner": "test@windmill.dev", "removed": ["s10"] }),
)
.await?;
assert_eq!(resp.status(), 200);
// Removal empties both prefixes.
let resp = push(
&base,
@@ -1083,3 +1234,349 @@ async fn test_backup_writes_are_refused_for_the_wrong_owner_token_or_id(
assert!(files_under(storage_dir.path()).is_empty());
Ok(())
}
/// Sets the object's modification time `days` back: the FilesystemStorage answers
/// `last_modified` from it, so this is a session no push touched since.
fn age_object(path: &std::path::Path, days: u64) -> std::io::Result<()> {
let at = std::time::SystemTime::now() - std::time::Duration::from_secs(days * 86_400);
std::fs::File::options()
.write(true)
.open(path)?
.set_modified(at)
}
#[sqlx::test(fixtures("base"))]
async fn test_expired_backups_are_swept_by_age_and_left_out_of_the_listing(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let storage_dir = tempfile::tempdir()?;
configure_primary_lfs(&db, &storage_dir.path().to_string_lossy()).await?;
let chat = |sid: &str, cid: &str| {
json!({ "id": cid, "record": { "id": cid, "sessionId": sid, "lastModified": 2,
"actualMessages": [], "displayMessages": [] } })
};
let whole = |sid: &str| {
json!({
"id": sid, "whole": true, "epoch": 0,
"head": { "id": sid, "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" },
"chats": [chat(sid, "c1")],
"images": [{ "chat_id": "c1", "id": "img1", "data_url": "data:image/png;base64,AAAA" }],
"artifacts": { "items": [], "versions": [] }
})
};
// Two pushes split over parts of which only the first part landed: one a browser
// abandoned long ago (its token aged past the retention), one still in flight.
let opening = |sid: &str| {
json!({
"id": sid, "whole": true, "epoch": 0, "push": format!("t-{sid}"), "opens": true,
"partial": true, "chats": [chat(sid, "c1")],
"head": { "id": sid, "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" }
})
};
let resp = push(
&base,
"SECRET_TOKEN",
json!({ "owner": "test@windmill.dev", "sessions": [
whole("old"), whole("live"), opening("abandoned"), opening("inflight")
] }),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let root = user_root(storage_dir.path(), "test@windmill.dev");
age_object(&root.join("index/old/0"), 40)?;
age_object(&root.join("index/abandoned/push"), 40)?;
let listed = |listing: Value| -> Vec<String> {
let mut ids: Vec<String> = listing["sessions"]
.as_array()
.unwrap()
.iter()
.map(|s| s["id"].as_str().unwrap().to_string())
.collect();
ids.sort();
ids
};
let objects = |root: &std::path::Path| -> Vec<String> {
files_under(root)
.into_iter()
.map(|(p, _)| p.strip_prefix(root).unwrap().to_string_lossy().into_owned())
.collect()
};
// Without a retention nothing is swept, however old.
windmill_api::sweep_expired_ai_session_backups(&db).await;
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live", "old"]);
let set_retention = |days: Value| {
authed(
client().post(format!("{base}/workspaces/edit_copilot_config")),
"SECRET_TOKEN",
)
.json(&json!({ "sessions_retention_days": days }))
.send()
};
let resp = set_retention(json!(0)).await?;
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
let resp = set_retention(json!(30)).await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
// The listing leaves the expired session out before the sweep reaches it.
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live"]);
assert!(root.join("sessions/old/head.json").exists());
// A removal cut short (a directory stands where the head is, so it cannot be unlinked)
// leaves the sweep's record with the markers gone; the next pass finds it and finishes.
let head = root.join("sessions/old/head.json");
std::fs::remove_file(&head)?;
std::fs::create_dir(&head)?;
std::fs::write(head.join("planted"), b"")?;
windmill_api::sweep_expired_ai_session_backups(&db).await;
assert!(root.join("index/old/sweep").exists());
assert!(!root.join("index/old/0").exists());
assert!(root.join("sessions/old/chats/c1.json").exists());
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live"]);
std::fs::remove_dir_all(&head)?;
windmill_api::sweep_expired_ai_session_backups(&db).await;
let remaining = objects(&root);
assert!(
remaining
.iter()
.all(|p| !p.contains("/old/") && !p.contains("/abandoned/")),
"{remaining:?}"
);
for kept in [
"index/live/0",
"sessions/live/head.json",
"sessions/live/chats/c1.json",
"images/live/c1/img1",
"index/inflight/push",
"sessions/inflight/head.json",
] {
assert!(
remaining.iter().any(|p| p == kept),
"{kept} in {remaining:?}"
);
}
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live"]);
// A second pass has nothing to do; a session pushed again since its marker aged is
// renewed by the push, which rewrites the marker.
windmill_api::sweep_expired_ai_session_backups(&db).await;
age_object(&root.join("index/live/0"), 40)?;
let resp = push(
&base,
"SECRET_TOKEN",
json!({ "owner": "test@windmill.dev",
"sessions": [{ "id": "live", "epoch": 0, "chats": [chat("live", "c2")] }] }),
)
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
windmill_api::sweep_expired_ai_session_backups(&db).await;
let mut after = objects(&root);
after.sort();
let mut expected = remaining.clone();
expected.push("sessions/live/chats/c2.json".to_string());
expected.sort();
assert_eq!(after, expected);
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live"]);
Ok(())
}
/// Puts the process-wide instance store back to none, even when an assertion fails.
struct ResetInstanceStore;
impl Drop for ResetInstanceStore {
fn drop(&mut self) {
if let Ok(mut store) = windmill_object_store::OBJECT_STORE_SETTINGS.try_write() {
*store = None;
}
}
}
/// Puts the process-wide license key id back to none, an Enterprise plan in this build,
/// even when an assertion fails.
struct ResetLicensePlan;
impl Drop for ResetLicensePlan {
fn drop(&mut self) {
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new(String::new()));
}
}
/// A workspace without storage of its own backs up to the instance object store, every
/// answer saying so (`fallback`); a storage of its own, once configured, answers instead,
/// under a generation past everything the workspace left in the instance store, which the
/// change deletes; a plan switched to Pro stops the fallback with the store still loaded.
#[sqlx::test(fixtures("base"))]
async fn test_backups_fall_back_to_the_instance_storage(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
// The instance store, built from its settings as `reload_object_store_setting` does.
let instance_dir = tempfile::tempdir()?;
let instance_root = instance_dir.path().to_string_lossy().to_string();
*windmill_object_store::OBJECT_STORE_SETTINGS.write().await = Some(
windmill_object_store::build_object_store_from_settings(
windmill_object_store::ObjectSettings::Filesystem(
windmill_object_store::FilesystemSettings { root_path: instance_root.clone() },
),
None,
)
.await?,
);
let _reset = ResetInstanceStore;
let in_instance = instance_dir
.path()
.join("windmill_ai_sessions/test-workspace");
// Turned off by the instance setting: the browser is told to stop trying.
set_instance_fallback(&db, Some(false)).await?;
assert_eq!(list(&base, "SECRET_TOKEN").await?["enabled"], false);
set_instance_fallback(&db, None).await?;
// On, as it is unless turned off: the backups land in the instance store, under the
// workspace's prefix, and every answer says which kind of store it came from.
let head =
json!({ "id": "s1", "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" });
let entry = json!({ "id": "s1", "whole": true, "head": head, "chats": [{ "id": "c1", "record": { "id": "c1" } }] });
let push_whole = || {
push(
&base,
"SECRET_TOKEN",
json!({ "owner": "test@windmill.dev", "sessions": [entry.clone()] }),
)
};
let resp = push_whole().await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let pushed: Value = resp.json().await?;
assert_eq!(pushed["fallback"], true);
assert_eq!(pushed["results"], json!([{ "id": "s1" }]));
let listing = list(&base, "SECRET_TOKEN").await?;
assert_eq!(listing["enabled"], true);
assert_eq!(listing["fallback"], true);
assert_eq!(listing["sessions"][0]["id"], "s1");
let fallback_storage_id = listing["storage_id"].clone();
let pulled = pull(&base, "SECRET_TOKEN", &["s1"]).await?;
assert_eq!(pulled["fallback"], true);
assert_eq!(pulled["sessions"][0]["head"], head);
assert!(!files_under(&in_instance).is_empty());
// The workspace's storage usage counts them, under a name of their own.
let resp = authed(
client().get(format!("{base}/job_helpers/storage_usage")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let usage: Value = resp.json().await?;
let fallback_usage = usage["storages"]
.as_array()
.unwrap()
.iter()
.find(|s| s["storage"] == "_ai_sessions_fallback_")
.unwrap_or_else(|| panic!("no fallback usage in {usage}"));
assert!(fallback_usage["bytes"].as_i64().unwrap() > 0);
// The retention sweep reaches what the instance store keeps for the workspace, choosing
// that store from the row it reads the generation from.
let instance_user = user_root(instance_dir.path(), "test@windmill.dev");
age_object(&instance_user.join("index/s1/0"), 40)?;
sqlx::query(
"UPDATE workspace_settings SET ai_config = '{\"sessions_retention_days\": 30}' \
WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
windmill_api::sweep_expired_ai_session_backups(&db).await;
assert!(!instance_user.join("index/s1/0").exists());
assert!(!instance_user.join("sessions/s1/head.json").exists());
// Pushed again, so the rotation below has a backup to delete.
assert_eq!(push_whole().await?.status(), 200);
assert!(!files_under(&in_instance).is_empty());
// A key rotation sweeps the older generation out of the instance store too.
rotate(&base, &"c".repeat(64)).await?;
wait_until_empty(&in_instance, "a rotation on the instance store").await;
assert_eq!(list(&base, "SECRET_TOKEN").await?["sessions"], json!([]));
assert_eq!(push_whole().await?.status(), 200);
assert!(!files_under(&in_instance).is_empty());
// A storage of its own answers instead, under a generation the configuration moved past
// everything the workspace left in the instance store: nothing there is read again,
// whichever store a later return to the fallback finds, and it is deleted.
let before = list(&base, "SECRET_TOKEN").await?;
let storage_dir = tempfile::tempdir()?;
configure_primary_lfs_via_route(&base, &storage_dir.path().to_string_lossy()).await?;
wait_until_empty(&in_instance, "configuring a workspace storage").await;
let listing = list(&base, "SECRET_TOKEN").await?;
assert_eq!(listing["enabled"], true);
assert!(listing.get("fallback").is_none(), "{listing}");
assert_ne!(listing["storage_id"], fallback_storage_id);
assert_eq!(
listing["backup_generation"].as_i64(),
before["backup_generation"].as_i64().map(|g| g + 1),
"configuring a storage over the fallback must move the generation on"
);
assert_eq!(listing["sessions"], json!([]));
assert_eq!(push_whole().await?.status(), 200);
assert!(!files_under(storage_dir.path()).is_empty());
assert!(files_under(&in_instance).is_empty());
let resp = authed(
client().get(format!("{base}/job_helpers/storage_usage?refresh=true")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let usage: Value = resp.json().await?;
assert!(
!usage.to_string().contains("_ai_sessions_fallback_"),
"nothing is counted in the instance store for a workspace with storage: {usage}"
);
// Pointed at the instance store's own bucket, a storage of its own keeps its live
// backups there under the current generation, which no storage change deletes.
configure_primary_lfs_via_route(&base, &instance_root).await?;
assert_eq!(push_whole().await?.status(), 200);
assert!(!files_under(&in_instance).is_empty());
let same = list(&base, "SECRET_TOKEN").await?;
configure_primary_lfs_via_route(&base, &instance_root).await?;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
assert!(!files_under(&in_instance).is_empty());
let listing = list(&base, "SECRET_TOKEN").await?;
assert!(listing.get("fallback").is_none(), "{listing}");
assert_eq!(listing["backup_generation"], same["backup_generation"]);
assert_eq!(listing["sessions"][0]["id"], "s1");
// Back to no storage of its own, the fallback answers; a plan switched to Pro while the
// instance store stays loaded stops it at once, for the listing and the push alike.
let resp = authed(
client().post(format!("{base}/workspaces/edit_large_file_storage_config")),
"SECRET_TOKEN",
)
.json(&json!({ "large_file_storage": null }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
assert_eq!(list(&base, "SECRET_TOKEN").await?["fallback"], true);
let _enterprise_again = ResetLicensePlan;
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new("test_pro".to_string()));
assert_eq!(list(&base, "SECRET_TOKEN").await?["enabled"], false);
let resp = push_whole().await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let pushed: Value = resp.json().await?;
assert_eq!(pushed["enabled"], false, "{pushed}");
Ok(())
}
+94
View File
@@ -1209,6 +1209,100 @@ export function main() {
Ok(())
}
/// A deployed flow runs an inline step as the `flow_node` its deploy rewrote it into,
/// a `FlowScript` job rather than the preview job the editor runs. A workflow-as-code
/// step's `task()` children must dispatch from that kind too, as re-runs of the same
/// node, or the step passes its editor test and fails once deployed.
///
/// The step is cached: a child that shared the parent's result-cache key would hand
/// its own result (`10`) back to the parent on resume, in place of the workflow's.
#[sqlx::test(fixtures("base", "wac_flow_script"))]
async fn test_bun_wac_task_dispatch_from_flow_script(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::flows::FlowNodeId;
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let node = FlowNodeId(3000000000000011);
let job = RunJob::from(JobPayload::FlowScript {
id: node,
path: "f/system/wac_flow_script/a".to_string(),
language: ScriptLang::Bun,
cache_ttl: Some(60),
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(),
})
.arg("n", serde_json::json!(5))
.run_until_complete(&db, false, port)
.await;
assert_eq!(
job.json_result().unwrap(),
serde_json::json!({"doubled": 10})
);
let children: Vec<(String, Option<i64>, Option<i32>)> = sqlx::query_as(
"SELECT kind::text, runnable_id, cache_ttl FROM v2_job WHERE parent_job = $1",
)
.bind(job.id)
.fetch_all(&db)
.await?;
assert_eq!(
children,
vec![("flowscript".to_string(), Some(node.0), None)],
"the task child re-runs the parent's flow node, outside the result cache"
);
Ok(())
}
/// `task(fn, { cache_ttl })` on an inline task of a deployed flow's step: the child runs
/// the parent's code with the parent's arguments, so its result-cache key carries its
/// step key, or the parent and every sibling would read its result back as their own.
#[sqlx::test(fixtures("base", "wac_flow_script"))]
async fn test_bun_wac_inline_task_cache_is_per_task(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::flows::FlowNodeId;
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let payload = || JobPayload::FlowScript {
id: FlowNodeId(3000000000000012),
path: "f/system/wac_flow_script/a".to_string(),
language: ScriptLang::Bun,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(),
};
let mut children_from_cache = Vec::new();
for _ in 0..2 {
let job = RunJob::from(payload())
.arg("n", serde_json::json!(5))
.run_until_complete(&db, false, port)
.await;
assert_eq!(
job.json_result().unwrap(),
serde_json::json!({"doubled": 10, "tripled": 15})
);
let from_cache: i64 = sqlx::query_scalar(
"SELECT count(*) FROM job_logs l JOIN v2_job j ON j.id = l.job_id \
WHERE j.parent_job = $1 AND l.logs LIKE '%found in cache%'",
)
.bind(job.id)
.fetch_one(&db)
.await?;
children_from_cache.push(from_cache);
}
assert_eq!(
children_from_cache,
vec![0, 2],
"the second run serves each task from its own cache entry"
);
Ok(())
}
// ============================================================================
// Environment Variable Tests
// ============================================================================
+46
View File
@@ -0,0 +1,46 @@
-- A three-deep fork chain whose schedule rows were cloned down at fork time.
-- The middle fork has since deleted its copy, so the leaf's schedule shares
-- its cron only with the root — the shape a direct-parent check misses.
INSERT INTO workspace (id, name, owner, parent_workspace_id) VALUES
('sfc-root', 'sfc-root', 'sfc-admin', NULL),
('sfc-mid', 'sfc-mid', 'sfc-admin', 'sfc-root'),
('sfc-leaf', 'sfc-leaf', 'sfc-admin', 'sfc-mid');
INSERT INTO workspace_key (workspace_id, kind, key) VALUES
('sfc-root', 'cloud', 'sfc-root-key'),
('sfc-mid', 'cloud', 'sfc-mid-key'),
('sfc-leaf', 'cloud', 'sfc-leaf-key');
INSERT INTO workspace_settings (workspace_id) VALUES
('sfc-root'), ('sfc-mid'), ('sfc-leaf');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('sfc-root', 'all', 'All users', '{}'),
('sfc-mid', 'all', 'All users', '{}'),
('sfc-leaf', 'all', 'All users', '{}');
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('sfc-admin@windmill.dev', 'x', 'password', true, true, 'SFC Admin', 'sfc-admin');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('sfc-root', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin'),
('sfc-mid', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin'),
('sfc-leaf', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin');
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
VALUES (encode(sha256('SFC_ADMIN_TOKEN'::bytea), 'hex'), 'SFC_ADMIN_', 'SFC_ADMIN_TOKEN', 'sfc-admin@windmill.dev', 't', true);
-- Enabling pushes the next run, which needs the scheduled script to exist.
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES
('sfc-leaf', 'sfc-admin', 'export async function main() { return "ok" }', '{}', '', '', 'f/shared/job', 7788001, 'deno', '');
INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, is_flow, email, timezone, extra_perms, permissioned_as)
VALUES
('sfc-root', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', true, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin'),
('sfc-leaf', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin'),
-- A path only the leaf has: nothing above shares it.
('sfc-leaf', 'f/shared/own', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin');
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
+53
View File
@@ -0,0 +1,53 @@
-- A deployed flow whose inline bun step is workflow-as-code calling task(), in the
-- shape the deploy leaves behind: the RawScript module rewritten into a flow_node that
-- the step then runs as a FlowScript job. No lock, so the worker resolves
-- windmill-client at run time like the other bun fixtures.
INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES (
'test-workspace', '', '',
'f/system/wac_flow_script',
'{}',
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"n":{"type":"integer","description":""}},"required":[],"type":"object"}',
'{"modules":[{"id":"a","value":{"type":"flowscript","id":3000000000000011,"language":"bun","input_transforms":{"n":{"expr":"flow_input.n","type":"javascript"}}}}]}',
'system'
);
INSERT INTO public.flow_node(id, workspace_id, path, hash_v2, lock, code) VALUES (
3000000000000011,
'test-workspace',
'f/system/wac_flow_script',
'0000000000000000000000000000000000000000000000000000000000000011',
NULL,
E'import { workflow, task } from "windmill-client";
const double = task(async (n: number) => {
return n * 2;
});
export const main = workflow(async (n: number) => {
const d = await double(n);
return { doubled: d };
});'
);
-- The same flow's step with two tasks that cache their own result.
INSERT INTO public.flow_node(id, workspace_id, path, hash_v2, lock, code) VALUES (
3000000000000012,
'test-workspace',
'f/system/wac_flow_script',
'0000000000000000000000000000000000000000000000000000000000000012',
NULL,
E'import { workflow, task } from "windmill-client";
const double = task(async (n: number) => {
return n * 2;
}, { cache_ttl: 60 });
const triple = task(async (n: number) => {
return n * 3;
}, { cache_ttl: 60 });
export const main = workflow(async (n: number) => {
const d = await double(n);
const t = await triple(n);
return { doubled: d, tripled: t };
});'
);
+98 -2
View File
@@ -12,8 +12,8 @@
use sqlx::{Pool, Postgres};
use windmill_common::git_sync_ee::{
create_repo_webhook, git_credential_for_url, repo_provider, repo_supports_managed_git_features,
set_git_credential, GitProvider,
create_repo_webhook, git_app_installations_for, git_credential_for_url, managed_pr_base_branch,
repo_provider, repo_supports_managed_git_features, set_git_credential, GitProvider,
};
use windmill_common::workspaces::GitCredentialProvider;
@@ -278,3 +278,99 @@ async fn an_unreachable_gitlab_host_is_the_reported_error(
);
Ok(())
}
/// GitHub App installations are normally copied into a fork, but a workspace
/// attached as a dev workspace, or forked before its parent connected the App,
/// holds none, and neither does anything forked from it. The lookup reaches the
/// nearest workspace up the chain that holds some, and the background App path
/// (PR base resolution here) authenticates with that installation's token.
#[sqlx::test(fixtures("git_sync_fork_credential"))]
async fn app_installations_come_from_the_nearest_ancestor_holding_some(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use axum::{routing::get, Router};
use std::sync::{Arc, Mutex};
// A stand-in GitHub API: one repository, and a record of who asked for it.
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
let app = Router::new().route(
"/api/v3/repos/acme/repo",
get({
let seen = seen.clone();
move |headers: axum::http::HeaderMap| {
let seen = seen.clone();
async move {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
seen.lock().unwrap().push(auth);
axum::Json(serde_json::json!({ "default_branch": "trunk" }))
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let stub = format!("http://127.0.0.1:{port}");
// The root holds the installation, with a cached token so nothing is minted.
sqlx::query(
"UPDATE workspace_settings SET git_app_installations = $1::jsonb WHERE workspace_id = 'parent-ws'",
)
.bind(serde_json::json!([{
"installation_id": 42, "account_id": "acme", "jwt_token": "x",
"github_base_url": stub,
"installation_token": "root-token", "installation_token_expiration": 4102444800i64
}]))
.execute(&db)
.await?;
// The fork's copy of the resource names the App-backed repository.
sqlx::query("UPDATE resource SET value = $1::jsonb WHERE workspace_id = 'deep-fork-ws' AND path = 'u/admin/repo'")
.bind(serde_json::json!({ "url": format!("{stub}/acme/repo.git"), "is_github_app": true }))
.execute(&db)
.await?;
assert_eq!(
git_app_installations_for(&db, "deep-fork-ws").await?,
("parent-ws".to_string(), vec![(42, Some(stub.clone()))]),
"two levels down, the root's installations are the ones to use"
);
assert_eq!(
git_app_installations_for(&db, "orphan-ws").await?,
("orphan-ws".to_string(), vec![]),
"a workspace with nothing above it resolves nothing"
);
assert_eq!(
managed_pr_base_branch(&db, "deep-fork-ws", REPO)
.await?
.as_deref(),
Some("trunk"),
"the background App path reaches the repository through the root's installation"
);
let seen = seen.lock().unwrap().clone();
assert!(
!seen.is_empty() && seen.iter().all(|auth| auth == "Bearer root-token"),
"every call authenticated with the root's cached token: {seen:?}"
);
// A closer holder takes precedence over the root.
sqlx::query(
"UPDATE workspace_settings SET git_app_installations = $1::jsonb WHERE workspace_id = 'fork-ws'",
)
.bind(serde_json::json!([{
"installation_id": 7, "account_id": "acme", "jwt_token": "x",
"github_base_url": stub,
"installation_token": "mid-token", "installation_token_expiration": 4102444800i64
}]))
.execute(&db)
.await?;
assert_eq!(
git_app_installations_for(&db, "deep-fork-ws").await?.0,
"fork-ws",
"the nearest holder wins over the root"
);
Ok(())
}
+59
View File
@@ -0,0 +1,59 @@
//! Enabling a schedule in a fork warns about every ancestor sharing the path,
//! not only the direct parent: the row was cloned down the whole chain, so the
//! cron is shared with whichever ancestors still hold a copy.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
async fn set_enabled(
base: &str,
path: &str,
enabled: bool,
force: bool,
) -> anyhow::Result<(u16, String)> {
let resp = reqwest::Client::new()
.post(format!("{base}/api/w/sfc-leaf/schedules/setenabled/{path}"))
.header("Authorization", "Bearer SFC_ADMIN_TOKEN")
.json(&json!({ "enabled": enabled, "force": force }))
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
}
#[sqlx::test(fixtures("schedule_fork_conflict"))]
async fn enabling_in_a_fork_names_the_nearest_ancestor_sharing_the_path(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}", server.addr.port());
let (status, body) = set_enabled(&base, "f/shared/nightly", true, false).await?;
assert_eq!(status, 400, "{body}");
assert!(
body.contains("fork-conflict:schedule:sfc-root"),
"the middle fork deleted its copy, so the root is the one still sharing the cron: {body}"
);
let (status, body) = set_enabled(&base, "f/shared/own", true, false).await?;
assert_eq!(
status, 200,
"a path nothing upstream has enables freely: {body}"
);
sqlx::query(
"INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, is_flow, email, timezone, extra_perms, permissioned_as)
VALUES ('sfc-mid', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin')",
)
.execute(&db)
.await?;
let (status, body) = set_enabled(&base, "f/shared/nightly", true, false).await?;
assert_eq!(status, 400, "{body}");
assert!(
body.contains("fork-conflict:schedule:sfc-mid"),
"with the parent holding a copy again, it is the nearest and gets named: {body}"
);
Ok(())
}
+63 -5
View File
@@ -3,18 +3,23 @@
//! extractor actually grants. Membership is not the only path: a superadmin is authed into
//! any existing workspace without a `usr` row, and `admins` has no `usr` rows at all, so
//! answering from `usr` alone reports live workspaces as unresolvable and the client deletes
//! sessions that still work.
//! sessions that still work. `POST /workspaces/session_workspace_retention`, the AI session
//! retention the same client deletes its own copies by, is a workspace setting and answers to
//! the stricter bar, which is why the two are separate routes and tested together.
use serde_json::json;
use sqlx::{Pool, Postgres};
use std::collections::HashMap;
use windmill_test_utils::*;
async fn status(port: u16, token: &str, ids: &[&str]) -> anyhow::Result<HashMap<String, String>> {
async fn post<T: serde::de::DeserializeOwned>(
port: u16,
route: &str,
token: &str,
ids: &[&str],
) -> anyhow::Result<T> {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/workspaces/session_workspace_status"
))
.post(format!("http://localhost:{port}/api/workspaces/{route}"))
.header("Authorization", format!("Bearer {token}"))
.json(&json!({ "workspace_ids": ids }))
.send()
@@ -23,6 +28,14 @@ async fn status(port: u16, token: &str, ids: &[&str]) -> anyhow::Result<HashMap<
Ok(resp.json().await?)
}
async fn status(port: u16, token: &str, ids: &[&str]) -> anyhow::Result<HashMap<String, String>> {
post(port, "session_workspace_status", token, ids).await
}
async fn retention(port: u16, token: &str, ids: &[&str]) -> anyhow::Result<HashMap<String, u32>> {
post(port, "session_workspace_retention", token, ids).await
}
#[sqlx::test(fixtures("base", "session_workspace_status"))]
async fn test_superadmin_reaches_workspaces_without_a_usr_row(
db: Pool<Postgres>,
@@ -60,3 +73,48 @@ async fn test_superadmin_reaches_workspaces_without_a_usr_row(
Ok(())
}
/// The retention a browser deletes its own copies by is a workspace setting, so unlike the
/// status it is told only to a caller the authed extractor would let in.
#[sqlx::test(fixtures("base", "session_workspace_status"))]
async fn test_session_retention_is_told_only_to_members_who_can_be_authed(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ids = ["foreign-workspace", "test-workspace", "no-such-workspace"];
sqlx::query(
"UPDATE workspace_settings SET ai_config = '{\"sessions_retention_days\": 7}' \
WHERE workspace_id IN ('test-workspace', 'foreign-workspace')",
)
.execute(&db)
.await?;
// test@windmill.dev is a superadmin: authed into every workspace that exists.
let sa = retention(port, "SECRET_TOKEN", &ids).await?;
assert_eq!(sa["test-workspace"], 7);
assert_eq!(sa["foreign-workspace"], 7);
assert!(!sa.contains_key("no-such-workspace"));
// test2@windmill.dev is a member of test-workspace only.
let usr = retention(port, "SECRET_TOKEN_2", &ids).await?;
assert_eq!(usr["test-workspace"], 7);
assert!(!usr.contains_key("foreign-workspace"));
// A disabled membership still reconciles its sessions — the status stays `active` — but
// cannot be authed into the workspace, so it is told no setting.
sqlx::query("UPDATE usr SET disabled = true WHERE workspace_id = 'test-workspace'")
.execute(&db)
.await?;
assert_eq!(
status(port, "SECRET_TOKEN_2", &ids).await?["test-workspace"],
"active"
);
assert!(!retention(port, "SECRET_TOKEN_2", &ids)
.await?
.contains_key("test-workspace"));
Ok(())
}
@@ -902,6 +902,7 @@ mod tests {
attachments: None,
has_websearch: false,
prompt_cache_key: None,
reasoning_summary: false,
};
AnthropicQueryBuilder::new(AIProvider::Anthropic, platform)
+179 -15
View File
@@ -1,6 +1,7 @@
use crate::{
ai_providers::AIProvider,
ai_types::OpenAIToolCall,
credentials::ProviderCredentials,
image_handler::{prepare_messages_for_api, s3_object_to_content_part},
proxy::{build_openai_compatible_proxy_request, ProxyBuildArgs, ProxyRequest},
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
@@ -11,7 +12,14 @@ use crate::{
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use windmill_common::{client::AuthedClient, error::Error};
use std::{
collections::{BTreeMap, HashMap},
hash::{DefaultHasher, Hash, Hasher},
time::{Duration, Instant},
};
use windmill_common::{cache::Cache, client::AuthedClient, error::Error};
use super::REASONING_OFF_SENTINEL;
// Responses API structures
#[derive(Deserialize)]
@@ -192,13 +200,79 @@ pub struct ResponsesApiTextFormat {
pub format: ResponsesApiTextFormatConfig,
}
/// Reasoning config for the Responses API (`reasoning: { effort }`).
/// The summary is intentionally not requested, mirroring the copilot chat: OpenAI
/// gates reasoning summaries behind organization verification, so asking for one
/// would fail the request for unverified orgs.
/// Reasoning config for the Responses API (`reasoning: { effort, summary }`).
#[derive(Serialize)]
pub struct ResponsesApiReasoning {
pub effort: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
lazy_static::lazy_static! {
/// Refused reasoning summaries, so later requests skip asking instead of paying a
/// rejected call each. A refusal belongs to the organization the request bills or to the
/// model, so the key holds the model and everything that authenticates (the API key and
/// the resource headers, which can carry it instead). Entries expire as an org gets verified.
static ref REASONING_SUMMARY_UNAVAILABLE: Cache<(String, u64), Instant> = Cache::new(500);
}
const REASONING_SUMMARY_UNAVAILABLE_TTL: Duration = Duration::from_secs(3600);
fn reasoning_summary_cache_key(
base_url: &str,
model: &str,
api_key: Option<&str>,
custom_headers: &HashMap<String, String>,
) -> (String, u64) {
let mut hasher = DefaultHasher::new();
model.hash(&mut hasher);
api_key.hash(&mut hasher);
// Sorted: two maps with the same entries can iterate them in different orders.
custom_headers
.iter()
.collect::<BTreeMap<_, _>>()
.hash(&mut hasher);
(base_url.to_string(), hasher.finish())
}
fn credentials_cache_key(credentials: &ProviderCredentials, model: &str) -> (String, u64) {
reasoning_summary_cache_key(
&credentials.base_url,
model,
credentials.api_key.as_deref(),
&credentials.custom_headers,
)
}
/// Whether this model is known to be refused reasoning summaries with these credentials.
pub fn is_reasoning_summary_unavailable(credentials: &ProviderCredentials, model: &str) -> bool {
REASONING_SUMMARY_UNAVAILABLE
.get(&credentials_cache_key(credentials, model))
.is_some_and(|learned_at| learned_at.elapsed() < REASONING_SUMMARY_UNAVAILABLE_TTL)
}
/// Record that this model was refused a reasoning summary with these credentials.
pub fn remember_reasoning_summary_unavailable(credentials: &ProviderCredentials, model: &str) {
REASONING_SUMMARY_UNAVAILABLE.insert(credentials_cache_key(credentials, model), Instant::now());
}
/// Whether a rejected request was refused over its reasoning summary, e.g. `Your
/// organization must be verified to generate reasoning summaries` (param
/// `reasoning.summary`). An OpenAI-kind resource can also point at a gateway that validates
/// the body strictly and names only the unknown `summary` property.
pub fn rejects_reasoning_summary(status: u16, body: &str) -> bool {
// 422 is how FastAPI-based gateways reject a body that fails validation.
if !matches!(status, 400 | 403 | 422) {
return false;
}
let body = body.to_lowercase();
let unknown_field = body.contains("additional properties are not allowed")
|| body.contains("unrecognized request argument")
|| body.contains("extra inputs are not permitted");
body.contains("reasoning.summary")
|| body.contains("verified to generate reasoning summar")
|| body.contains("verified to stream reasoning summar")
|| (unknown_field && body.contains("summary"))
}
#[derive(Serialize)]
@@ -435,9 +509,13 @@ impl OpenAIQueryBuilder {
tools,
stream: Some(true),
temperature: args.temperature,
reasoning: args
.reasoning_effort
.map(|effort| ResponsesApiReasoning { effort: effort.to_string() }),
reasoning: args.reasoning_effort.map(|effort| ResponsesApiReasoning {
effort: effort.to_string(),
// A request that does not reason has nothing to summarize, yet asking still
// gets an unverified organization's request rejected.
summary: (args.reasoning_summary && effort != REASONING_OFF_SENTINEL)
.then(|| "auto".to_string()),
}),
max_output_tokens: args.max_tokens,
text,
prompt_cache_key: args.prompt_cache_key,
@@ -538,9 +616,8 @@ impl QueryBuilder for OpenAIQueryBuilder {
} else {
Some(parser.accumulated_content)
},
// The Responses stream has no reasoning-summary event in
// `OpenAIResponsesSSEEvent`, so nothing thinks out loud on this path yet.
reasoning: None,
reasoning: (!parser.accumulated_reasoning.is_empty())
.then_some(parser.accumulated_reasoning),
tool_calls: parser.accumulated_tool_calls.into_values().collect(),
events_str: Some(parser.events_str),
annotations: parser.annotations,
@@ -640,8 +717,11 @@ mod tests {
}
}
async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String {
let args = BuildRequestArgs {
fn text_args<'a>(
messages: &'a [OpenAIMessage],
system_prompt: Option<&'a str>,
) -> BuildRequestArgs<'a> {
BuildRequestArgs {
messages,
tools: None,
model: "gpt-5",
@@ -655,14 +735,98 @@ mod tests {
attachments: None,
has_websearch: false,
prompt_cache_key: Some(PROMPT_CACHE_KEY),
};
reasoning_summary: true,
}
}
async fn build_body(args: &BuildRequestArgs<'_>) -> String {
OpenAIQueryBuilder::new(AIProvider::OpenAI)
.build_request(&args, &client(), "test-workspace")
.build_request(args, &client(), "test-workspace")
.await
.unwrap()
}
async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String {
build_body(&text_args(messages, system_prompt)).await
}
async fn reasoning_of(effort: Option<&str>, reasoning_summary: bool) -> serde_json::Value {
let messages = vec![message("user", "hi")];
let args = BuildRequestArgs {
reasoning_effort: effort,
reasoning_summary,
..text_args(&messages, None)
};
let request: serde_json::Value = serde_json::from_str(&build_body(&args).await).unwrap();
request["reasoning"].clone()
}
#[tokio::test]
async fn requests_a_reasoning_summary_only_when_the_model_reasons() {
assert_eq!(
reasoning_of(Some("high"), true).await,
serde_json::json!({ "effort": "high", "summary": "auto" })
);
assert_eq!(
reasoning_of(Some("none"), true).await,
serde_json::json!({ "effort": "none" })
);
assert_eq!(
reasoning_of(Some("high"), false).await,
serde_json::json!({ "effort": "high" })
);
assert!(reasoning_of(None, true).await.is_null());
}
#[test]
fn recognizes_a_refused_reasoning_summary() {
let unverified = r#"{"error":{"message":"Your organization must be verified to generate reasoning summaries. Please go to: https://platform.openai.com/settings/organization/general and click on Verify Organization.","type":"invalid_request_error","param":"reasoning.summary","code":"unsupported_value"}}"#;
assert!(rejects_reasoning_summary(400, unverified));
assert!(!rejects_reasoning_summary(500, unverified));
assert!(rejects_reasoning_summary(
400,
r#"{"detail":"Additional properties are not allowed ('summary' was unexpected)"}"#
));
assert!(rejects_reasoning_summary(
422,
r#"{"detail":[{"type":"extra_forbidden","loc":["body","reasoning","summary"],"msg":"Extra inputs are not permitted","input":"auto"}]}"#
));
assert!(!rejects_reasoning_summary(
400,
r#"{"error":{"message":"Invalid 'prompt_cache_key': string too long","param":"prompt_cache_key"}}"#
));
}
/// A resource can authenticate through `headers` with no API key: one organization's
/// refusal must not withhold summaries from another's.
#[test]
fn keys_a_refused_summary_by_the_header_credential() {
let headers = |pairs: &[(&str, &str)]| {
pairs
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect::<HashMap<_, _>>()
};
let url = "https://api.openai.com/v1";
let org_a = headers(&[("Authorization", "Bearer org-a"), ("X-Trace", "1")]);
let org_a_reordered = headers(&[("X-Trace", "1"), ("Authorization", "Bearer org-a")]);
let org_b = headers(&[("Authorization", "Bearer org-b"), ("X-Trace", "1")]);
assert_ne!(
reasoning_summary_cache_key(url, "gpt-5", None, &org_a),
reasoning_summary_cache_key(url, "gpt-5", None, &org_b)
);
assert_eq!(
reasoning_summary_cache_key(url, "gpt-5", None, &org_a),
reasoning_summary_cache_key(url, "gpt-5", None, &org_a_reordered)
);
// A model can refuse summaries that another model on the same credentials streams.
assert_ne!(
reasoning_summary_cache_key(url, "gpt-5", None, &org_a),
reasoning_summary_cache_key(url, "gpt-5-mini", None, &org_a)
);
}
/// The worker prepends the system prompt as a system message *and* passes it as
/// `system_prompt`; the request must still carry it exactly once.
#[tokio::test]
+3
View File
@@ -27,6 +27,9 @@ pub struct BuildRequestArgs<'a> {
/// the prefix (the step), never from the request. `None` retries a key the
/// endpoint rejected.
pub prompt_cache_key: Option<&'a str>,
/// Ask for a summary of the model's reasoning where the provider streams one.
/// `false` once the provider refused summaries to these credentials.
pub reasoning_summary: bool,
}
/// Response from AI provider
+63
View File
@@ -815,6 +815,14 @@ pub enum OpenAIResponsesSSEEvent {
#[serde(rename = "response.output_text.annotation.added")]
AnnotationAdded { annotation: OpenAIUrlCitationEvent },
/// A new reasoning summary part starts (only sent when `reasoning.summary` was requested)
#[serde(rename = "response.reasoning_summary_part.added")]
ReasoningSummaryPartAdded {},
/// Reasoning summary text delta
#[serde(rename = "response.reasoning_summary_text.delta")]
ReasoningSummaryTextDelta { delta: String },
/// Catch-all for unknown event types
#[serde(other)]
Other,
@@ -823,6 +831,8 @@ pub enum OpenAIResponsesSSEEvent {
/// OpenAI Responses API SSE Parser for streaming responses
pub struct OpenAIResponsesSSEParser {
pub accumulated_content: String,
/// The reasoning summary streamed before the answer, kept so it can be stored with it.
pub accumulated_reasoning: String,
pub accumulated_tool_calls: HashMap<String, OpenAIToolCall>,
/// Maps item_id -> (name, call_id) for function calls
tool_call_metadata: HashMap<String, (String, String)>,
@@ -836,12 +846,15 @@ pub struct OpenAIResponsesSSEParser {
pub used_websearch: bool,
/// Token usage from response.completed event
pub usage: Option<OpenAIResponsesUsage>,
/// Reasoning summary parts seen so far, to separate them as paragraphs
reasoning_summary_parts: usize,
}
impl OpenAIResponsesSSEParser {
pub fn new(stream_event_processor: Box<dyn StreamEventSink>) -> Self {
Self {
accumulated_content: String::new(),
accumulated_reasoning: String::new(),
accumulated_tool_calls: HashMap::new(),
tool_call_metadata: HashMap::new(),
tool_call_arguments: HashMap::new(),
@@ -850,6 +863,7 @@ impl OpenAIResponsesSSEParser {
annotations: Vec::new(),
used_websearch: false,
usage: None,
reasoning_summary_parts: 0,
}
}
}
@@ -959,6 +973,28 @@ impl SSEParser for OpenAIResponsesSSEParser {
}
}
OpenAIResponsesSSEEvent::ReasoningSummaryPartAdded {} => {
self.reasoning_summary_parts += 1;
if self.reasoning_summary_parts > 1 {
self.accumulated_reasoning.push_str("\n\n");
let event =
StreamingEvent::ReasoningTokenDelta { content: "\n\n".to_string() };
self.stream_event_processor
.send(event, &mut self.events_str)
.await?;
}
}
OpenAIResponsesSSEEvent::ReasoningSummaryTextDelta { delta } => {
if !delta.is_empty() {
self.accumulated_reasoning.push_str(&delta);
let event = StreamingEvent::ReasoningTokenDelta { content: delta };
self.stream_event_processor
.send(event, &mut self.events_str)
.await?;
}
}
// Ignore other event types
OpenAIResponsesSSEEvent::Done {}
| OpenAIResponsesSSEEvent::Created {}
@@ -1015,6 +1051,33 @@ mod tests {
assert_eq!(token_usage.total_tokens, Some(4821));
}
struct ReasoningSink;
#[async_trait::async_trait]
impl StreamEventSink for ReasoningSink {
async fn send(&self, event: StreamingEvent, events_str: &mut String) -> Result<(), Error> {
if let StreamingEvent::ReasoningTokenDelta { content } = event {
events_str.push_str(&content);
}
Ok(())
}
}
#[tokio::test]
async fn streams_openai_responses_reasoning_summary_parts_as_paragraphs() {
let mut parser = OpenAIResponsesSSEParser::new(Box::new(ReasoningSink));
for data in [
r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}"#,
r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","output_index":0,"summary_index":0,"delta":"**Planning**"}"#,
r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","output_index":0,"summary_index":1,"part":{"type":"summary_text","text":""}}"#,
r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","output_index":0,"summary_index":1,"delta":"Then answer"}"#,
] {
parser.parse_event_data(data).await.unwrap();
}
assert_eq!(parser.events_str, "**Planning**\n\nThen answer");
assert_eq!(parser.accumulated_reasoning, "**Planning**\n\nThen answer");
}
#[test]
fn openai_delta_parses_reasoning_content() {
// DeepSeek and similar stream reasoning under `reasoning_content`.
+1 -2
View File
@@ -377,8 +377,7 @@ pub struct AIAgentResult<'a> {
/// The model's thinking across every iteration of the loop, in order, blank-line
/// separated. Present whenever the provider's parser surfaced any, whether or not
/// the step streams, so a downstream step never has to pick it out of `wm_stream`.
/// Absent when the model thought nothing, and on the OpenAI Responses path, whose
/// parser does not return reasoning yet.
/// Absent when the model thought nothing.
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
+3
View File
@@ -1141,6 +1141,9 @@ impl NewToken {
/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally
/// skip it, since their scopes derive from the action being authorized, not the
/// caller's token).
///
/// A token the system mints for itself with an `expiration` needs a label reserved in
/// `windmill_common::auth::is_user_token`, or its expiry alerts its owner (docs/auth-surface.md).
pub async fn create_token_internal(
tx: &mut sqlx::PgConnection,
db: &DB,
+35 -2
View File
@@ -77,6 +77,10 @@ pub fn workspaced_service() -> Router {
"/list_paths_from_workspace_runnable/{runnable_kind}/{*path}",
get(list_paths_from_workspace_runnable),
)
.route(
"/list_paths_linking_agent/{*path}",
get(list_paths_linking_agent),
)
.route("/history_update/v/{version}", post(update_flow_history))
.route("/get/v/{version}", get(get_flow_version_by_id))
.route("/get/v/{version}/p/{*path}", get(get_flow_version))
@@ -150,6 +154,7 @@ async fn list_flows(
"favorite.path IS NOT NULL as starred",
"ws_error_handler_muted",
"o.labels",
"(o.value->>'chat_input_enabled')::bool as chat_input_enabled",
"draft.email IS NOT NULL as is_draft",
// Per-path draft owners as a JSON array; see scripts.rs for the rationale
// (non-member superadmin identity fallback via `password`, legacy NULL-email row).
@@ -297,6 +302,10 @@ async fn list_flows(
ws_error_handler_muted: None,
deployment_msg: None,
labels: None,
chat_input_enabled: v
.get("value")
.and_then(|fv| fv.get("chat_input_enabled"))
.and_then(|b| b.as_bool()),
// No deployed row to inherit folder labels from.
inherited_labels: None,
is_draft: true,
@@ -508,7 +517,7 @@ async fn list_paths_from_workspace_runnable(
FROM workspace_runnable_dependencies wru
JOIN flow f
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#,
WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3"#,
path,
matches!(runnable_kind, RunnableKind::Flow),
w_id
@@ -521,7 +530,7 @@ async fn list_paths_from_workspace_runnable(
FROM workspace_runnable_dependencies wru
JOIN flow f
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#,
WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3"#,
path,
matches!(runnable_kind, RunnableKind::Flow),
w_id
@@ -534,6 +543,30 @@ async fn list_paths_from_workspace_runnable(
Ok(Json(runnables))
}
/// Flows with a step linked to the `ai_agent` resource at `path`, as of their last deploy.
async fn list_paths_linking_agent(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Vec<String>> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:agent/{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let flows = sqlx::query_scalar!(
r#"SELECT DISTINCT f.path
FROM workspace_runnable_dependencies wru
JOIN flow f
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
WHERE wru.runnable_path = $1 AND wru.runnable_is_agent AND wru.workspace_id = $2"#,
path,
w_id
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(flows))
}
async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> {
#[cfg(not(feature = "enterprise"))]
if new_flow.ws_error_handler_muted.is_some_and(|val| val) {
@@ -179,9 +179,11 @@ async fn test_trigger_token_labels_still_creatable(db: Pool<Postgres>) -> anyhow
"http-test-user-2-cd34",
"email-test-user-2-ef56",
"my-ci-token",
// Minted client-side by the editor (every TypeScript editor load) and the debugger.
// Minted client-side by the editor (every TypeScript editor load), the debugger and
// the object-storage "Test from a worker" button.
"Ephemeral lsp token",
"debugger-token",
"ephemeral-test-connection: s3_bucket",
] {
let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await;
assert_eq!(
+13 -4
View File
@@ -38,8 +38,8 @@ use windmill_common::{
FlowVersionInfo, DB,
};
use windmill_queue::{
cancel_job, get_result_and_success_by_id_from_flow, push, PushArgs, PushArgsOwned,
PushIsolationLevel,
cancel_job, get_result_and_success_by_id_from_flow, parse_result_object, push, PushArgs,
PushArgsOwned, PushIsolationLevel,
};
use crate::types::RunJobQuery;
@@ -374,9 +374,9 @@ pub async fn run_wait_result_internal(
}
pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result<Response> {
let composite_result = serde_json::from_str::<WindmillCompositeResult>(result.get());
let composite_result = parse_result_object::<WindmillCompositeResult>(result.get());
match composite_result {
Ok(WindmillCompositeResult {
Some(WindmillCompositeResult {
windmill_status_code,
windmill_content_type,
windmill_headers,
@@ -1192,4 +1192,13 @@ mod result_to_response_tests {
assert!(res.is_err(), "hop-by-hop header must be rejected: {name}");
}
}
#[tokio::test]
async fn array_result_is_not_a_composite_response() {
let json = r#"[201,"text/html",null,null,"<h1>hi</h1>"]"#;
let resp = result_to_response(raw(json), true).expect("response");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_bytes(resp).await, json.as_bytes());
}
}
+17 -29
View File
@@ -1129,35 +1129,23 @@ pub async fn set_enabled(
check_scopes(&authed, || format!("schedules:write:{}", path))?;
reject_reserved_schedule_path(path)?;
// Block enabling a schedule in a fork when the parent has the same path
// (regardless of parent's enabled flag), unless force=true. Two enabled
// crons fire in lockstep; even when the parent is currently disabled the
// user is likely to re-enable it later, at which point both fire — better
// to surface that risk at every fork-side enable. There's no namespacing
// fix for schedules (Phase 3 doesn't help cron); the user has to confirm
// or point the script at fork-only side effects.
// Block enabling a schedule in a fork when an ancestor has the same path
// (regardless of its enabled flag), unless force=true. Two enabled crons
// fire in lockstep; even when the ancestor is currently disabled the user
// is likely to re-enable it later, at which point both fire — better to
// surface that risk at every fork-side enable. There's no namespacing fix
// for schedules (Phase 3 doesn't help cron); the user has to confirm or
// point the script at fork-only side effects.
if payload.enabled && !payload.force {
let parent_id: Option<String> = sqlx::query_scalar!(
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
&w_id
if let Some(ancestor_id) = windmill_common::workspaces::nearest_fork_ancestor_having(
&mut *tx, "schedule", &w_id, path,
)
.fetch_optional(&mut *tx)
.await?
.flatten();
if let Some(parent_id) = parent_id {
let exists: Option<bool> = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM schedule WHERE workspace_id = $1 AND path = $2)",
&parent_id,
path,
)
.fetch_one(&mut *tx)
.await?;
if exists == Some(true) {
return Err(Error::BadRequest(format!(
"fork-conflict:schedule:{}",
parent_id
)));
}
{
return Err(Error::BadRequest(format!(
"fork-conflict:schedule:{}",
ancestor_id
)));
}
}
let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?;
@@ -1699,9 +1687,9 @@ pub use windmill_queue::schedule::clear_schedule;
#[derive(Deserialize)]
pub struct SetEnabled {
pub enabled: bool,
/// Bypass the parent-state warning when enabling a schedule in a fork
/// whose parent has the same path enabled. The frontend sets this after
/// the user confirms the duplicate-firing dialog.
/// Bypass the fork-conflict warning when enabling a schedule in a fork
/// while an ancestor workspace has the same path. The frontend sets this
/// after the user confirms the duplicate-firing dialog.
#[serde(default)]
pub force: bool,
}
+5 -2
View File
@@ -3882,8 +3882,8 @@ async fn update_token_label(
Path(token_prefix): Path<String>,
Json(req): Json<UpdateTokenLabelRequest>,
) -> Result<String> {
// The new label must not collide with a system-token namespace (`session`,
// `ephemeral*`, `debugger-token`, `mcp-oauth-*`): those labels are
// The new label must not collide with a system-token namespace (see
// `windmill_common::auth::is_user_token`): those labels are
// load-bearing, and a user-set collision would orphan the token — hidden
// from the UI (`isUserToken`) and rejected by the editability guard below —
// while it still authenticates. (`is_user_token(None)` is true, so clearing
@@ -3922,6 +3922,9 @@ async fn update_token_label(
AND lower(label) NOT LIKE 'ephemeral%'
AND label <> 'debugger-token'
AND label NOT LIKE 'mcp-oauth-%'
AND NOT starts_with(label, 'embed_app:')
AND NOT starts_with(label, 'sdk_app:')
AND NOT starts_with(label, 'impersonation:')
))
RETURNING token_prefix",
req.label.as_deref(),
@@ -1,5 +1,6 @@
//! What the workspace key rotation and the AI session backup routes
//! (`windmill-api/src/ai_sessions.rs`) share about the backups in the workspace storage.
//! What the workspace key rotation, the workspace storage settings and the AI session backup
//! routes (`windmill-api/src/ai_sessions.rs`) share about the backups: the store they live
//! in, and what a rotation or a storage change deletes.
//!
//! The backups are ciphertext under the workspace key and live under a prefix named by a
//! generation the rotation bumps (`workspace_settings.ai_sessions_backup_generation`) in the
@@ -13,17 +14,29 @@
//! one to read with and nothing to rewrite in place. A generation is never reused, so no
//! deletion, however late, can touch live objects; a rotation that fails before its commit
//! bumps nothing and deletes nothing; two rotations racing serialize on the key row.
//!
//! A workspace without storage of its own keeps its backups in the instance object store
//! instead, under the same layout and key, while `ai_sessions_instance_storage_fallback`
//! allows it. Configuring a storage for such a workspace bumps the generation in the
//! transaction that sets it, so everything the workspace left in any instance store sits
//! under a generation the routes never read again: a later return to the instance store,
//! whichever it is by then, starts from a newer one. That is what lets a storage change
//! delete the older generations from the instance store without fencing against what
//! happens next, and a browser retire a removal owed to an instance store once the
//! workspace's own storage answered.
use std::sync::Arc;
use futures::TryStreamExt;
use futures::{StreamExt, TryStreamExt};
use windmill_common::error::{Error, Result};
use windmill_common::utils::calculate_hash;
use windmill_common::DB;
use windmill_object_store::object_store_reexports::{
ObjectStore, ObjectStoreError, Path as ObjectPath,
};
use windmill_object_store::{object_store_error_to_error, ObjectStoreResource};
use windmill_object_store::{
object_store_error_to_error, object_store_location, ObjectStoreResource,
};
use windmill_types::s3::LargeFileStorage;
/// The root of every AI session backup key in a workspace's storage.
@@ -31,6 +44,9 @@ pub const ROOT: &str = "windmill_ai_sessions";
/// The push body cap: no object written through the routes is larger. One that is was
/// planted by whoever holds the bucket's credentials, and is left unread.
pub const MAX_OBJECT_BYTES: usize = 32 * 1024 * 1024;
/// The storage name the workspace's backups in the instance store count under in its
/// storage usage, next to `_default_` and the secondary storages.
pub const FALLBACK_STORAGE: &str = "_ai_sessions_fallback_";
const IO_CONCURRENCY: usize = 8;
@@ -39,34 +55,77 @@ pub fn generation_prefix(w_id: &str, generation: i64) -> String {
format!("{ROOT}/{w_id}/g{generation}")
}
/// The prefix of everything the workspace ever backed up, whatever the generation.
fn workspace_prefix(w_id: &str) -> ObjectPath {
ObjectPath::from(format!("{ROOT}/{w_id}"))
}
/// Names the storage the backups are in, by what locates its objects (endpoint, region,
/// bucket; never the credentials, which rotate), so a browser tells that its sync state was
/// recorded against another storage; the generation, answered alongside, tells it a
/// rotation happened in this one.
pub fn storage_id(resource: &ObjectStoreResource) -> String {
let location = match resource {
ObjectStoreResource::S3(s) => format!(
"s3:{}:{}:{}:{}",
s.endpoint,
s.port.unwrap_or_default(),
s.region,
s.bucket
),
ObjectStoreResource::Azure(a) => format!(
"azure:{}:{}:{}",
a.endpoint.as_deref().unwrap_or_default(),
a.account_name,
a.container_name
),
ObjectStoreResource::Gcs(g) => format!("gcs:{}", g.bucket),
ObjectStoreResource::Filesystem(f) => format!("fs:{}", f.root_path),
};
calculate_hash(&location)[..16].to_string()
calculate_hash(&object_store_location(resource))[..16].to_string()
}
/// The workspace's primary storage, resolved without a caller: a rotation runs the
/// deletion off its own request.
async fn primary_store(db: &DB, w_id: &str) -> Result<Option<Arc<dyn ObjectStore>>> {
/// Where a workspace's backups live: its primary storage, or the instance object store
/// standing in for it.
pub struct BackupStore {
pub store: Arc<dyn ObjectStore>,
pub storage_id: String,
pub fallback: bool,
}
/// The instance object store, for a workspace without storage of its own: loaded from
/// settings that say where its objects are, and not turned off by
/// `ai_sessions_instance_storage_fallback`, which is on unless set to false. Named like a
/// workspace storage, by that location, in a namespace of its own. Never on the Pro plan,
/// checked on every call: a store loaded before a switch to Pro stays loaded. Never in a
/// build without `private`, which has neither workspace storage nor the quota the fallback
/// counts toward.
///
/// Authorizes nothing, and the store reaches every workspace's objects: the caller must have
/// authorized the user for the workspace and keep what it reads and writes under that
/// user's prefix in it, as the backup routes do.
pub async fn fallback_store(db: &DB) -> Result<Option<BackupStore>> {
#[cfg(not(feature = "private"))]
{
let _ = db;
Ok(None)
}
#[cfg(feature = "private")]
{
if matches!(
windmill_common::ee_oss::get_license_plan().await,
windmill_common::ee_oss::LicensePlan::Pro
) {
return Ok(None);
}
let Some((store, Some(location))) =
windmill_object_store::get_object_store_with_location().await
else {
return Ok(None);
};
let setting = windmill_common::global_settings::load_value_from_global_settings(
db,
windmill_common::global_settings::AI_SESSIONS_INSTANCE_STORAGE_FALLBACK_SETTING,
)
.await?;
if matches!(setting, Some(serde_json::Value::Bool(false))) {
return Ok(None);
}
Ok(Some(BackupStore {
storage_id: calculate_hash(&format!("instance:{location}"))[..16].to_string(),
store,
fallback: true,
}))
}
}
/// The workspace's primary storage, resolved without a caller: a rotation runs its deletion
/// off its own request, and the retention sweep off any. The caller must be the server
/// itself; nothing here checks who asks.
pub async fn primary_store(db: &DB, w_id: &str) -> Result<Option<BackupStore>> {
let Some(lfs_json) = sqlx::query_scalar!(
"SELECT large_file_storage FROM workspace_settings WHERE workspace_id = $1",
w_id
@@ -91,9 +150,19 @@ async fn primary_store(db: &DB, w_id: &str) -> Result<Option<Arc<dyn ObjectStore
.await?
};
let resource = windmill_object_store::lfs_to_object_store_resource(&lfs, resource_value)?;
Ok(Some(
windmill_object_store::build_object_store_client(&resource).await?,
))
Ok(Some(BackupStore {
store: windmill_object_store::build_object_store_client(&resource).await?,
storage_id: storage_id(&resource),
fallback: false,
}))
}
/// The store the workspace's backups live in, resolved without a caller.
async fn workspace_store(db: &DB, w_id: &str) -> Result<Option<BackupStore>> {
if let Some(primary) = primary_store(db, w_id).await? {
return Ok(Some(primary));
}
fallback_store(db).await
}
/// The generation an object key sits under, `None` for a key of no generation (an older
@@ -107,40 +176,41 @@ fn generation_of(w_id: &str, key: &ObjectPath) -> Option<i64> {
.ok()
}
/// Deletes, off the request and as the listing streams, every object of the workspace's
/// backups from a generation older than `current`, once the rotation that made `current`
/// the generation has committed: nothing writes there any more but a push that resolved its
/// prefix before the commit, junk the browser's next push of that session rewrites under the
/// current prefix, as is anything a deletion cut short left behind. For the rotation route,
/// which authorized its caller as a superadmin.
/// Deletes, as the listing streams, every object of the workspace's backups in the store
/// from a generation older than `current`.
async fn delete_older(store: &Arc<dyn ObjectStore>, w_id: &str, current: i64) -> Result<()> {
store
.list(Some(&workspace_prefix(w_id)))
.map_err(object_store_error_to_error)
.try_for_each_concurrent(IO_CONCURRENCY, |meta| async move {
if generation_of(w_id, &meta.location).is_some_and(|g| g >= current) {
return Ok(());
}
match store.delete(&meta.location).await {
Ok(()) | Err(ObjectStoreError::NotFound { .. }) => Ok(()),
Err(e) => Err(object_store_error_to_error(e)),
}
})
.await
}
/// Deletes, off the request, every object of the workspace's backups from a generation
/// older than `current`, once the rotation that made `current` the generation has
/// committed: nothing writes there any more but a push that resolved its prefix before the
/// commit, junk the browser's next push of that session rewrites under the current prefix,
/// as is anything a deletion cut short left behind. For the rotation route, which
/// authorized its caller as a superadmin.
pub(crate) fn spawn_delete_older(db: DB, w_id: String, current: i64) {
tokio::spawn(async move {
let store = match primary_store(&db, &w_id).await {
Ok(Some(store)) => store,
let store = match workspace_store(&db, &w_id).await {
Ok(Some(store)) => store.store,
Ok(None) => return,
Err(e) => {
tracing::warn!("older AI session backups of {w_id} left in place: {e:#}");
return;
}
};
let prefix = ObjectPath::from(format!("{ROOT}/{w_id}"));
let deleted = store
.list(Some(&prefix))
.map_err(object_store_error_to_error)
.try_for_each_concurrent(IO_CONCURRENCY, |meta| {
let (store, w_id) = (&store, &w_id);
async move {
if generation_of(w_id, &meta.location).is_some_and(|g| g >= current) {
return Ok(());
}
match store.delete(&meta.location).await {
Ok(()) | Err(ObjectStoreError::NotFound { .. }) => Ok(()),
Err(e) => Err(object_store_error_to_error(e)),
}
}
})
.await;
match deleted {
match delete_older(&store, &w_id, current).await {
Ok(()) => {
tracing::info!("deleted the AI session backups of {w_id} older than g{current}")
}
@@ -148,3 +218,57 @@ pub(crate) fn spawn_delete_older(db: DB, w_id: String, current: i64) {
}
});
}
/// Deletes, off the request, what the workspace's backups left in the instance store under
/// a generation older than `current`, the one a storage settings change committed. Nothing
/// reads there: the routes use the workspace's own storage, or, back in the instance store,
/// `current` or a newer generation, since configuring a storage over the fallback bumped
/// it. So it runs whatever the storage is now and whatever the setting says (copies from
/// when it was on may be there), and a deletion that is slow, cut short or overtaken by a
/// later change deletes nothing live. For the storage settings route, which authorized its
/// caller as a workspace admin.
pub(crate) fn spawn_delete_fallback(w_id: String, current: i64) {
tokio::spawn(async move {
let Some(instance) = windmill_object_store::get_object_store().await else {
return;
};
match delete_older(&instance, &w_id, current).await {
Ok(()) => tracing::info!(
"deleted the AI session backups of {w_id} older than g{current} from the instance store"
),
Err(e) => tracing::warn!(
"deleting the AI session backups of {w_id} from the instance store: {e:#}"
),
}
});
}
/// The bytes of the workspace's backups in the instance store, for its storage usage while
/// it has no storage of its own (once it has one nothing writes there, and the change
/// deleted what was): `None` when it has one, when there is no instance store, or when
/// there is nothing, so no empty usage entry shows up. Whether the setting is on or off,
/// since copies from when it was on may be there.
///
/// Authorizes nothing: for the storage usage recount, which reports a total for the
/// workspace it was run for and hands out nothing it read.
pub async fn fallback_bytes(db: &DB, w_id: &str) -> Result<Option<i64>> {
let has_storage = sqlx::query_scalar!(
r#"SELECT large_file_storage IS NOT NULL AS "has_storage!" FROM workspace_settings WHERE workspace_id = $1"#,
w_id
)
.fetch_optional(db)
.await?
.unwrap_or(false);
if has_storage {
return Ok(None);
}
let Some(instance) = windmill_object_store::get_object_store().await else {
return Ok(None);
};
let mut total: i64 = 0;
let mut stream = instance.list(Some(&workspace_prefix(w_id)));
while let Some(meta) = stream.next().await {
total += meta.map_err(object_store_error_to_error)?.size as i64;
}
Ok((total > 0).then_some(total))
}
@@ -227,6 +227,10 @@ pub fn global_service() -> Router {
.route("/list", get(list_workspaces))
.route("/users", get(user_workspaces))
.route("/session_workspace_status", post(session_workspace_status))
.route(
"/session_workspace_retention",
post(session_workspace_retention),
)
.route("/create", post(create_workspace))
.route("/create_fork", post(deprecated_create_workspace_fork))
.route("/exists", post(exists_workspace))
@@ -1304,26 +1308,22 @@ async fn get_git_sync_deploy_mode(
let configured = !settings.repositories.is_empty();
// Auto-pull runs only on Enterprise-licensed instances (see poll_git_auto_pull);
// without a caller branch there is nothing to match. Either way deploy_on_push
// stays false and the caller falls back (git push via CI, or wmill sync push).
// Auto-pull runs only in builds that compile the poller (`private`); without a
// caller branch there is nothing to match. Either way deploy_on_push stays
// false and the caller falls back (git push via CI, or wmill sync push).
let Some(branch) = q.branch.as_deref() else {
return Ok(Json(GitSyncDeployMode {
configured,
deploy_on_push: false,
}));
};
let licensed = matches!(
windmill_common::ee_oss::get_license_plan().await,
windmill_common::ee_oss::LicensePlan::Enterprise
);
// Count the auto-pull repos that would deploy this branch. We deliberately do
// not check the caller's remote URL: with exactly one such repo the local
// checkout is unambiguously it, and with several we can't tell which is the
// caller's, so we report false and let the CLI ask the user.
let mut matches = 0u32;
if licensed && !root_deleted {
if cfg!(feature = "private") && !root_deleted {
for repo in &settings.repositories {
let Some(auto_pull) = repo.auto_pull.as_ref() else {
continue;
@@ -2093,6 +2093,17 @@ async fn edit_large_file_storage_config(
serde_json::to_value::<LargeFileStorageWithSecondary>(lfs_config)
.map_err(|err| Error::internal_err(err.to_string()))?;
// A workspace whose AI session backups fell back to the instance store leaves it
// here: the generation moves on, so nothing it left in any instance store is read
// again, whichever one a later return to the fallback finds (`ai_session_backups`).
sqlx::query!(
"UPDATE workspace_settings SET ai_sessions_backup_generation = \
ai_sessions_backup_generation + 1 \
WHERE workspace_id = $1 AND large_file_storage IS NULL",
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
serialized_lfs_config,
@@ -2108,8 +2119,23 @@ async fn edit_large_file_storage_config(
.execute(&mut *tx)
.await?;
}
let backups_generation = sqlx::query_scalar!(
"SELECT ai_sessions_backup_generation FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
// Read by nothing any more, whatever the storage is now: what the AI session backups
// left in the instance store under a generation older than the one just committed.
#[cfg(feature = "parquet")]
if let Some(generation) = backups_generation {
crate::ai_session_backups::spawn_delete_fallback(w_id.clone(), generation);
}
#[cfg(not(feature = "parquet"))]
let _ = backups_generation;
// Trigger git sync for large file storage changes
handle_deployment_metadata(
&authed.email,
@@ -3732,54 +3758,6 @@ fn cleanup_legacy_git_sync_settings_in_memory(
#[cfg(not(feature = "enterprise"))]
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
/// Auto-pull is licensed per plan, not just per build: the poller only serves
/// Enterprise plans at runtime, so the save path must reject the setting too —
/// otherwise an EE binary without the plan could still register a webhook and
/// receive webhook-driven pulls.
#[cfg(feature = "enterprise")]
async fn check_git_sync_ee_license(feature: &str) -> Result<()> {
if !matches!(
windmill_common::ee_oss::get_license_plan().await,
windmill_common::ee_oss::LicensePlan::Enterprise
) {
return Err(Error::BadRequest(format!(
"{feature} requires an Enterprise license"
)));
}
Ok(())
}
#[cfg(feature = "enterprise")]
async fn check_auto_pull_license() -> Result<()> {
check_git_sync_ee_license("Automatic pull from git").await
}
/// In-app PR creation (promotion/fork deploy branches) drives GitHub API calls
/// from the deploy completion hook; runtime-gate it like auto-pull.
#[cfg(feature = "enterprise")]
async fn check_open_prs_license<'a>(
mut repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
) -> Result<()> {
if repos.any(|r| r.promotion_open_prs || r.fork_open_prs) {
check_git_sync_ee_license("Opening pull requests from Windmill").await?;
}
Ok(())
}
/// Promotion mode (`use_individual_branch`: per-item `wm_deploy/**` deploy
/// branches) is an EE feature; runtime-gate it like auto-pull and PR creation
/// so an enterprise binary without an active plan can't enable it via either
/// git-sync edit endpoint.
#[cfg(feature = "enterprise")]
async fn check_promotion_license<'a>(
mut repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
) -> Result<()> {
if repos.any(|r| r.use_individual_branch.unwrap_or(false)) {
check_git_sync_ee_license("Promotion mode").await?;
}
Ok(())
}
/// Promotion on a dev workspace needs the dev-aware sync script (hub >= 28796):
/// an older pinned script bundles a CLI that force-disables per-item branches
/// on every fork, so enabling promotion would silently keep deploying to the
@@ -4031,18 +4009,6 @@ async fn edit_git_sync_config(
));
}
#[cfg(feature = "enterprise")]
if git_sync_settings
.repositories
.iter()
.any(|r| r.auto_pull.as_ref().is_some_and(|a| a.enabled))
{
check_auto_pull_license().await?;
}
#[cfg(feature = "enterprise")]
check_open_prs_license(git_sync_settings.repositories.iter()).await?;
#[cfg(feature = "enterprise")]
check_promotion_license(git_sync_settings.repositories.iter()).await?;
#[cfg(feature = "enterprise")]
check_dev_promotion_script_version(&db, &w_id, git_sync_settings.repositories.iter())
.await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
@@ -4280,19 +4246,6 @@ async fn edit_git_sync_repository(
));
}
#[cfg(feature = "enterprise")]
if new_config
.repository
.auto_pull
.as_ref()
.is_some_and(|a| a.enabled)
{
check_auto_pull_license().await?;
}
#[cfg(feature = "enterprise")]
check_open_prs_license(std::iter::once(&new_config.repository)).await?;
#[cfg(feature = "enterprise")]
check_promotion_license(std::iter::once(&new_config.repository)).await?;
#[cfg(feature = "enterprise")]
check_dev_promotion_script_version(&db, &w_id, std::iter::once(&new_config.repository)).await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
check_dev_promotion_targets_parent_repo(&db, &w_id, std::iter::once(&new_config.repository))
@@ -4398,13 +4351,6 @@ async fn edit_git_sync_repository(
}
_ => {}
}
// The request-side license gate above only saw the submitted config; the
// preservation can resurrect an enabled auto_pull (None arm), so re-check
// the effective state before it gets written and reconciled.
#[cfg(feature = "enterprise")]
if updated.auto_pull.as_ref().is_some_and(|a| a.enabled) {
check_auto_pull_license().await?;
}
*existing_repo = updated;
} else {
// Repository doesn't exist, add it as a new repository
@@ -5603,6 +5549,14 @@ struct SessionWorkspaceStatusRequest {
workspace_ids: Vec<String>,
}
/// `ai_config.sessions_retention_days` as stored, `None` when unset or not a count of days.
pub fn sessions_retention_days(value: Option<&serde_json::Value>) -> Option<u32> {
value
.and_then(|v| v.as_u64())
.filter(|days| *days >= 1)
.and_then(|days| u32::try_from(days).ok())
}
/// Reconciliation support for client-side AI sessions, which the backend cannot touch
/// directly. The client posts the workspace ids its sessions reference and uses the
/// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row, or
@@ -5652,6 +5606,42 @@ async fn session_workspace_status(
Ok(Json(statuses))
}
/// The AI session retention a browser deletes its local copies by (docs/ai-session-backups.md).
/// Its own route, not a field on the status above, whose shape an older tab still reads. Unlike
/// a status, it answers only for a workspace this caller can be authed into: a setting is the
/// workspace's to tell, so a disabled membership gets none though its sessions still reconcile.
async fn session_workspace_retention(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Json(req): Json<SessionWorkspaceStatusRequest>,
) -> JsonResult<HashMap<String, u32>> {
if req.workspace_ids.len() > 1000 {
return Err(Error::BadRequest(
"Too many workspace ids (max 1000)".to_string(),
));
}
let email = &authed.email;
let is_superadmin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
let rows = sqlx::query!(
"SELECT workspace_settings.workspace_id AS \"id!\",
workspace_settings.ai_config->'sessions_retention_days' AS retention
FROM workspace_settings
LEFT JOIN usr ON usr.workspace_id = workspace_settings.workspace_id AND usr.email = $2
WHERE workspace_settings.workspace_id = ANY($1)
AND ($3 OR (usr.email IS NOT NULL AND NOT usr.disabled))",
&req.workspace_ids[..],
email,
is_superadmin,
)
.fetch_all(&db)
.await?;
let days = rows
.into_iter()
.filter_map(|r| sessions_retention_days(r.retention.as_ref()).map(|days| (r.id, days)))
.collect();
Ok(Json(days))
}
/// The instance critical alert channels belong to the instance operator, who on cloud is
/// not the workspace owner and never opted into a tenant's job failures. Fork workspaces run
/// throwaway copies of their parent's runnables, so instance-wide operational alerting must
@@ -7397,8 +7387,8 @@ async fn clone_workspace_runnable_dependencies(
) -> Result<()> {
// Clone workspace_runnable_dependencies
sqlx::query!(
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path)
SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path)
SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path
FROM workspace_runnable_dependencies
WHERE workspace_id = $2",
target_workspace_id,
+79 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.812.0
version: 1.813.0
title: Windmill API
contact:
@@ -1277,6 +1277,37 @@ paths:
- archived
- deleted
/workspaces/session_workspace_retention:
post:
summary: get the AI session retention of workspaces referenced by client-side sessions
operationId: getSessionWorkspaceRetention
tags:
- workspace
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
workspace_ids:
type: array
items:
type: string
required:
- workspace_ids
responses:
"200":
description: >-
map of workspace id to its `ai_config.sessions_retention_days`; a workspace
without a retention, or one the caller cannot be authenticated into, is absent
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
/w/{workspace}/workspaces/get_as_superadmin:
get:
summary: get workspace as super admin (require to be super admin)
@@ -11979,6 +12010,13 @@ paths:
properties:
draft_only:
type: boolean
chat_input_enabled:
type: boolean
description: |
`chat_input_enabled` of the flow's value,
projected so the list can mark flows that open
as a chat. Omitted when the value has no such
field.
is_draft:
type: boolean
description: |
@@ -12072,6 +12110,25 @@ paths:
items:
type: string
/w/{workspace}/flows/list_paths_linking_agent/{path}:
get:
summary: list flow paths with a step linked to a saved agent
operationId: listFlowPathsLinkingAgent
tags:
- flow
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: paths of the flows linking the `ai_agent` resource, as of their last deploy
content:
application/json:
schema:
type: array
items:
type: string
/w/{workspace}/flows/get/v/{version}:
get:
summary: get flow version
@@ -13222,6 +13279,9 @@ paths:
backup_generation:
type: integer
description: bumped by every workspace key rotation; sync state recorded under another one is void
fallback:
type: boolean
description: the storage answered from is the instance object store, standing in for a workspace without storage of its own; a removal owed to it is retired by any answer from the workspace's own storage
sessions:
type: array
description: the newest 500 at most
@@ -13273,6 +13333,8 @@ paths:
type: string
backup_generation:
type: integer
fallback:
type: boolean
sessions:
type: array
items:
@@ -13327,6 +13389,8 @@ paths:
type: string
backup_generation:
type: integer
fallback:
type: boolean
results:
type: array
items:
@@ -28441,6 +28505,14 @@ components:
description: >-
Stops browsers from backing their AI sessions up to the workspace's object
storage. Read from the workspace's own settings like `copilot_disabled`.
sessions_retention_days:
type: integer
minimum: 1
maximum: 3650
description: >-
The server deletes the backup of a session no push has reached for this many
days. Unset keeps backups until the user deletes the session. Read from the
workspace's own settings like `copilot_disabled`.
AISessionBackupListing:
type: object
@@ -28886,6 +28958,12 @@ components:
type: boolean
has_deploy_errors:
type: boolean
chat_input_enabled:
type: boolean
description: >-
flow-only. `chat_input_enabled` of the flow's value, projected so
the list can mark flows that open as a chat. Omitted when the
value has no such field.
raw_app:
type: boolean
execution_mode:
+20
View File
@@ -455,6 +455,12 @@ pub struct AIConfig {
/// (`ai_sessions.rs`). Read from the workspace's own row like `copilot_disabled`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sessions_storage_disabled: bool,
/// The server's sweep (`ai_sessions.rs`) deletes the backup of a session no push has
/// reached for this many days. The copies in members' browsers are untouched. Unset
/// keeps backups until the user deletes the session. Read from the workspace's own row
/// like `copilot_disabled`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sessions_retention_days: Option<u32>,
}
/// Negotiated rates in USD per million tokens. An unset cache rate is read as the
@@ -491,6 +497,9 @@ impl ModelPriceOverride {
}
}
/// Ten years: past any plausible retention, and well within what a day count is turned into.
pub const MAX_SESSIONS_RETENTION_DAYS: u32 = 3650;
impl AIConfig {
pub fn validate_model_pricing(&self) -> Result<()> {
for (key, price) in self.model_pricing.iter().flatten() {
@@ -499,6 +508,17 @@ impl AIConfig {
Ok(())
}
pub fn validate_sessions_retention(&self) -> Result<()> {
match self.sessions_retention_days {
Some(days) if !(1..=MAX_SESSIONS_RETENTION_DAYS).contains(&days) => {
Err(Error::BadRequest(format!(
"AI session retention must be between 1 and {MAX_SESSIONS_RETENTION_DAYS} days (got {days})"
)))
}
_ => Ok(()),
}
}
pub fn has_providers(&self) -> bool {
self.providers
.as_ref()
+363 -45
View File
@@ -30,8 +30,9 @@ use serde_json::value::RawValue;
use std::sync::Arc;
use windmill_api_auth::is_effectively_unscoped;
use windmill_api_workspaces::ai_session_backups::{
generation_prefix, storage_id, MAX_OBJECT_BYTES,
fallback_store, generation_prefix, primary_store, storage_id, MAX_OBJECT_BYTES,
};
use windmill_api_workspaces::workspaces::sessions_retention_days;
use windmill_common::error::{Error, JsonResult, Result};
use windmill_common::utils::calculate_hash;
use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key};
@@ -65,6 +66,46 @@ const MAX_LISTED_OBJECTS: usize = 5000;
const MAX_LIST_SCAN: usize = 50_000;
const LIST_MAX: usize = 500;
const IO_CONCURRENCY: usize = 8;
/// Sessions the retention sweep deletes per workspace and pass at most; the rest wait for
/// the next pass.
const SWEEP_MAX_PER_WORKSPACE: usize = 1000;
/// Session-level advisory lock of the retention sweep: one server at a time runs it.
const SWEEP_LOCK_ID: i64 = 0x5745_4550_4149;
/// The name of the sweep's record next to a session's markers (see `Backend::sweep_key`).
const SWEEP_RECORD: &str = "sweep";
/// The name of a split push's token next to a session's markers (see `Backend::push_key`).
const PUSH_TOKEN: &str = "push";
/// A marker modified before this is past a retention of `days`.
fn retention_cutoff(days: u32) -> chrono::DateTime<chrono::Utc> {
chrono::Utc::now() - chrono::Duration::days(i64::from(days))
}
/// What a key under the `index/` prefix is.
enum IndexEntry {
/// The marker that lists the session, named by its epoch.
Marker(u32),
/// The retention sweep's record (see `Backend::sweep_key`).
Sweep,
/// The token of a push split over parts (see `Backend::push_key`).
Push,
}
/// The session a key under the `index/` prefix belongs to, and what the key is.
fn index_entry<'a>(index: &ObjectPath, key: &'a ObjectPath) -> Option<(&'a str, IndexEntry)> {
// `Path` drops the trailing delimiter, so the remainder starts with one.
let rel = key.as_ref().strip_prefix(index.as_ref())?;
let (sid, name) = rel.trim_start_matches('/').split_once('/')?;
if sid.is_empty() {
return None;
}
let entry = match name {
SWEEP_RECORD => IndexEntry::Sweep,
PUSH_TOKEN => IndexEntry::Push,
epoch => IndexEntry::Marker(epoch.parse().ok()?),
};
Some((sid, entry))
}
pub fn workspaced_service() -> Router {
Router::new()
@@ -101,6 +142,12 @@ struct Backend {
/// owed to the storage alone (a rotation deleted the older generation's copy anyway).
storage_id: String,
generation: i64,
/// `ai_config.sessions_retention_days`: a session whose marker is older is not listed,
/// whether or not the sweep has deleted it yet.
retention_days: Option<u32>,
/// The store is the instance object store standing in for a workspace without storage
/// of its own (`ai_session_backups::fallback_store`).
fallback: bool,
}
impl Backend {
@@ -108,6 +155,12 @@ impl Backend {
ObjectPath::from(format!("{}/index/", self.prefix))
}
/// The moment a marker's modification time must reach to count as live, under the
/// workspace's retention; `None` without one.
fn retention_cutoff(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.retention_days.map(retention_cutoff)
}
/// The marker that lists the session, named by the session's move count so that of a
/// session two workspaces list, the copy moved last is told from the listing alone.
fn index_key(&self, sid: &str, epoch: u32) -> ObjectPath {
@@ -118,10 +171,18 @@ impl Backend {
ObjectPath::from(format!("{}/index/{sid}/", self.prefix))
}
/// The token of the push split over parts in progress, under the session so a removal
/// or the next whole push clears it with the rest.
/// Written by the retention sweep before it deletes anything of a session, and deleted
/// last (`remove_session`): what finds a removal the sweep started and could not finish,
/// the markers being gone by then. Not an epoch, so nothing lists or pulls a session by it.
fn sweep_key(&self, sid: &str) -> ObjectPath {
ObjectPath::from(format!("{}/index/{sid}/{SWEEP_RECORD}", self.prefix))
}
/// The token of the push split over parts in progress, next to the markers so a removal
/// or the next whole push clears it with them, and the retention sweep, which walks the
/// markers, finds one a browser abandoned.
fn push_key(&self, sid: &str) -> ObjectPath {
ObjectPath::from(format!("{}/sessions/{sid}/push", self.prefix))
ObjectPath::from(format!("{}/index/{sid}/{PUSH_TOKEN}", self.prefix))
}
fn session_prefix(&self, sid: &str) -> ObjectPath {
@@ -307,7 +368,12 @@ impl Backend {
let mut stream = self.store.list(Some(&prefix));
while let Some(meta) = stream.next().await {
let meta = meta.map_err(object_store_error_to_error)?;
listed |= marker;
// The sweep's record is not a marker: a session it started removing is absent.
listed |= marker
&& meta
.location
.filename()
.is_some_and(|name| name.parse::<u32>().is_ok());
acc = fold(
acc,
meta.location.as_ref(),
@@ -408,34 +474,61 @@ fn require_json_object(kind: &str, raw: &RawValue, max_bytes: usize) -> Result<(
Ok(())
}
/// `None` when the workspace has nowhere to keep backups: no primary storage configured, or
/// the admin switched them off. Both read as `enabled: false` so the browser stops trying.
/// `None` when the workspace has nowhere to keep backups: no primary storage configured and
/// no instance store to stand in, or the admin switched them off. Both read as
/// `enabled: false` so the browser stops trying.
async fn backend(authed: &ApiAuthed, db: &DB, w_id: &str) -> Result<Option<Backend>> {
let (disabled, generation) = sqlx::query_as::<_, (Option<bool>, i64)>(
"SELECT (ai_config->>'sessions_storage_disabled')::bool, ai_sessions_backup_generation \
FROM workspace_settings WHERE workspace_id = $1",
)
.bind(w_id)
.fetch_optional(db)
.await?
.unwrap_or((None, 0));
let (disabled, retention, generation, has_storage) =
sqlx::query_as::<_, (Option<bool>, Option<serde_json::Value>, i64, bool)>(
"SELECT (ai_config->>'sessions_storage_disabled')::bool, \
ai_config->'sessions_retention_days', ai_sessions_backup_generation, \
large_file_storage IS NOT NULL \
FROM workspace_settings WHERE workspace_id = $1",
)
.bind(w_id)
.fetch_optional(db)
.await?
.unwrap_or((None, None, 0, false));
if disabled.unwrap_or(false) {
return Ok(None);
}
let (_, resource) =
crate::job_helpers_oss::get_workspace_s3_resource(authed, db, None, w_id, None).await?;
let Some(resource) = resource else {
return Ok(None);
let retention_days = sessions_retention_days(retention.as_ref());
// Decided from the row the generation came from: the instance store is written only
// under a generation read while the workspace had no storage of its own, which
// configuring one moves past (`ai_session_backups`).
let (store, storage_id, fallback) = if has_storage {
let (_, resource) =
crate::job_helpers_oss::get_workspace_s3_resource(authed, db, None, w_id, None).await?;
let Some(resource) = resource else {
return Ok(None);
};
(
build_object_store_client(&resource).await?,
storage_id(&resource),
false,
)
} else {
// The instance store stands in, under the same layout and the same key.
match fallback_store(db).await? {
Some(f) => (f.store, f.storage_id, true),
None => return Ok(None),
}
};
let store = build_object_store_client(&resource).await?;
let user = calculate_hash(&authed.email);
// Keyed per user, not per workspace: anyone who can write the bucket could otherwise copy
// another member's ciphertext under their own prefix and have `pull` decrypt it for them.
let key = get_workspace_key(w_id, db).await?;
let mc = crypt_from_key_with_suffix(&key, &user);
let storage_id = storage_id(&resource);
let prefix = format!("{}/{user}", generation_prefix(w_id, generation));
Ok(Some(Backend { store, mc, prefix, storage_id, generation }))
Ok(Some(Backend {
store,
mc,
prefix,
storage_id,
generation,
retention_days,
fallback,
}))
}
#[derive(Serialize)]
@@ -455,6 +548,12 @@ struct ListResponse {
storage_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_generation: Option<i64>,
/// The storage is the instance store standing in for a workspace without one of its
/// own; a removal owed to it is retired by any answer from the workspace's own storage
/// once it has one (configuring it moved the generation past everything the workspace
/// left in any instance store).
#[serde(skip_serializing_if = "std::ops::Not::not")]
fallback: bool,
sessions: Vec<SessionListing>,
/// The user has more sessions than the answer names.
#[serde(skip_serializing_if = "std::ops::Not::not")]
@@ -462,7 +561,9 @@ struct ListResponse {
}
/// A session is listed once a push entry of it landed whole (its marker is written last);
/// a push that failed before that left objects the listing does not name.
/// a push that failed before that left objects the listing does not name. One past the
/// workspace's retention is not listed either, whether or not the sweep has reached it, so a
/// browser never restores it.
async fn list(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -474,11 +575,13 @@ async fn list(
enabled: false,
storage_id: None,
backup_generation: None,
fallback: false,
sessions: vec![],
truncated: false,
}));
};
let prefix = backend.index_prefix();
let cutoff = backend.retention_cutoff();
let mut stream = backend.store.list(Some(&prefix));
// One marker per session, whatever the session holds: the newest LIST_MAX are kept as
// the scan goes (a min-heap drops the oldest), and the scan itself is bounded.
@@ -494,19 +597,12 @@ async fn list(
truncated = true;
break;
}
// `Path` drops the trailing delimiter, so the remainder starts with one.
let Some(rel) = meta.location.as_ref().strip_prefix(prefix.as_ref()) else {
continue;
};
let Some((sid, epoch)) = rel.trim_start_matches('/').split_once('/') else {
continue;
};
let Ok(epoch) = epoch.parse::<u32>() else {
continue;
};
if sid.is_empty() || sid.contains('/') {
if cutoff.is_some_and(|cutoff| meta.last_modified < cutoff) {
continue;
}
let Some((sid, IndexEntry::Marker(epoch))) = index_entry(&prefix, &meta.location) else {
continue;
};
newest.push(std::cmp::Reverse((
meta.last_modified,
epoch,
@@ -526,6 +622,7 @@ async fn list(
enabled: true,
storage_id: Some(backend.storage_id.clone()),
backup_generation: Some(backend.generation),
fallback: backend.fallback,
sessions,
truncated,
}))
@@ -594,6 +691,8 @@ struct PullResponse {
storage_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_generation: Option<i64>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
fallback: bool,
sessions: Vec<PulledSession>,
/// Ids that did not fit the response budget; ask for them again.
deferred: Vec<String>,
@@ -839,6 +938,7 @@ async fn pull(
enabled: false,
storage_id: None,
backup_generation: None,
fallback: false,
sessions: vec![],
deferred: vec![],
}));
@@ -861,6 +961,7 @@ async fn pull(
enabled: true,
storage_id: Some(backend.storage_id),
backup_generation: Some(backend.generation),
fallback: backend.fallback,
sessions,
deferred,
}))
@@ -952,6 +1053,8 @@ struct PushResponse {
storage_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_generation: Option<i64>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
fallback: bool,
results: Vec<PushResult>,
}
@@ -1167,15 +1270,29 @@ async fn push_session(backend: &Backend, s: &PushedSession) -> Result<(usize, bo
Ok((written, false))
}
/// The marker goes first so a removal cut short leaves nothing listed, then the head so
/// The markers go first so a removal cut short leaves nothing listed, then the head so
/// nothing pulls either, and no push takes it for a session still there (see `push_session`).
/// The retention sweep's record goes last (see `Backend::sweep_key`).
async fn remove_session(backend: &Backend, sid: &str) -> Result<()> {
let sweep = backend.sweep_key(sid);
backend
.delete_prefix(&backend.index_session_prefix(sid))
.store
.list(Some(&backend.index_session_prefix(sid)))
.map_err(object_store_error_to_error)
.try_for_each_concurrent(IO_CONCURRENCY, |meta| {
let sweep = &sweep;
async move {
if meta.location == *sweep {
return Ok(());
}
backend.delete(&meta.location).await
}
})
.await?;
backend.delete(&backend.head_key(sid)).await?;
backend.delete_prefix(&backend.session_prefix(sid)).await?;
backend.delete_prefix(&backend.images_prefix(sid)).await
backend.delete_prefix(&backend.images_prefix(sid)).await?;
backend.delete(&sweep).await
}
/// One writer per session at a time, across servers: a push and a removal of the same
@@ -1222,6 +1339,205 @@ async fn remove_session_locked(db: &DB, backend: &Backend, sid: &str) -> Result<
result
}
/// Deletes, in every workspace with `ai_config.sessions_retention_days`, the backups of the
/// sessions whose marker is older than that: the marker is rewritten by every push that
/// completes, so its modification time is the session's last activity as the storage clocks
/// it. For the monitor, on every server: a session-level advisory lock keeps one pass at a
/// time across them. The walk reads markers only, one object per session and nothing of what
/// the sessions hold, under each user's prefix in turn (`list_with_delimiter` names the
/// users), and deletes at most `SWEEP_MAX_PER_WORKSPACE` sessions per workspace and pass. A
/// session goes under its lock (`lock_session`), once its markers are listed again there and
/// still all older (see `sweep_session`). A removal cut short leaves the sweep's record next
/// to the markers, which the walk also collects, so the next pass finishes it.
pub async fn sweep_expired_ai_session_backups(db: &DB) {
let mut lock_conn = match db.acquire().await {
Ok(conn) => conn,
Err(e) => {
tracing::error!("AI session retention: could not acquire a connection: {e:#}");
return;
}
};
let locked: bool = match sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
.bind(SWEEP_LOCK_ID)
.fetch_one(&mut *lock_conn)
.await
{
Ok(locked) => locked,
Err(e) => {
tracing::error!("AI session retention: advisory lock failed: {e:#}");
return;
}
};
if !locked {
return;
}
if let Err(e) = sweep_workspaces(db).await {
tracing::error!("AI session retention sweep failed: {e:#}");
}
if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(SWEEP_LOCK_ID)
.execute(&mut *lock_conn)
.await
{
tracing::error!("AI session retention: advisory unlock failed: {e:#}");
}
}
async fn sweep_workspaces(db: &DB) -> Result<()> {
let workspaces = sqlx::query_as::<_, (String, Option<serde_json::Value>, i64, bool)>(
"SELECT workspace_id, ai_config->'sessions_retention_days', ai_sessions_backup_generation, \
large_file_storage IS NOT NULL \
FROM workspace_settings \
WHERE ai_config->'sessions_retention_days' IS NOT NULL",
)
.fetch_all(db)
.await?;
for (w_id, retention, generation, has_storage) in workspaces {
let Some(days) = sessions_retention_days(retention.as_ref()) else {
continue;
};
match sweep_workspace(db, &w_id, days, generation, has_storage).await {
Ok(0) => {}
Ok(deleted) => tracing::info!(
"AI session retention deleted {deleted} session backups of {w_id} older than {days} days"
),
Err(e) => tracing::warn!("AI session retention sweep of {w_id}: {e:#}"),
}
}
Ok(())
}
/// `has_storage` comes from the row `generation` was read from, as in `backend`: the instance
/// store is swept only under a generation read while the workspace had no storage of its own.
async fn sweep_workspace(
db: &DB,
w_id: &str,
days: u32,
generation: i64,
has_storage: bool,
) -> Result<usize> {
let resolved = if has_storage {
primary_store(db, w_id).await?
} else {
fallback_store(db).await?
};
let Some(resolved) = resolved else {
return Ok(0);
};
let key = get_workspace_key(w_id, db).await?;
let (store, storage_id) = (resolved.store, resolved.storage_id);
let cutoff = retention_cutoff(days);
let root = ObjectPath::from(generation_prefix(w_id, generation));
let users = store
.list_with_delimiter(Some(&root))
.await
.map_err(object_store_error_to_error)?
.common_prefixes;
let mut deleted = 0;
for user_prefix in users {
let Some(user) = user_prefix.filename() else {
continue;
};
// The sweep decrypts nothing; the cipher is only what a `Backend` is made of.
let backend = Backend {
store: store.clone(),
mc: crypt_from_key_with_suffix(&key, user),
prefix: user_prefix.to_string(),
storage_id: storage_id.clone(),
generation,
retention_days: Some(days),
fallback: resolved.fallback,
};
let index = backend.index_prefix();
let mut markers = backend.store.list(Some(&index));
let mut expired = std::collections::BTreeSet::new();
while let Some(meta) = markers.next().await {
let meta = meta.map_err(object_store_error_to_error)?;
let sid = match index_entry(&index, &meta.location) {
Some((sid, IndexEntry::Sweep)) => sid,
Some((sid, IndexEntry::Marker(_) | IndexEntry::Push))
if meta.last_modified < cutoff =>
{
sid
}
_ => continue,
};
expired.insert(sid.to_string());
if deleted + expired.len() >= SWEEP_MAX_PER_WORKSPACE {
break;
}
}
for sid in expired {
match sweep_session(db, &backend, &sid, cutoff).await {
Ok(true) => deleted += 1,
Ok(false) => {}
Err(e) => tracing::warn!(
"AI session retention left the backup of {sid} in {w_id} for the next pass: {e:#}"
),
}
}
if deleted >= SWEEP_MAX_PER_WORKSPACE {
break;
}
}
Ok(deleted)
}
/// True when the session was deleted. Under the session's lock its markers are listed again:
/// one a push renewed since the walk keeps the session. A session with none is left alone
/// while a push split over parts is between two of them (its token younger than the
/// retention) or it is gone, unless the sweep's record says a removal was started; an older
/// token is a split push a browser abandoned, whose landed parts nothing lists. The record is
/// written before anything is deleted and removed last, so a removal cut short is found again
/// by the next pass.
async fn sweep_session(
db: &DB,
backend: &Backend,
sid: &str,
cutoff: chrono::DateTime<chrono::Utc>,
) -> Result<bool> {
let tx = lock_session(db, backend, sid).await?;
let result = async {
let (sweep, push) = (backend.sweep_key(sid), backend.push_key(sid));
let mut entries = backend.store.list(Some(&backend.index_session_prefix(sid)));
let (mut listed, mut renewed, mut started, mut abandoned) = (false, false, false, false);
while let Some(meta) = entries.next().await {
let meta = meta.map_err(object_store_error_to_error)?;
if meta.location == sweep {
started = true;
} else if meta.location == push {
abandoned = meta.last_modified < cutoff;
} else {
listed = true;
renewed |= meta.last_modified >= cutoff;
}
}
if renewed {
// A push listed the session again over a removal cut short before its markers
// went, which had deleted nothing else.
if started {
backend.delete(&sweep).await?;
}
return Ok(false);
}
if !listed && !started && !abandoned {
return Ok(false);
}
if !started {
backend
.store
.put(&sweep, PutPayload::new())
.await
.map_err(object_store_error_to_error)?;
}
remove_session(backend, sid).await?;
Ok(true)
}
.await;
tx.commit().await?;
result
}
async fn push(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1241,6 +1557,7 @@ async fn push(
enabled: false,
storage_id: None,
backup_generation: None,
fallback: false,
results: vec![],
}));
};
@@ -1294,16 +1611,16 @@ async fn push(
results.push(PushResult { id: sid.clone(), error, needs_whole: false });
}
// Overwrites and deletes make this an over-count; the periodic recount the quota check
// schedules once usage is stale settles it.
// schedules once usage is stale settles it. Bytes in the instance store count under a
// name of their own, which the recount lists there.
#[cfg(not(feature = "enterprise"))]
if written > 0 {
crate::job_helpers_oss::bump_storage_usage(
&db,
&w_id,
windmill_object_store::DEFAULT_STORAGE,
written as i64,
)
.await;
let storage = if backend.fallback {
windmill_api_workspaces::ai_session_backups::FALLBACK_STORAGE
} else {
windmill_object_store::DEFAULT_STORAGE
};
crate::job_helpers_oss::bump_storage_usage(&db, &w_id, storage, written as i64).await;
}
#[cfg(feature = "enterprise")]
let _ = written;
@@ -1311,6 +1628,7 @@ async fn push(
enabled: true,
storage_id: Some(backend.storage_id),
backup_generation: Some(backend.generation),
fallback: backend.fallback,
results,
}))
}
+9 -3
View File
@@ -57,7 +57,7 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
auth::TOKEN_PREFIX_LEN,
auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN},
cache::{self, future::FutureCachedExt},
db::{DbWithOptAuthed, UserDB},
error::{to_anyhow, Error, JsonResult, Result},
@@ -1522,7 +1522,10 @@ async fn mint_raw_app_sdk_token(
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
(label, exp)
}
None => (format!("sdk_app:{app_path}"), requested_exp),
None => (
format!("{RAW_APP_SDK_TOKEN_LABEL_PREFIX}{app_path}"),
requested_exp,
),
};
let token_config = NewToken::new(
Some(label),
@@ -1804,7 +1807,10 @@ pub async fn mint_app_embed_token(
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
(label, exp)
}
None => (format!("embed_app:{app_path}"), requested_exp),
None => (
format!("{APP_EMBED_TOKEN_LABEL_PREFIX}{app_path}"),
requested_exp,
),
};
let token_config = NewToken::new(
Some(label),
+2
View File
@@ -71,6 +71,8 @@ mod ai_free_tier_ee;
mod ai_free_tier_oss;
#[cfg(feature = "parquet")]
mod ai_sessions;
#[cfg(feature = "parquet")]
pub use ai_sessions::sweep_expired_ai_session_backups;
mod ai_shared_artifacts;
mod apps;
mod apps_raw_bundle;
+20 -5
View File
@@ -116,6 +116,10 @@ struct RunnableItem {
use_codebase: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
has_deploy_errors: Option<bool>,
// flow-only: projected from the value so the home list can badge flows that
// open as a chat without fetching every flow's value.
#[serde(skip_serializing_if = "Option::is_none")]
chat_input_enabled: Option<bool>,
// app-only
#[serde(skip_serializing_if = "Option::is_none")]
raw_app: Option<bool>,
@@ -274,7 +278,7 @@ fn branch_sqls() -> Branches {
o.ws_error_handler_muted, o.created_at as edited_at, \
o.hash, o.language::text as language, o.kind::text as script_kind, o.auto_kind, \
o.codebase IS NOT NULL as use_codebase, \
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, \
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \
FROM script o \
@@ -291,6 +295,7 @@ fn branch_sqls() -> Branches {
o.ws_error_handler_muted, o.edited_at, \
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
(o.value->>'chat_input_enabled')::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
FROM flow o \
@@ -306,7 +311,7 @@ fn branch_sqls() -> Branches {
{draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \
NULL::bool as ws_error_handler_muted, av.created_at as edited_at, \
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \
o.versions[array_upper(o.versions, 1)] as version, \
COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
@@ -344,11 +349,21 @@ fn draft_branch_sql(kind: &str) -> String {
let kind_cols = match kind {
"script" => {
"d.value->>'language' as language, d.value->>'kind' as script_kind, \
d.value->>'auto_kind' as auto_kind, false as raw_app"
d.value->>'auto_kind' as auto_kind, false as raw_app, \
NULL::bool as chat_input_enabled"
}
// Type-guarded rather than a bare `::bool` cast: draft JSON is stored
// unvalidated, so a malformed value must yield NULL, not abort the list,
// and the string "true" must not count as enabled.
"flow" => {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
false as raw_app, \
CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled"
}
_ => {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
(d.typ = 'raw_app') as raw_app"
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled"
}
};
format!(
@@ -359,7 +374,7 @@ fn draft_branch_sql(kind: &str) -> String {
NULL::text[] as labels, NULL::text[] as inherited_labels, \
NULL::bool as ws_error_handler_muted, o.created_at as edited_at, \
NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \
o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \
FROM ( \
+7
View File
@@ -108,6 +108,7 @@ async fn edit_copilot_config(
}
ai_config.validate_model_pricing()?;
ai_config.validate_sessions_retention()?;
let mut tx = db.begin().await?;
@@ -148,6 +149,7 @@ async fn edit_copilot_config(
let workspace_has_config = ai_config.has_providers();
let copilot_disabled = ai_config.copilot_disabled;
let sessions_storage_disabled = ai_config.sessions_storage_disabled;
let sessions_retention_days = ai_config.sessions_retention_days;
let instance_ai_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
@@ -176,6 +178,7 @@ async fn edit_copilot_config(
};
effective_ai_config.copilot_disabled = copilot_disabled;
effective_ai_config.sessions_storage_disabled = sessions_storage_disabled;
effective_ai_config.sessions_retention_days = sessions_retention_days;
Ok(Json(EditCopilotConfigResponse {
effective_ai_config,
@@ -217,6 +220,9 @@ async fn get_copilot_info(
let sessions_storage_disabled = workspace_ai_config
.as_ref()
.is_some_and(|c| c.0.sessions_storage_disabled);
let sessions_retention_days = workspace_ai_config
.as_ref()
.and_then(|c| c.0.sessions_retention_days);
let instance_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
@@ -242,6 +248,7 @@ async fn get_copilot_info(
};
effective.copilot_disabled = copilot_disabled;
effective.sessions_storage_disabled = sessions_storage_disabled;
effective.sessions_retention_days = sessions_retention_days;
Ok(Json(effective))
}
+26 -7
View File
@@ -19,10 +19,11 @@ use crate::{
};
/// Whether `label` denotes a user-created token rather than a system token
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
/// notifications and username overrides all key off them — so they must not be
/// user-editable. `None` (no label) is treated as a user token.
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`). System-token labels are load-bearing —
/// session cleanup, super_admin propagation, expiry notifications and username overrides
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
/// a user token.
///
/// This is the canonical copy. When updating it, also update its mirrors:
/// - the `update_token_label` editability guard (SQL `WHERE`) in
@@ -40,15 +41,30 @@ pub fn is_user_token(label: Option<&str>) -> bool {
&& !l.to_lowercase().starts_with("ephemeral")
&& l != "debugger-token"
&& !l.starts_with("mcp-oauth-")
// Short-lived tokens the server mints per app open or per service-account
// impersonation (EE `users_ee.rs`) and nobody manages, so an expiry warning
// for one is noise.
&& !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX)
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
&& !l.starts_with("impersonation:")
}
}
}
/// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe
/// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
/// Label prefix, followed by the app path, of the token a raw app's bundle uses for the
/// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`
/// and `debugger-token` are minted by the editor and the debugger through that same handler,
/// so reserving them would break those features.
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
/// `debugger-token` and `ephemeral-test-connection: *` are minted by the editor, the debugger
/// and object-storage connection tests through that same handler, so reserving them would
/// break those features.
///
/// `username_override_from_label` trusts a label to name the entity acting only if it is in
/// here, so anything added must be unmintable by a member.
@@ -961,6 +977,9 @@ mod tests {
assert!(!is_user_token(Some("Ephemeral lsp token")));
assert!(!is_user_token(Some("debugger-token")));
assert!(!is_user_token(Some("mcp-oauth-client")));
assert!(!is_user_token(Some("embed_app:f/team/dashboard")));
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
}
#[test]
@@ -84,6 +84,11 @@ pub const SANDBOX_REGISTRY_AUTH_SETTING: &str = "sandbox_registry_auth";
// windmill-worker/src/ssh_executor_ee.rs.
pub const SSH_EXECUTION_SETTING: &str = "ssh_execution_enabled";
pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config";
/// Whether the instance object store stands in for a workspace without storage of its own
/// as the place its members' AI sessions are backed up to. On unless the row says `false`;
/// inert without an instance object store.
pub const AI_SESSIONS_INSTANCE_STORAGE_FALLBACK_SETTING: &str =
"ai_sessions_instance_storage_fallback";
/// Compile a newly deployed script's binary right after its dependency job and push it
/// to the instance object store, so the first run does not pay the compile. Inert unless
/// instance object storage is configured — without it the binary would only ever land in
@@ -264,6 +264,8 @@ pub struct GlobalSettings {
pub disable_hub: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_build_binary_on_deploy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_sessions_instance_storage_fallback: Option<bool>,
// String settings
#[serde(skip_serializing_if = "Option::is_none")]
+42
View File
@@ -1788,6 +1788,48 @@ pub async fn workspace_with_fork_ancestors(db: &crate::DB, w_id: &str) -> Result
Ok(chain)
}
/// The nearest fork ancestor of `w_id` holding a row at `path` in `table`, or `None` when no
/// ancestor does (or `w_id` is not a fork). Fork creation clones trigger and schedule rows down
/// the whole chain, so a row shares its upstream identifier (Kafka group, PG slot, cron) with
/// every ancestor that still has one, not just the direct parent, which may have deleted its
/// copy since.
///
/// Runs on the caller's connection so it sees the caller's transaction, and uncached: the
/// answer depends on the target table, not only on lineage.
///
/// `table` is interpolated into SQL, hence `'static`: a trigger's `TABLE_NAME` or a literal,
/// never caller input. Reads lineage for any `w_id` with no authorization check, like
/// [`fork_ancestor_chain`], so the caller must already be authorized for `w_id`.
pub async fn nearest_fork_ancestor_having(
conn: &mut sqlx::PgConnection,
table: &'static str,
w_id: &str,
path: &str,
) -> Result<Option<String>> {
sqlx::query_scalar(&format!(
r#"
WITH RECURSIVE chain AS (
SELECT id, parent_workspace_id, 0 AS depth
FROM workspace WHERE id = $1
UNION ALL
SELECT w.id, w.parent_workspace_id, chain.depth + 1
FROM workspace w
JOIN chain ON w.id = chain.parent_workspace_id
WHERE chain.depth < 20
)
SELECT chain.id FROM chain
JOIN {table} t ON t.workspace_id = chain.id AND t.path = $2
WHERE chain.depth > 0
ORDER BY chain.depth LIMIT 1
"#
))
.bind(w_id)
.bind(path)
.fetch_optional(&mut *conn)
.await
.map_err(|e| Error::internal_err(format!("resolving fork ancestors of {w_id}: {e:#}")))
}
lazy_static::lazy_static! {
/// workspace id -> (root workspace id, expiry ts). Read once per job start, so correctness
/// rests on the invalidation rather than on the TTL: every mutation that can change the answer
+116 -39
View File
@@ -118,6 +118,10 @@ pub fn object_store_error_to_error(err: object_store::Error) -> error::Error {
pub struct ExpirableObjectStore {
pub store: Arc<dyn ObjectStore>,
pub refresh: Option<ObjectStoreRefresh>,
/// What locates the store's objects ([`object_store_location`]), for a store built from
/// settings. Kept with the store rather than read off the settings again, so a server
/// whose reload is still pending never names one store by another's location.
pub location: Option<String>,
}
#[cfg(feature = "parquet")]
@@ -155,7 +159,7 @@ impl ObjectStoreRefresh {
#[cfg(feature = "parquet")]
impl From<Arc<dyn ObjectStore>> for ExpirableObjectStore {
fn from(store: Arc<dyn ObjectStore>) -> Self {
Self { store, refresh: None }
Self { store, refresh: None, location: None }
}
}
@@ -197,6 +201,15 @@ static CACHE_OVERRIDE_GENERATION: std::sync::atomic::AtomicU64 =
async fn resolve_object_store(
settings_lock: &RwLock<Option<ExpirableObjectStore>>,
) -> Option<Arc<dyn ObjectStore>> {
resolve_object_store_with_location(settings_lock)
.await
.map(|(store, _)| store)
}
#[cfg(feature = "parquet")]
async fn resolve_object_store_with_location(
settings_lock: &RwLock<Option<ExpirableObjectStore>>,
) -> Option<(Arc<dyn ObjectStore>, Option<String>)> {
let settings = settings_lock.read().await;
let Some(s) = settings.as_ref() else {
return None;
@@ -212,18 +225,18 @@ async fn resolve_object_store(
// A reload may have installed a different store while the credentials were
// being minted; that one reflects newer config, so the refresh is stale.
Some(current) if !Arc::ptr_eq(&current.store, &refreshed_from) => {
Some(current.store.clone())
Some((current.store.clone(), current.location.clone()))
}
Some(_) => {
let arc = new_store.store.clone();
let found = (new_store.store.clone(), new_store.location.clone());
*settings = Some(new_store);
Some(arc)
Some(found)
}
// Cleared while refreshing.
None => None,
}
}
_ => Some(s.store.clone()),
_ => Some((s.store.clone(), s.location.clone())),
}
}
@@ -232,6 +245,13 @@ pub async fn get_object_store() -> Option<Arc<dyn ObjectStore>> {
resolve_object_store(&OBJECT_STORE_SETTINGS).await
}
/// The instance object store with what locates its objects ([`object_store_location`]),
/// read together; the location is `None` for a store installed without settings.
#[cfg(feature = "parquet")]
pub async fn get_object_store_with_location() -> Option<(Arc<dyn ObjectStore>, Option<String>)> {
resolve_object_store_with_location(&OBJECT_STORE_SETTINGS).await
}
/// The store the dependency cache reads and writes: the worker group's override when it has one,
/// the instance object store otherwise. Anything the server must also reach goes through
/// [`get_object_store`] instead.
@@ -422,20 +442,22 @@ pub async fn reload_object_store_setting(db: &windmill_common::DB) -> ObjectStor
tracing::error!("S3 cache is not available for pro plan");
return ObjectStoreReload::Never;
}
*s3_cache_settings = build_s3_client_from_settings(S3Settings {
bucket: None,
region: None,
access_key: None,
secret_key: None,
endpoint: None,
store_logs: None,
path_style: None,
allow_http: None,
port: None,
})
*s3_cache_settings = build_object_store_from_settings(
ObjectSettings::S3(S3Settings {
bucket: None,
region: None,
access_key: None,
secret_key: None,
endpoint: None,
store_logs: None,
path_style: None,
allow_http: None,
port: None,
}),
Some(db),
)
.await
.ok()
.map(|x| ExpirableObjectStore::from(x))
} else {
*s3_cache_settings = None;
}
@@ -887,19 +909,49 @@ impl ObjectStore for FilesystemStoreIgnoringAttributes {
}
}
/// What locates a store's objects: endpoint, port, region and bucket (or account and
/// container, or root), never the credentials, which rotate. Two stores with the same
/// location hold the same objects.
pub fn object_store_location(resource: &ObjectStoreResource) -> String {
match resource {
ObjectStoreResource::S3(s) => format!(
"s3:{}:{}:{}:{}",
s.endpoint,
s.port.unwrap_or_default(),
s.region,
s.bucket
),
ObjectStoreResource::Azure(a) => format!(
"azure:{}:{}:{}",
a.endpoint.as_deref().unwrap_or_default(),
a.account_name,
a.container_name
),
ObjectStoreResource::Gcs(g) => format!("gcs:{}", g.bucket),
ObjectStoreResource::Filesystem(f) => format!("fs:{}", f.root_path),
}
}
#[cfg(feature = "parquet")]
pub async fn build_object_store_from_settings(
settings: ObjectSettings,
init_private_key: Option<&windmill_common::DB>,
) -> error::Result<ExpirableObjectStore> {
let located =
|store: Arc<dyn ObjectStore>, resource: ObjectStoreResource| ExpirableObjectStore {
store,
refresh: None,
location: Some(object_store_location(&resource)),
};
match settings {
ObjectSettings::S3(s3_settings) => build_s3_client_from_settings(s3_settings)
.await
.map(|x| ExpirableObjectStore::from(x)),
ObjectSettings::Azure(azure_settings) => {
let azure_blob_resource = azure_settings;
build_azure_blob_client(&azure_blob_resource).map(|x| ExpirableObjectStore::from(x))
ObjectSettings::S3(s3_settings) => {
let s3_resource = s3_resource_from_settings(s3_settings);
build_s3_client(&s3_resource)
.await
.map(|x| located(x, ObjectStoreResource::S3(s3_resource)))
}
ObjectSettings::Azure(azure_settings) => build_azure_blob_client(&azure_settings)
.map(|x| located(x, ObjectStoreResource::Azure(azure_settings))),
ObjectSettings::AwsOidc(ref s3_aws_oidc_settings) => {
let token_generator = crate::job_s3_helpers_oss::TokenGenerator::AsServerInstance();
let res = crate::job_s3_helpers_oss::generate_s3_aws_oidc_resource(
@@ -914,17 +966,14 @@ pub async fn build_object_store_from_settings(
.map(|x| ExpirableObjectStore {
store: x,
refresh: Some(ObjectStoreRefresh::new(settings.clone(), res.expiration())),
location: Some(object_store_location(&res)),
})
}
ObjectSettings::Gcs(gcs_settings) => {
let gcs_resource = gcs_settings;
build_gcs_client(&gcs_resource)
.await
.map(|x| ExpirableObjectStore::from(x))
}
ObjectSettings::Filesystem(fs) => {
build_filesystem_client(&fs.root_path).map(|x| ExpirableObjectStore::from(x))
}
ObjectSettings::Gcs(gcs_settings) => build_gcs_client(&gcs_settings)
.await
.map(|x| located(x, ObjectStoreResource::Gcs(gcs_settings))),
ObjectSettings::Filesystem(fs) => build_filesystem_client(&fs.root_path)
.map(|x| located(x, ObjectStoreResource::Filesystem(fs))),
}
}
@@ -937,14 +986,14 @@ fn none_if_empty(s: Option<String>) -> Option<String> {
}
}
/// The S3 resource instance settings resolve to, the environment filling in what they
/// leave out.
#[cfg(feature = "parquet")]
pub async fn build_s3_client_from_settings(
settings: S3Settings,
) -> error::Result<Arc<dyn ObjectStore>> {
fn s3_resource_from_settings(settings: S3Settings) -> S3Resource {
let region = none_if_empty(settings.region)
.unwrap_or_else(|| std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()));
let s3_resource = S3Resource {
S3Resource {
endpoint: none_if_empty(settings.endpoint).unwrap_or_else(|| {
std::env::var("S3_ENDPOINT").unwrap_or_else(|_| format!("s3.{region}.amazonaws.com"))
}),
@@ -959,9 +1008,7 @@ pub async fn build_s3_client_from_settings(
port: settings.port,
token: None,
expiration: None,
};
build_s3_client(&s3_resource).await
}
}
// Resolving the default chain goes over the network (ECS/IMDS) on instances relying on an
@@ -2624,6 +2671,36 @@ mod tests {
reload_cache_object_store_override(&db, None).await;
}
/// A store built from settings is located by where its objects are, not by how the
/// client describes itself: an S3 client prints only its bucket, so the same bucket name
/// on another endpoint would otherwise pass for the same store.
#[cfg(feature = "parquet")]
#[tokio::test]
async fn test_settings_store_location_tells_endpoints_apart() {
let s3 = |endpoint: &str| {
ObjectSettings::S3(S3Settings {
bucket: Some("windmill".to_string()),
region: Some("us-east-1".to_string()),
access_key: Some("key".to_string()),
secret_key: Some("secret".to_string()),
endpoint: Some(endpoint.to_string()),
allow_http: Some(true),
path_style: Some(true),
store_logs: None,
port: None,
})
};
let a = build_object_store_from_settings(s3("minio.internal:9000"), None)
.await
.unwrap();
let b = build_object_store_from_settings(s3("s3.us-east-1.amazonaws.com"), None)
.await
.unwrap();
assert_eq!(a.store.to_string(), b.store.to_string());
assert!(a.location.is_some());
assert_ne!(a.location, b.location);
}
// --- get_logs_from_store test ---
#[cfg(feature = "parquet")]
+49 -3
View File
@@ -654,6 +654,16 @@ pub struct ResultMetadata {
pub wm_failure: Option<String>,
}
/// Parses a marker struct out of a job result, which only an object can carry.
/// A derived `Deserialize` also accepts an array, filling fields by position, so
/// without the check a result like `[[], "boom"]` reads as `wm_failure: "boom"`.
pub fn parse_result_object<T: serde::de::DeserializeOwned>(result: &str) -> Option<T> {
if !result.trim_start().starts_with('{') {
return None;
}
serde_json::from_str(result).ok()
}
/// Sentinel `error.name` we inject into a result when retagging a successful
/// run as a failure due to `wm_failure`. Used downstream to detect that
/// the result is already in the standard `{ error: { name, message }, ... }`
@@ -674,8 +684,7 @@ pub fn is_pre_shaped_wm_failure_result(result: &str) -> bool {
struct NameOnly {
name: String,
}
serde_json::from_str::<Marker>(result)
.ok()
parse_result_object::<Marker>(result)
.and_then(|m| m.error)
.map(|e| e.name == MANUAL_FAILURE_ERROR_NAME)
.unwrap_or(false)
@@ -721,7 +730,7 @@ impl ValidableJson for Box<RawValue> {
}
fn result_metadata(&self) -> ResultMetadata {
serde_json::from_str::<ResultMetadata>(self.get()).unwrap_or_default()
parse_result_object::<ResultMetadata>(self.get()).unwrap_or_default()
}
fn size(&self) -> usize {
@@ -774,6 +783,10 @@ impl ValidableJson for serde_json::Value {
}
fn result_metadata(&self) -> ResultMetadata {
// An array would decode positionally, see `parse_result_object`.
if !self.is_object() {
return ResultMetadata::default();
}
serde_json::from_value::<ResultMetadata>(self.clone()).unwrap_or_default()
}
@@ -7876,3 +7889,36 @@ mod git_sync_concurrency_key_tests {
assert!(a.len() <= 255 && b.len() <= 255);
}
}
#[cfg(test)]
mod result_metadata_tests {
use super::{ResultMetadata, ValidableJson};
use serde_json::value::RawValue;
fn from_raw(json: &str) -> ResultMetadata {
RawValue::from_string(json.to_string())
.unwrap()
.result_metadata()
}
fn from_value(json: &str) -> ResultMetadata {
serde_json::from_str::<serde_json::Value>(json)
.unwrap()
.result_metadata()
}
#[test]
fn array_result_carries_no_markers() {
for json in [r#"[["label"], "boom"]"#, r#"[null, "boom"]"#] {
for meta in [from_raw(json), from_value(json)] {
assert!(
meta.wm_labels.is_none() && meta.wm_failure.is_none(),
"{json}"
);
}
}
let meta = from_raw(r#"{"wm_labels": ["label"], "wm_failure": "boom"}"#);
assert_eq!(meta.wm_labels, Some(vec!["label".to_string()]));
assert_eq!(meta.wm_failure.as_deref(), Some("boom"));
}
}
+21 -55
View File
@@ -97,14 +97,14 @@ pub trait TriggerCrud: Send + Sync + 'static {
const DEPLOYMENT_NAME: &'static str;
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[];
const IS_ALLOWED_ON_CLOUD: bool;
/// Whether enabling this trigger in a fork while the parent has the same
/// path enabled is a real conflict (shared upstream resource). True for
/// Whether enabling this trigger in a fork while an ancestor workspace has
/// the same path is a real conflict (shared upstream resource). True for
/// listener-based kinds where two consumers compete (Kafka group, PG slot,
/// SQS queue, etc.) and for Websocket where both subscribers fire on every
/// broadcast. False for kinds whose upstream identifier is implicitly
/// workspace-scoped at runtime (HTTP routes, Email local_part — clones for
/// the non-workspaced sub-case are filtered out, so any cloned row is
/// already collision-free vs. the parent).
/// already collision-free vs. its ancestors).
const FORK_CONFLICT_ON_ENABLE: bool = true;
fn get_deployed_object(path: String, parent_path: Option<String>) -> DeployedObject;
@@ -1095,54 +1095,14 @@ async fn exists_trigger<T: TriggerCrud>(
#[derive(serde::Deserialize)]
struct SetTriggerModePayload {
mode: TriggerMode,
/// When true, bypass the parent-state warning that would otherwise reject
/// enabling a trigger that's already enabled in the parent workspace.
/// The frontend sets this after the user confirms the duplicate-execution
/// dialog. See windmill-trigger/src/handler.rs::set_trigger_mode for the
/// full check.
/// When true, bypass the fork-conflict warning that would otherwise reject
/// enabling a trigger an ancestor workspace also has at this path. The
/// frontend sets this after the user confirms the duplicate-execution
/// dialog. See `set_trigger_mode` for the full check.
#[serde(default)]
force: bool,
}
/// Returns the parent workspace id when this workspace is a fork *and* the
/// parent has a row at the same trigger path. Used to gate enabling a trigger
/// in a fork behind an explicit `force=true` confirmation: the fork's row was
/// cloned from the parent, so its upstream identifier (Kafka group, PG slot,
/// SQS queue URL, etc.) is shared by construction. The risk is independent of
/// the parent's current `mode`: if the parent is enabled, the two listeners
/// compete; if it's disabled, the fork can destructively take over shared
/// state (e.g. advance the PG WAL, claim an MQTT client_id) before the parent
/// re-enables. Either way, the user should be asked to confirm.
async fn parent_has_trigger(
tx: &mut PgConnection,
table_name: &str,
workspace_id: &str,
path: &str,
) -> Result<Option<String>> {
let parent: Option<String> =
sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1")
.bind(workspace_id)
.fetch_optional(&mut *tx)
.await?
.flatten();
let Some(parent_id) = parent else {
return Ok(None);
};
let exists: Option<bool> = sqlx::query_scalar(&format!(
"SELECT EXISTS(SELECT 1 FROM {} WHERE workspace_id = $1 AND path = $2)",
table_name
))
.bind(&parent_id)
.bind(path)
.fetch_one(&mut *tx)
.await?;
Ok(if exists == Some(true) {
Some(parent_id)
} else {
None
})
}
async fn set_trigger_mode<T: TriggerCrud>(
Extension(handler): Extension<Arc<T>>,
authed: ApiAuthed,
@@ -1157,22 +1117,28 @@ async fn set_trigger_mode<T: TriggerCrud>(
let mut tx = user_db.begin(&authed).await?;
// Block transitioning a trigger in a fork to any mode that attaches a
// listener (Enabled or Suspended) when the parent has the same path,
// listener (Enabled or Suspended) when an ancestor has the same path,
// unless the caller passes force=true. Suspended still keeps the
// listener attached — it just stops auto-running queued jobs — so a
// suspended fork would still split Kafka events / share a PG slot
// with the parent. The cloned upstream identifier is shared by
// construction; the risk is independent of the parent's current mode.
// Skipped for kinds where the upstream identifier is already
// workspace-scoped at runtime (HTTP, Email).
// with the ancestor. The risk is independent of the ancestor's current
// mode: enabled, the two listeners compete; disabled, the fork can
// destructively take over shared state (advance the PG WAL, claim an
// MQTT client_id) before it re-enables. Skipped for kinds where the
// upstream identifier is already workspace-scoped at runtime (HTTP, Email).
if T::FORK_CONFLICT_ON_ENABLE && payload.mode != TriggerMode::Disabled && !payload.force {
if let Some(parent_id) =
parent_has_trigger(&mut *tx, T::TABLE_NAME, &workspace_id, path).await?
if let Some(ancestor_id) = windmill_common::workspaces::nearest_fork_ancestor_having(
&mut *tx,
T::TABLE_NAME,
&workspace_id,
path,
)
.await?
{
return Err(Error::BadRequest(format!(
"fork-conflict:{}:{}",
T::TRIGGER_TYPE,
parent_id
ancestor_id
)));
}
}
+5
View File
@@ -98,6 +98,11 @@ pub struct ListableFlow {
pub deployment_msg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
/// Projected from the flow value so a list can mark a flow that opens as a
/// chat without fetching every flow's value.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_input_enabled: Option<bool>,
/// True when the authed user has a draft for this flow (draft-only or layered
/// over the deployed row). See ListableScript in scripts.rs.
#[serde(default)]
+22 -1
View File
@@ -26,6 +26,10 @@ use windmill_ai::{
image_handler::upload_image_to_s3,
providers::{
create_chat_completions_query_builder, create_query_builder, is_chat_completions_only,
openai::{
is_reasoning_summary_unavailable, rejects_reasoning_summary,
remember_reasoning_summary_unavailable,
},
remember_chat_completions_only,
},
proxy::{
@@ -1300,6 +1304,10 @@ pub async fn run_agent(
attachments: args.user_attachments.as_deref(),
has_websearch,
prompt_cache_key: include_prompt_cache_key.then_some(prompt_cache_key.as_str()),
reasoning_summary: !is_reasoning_summary_unavailable(
&credentials,
args.provider.get_model(),
),
};
// A worker cannot run the client credentials exchange, so an OAuth resource
@@ -1350,7 +1358,8 @@ pub async fn run_agent(
// An endpoint can reject the request shape rather than the model:
// `stream_options` and `prompt_cache_key`, which not every OpenAI-compatible
// gateway accepts, and the route itself, when an Azure resource is outside
// gateway accepts, a reasoning summary, which OpenAI refuses to unverified
// organizations, and the route itself, when an Azure resource is outside
// the Responses API's model/region matrix. Each is retried once with that
// part dropped.
// Set where the route is found to be absent, and read once the fallback has
@@ -1407,6 +1416,9 @@ pub async fn run_agent(
&& status.as_u16() == 400
&& text.contains("prompt_cache_key");
let summary_refused = build_args.reasoning_summary
&& rejects_reasoning_summary(status.as_u16(), &text);
// Only the first call of the step may re-route: an endpoint that
// does not serve this API rejects that one already, whereas a
// rejection once the conversation is under way is about the
@@ -1430,6 +1442,15 @@ pub async fn run_agent(
);
include_prompt_cache_key = false;
build_args.prompt_cache_key = None;
} else if summary_refused {
tracing::info!(
"Retrying request without the reasoning summary the endpoint refused"
);
remember_reasoning_summary_unavailable(
&credentials,
args.provider.get_model(),
);
build_args.reasoning_summary = false;
} else if route_unserved {
tracing::info!(
"Endpoint rejected the request ({}), falling back to chat/completions",
+48 -16
View File
@@ -2874,15 +2874,17 @@ pub async fn handle_wac_v2_output(
.collect();
// Resolve job_payload once (same for all children since they re-run
// the parent script)
// the parent script). The step's cache setting is for the workflow's
// result; a task is cached only through its own `cache_ttl` option,
// under a key of its own (see `cached_result_path`).
let job_payload_template = match job.kind {
JobKind::Script => {
if let Some(hash) = job.runnable_id {
Ok(JobPayload::ScriptHash {
hash,
path: job.runnable_path.clone().unwrap_or_default(),
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language: job.script_lang.unwrap_or(ScriptLang::Bun),
priority: job.priority,
@@ -2897,6 +2899,27 @@ pub async fn handle_wac_v2_output(
))
}
}
// A deployed flow runs an inline step as the `flow_node` its deploy
// rewrote it into; the child re-runs that node the way a `Script`
// child re-runs its hash, so `runnable_id` (the checkpoint's source
// hash) stays the same across parent and children.
JobKind::FlowScript => {
if let Some(id) = job.runnable_id {
Ok(JobPayload::FlowScript {
id: windmill_common::flows::FlowNodeId(id.0),
path: job.runnable_path.clone().unwrap_or_default(),
language: job.script_lang.unwrap_or(ScriptLang::Bun),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: ConcurrencySettings::default(),
})
} else {
Err(error::Error::internal_err(
"WAC v2 FlowScript job missing runnable_id".to_string(),
))
}
}
JobKind::Preview => {
let row: Option<(Option<String>, Option<String>)> = sqlx::query_as(
"SELECT raw_code, raw_lock FROM v2_job WHERE id = $1 AND workspace_id = $2",
@@ -2912,8 +2935,8 @@ pub async fn handle_wac_v2_output(
hash: None,
language: job.script_lang.unwrap_or(ScriptLang::Bun),
lock: lock,
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: ConcurrencySettingsWithCustom::default(),
debouncing_settings: DebouncingSettings::default(),
@@ -3012,6 +3035,12 @@ pub async fn handle_wac_v2_output(
let mut pushed_ids: Vec<Uuid> = Vec::with_capacity(num_steps);
let push_result: error::Result<()> = async {
for (step, (_, child_uuid)) in steps.iter().zip(job_ids.iter()) {
// A task with a runnable of its own (a deployed script or flow) queues
// at that runnable's priority; any other task is the parent's code and
// queues at the parent's.
let own_runnable = matches!(step.dispatch_type.as_str(), "script" | "flow")
&& !step.script.starts_with("./");
// Resolve job payload based on dispatch_type
let (job_payload, child_args, is_external, on_behalf_of) =
match step.dispatch_type.as_str() {
@@ -3025,8 +3054,8 @@ pub async fn handle_wac_v2_output(
hash: None,
language: module.language,
lock: module.lock,
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: ConcurrencySettingsWithCustom::default(),
debouncing_settings: DebouncingSettings::default(),
@@ -3110,7 +3139,8 @@ pub async fn handle_wac_v2_output(
let mut job_payload = job_payload;
if let Some(cache_ttl) = step.cache_ttl {
match &mut job_payload {
JobPayload::ScriptHash { cache_ttl: ref mut ct, .. } => {
JobPayload::ScriptHash { cache_ttl: ref mut ct, .. }
| JobPayload::FlowScript { cache_ttl: ref mut ct, .. } => {
*ct = Some(cache_ttl)
}
JobPayload::Code(ref mut code) => code.cache_ttl = Some(cache_ttl),
@@ -3122,7 +3152,8 @@ pub async fn handle_wac_v2_output(
|| step.concurrency_time_window_s.is_some()
{
match &mut job_payload {
JobPayload::ScriptHash { concurrency_settings: ref mut cs, .. } => {
JobPayload::ScriptHash { concurrency_settings: ref mut cs, .. }
| JobPayload::FlowScript { concurrency_settings: ref mut cs, .. } => {
if let Some(limit) = step.concurrent_limit {
cs.concurrent_limit = Some(limit);
}
@@ -3188,13 +3219,14 @@ pub async fn handle_wac_v2_output(
job.visible_to_owner,
step.tag.clone().or_else(|| Some(job.tag.clone())),
step.timeout.or(job.timeout),
None, // flow_step_id
step.priority, // priority_override
None, // authed
false, // running
None, // end_user_email
None, // trigger
None, // suspended_mode
None, // flow_step_id
step.priority
.or(if own_runnable { None } else { job.priority }),
None, // authed
false, // running
None, // end_user_email
None, // trigger
None, // suspended_mode
)
.await?;
+28 -2
View File
@@ -1559,7 +1559,7 @@ pub async fn cached_result_path(
client: &AuthedClient,
job: &MiniPulledJob,
raw_data: Option<&RawData>,
) -> String {
) -> windmill_common::error::Result<String> {
let mut hasher = sha2::Sha256::new();
hasher.update(&[job.kind as u8]);
if let Some(ScriptHash(hash)) = job.runnable_id {
@@ -1574,6 +1574,13 @@ pub async fn cached_result_path(
_ => {}
}
}
// A workflow-as-code task child runs its parent's code with the parent's
// arguments; the step it executes is what tells its result from the parent's
// and from its siblings'.
if let Some(step_key) = wac_executing_key(db, job).await? {
hasher.update(b"wac_step:");
hasher.update(step_key.as_bytes());
}
hash_args(
db,
client,
@@ -1584,7 +1591,26 @@ pub async fn cached_result_path(
job.cache_ignore_s3_path.unwrap_or(false),
)
.await;
format!("g/results/{:064x}", hasher.finalize())
Ok(format!("g/results/{:064x}", hasher.finalize()))
}
/// The checkpoint step key a workflow-as-code parent seeded for this child at push
/// time; `None` for any job that is not such a child.
async fn wac_executing_key(
db: &DB,
job: &MiniPulledJob,
) -> windmill_common::error::Result<Option<String>> {
if job.parent_job.is_none() || job.flow_step_id.is_some() {
return Ok(None);
}
let key: Option<Option<String>> = sqlx::query_scalar(
"SELECT workflow_as_code_status->'_checkpoint'->>'_executing_key' \
FROM v2_job_status WHERE id = $1",
)
.bind(job.id)
.fetch_optional(db)
.await?;
Ok(key.flatten())
}
#[cfg(feature = "parquet")]
@@ -32,8 +32,8 @@ use windmill_common::bench::{BenchmarkInfo, BenchmarkIter};
use windmill_queue::{
append_logs, asset_dispatch, get_mini_completed_job, is_pre_shaped_wm_failure_result,
CanceledBy, FlowRunners, JobCompleted, MiniCompletedJob, MiniPulledJob, ValidableJson,
WrappedError, INIT_SCRIPT_TAG, MANUAL_FAILURE_ERROR_NAME,
parse_result_object, CanceledBy, FlowRunners, JobCompleted, MiniCompletedJob, MiniPulledJob,
ValidableJson, WrappedError, INIT_SCRIPT_TAG, MANUAL_FAILURE_ERROR_NAME,
};
use serde_json::{json, value::RawValue, Value};
@@ -72,13 +72,11 @@ struct NestedErrorMessage {
/// named `name`/`message`), and we want OTel to record the ManualFailure
/// rather than the user's sibling fields.
fn extract_error_message(raw: &str) -> Option<ErrorMessage> {
let nested = serde_json::from_str::<NestedErrorMessage>(raw)
.ok()
.map(|n| n.error);
let nested = parse_result_object::<NestedErrorMessage>(raw).map(|n| n.error);
if matches!(&nested, Some(em) if em.name == MANUAL_FAILURE_ERROR_NAME) {
return nested;
}
if let Ok(em) = serde_json::from_str::<ErrorMessage>(raw) {
if let Some(em) = parse_result_object::<ErrorMessage>(raw) {
return Some(em);
}
nested
@@ -1256,18 +1254,6 @@ async fn maybe_open_git_sync_deploy_pr(
if row.marker.is_none() {
return;
}
// Runtime Enterprise gate, like the poller: the toggles may have been set
// while a license was active (or written directly), and this hook drives
// GitHub API calls with the installation token.
if !matches!(
windmill_common::ee_oss::get_license_plan().await,
windmill_common::ee_oss::LicensePlan::Enterprise
) {
tracing::warn!(
"git sync PR: skipping PR creation for {workspace_id}: requires an Enterprise license"
);
return;
}
let Some(repo_path) = row.repo_path else {
return;
};
+1 -1
View File
@@ -4645,7 +4645,7 @@ pub async fn handle_queued_job(
let cached_res_path = if job.cache_ttl.is_some() {
match conn {
Connection::Sql(db) => {
Some(cached_result_path(db, &client, &job, preview_data.as_ref()).await)
Some(cached_result_path(db, &client, &job, preview_data.as_ref()).await?)
}
Connection::Http(_) => None,
}
+2 -1
View File
@@ -2005,7 +2005,8 @@ pub async fn update_flow_status_after_job_completion_internal(
if flow_job.cache_ttl.is_some() && success {
let flow = RawData::Flow(flow_data.clone());
let cached_res_path = cached_result_path(db, client, &flow_job, Some(&flow)).await;
let cached_res_path =
cached_result_path(db, client, &flow_job, Some(&flow)).await?;
save_in_cache(
db,
@@ -1795,6 +1795,17 @@ async fn lock_modules(
agent,
tool_inputs,
} => {
if let Some(agent_path) = agent.as_deref().filter(|_| !skip_flow_update) {
sqlx::query!(
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) VALUES ($1, $2, FALSE, TRUE, $3) ON CONFLICT DO NOTHING",
job_path,
agent_path,
job.workspace_id,
)
.execute(db)
.await?;
}
// Extract FlowModules from tools and track their original indices
// MCP tools don't need locking, so we filter them out
let mut flow_modules = Vec::new();
+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.812.0";
export const VERSION = "v1.813.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.812.0",
"version": "1.813.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-chat",
"version": "1.812.0",
"version": "1.813.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.812.0",
"version": "1.813.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+42
View File
@@ -128,6 +128,8 @@ import {
} from "../../utils/metadata.ts";
import {
DoubleLinkedDependencyTree,
LocalScripts,
resolvePlaceholdersFromLocal,
uploadScripts,
} from "../../utils/dependency_tree.ts";
import {
@@ -176,6 +178,7 @@ import {
isDbtModulePath,
isDbtGeneratedPath,
isModuleEntryPoint,
scriptPathToRemotePath,
getScriptBasePathFromModulePath,
hasWrongFormatSuffix,
DBT_DESCRIPTOR_NAME,
@@ -3385,6 +3388,37 @@ async function addToChangedIfNotExists(p: string, tracker: ChangeTracker) {
}
}
/**
* Index the checkout's standalone scripts by the remote path a relative import
* resolves to, reusing the content the local/remote diff already read.
*
* Same classification as `addToChangedIfNotExists`: a flow or app inline script
* is not addressable as an import target, and a module bundle is addressed by
* its entry point.
*/
function localScriptsByRemotePath(
localMap: Record<string, string>,
): LocalScripts {
const byRemotePath: LocalScripts = new Map();
for (const [p, content] of Object.entries(localMap)) {
if (isScriptModulePath(p)) {
if (!isModuleEntryPoint(p)) continue;
} else if (
!hasScriptExt(p) ||
isDatatableMigrationPath(p) ||
isFileResource(p) ||
isFilesetResource(p) ||
isFlowPath(p) ||
isAppPath(p) ||
isRawAppPath(p)
) {
continue;
}
byRemotePath.set(scriptPathToRemotePath(p), { localPath: p, content });
}
return byRemotePath;
}
export async function buildTracker(changes: Change[]) {
const tracker: ChangeTracker = {
scripts: [],
@@ -5074,6 +5108,14 @@ export async function push(
}
if (autoRegenerate && tree) {
// Pass 1 only ever walks the change set, so anything imported through a
// module the push leaves alone is still a dead end here.
await resolvePlaceholdersFromLocal(
tree,
localScriptsByRemotePath(localMap),
opts.defaultTs,
);
// Propagate staleness through imports + upload script content to
// raw_script_temp so the dep job can resolve cross-folder relative imports
// via temp_script_refs (instead of hitting 404s for not-yet-deployed
+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.812.0";
export const VERSION = "1.813.0";
+35 -11
View File
@@ -4608,6 +4608,14 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
# it grows with both the width of the fan-out and \`\`attempts\`\`. Retries with
# no \`\`delay\`\` all go out in a single round.
#
# \`\`cache_ttl\`\` serves a previous result of the task for that many seconds
# instead of running it again. A task is keyed on its step key (its name and
# call order) and the workflow's input, not on the arguments it is called
# with, so cache one only when whether it runs, and what it receives, follow
# from the workflow's input alone. A \`\`task_script\`\` target is keyed on the
# arguments it is called with. It has no effect on a \`\`task_flow\`\` target,
# which keeps its flow's own cache policy.
#
# Usage::
#
# @task
@@ -6012,8 +6020,8 @@ export async function main(user_id: string) {
const users = await sql\`SELECT * FROM users WHERE active = \${true}\`.fetch();
// Insert/Update
await sql\`INSERT INTO users (name, email) VALUES (\${name}, \${email})\`;
await sql\`UPDATE users SET name = \${newName} WHERE id = \${user_id}\`;
await sql\`INSERT INTO users (name, email) VALUES (\${name}, \${email})\`.execute();
await sql\`UPDATE users SET name = \${newName} WHERE id = \${user_id}\`.execute();
return user;
}
@@ -6032,8 +6040,8 @@ def main(user_id: str):
users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()
# Insert/Update
db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email)
db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id)
db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()
db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()
return user
\`\`\`
@@ -6042,13 +6050,14 @@ def main(user_id: str):
1. **Check existing tables** before creating new ones reuse beats schema growth.
2. **Use parameterized queries** never concatenate user input into SQL.
3. **Keep runnables focused** one function per runnable; small surface area.
4. **Use descriptive keys** \`get_user\`, not \`a\`.
5. **Always whitelist tables** adding a runnable that queries a new table requires the table to be in \`data.tables\` first.
6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
7. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable.
8. **Deploy what a path runnable points at** a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.
9. **Use \`windmill-chat\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.
3. **Terminate every datatable statement** the tagged template and \`db.query(...)\` only build a statement. It runs when you call \`fetch\` / \`fetchOne\` / \`fetchOneScalar\` / \`execute\` (\`fetch\` / \`fetch_one\` / \`fetch_one_scalar\` / \`execute\` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.
4. **Keep runnables focused** one function per runnable; small surface area.
5. **Use descriptive keys** \`get_user\`, not \`a\`.
6. **Always whitelist tables** adding a runnable that queries a new table requires the table to be in \`data.tables\` first.
7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable.
9. **Deploy what a path runnable points at** a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.
10. **Use \`windmill-chat\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.
`,
"triggers": `---
name: triggers
@@ -6741,6 +6750,13 @@ export interface TaskRetry {
export interface TaskOptions {
timeout?: number;
tag?: string;
/** Seconds during which a previous result of this task is served instead of
* running it again. A task written inline in the workflow is keyed on its
* step key (its name and call order) and the workflow's input, not on the
* arguments it is called with, so cache one only when whether it runs, and
* what it receives, follow from the workflow's input alone. A \`taskScript\`
* target is keyed on the arguments it is called with. It has no effect on a
* \`taskFlow\` target, which keeps its flow's own cache policy. */
cache_ttl?: number;
priority?: number;
concurrency_limit?: number;
@@ -6932,6 +6948,14 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# it grows with both the width of the fan-out and \`\`attempts\`\`. Retries with
# no \`\`delay\`\` all go out in a single round.
#
# \`\`cache_ttl\`\` serves a previous result of the task for that many seconds
# instead of running it again. A task is keyed on its step key (its name and
# call order) and the workflow's input, not on the arguments it is called
# with, so cache one only when whether it runs, and what it receives, follow
# from the workflow's input alone. A \`\`task_script\`\` target is keyed on the
# arguments it is called with. It has no effect on a \`\`task_flow\`\` target,
# which keeps its flow's own cache policy.
#
# Usage::
#
# @task
+79 -2
View File
@@ -5,7 +5,7 @@
import { Workspace } from "../commands/workspace/workspace.ts";
import * as wmill from "../../gen/services.gen.ts";
import type { ScriptLang } from "../../gen/types.gen.ts";
import { ScriptLanguage } from "./script_common.ts";
import { ScriptLanguage, inferContentTypeFromFilePath } from "./script_common.ts";
import {
filterWorkspaceDependencies,
generateScriptHash,
@@ -14,6 +14,65 @@ import {
updateMetadataGlobalLock,
} from "./metadata.ts";
import { generateHash } from "./utils.ts";
import { extractRelativeImports } from "./relative_imports.ts";
/** A local script file, keyed in `LocalScripts` by its Windmill remote path. */
export interface LocalScriptSource {
localPath: string;
content: string;
}
export type LocalScripts = Map<string, LocalScriptSource>;
/**
* Give every import target that is only a placeholder its local content and its
* own imports, so the graph continues through it. A tree seeded from a subset of
* the checkout otherwise dead-ends at any module outside that subset a
* re-export barrel needing no edit, typically hiding what it re-exports from
* `getTempScriptRefs`, which then resolves it against the deployed copy.
*/
export async function resolvePlaceholdersFromLocal(
tree: DoubleLinkedDependencyTree,
localScripts: LocalScripts,
defaultTs: "bun" | "deno" | undefined
): Promise<void> {
// A module resolved here can expose placeholders of its own (a barrel behind
// a barrel), so keep going until a round resolves nothing.
for (;;) {
let resolved = false;
for (const remotePath of tree.placeholderPaths()) {
const local = localScripts.get(remotePath);
if (!local) continue;
let language: ScriptLanguage;
try {
language = inferContentTypeFromFilePath(local.localPath, defaultTs);
} catch {
// A bare `.sql` names no dialect, so its imports cannot be read here.
continue;
}
const imports = await extractRelativeImports(
local.content,
remotePath,
language
);
// Never directly stale: it is outside the change set, so nothing relocks
// it. It is here to carry edges, and to be uploaded if it differs from
// what is deployed.
await tree.addNode(
remotePath,
local.content,
language,
"",
imports,
"script",
remotePath,
local.localPath,
false
);
resolved = true;
}
if (!resolved) break;
}
}
/**
* Diff local scripts against deployed versions, upload only those that differ.
@@ -97,6 +156,9 @@ interface DependencyNode {
originalPath: string; // Original path passed to handler (with extension for scripts)
isRawApp?: boolean; // Only set for apps
isDirectlyStale: boolean; // True if this item's content changed (vs transitively stale)
// True while the node exists only because something imports it, so it carries
// no content and no imports of its own.
isPlaceholder: boolean;
}
export class DoubleLinkedDependencyTree {
@@ -130,9 +192,11 @@ export class DoubleLinkedDependencyTree {
content: "", stalenessHash: "", language: "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "script", folder: "", originalPath: "", isDirectlyStale: false,
isPlaceholder: true,
});
}
const node = this.nodes.get(path)!;
node.isPlaceholder = false;
node.content = content;
node.stalenessHash = stalenessHash;
node.language = language;
@@ -155,7 +219,7 @@ export class DoubleLinkedDependencyTree {
stalenessHash: "", language: depsInfo?.language ?? "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "dependencies", folder: "", originalPath: depsPath,
isDirectlyStale: !isUpToDate,
isDirectlyStale: !isUpToDate, isPlaceholder: false,
});
}
}
@@ -169,6 +233,7 @@ export class DoubleLinkedDependencyTree {
content: "", stalenessHash: "", language: "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "script", folder: "", originalPath: "", isDirectlyStale: false,
isPlaceholder: true,
});
}
this.nodes.get(importPath)!.importedBy.add(path);
@@ -309,6 +374,18 @@ export class DoubleLinkedDependencyTree {
return this.nodes.keys();
}
/**
* Paths that exist only as somebody's import target, so the traversal stops
* at them instead of continuing into what they themselves import.
*/
placeholderPaths(): string[] {
const result: string[] = [];
for (const [path, node] of this.nodes.entries()) {
if (node.isPlaceholder) result.push(path);
}
return result;
}
/**
* Returns paths of all stale nodes (those with a staleReason).
*/
+63 -1
View File
@@ -2,7 +2,10 @@ import { expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import * as path from "node:path";
import { DoubleLinkedDependencyTree } from "../src/utils/dependency_tree.ts";
import {
DoubleLinkedDependencyTree,
resolvePlaceholdersFromLocal,
} from "../src/utils/dependency_tree.ts";
// addNode consults wmill-lock.yaml from cwd for workspace deps; run inside a
// temp dir so the test never reads/writes the repo's own lock file.
@@ -104,3 +107,62 @@ test("getAllTempScriptRefs is a superset of getTempScriptRefs for any node", asy
});
});
});
// Two barrels deep so the fixpoint matters: resolving the first one is what
// puts the second in the tree, and only a further round reaches the leaf.
test("resolvePlaceholdersFromLocal walks the graph through unresolved barrels", async () => {
await withTempDir(async () => {
const tree = new DoubleLinkedDependencyTree();
await tree.addNode(
"f/app/consumer",
`import { subtract } from "../barrel/index.ts"`,
"bun",
"",
["f/barrel/index"],
"script",
"f/app/consumer",
"f/app/consumer.ts",
true,
);
await tree.addNode(
"f/barrel/helper",
"export function subtract(a: number, b: number) { return a - b }",
"bun",
"",
[],
"script",
"f/barrel/helper",
"f/barrel/helper.ts",
true,
);
// Neither barrel is in the change set, so both are bare import targets.
expect(tree.getTempScriptRefs("f/app/consumer")).toEqual({});
await resolvePlaceholdersFromLocal(
tree,
new Map([
[
"f/barrel/index",
{
localPath: "f/barrel/index.ts",
content: `export * from "./mid.ts"`,
},
],
[
"f/barrel/mid",
{
localPath: "f/barrel/mid.ts",
content: `export * from "./helper.ts"`,
},
],
]),
"bun",
);
// Only the leaf diverged from deployed, so only it was uploaded.
tree.setContentHash("f/barrel/helper", "hash_helper");
expect(tree.getTempScriptRefs("f/app/consumer")).toEqual({
"f/barrel/helper": "hash_helper",
});
});
});
@@ -268,3 +268,78 @@ test(
});
},
);
// The importer and the leaf change; the barrel between them does not, so it is
// absent from the push's change set. See `resolvePlaceholdersFromLocal`.
test(
"sync push --auto-metadata succeeds when a changed leaf sits behind an unchanged barrel",
{ timeout: 180000 },
async () => {
await withTestBackend(async (backend, tempDir) => {
await writeFile(`${tempDir}/wmill.yaml`, wmillYaml);
await createLocalScript(
tempDir,
"f/barrel",
"helper",
"bun",
`export function add(a: number, b: number) { return a + b; }\n`,
);
await createLocalScript(
tempDir,
"f/barrel",
"index",
"bun",
`export * from "./helper.ts";\n`,
);
await createLocalScript(
tempDir,
"f/app",
"consumer",
"bun",
`import { add } from "../barrel/index.ts";
export async function main() { return add(1, 2); }
`,
);
const deploy = await backend.runCLICommand(
["sync", "push", "--yes", "--auto-metadata"],
tempDir,
);
if (deploy.code !== 0) {
console.log("STDOUT:", deploy.stdout);
console.log("STDERR:", deploy.stderr);
}
expect(deploy.code).toBe(0);
// Add an export to the leaf and use it from the importer. The barrel
// re-exports it already, so it stays byte-identical and out of the push.
await writeFile(
`${tempDir}/f/barrel/helper.ts`,
`export function add(a: number, b: number) { return a + b; }
export function subtract(a: number, b: number) { return a - b; }
`,
);
await writeFile(
`${tempDir}/f/app/consumer.ts`,
`import { subtract } from "../barrel/index.ts";
export async function main() { return subtract(3, 1); }
`,
);
const result = await backend.runCLICommand(
["sync", "push", "--yes", "--auto-metadata"],
tempDir,
);
if (result.code !== 0) {
console.log("STDOUT:", result.stdout);
console.log("STDERR:", result.stderr);
}
expect(result.code).toBe(0);
const combined = result.stdout + result.stderr;
expect(combined).not.toContain("No matching export");
expect(combined).not.toContain("Failed to generate lockfile");
});
},
);
+137 -3
View File
@@ -129,11 +129,58 @@ against it for the same reason.
The feature is on wherever the workspace has primary storage, and off with
`ai_config.sessions_storage_disabled` (the `copilot_disabled` pattern: no migration, carried by
settings export and the CLI). A build without `parquet` has no routes (404), a workspace without
storage answers `enabled: false`; either turns the backup off for ten minutes, after which the
storage and nothing to stand in for it answers `enabled: false`; either turns the backup off
for ten minutes, after which the
page asks again on its own (a flush for whatever is pending, and a restore), and the AI
settings page tells the mirror at once when the switch is saved there (the off state is
forgotten, the rows that went stale are marked again, a restore runs).
## The instance store standing in
A workspace without storage of its own keeps its backups in the instance object store
(`object_store_cache_config`, loaded the way every other use of it is, so never with
`DISABLE_S3_STORE`; the plan is checked on every request and Pro never falls back, since a
store loaded before a switch to Pro stays loaded), under the same layout and the same
per-user key,
while the instance setting `ai_sessions_instance_storage_fallback` allows it (on unless set
to false; the instance settings page shows it under Object Storage). A build without
`private` has neither workspace storage nor the quota below, and never falls back. Every
answer says which kind of store it came from (`fallback`), and the instance store is named
(`storage_id`) by what locates its objects, the endpoint, region and bucket its settings
resolve to, in a namespace of its own: moving the instance store to another endpoint under
the same bucket name is a storage switch for the browsers, and a workspace bucket is never
taken for it. The location is kept with the loaded store, so a server whose reload is still
pending names the store it writes to. A route decides between the workspace's storage and
the instance store from the row it reads the generation from, so a push lands in the
instance store only under a generation read while the workspace had no storage. The store a
workspace's backups live in is resolved in one place
(`ai_session_backups::workspace_store`), for the routes and for the rotation's deletion of
older generations alike.
Configuring a storage for a workspace that had none (`edit_large_file_storage_config`) bumps
the backup generation in the transaction that sets it, so everything the workspace left in
any instance store sits under a generation the routes never read again: a later return to
the instance store, whichever it is by then, starts from a newer one. Every storage settings
change then deletes from the instance store, off the request, the workspace's generations
older than the one it committed, whether the setting is on or off (copies from when it was
on may be there). Nothing live is older, whatever happens next: a deletion that is slow, cut
short, or overtaken by the storage being dropped or pointed at the instance store's own
bucket touches only generations nothing reads. Dropping the storage bumps nothing and is a
switch like any other: the rows go stale, the sessions are pushed whole into the instance
store, and the old bucket keeps its copy. On the browser side a removal owed to an instance
store (the row names it apart, `storageName` in `sessionMirrorPlan.ts`) is retired by any
answer from the workspace's own storage, since that storage being there means the
generation moved past the copy; one owed to a workspace storage still waits for that
storage, whatever the instance store answered. Copies a deletion missed stay in the
operator's bucket unread, as a deleted workspace's copies do.
On CE the bytes in the instance store count toward the workspace's storage quota under a
storage name of their own (`_ai_sessions_fallback_`, listed by the periodic recount while
the workspace has no storage of its own, and left out when there are none), so a member
cannot fill the operator's bucket past what the workspace may use; on EE, where workspace
storage has no quota either, nothing bounds them but the per-push caps and the instance
setting.
## Conflicts and deletion
Last write wins across devices. The head carries no manifest; `pull` lists the session's prefix
@@ -154,7 +201,8 @@ its pulls are done, and just before they do the whole family is listed again (me
backups were off included, since a move from another device can land in a workspace between
the first listings and the pulls; a family of one, with nowhere else for a copy to show up, is
not): a session a later copy of which showed up elsewhere is left, with the family, for the
next time. Only a user-initiated `deleteSession` removes the backup; the next push from
next time. Only a user-initiated `deleteSession`, and the retention sweep below, remove the
backup; the next push from
another device that still has the session is refused with `needs_whole` (nothing of it is
written), its row goes stale without a backoff, and that device's next flush sends the session
whole; the workspace-lifecycle
@@ -172,7 +220,8 @@ session whose pieces could not be written: recording it would let the next flush
half-empty local state over the backup.
Every answer names the storage it came from (`storage_id`, a hash of what locates the objects,
endpoint, region and bucket, not the credentials, which rotate) and the backup generation a
endpoint, region and bucket, not the credentials, which rotate; the instance store standing
in for a workspace without one is named apart, see above) and the backup generation a
key rotation bumps (`backup_generation`). A sync row records both, and a row naming another
storage or generation goes stale and its session is marked again: a workspace pointed at a
new bucket, or whose key was rotated, holds nothing, and the server looks nowhere else, so
@@ -187,6 +236,91 @@ old one; a session whose own parts were answered from different storages is not
all. The listing a restore starts with runs the same check, so a storage switch is noticed at
the first push after it or on the next page load, whichever comes first.
## Retention
`ai_config.sessions_retention_days` (per workspace, in the AI settings; unset by default;
the `sessions_storage_disabled` pattern: no migration, carried by settings export and the
CLI; 1 to 3650) puts an age on sessions, counted from their last activity. Each side applies
it with its own clock against its own timestamps, so no clock is compared with another
machine's, and the two do not time the same event: the server counts the last push that
completed, a browser its last local activity, which includes reading new messages and is not
pushed. A backup swept while a browser still reads its copy comes back once that browser
writes to the session again (its incremental push is refused and goes whole):
- The server sweeps the object store (`sweep_expired_ai_session_backups`, from the monitor
about every 40 minutes on each server, one pass at a time under a session-level advisory
lock). For every workspace with a retention it takes the store its backups live in, its
own storage or the instance store standing in, decided from the row it reads the
generation from as the routes do, names the users under the generation prefix
(`list_with_delimiter`) and lists each user's `index/` once: one object
per session, nothing of what the sessions hold. A session whose marker is older than the
retention is removed under its lock (`lock_session`), once its markers, listed again
there, are still all older: a push that renewed the session between the walk and the lock
keeps it, and one split over parts either holds the lock or has the session unlisted with
its token next to the markers (`index/{sid}/push`), which the sweep leaves alone while the
token is younger than the retention: an older one is a push a browser abandoned, whose
landed parts nothing lists, and it goes the same way. Before deleting anything the sweep
writes a record next to
the markers (`index/{sid}/sweep`, not an epoch, so neither `list` nor `pull` counts it),
and `remove_session` deletes it last: a removal cut short, its markers already gone, is
found by the next pass and finished, unless a push listed the session again first. At
most 1000 sessions per workspace and pass; the rest wait for the next. `list` leaves an
expired marker out of its answer meanwhile, so a browser never restores a session the
sweep has not reached. The marker's modification time is the storage's clock and the
cutoff the server's. The sweep reaches only the backups the routes would: a deleted
workspace's stay in its storage, and so do those a workspace keeps in the instance store
once `ai_sessions_instance_storage_fallback` is set to false.
- The browser sweeps its own stores when a tab resolves the logged-in user
(`sweepExpiredSessions`, from the one `onUserChange` in `sessionState.svelte.ts`), before
that tab reads a single session. A session whose last activity is older than the retention
by the browser's clock is deleted locally, record, chats, images, attached files and
artifacts. A restored session carries the backup's time as its last activity, the storage's
clock, so it counts from the later of that and the moment it was restored here
(`restoredAt`): a browser clock ahead of the storage's never deletes a session it just
brought back. Archived sessions count like any other, and persisted unsent drafts by their
pending workspace.
The stores are shared by the user's tabs, and each keeps copies of the sessions in memory,
so every tab holds a shared Web Lock from before it reads them until it stops using them,
and the sweep deletes only while holding that lock exclusively, requested if available:
granted exactly when no tab of the user has the sessions loaded, which is why the sweep
runs where it does and nowhere else. Nothing holds a copy of what it deletes and nothing
writes the stores meanwhile, so it deletes one record at a time and without re-reading. It
also takes the tab lock the flush and the restore take, again only if available, so neither
plans nor stages a session half deleted; like the restore, it does not run where Web Locks
do not exist. With several tabs open nothing is swept, until one of them reloads alone.
The hold is only as good as the tabs that take it, so a tab still running a build from before
it has the sessions loaded and holds nothing. A tab loaded after that one, across a deploy,
can sweep a session the older tab has in memory, and a write there afterwards brings the
record back without its chats, which the next flush pushes. It needs a tab left open across a
deploy, a session untouched for the whole retention, and the user going back to that session
in the older tab; the next sweep deletes it again. The same window is open to the
workspace-lifecycle delete in `reconcileSessionsLifecycle`, which no lock guards at all.
What deletes is the retention the server gives as the sweep runs, asked for under both locks
(`POST /workspaces/session_workspace_retention`, its own route rather than a field on the
lifecycle status, whose answer a tab loaded before this version still reads). Never a
remembered one: a retention raised or cleared since would otherwise delete a session that is
now within it, and a persisted unsent draft has no backup to come back from. What the sweep
keeps in localStorage decides only whether to ask again — it asks when it has asked nothing
yet, when the answer it has is a day old, or when that answer marks a session expired — so
an ordinary load costs no request at all. An answer that does not arrive within five seconds
leaves the sessions for the next load rather than delete on what this browser guessed. That
route answers for a workspace the caller can be authed into, unlike the status: a status is
what to do with the caller's own sessions, a setting is the workspace's to tell, so a
disabled membership is told nothing though its sessions still reconcile.
Each session's record goes before its pieces, so nothing plans a push for it afterwards,
and a localStorage key written before the record and removed once every piece is gone makes
a later sweep finish a deletion that failed, unless a restore brought the session back
since. The record is deleted without the tombstone a user delete leaves, which is what lets
a restore bring it back. The session's dirty mark and sync row go with it (`sessionSwept`),
unless the row still carries a removal or a restore's staging. Nothing is sent to the storage: the local
copy's age says nothing about another device's, which may have pushed the session since,
and the server applies the rule to the backup on its own. A session swept here that the
storage still lists comes back on the next restore.
## Limits
Push bodies are packed to about 8 MB (UTF-8 bytes as sent), at most 100 entries, 200 removals and
+8
View File
@@ -12,6 +12,14 @@ Symbols, not line numbers, are cited: they drift less.
by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token`
mints one for any non-job token but returns plain text, no redirect.
- **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie.
- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items`
removes an expired `token` row, the monitor emails the owner and raises a critical alert (if
enabled); rows registered by `register_token_expiry_notification` also get an "expiring soon"
warning first. Neither happens when `is_user_token` (`windmill-common/src/auth.rs`) reserves
the label, so a token the system mints for itself, whether from the backend or from the frontend
through `tokens/create`, needs a reserved label. An `ephemeral-` prefix needs no other change
(keep it clear of `is_server_minted_label` if minted through `tokens/create`); a new prefix
also goes into the SQL and Svelte mirrors that function's doc lists.
- **Every superadmin route refuses a job token**: `require_super_admin`
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.812.0",
"version": "1.813.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.812.0",
"version": "1.813.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.812.0",
"version": "1.813.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,6 +3,7 @@
import Popover from './meltComponents/Popover.svelte'
import { autoPlacement } from '@floating-ui/core'
import ChangeInstanceUsernameInner from './ChangeInstanceUsernameInner.svelte'
import { AlertTriangle } from 'lucide-svelte'
interface Props {
email: string
@@ -24,9 +25,22 @@
closeButton
>
{#snippet trigger()}
<Button color={isConflict ? 'red' : 'light'} size="xs" spacingSize="xs2" nonCaptureEvent={true}
>{isConflict ? 'Fix username conflict' : 'Change username'}</Button
>
{#if isConflict}
<!-- An icon rather than a labelled button: the username column truncates text but
cannot truncate a button, so a wide trigger here forced the whole table to scroll. -->
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: AlertTriangle }}
btnClasses="text-yellow-600 dark:text-yellow-400"
title="No instance username. Click to fix the conflict."
aria-label="Fix username conflict"
nonCaptureEvent={true}
/>
{:else}
<Button variant="default" unifiedSize="xs" nonCaptureEvent={true}>Change username</Button>
{/if}
{/snippet}
{#snippet content()}
<ChangeInstanceUsernameInner
@@ -53,12 +53,17 @@
{#if item.icon}
<item.icon size={14} color={item.iconColor} class="shrink-0" {...item.iconProps ?? {}} />
{/if}
<p
title={item.disabled && item.tooltip ? undefined : item.displayName}
class="truncate grow min-w-0 whitespace-nowrap text-left"
>
{item.displayName}
</p>
<div class="grow min-w-0 text-left">
<p
title={item.disabled && item.tooltip ? undefined : item.displayName}
class="truncate whitespace-nowrap"
>
{item.displayName}
</p>
{#if item.description}
<p class="text-2xs text-secondary">{item.description}</p>
{/if}
</div>
{@render item.extra?.()}
{#if item.shortcut || item.selected || item.toggle !== undefined}
<!-- Single trailing group so `shortcut` and `selected` can coexist:
@@ -510,8 +510,8 @@
draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user ?? $userStore ?? undefined)])
)
// The path is passed, so a draft that renames the agent is refused here too: a rename is
// the resource editor's to deploy, and this dialog lists the agent under the path the
// flow links.
// the agent editor's to deploy, and this dialog lists the agent under the path the flow
// links.
agentRefusal = Object.fromEntries(
draftAgents.map((a) => [a.path, agentDraftDeployRefusal(a.state, a.path)])
)
@@ -19,6 +19,7 @@
import { Copy, Expand } from 'lucide-svelte'
import HighlightTheme from './HighlightTheme.svelte'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
import FlowGraphViewerStepHeader from './FlowGraphViewerStepHeader.svelte'
interface Props {
schema?: any | undefined
@@ -28,6 +29,8 @@
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
// editors); used to qualify resource links.
workspace?: string
/** Given, the step header starts with a back control that calls it. */
onBack?: () => void
}
let {
@@ -35,7 +38,8 @@
stepDetail = undefined,
jobScriptHash = undefined,
hideDefaultInputs = false,
workspace = undefined
workspace = undefined,
onBack = undefined
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let codeViewer: Drawer | undefined = $state()
@@ -104,57 +108,20 @@
{/if}
</div>
{:else if stepDetail == 'Input'}
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
{#if schema}
<SchemaViewer {schema} />
{:else}
<p class="font-medium text-secondary text-center pt-4 pb-8"> No input schema </p>
{/if}
{:else if stepDetail == 'Result'}
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
<p class="font-medium text-secondary text-center pt-4 pb-8"> End of the flow </p>
{:else if typeof stepDetail != 'string' && stepDetail.value}
<!-- A direct child of the scrolling root: a sticky row can only hold within its parent's
box, so wrapped with the path link below it would scroll away with that wrapper. -->
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
<div class="">
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
<Badge color="indigo">
{stepDetail.id}
</Badge>
{/if}
<span
class={twMerge(
'font-semibold text-emphasis text-sm',
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
)}
>
{#if stepDetail.summary}
{stepDetail.summary}
{:else if stepDetail.value.type == 'identity'}
Identity
{:else if stepDetail.value.type == 'forloopflow'}
For loop {#if stepDetail.value.parallel}(parallel){/if}
{#if stepDetail.value.skip_failures}(skip failures){/if}
{#if stepDetail.value.squash}(squash){/if}
{:else if stepDetail.value.type == 'branchall'}
Run all branches {#if stepDetail.value.parallel}(parallel){/if}
{:else if stepDetail.value.type == 'branchone'}
Run one branch
{:else if stepDetail.value.type == 'flow'}
Inner flow
{:else if stepDetail.value.type == 'whileloopflow'}
While loop {#if stepDetail.value.skip_failures}(skip failures){/if}
{#if stepDetail.value.squash}(squash){/if}
{:else if stepDetail.id === 'failure'}
Error handler
{:else if stepDetail.id === 'preprocessor'}
Preprocessor
{:else if stepDetail.value.type == 'rawscript'}
Inline {stepDetail.value.language} script
{:else if stepDetail.value.type == 'script'}
Workspace script
{:else if stepDetail.value.type == 'aiagent'}
AI Agent
{/if}
</span>
</div>
{#if stepDetail.value.type == 'script'}
<div class="pb-2">
<a
@@ -0,0 +1,80 @@
<script lang="ts">
import type { FlowModule } from '$lib/gen'
import { Badge, Button } from './common'
import { ArrowLeft } from 'lucide-svelte'
interface Props {
/** A module, or the graph's pseudo-nodes by id (`Input`, `Result`). */
stepDetail: FlowModule | string
/** Given, the row starts with a back control; the caller decides where back leads. */
onBack?: () => void
}
let { stepDetail, onBack = undefined }: Props = $props()
const module = $derived(typeof stepDetail === 'string' ? undefined : stepDetail)
// The error handler and the preprocessor are named by their role, not by an id badge.
const showId = $derived(
module?.id !== undefined && module.id !== 'failure' && module.id !== 'preprocessor'
)
const title = $derived.by((): string => {
if (typeof stepDetail === 'string') {
if (stepDetail === 'Input') return 'Flow inputs'
if (stepDetail === 'Result') return 'Result'
return stepDetail
}
if (stepDetail.summary) return stepDetail.summary
if (stepDetail.id === 'failure') return 'Error handler'
if (stepDetail.id === 'preprocessor') return 'Preprocessor'
const v = stepDetail.value
switch (v?.type) {
case 'identity':
return 'Identity'
case 'forloopflow':
return (
'For loop' +
(v.parallel ? ' (parallel)' : '') +
(v.skip_failures ? ' (skip failures)' : '') +
(v.squash ? ' (squash)' : '')
)
case 'whileloopflow':
return (
'While loop' + (v.skip_failures ? ' (skip failures)' : '') + (v.squash ? ' (squash)' : '')
)
case 'branchall':
return 'Run all branches' + (v.parallel ? ' (parallel)' : '')
case 'branchone':
return 'Run one branch'
case 'flow':
return 'Inner flow'
case 'rawscript':
return `Inline ${v.language} script`
case 'script':
return 'Workspace script'
case 'aiagent':
return 'AI Agent'
default:
return stepDetail.id
}
})
</script>
<!-- -top-2: the row pins at the scroll container's content edge, and FlowGraphViewerStep pads
its root by that much, so at top-0 the body would show through the padding above the row. -->
<div class="sticky -top-2 z-10 flex w-full items-center gap-2 bg-surface py-2">
{#if onBack}
<Button
unifiedSize="sm"
variant="subtle"
iconOnly
startIcon={{ icon: ArrowLeft }}
title="Back to the flow graph"
onclick={onBack}
/>
{/if}
{#if showId && module}
<Badge color="indigo">{module.id}</Badge>
{/if}
<span class="min-w-0 truncate text-sm font-semibold text-emphasis" {title}>{title}</span>
</div>
@@ -32,29 +32,29 @@
<ConfirmationModal
open={!!state}
title="Enable in fork conflicts with parent"
title="Enable in fork conflicts with upstream workspace"
confirmationText="Enable anyway"
onConfirmed={() => close(true)}
onCanceled={() => close(false)}
>
{#if state}
<p>
The parent workspace (<span class="font-mono">{state.parentWorkspaceId}</span>) has the same
{state.kindLabel} configured at this path. Because this fork's row was cloned from it, the upstream
identifier is shared.
The upstream workspace (<span class="font-mono">{state.upstreamWorkspaceId}</span>) this fork
descends from has the same {state.kindLabel} configured at this path. Because this fork's row was
cloned from it, the upstream identifier is shared.
</p>
<p class="mt-2">
{#if family === 'split'}
If both are enabled, the two listeners will compete on the same upstream and each side will
receive only a fraction of its events.
{:else if family === 'duplicate'}
If both are enabled, every event will fire the script twice once in the fork and once in
the parent.
If both are enabled, every event will fire the script twice: once in the fork and once in
the upstream workspace.
{:else if family === 'slot'}
The cloned <span class="font-mono">replication_slot_name</span> points at the same Postgres slot,
which only allows one consumer at a time. Enabling here will either fail with "slot already active"
if the parent is enabled, or hijack the slot's WAL position if it isn't causing the parent
to lose events when re-enabled.
if the upstream workspace is enabled, or hijack the slot's WAL position if it isn't, causing
it to lose events when re-enabled.
{:else}
Enabling it here may compete for the same upstream events or duplicate side effects.
{/if}
@@ -134,11 +134,15 @@
}
applyFormDefaults(nvalues)
// Apply select/select_python defaults so initialValues matches what InstanceSetting's $effect does
// Apply declared defaults before snapshotting initialValues, so a default shows without
// marking the form dirty: a select's mirrors InstanceSetting's $effect, a boolean's is
// what its toggle shows while the key is unset.
for (const category of settingsKeys) {
for (const s of settings[category]) {
if (
(s.fieldType === 'select' || s.fieldType === 'select_python') &&
(s.fieldType === 'select' ||
s.fieldType === 'select_python' ||
s.fieldType === 'boolean') &&
nvalues[s.key] == undefined &&
s.defaultValue
) {
+9
View File
@@ -61,6 +61,10 @@
popup?: boolean
firstTime?: boolean
autoRedirect?: boolean
/** Supplying this replaces the post-login redirect: the card hands back instead of
* navigating, and the host is expected to re-check access in place. For a gate on a
* page that stays mounted, `rd` is the URL already shown, so navigating there would
* re-run nothing. */
onLoginSuccess?: () => void
/** A refusal the popup relayed back, in the server's words. */
onLoginError?: (message: string) => void
@@ -290,6 +294,11 @@
// Finally, we check whether the user is a superadmin
refreshSuperadmin()
if (onLoginSuccess) {
onLoginSuccess()
return
}
redirectUser()
}
+44 -25
View File
@@ -30,6 +30,7 @@
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import { Alert, Button } from './common'
import { overlayStack, type OverlayStack } from './common/overlayHost.svelte'
import { random_adj } from './random_positive_adjetive'
import { ChevronDown, Copy, SearchCode } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
@@ -405,9 +406,11 @@
}
})
const openSearchWithPrefilledText: (t?: string) => void = getContext(
const openSearchWithPrefilledText: (t?: string, stack?: OverlayStack) => void = getContext(
'openSearchWithPrefilledText'
)
// Handed to the search so it stacks above the modal or drawer this field sits in.
const searchStack = overlayStack()
$effect.pre(() => {
;[meta?.name, meta?.owner, meta?.ownerKind]
@@ -451,14 +454,18 @@
initialPath !== path
)
let pathUsageInFlowsPromise = $derived(
(kind == 'script' || kind == 'flow') &&
ws &&
initialPath &&
FlowService.listFlowPathsFromWorkspaceRunnable({
workspace: ws,
path: initialPath,
runnableKind: kind
})
ws && initialPath
? kind == 'script' || kind == 'flow'
? FlowService.listFlowPathsFromWorkspaceRunnable({
workspace: ws,
path: initialPath,
runnableKind: kind
})
: kind == 'resource'
? // Only steps linked to a saved agent are tracked; other `$res:` references are not.
FlowService.listFlowPathsLinkingAgent({ workspace: ws, path: initialPath })
: undefined
: undefined
)
let pathUsageInAppsPromise = $derived(
(kind == 'script' || kind == 'flow') &&
@@ -647,24 +654,36 @@
</ul>
</Alert>
{/if}
{:else if displayPathChangedWarning && kind == 'resource'}
{@render renameMayBreakWarning()}
{/if}
{:catch}
<!-- A resource's references beyond linked agents are never looked up, so a failed lookup
still leaves it with the generic warning. -->
{#if displayPathChangedWarning && kind == 'resource'}
{@render renameMayBreakWarning()}
{/if}
{/await}
{:else if displayPathChangedWarning}
<Alert type="warning" class="mt-4" title="Moving may break other items relying on it">
You are renaming an item that may be depended upon by other items. This may break apps, flows
or resources. Find if it used elsewhere using the content search. Note that linked variables
and resources (having the same path) are automatically moved together.
<div class="flex pt-2">
<Button
variant="default"
on:click={() => {
openSearchWithPrefilledText('#')
}}
startIcon={{ icon: SearchCode }}
>
Search
</Button>
</div>
</Alert>
{@render renameMayBreakWarning()}
{/if}
</div>
{#snippet renameMayBreakWarning()}
<Alert type="warning" class="mt-4" title="Moving may break other items relying on it">
You are renaming an item that may be depended upon by other items. This may break apps, flows or
resources. Find if it used elsewhere using the content search. Note that linked variables and
resources (having the same path) are automatically moved together.
<div class="flex pt-2">
<Button
variant="default"
on:click={() => {
openSearchWithPrefilledText('#', searchStack)
}}
startIcon={{ icon: SearchCode }}
>
Search
</Button>
</div>
</Alert>
{/snippet}
@@ -89,7 +89,7 @@
}
</script>
<Drawer bind:this={drawer} size="1200px" {disableChatOffset} on:close={handleDrawerClose}>
<Drawer bind:this={drawer} size="1300px" {disableChatOffset} on:close={handleDrawerClose}>
<DrawerContent noPadding overflow_y={false} title="Instance settings" on:close={handleClose}>
{#snippet titleExtra()}
<MeltTooltip disablePopup={!uptodateVersion}>
@@ -26,11 +26,10 @@
CheckCircle2,
ExternalLink,
Pencil,
Settings,
UserMinus,
UserPlus
} from 'lucide-svelte'
import Badge from './common/badge/Badge.svelte'
import Tooltip from './Tooltip.svelte'
import DropdownV2 from './DropdownV2.svelte'
import Popover from './meltComponents/Popover.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
@@ -155,6 +154,11 @@
loadExtJwtPage(1)
let tab: string = $state('users')
let usersListShown = $derived(
tab === 'users' &&
!yamlMode &&
(usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0))
)
$effect(() => {
tab = $instanceSettingsSelectedTab
@@ -320,11 +324,14 @@
<!-- Main Content -->
<div class="flex-1 min-w-0 h-full">
<div class="h-full overflow-auto bg-surface">
<div class="h-fit px-8 py-4">
<!-- The users list scrolls inside a bounded table, so its tab fills the pane instead of
growing with it: that is what lets the header pin and keeps the horizontal
scrollbar in view rather than under hundreds of rows. -->
<div class={usersListShown ? 'h-full flex flex-col px-8 py-4' : 'h-fit px-8 py-4'}>
{#if tab === 'ai' && !yamlMode}
<InstanceAISettings {disableChatOffset} />
{:else if tab === 'users' && !yamlMode}
<div class="h-full">
<div class="flex-1 min-h-0 flex flex-col">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
<h3 class="mb-2"> Automatic username creation </h3>
@@ -373,7 +380,7 @@
<Tab value="guests" label="Guests" />
</Tabs>
{#if usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0)}
{#if usersListShown}
<SettingsPageHeader
title="Instance users ({users.length})"
description="Manage all users across your Windmill instance."
@@ -414,14 +421,10 @@
<p class="text-hint text-2xs mt-2">
{filteredUsers.length} user{filteredUsers.length !== 1 ? 's' : ''} found
</p>
<div class="mt-1">
<DataTable
shouldLoadMore={(filteredUsers?.length ?? 0) > 50}
loadMore={50}
on:loadMore={() => {
nbDisplayed += 50
}}
>
<!-- Shrinks but never grows: a short list keeps its box hugging the rows, a long one
is capped by the pane and scrolls inside, with a floor of a few rows. -->
<div class="mt-1 min-h-48">
<DataTable>
<Head>
<tr>
<Cell head first>Email</Cell>
@@ -434,7 +437,7 @@
<Cell head>Kind</Cell>
{/if}
<Cell head>Role</Cell>
<Cell head last>
<Cell head last actions>
<span class="sr-only">Actions</span>
</Cell>
</tr>
@@ -443,12 +446,22 @@
{#if filteredUsers && users}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, is_workspace_admin, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))}
{@const isServiceAccount = login_type === 'service_account'}
{@const groupRole =
role_source === 'instance_group' && (super_admin || devops)}
<!-- Any elevated role picked here is stored as manual and wins over the group on later
syncs; only a demotion to User is re-applied from the group. So only User locks. -->
{@const groupRoleTooltip =
'Role is set by an instance group. Superadmin and Devops can be set here, but demoting to User requires removing the user from the group.'}
{@const serviceAccountTooltip =
'Service accounts are always users in the instance. Their workspace role is managed in the workspace user settings.'}
<!-- Dimmed per cell content, not on the row: opacity on the row would make the pinned
actions cell translucent and let the columns scrolling under it show through. -->
<tr
class="{i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'} {disabled
? 'opacity-60'
? '[&>td>*]:opacity-60'
: ''}"
>
<Cell first class="max-w-[250px]">
<Cell first class="max-w-[240px]">
<div class="flex items-center gap-1.5">
{#if isServiceAccount}
<Bot size={16} class="text-blue-500 shrink-0" />
@@ -458,14 +471,6 @@
>{email}</a
>
{/if}
{#if workspace_id}
<a
href="{base}/?workspace={workspace_id}"
title="Workspace: {workspace_id}"
>
<Badge color="blue">{truncate(workspace_id, 20)}</Badge>
</a>
{/if}
{#if disabled}
<span
class="text-2xs px-1.5 py-0.5 rounded bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300 whitespace-nowrap"
@@ -475,7 +480,7 @@
</div>
</Cell>
{#if automateUsernameCreation}
<Cell class="max-w-[150px]">
<Cell class="max-w-[140px]">
{#if username}
<span title={username} class="truncate block">{username}</span>
{:else}
@@ -503,133 +508,157 @@
>
{#if activeOnly}
<Cell>
{#if is_workspace_admin}
Admin
{:else if operator_only}
Operator only
{:else}
Developer
{/if}
<span>
{#if is_workspace_admin}
Admin
{:else if operator_only}
Operator only
{:else}
Developer
{/if}
</span>
</Cell>
{/if}
<Cell>
{#if isServiceAccount}
<div class="flex items-center gap-1">
<!-- A service account has no `password` row, so it can never hold an
instance role: the group renders locked on "User" rather than hidden. -->
<div class="flex flex-col items-start">
{#key `${super_admin}_${devops}_${role_source}`}
<ToggleButtonGroup
disabled={isServiceAccount}
selected={super_admin
? 'super_admin'
: devops
? 'devops'
: 'user'}
on:selected={async (e) => {
if (email == $userStore?.email) {
sendUserToast('You cannot demote yourself', true)
listUsers(activeOnly)
return
}
let role = e.detail
if (role === 'super_admin') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: true,
is_devops: false
}
})
}
if (role === 'devops') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: true
}
})
}
if (role === 'user') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: false
}
})
}
sendUserToast('User updated')
listUsers(activeOnly)
}}
>
{#snippet children({ item })}
<ToggleButton
value={'user'}
small
label="User"
shortLabel="User"
disabled={isServiceAccount || groupRole}
tooltip={isServiceAccount
? serviceAccountTooltip
: groupRole
? groupRoleTooltip
: undefined}
{item}
/>
<ToggleButton
value={'devops'}
small
label="Devops"
shortLabel="Dev"
disabled={isServiceAccount}
tooltip={isServiceAccount
? serviceAccountTooltip
: "Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"}
{item}
/>
<ToggleButton
value={'super_admin'}
small
label="Superadmin"
shortLabel="Admin"
disabled={isServiceAccount}
tooltip={isServiceAccount
? serviceAccountTooltip
: undefined}
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/key}
{#if isServiceAccount}
<span
class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"
class="text-2xs text-tertiary mt-0.5 ml-1 whitespace-nowrap"
title={serviceAccountTooltip}
>
{is_workspace_admin
? 'Admin'
: operator_only
? 'Operator'
: 'Developer'}
in
{#if workspace_id}
<a
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
class="hover:underline"
title={workspace_id}
onclick={() => closeDrawer?.()}
>{truncate(workspace_id, 20)}</a
>
{:else}
its workspace
{/if}
</span>
<Tooltip>
Service-account role is managed in the workspace user settings.
</Tooltip>
</div>
{:else}
<div class="flex flex-col items-start">
{#key `${super_admin}_${devops}_${role_source}`}
<ToggleButtonGroup
selected={super_admin
? 'super_admin'
: devops
? 'devops'
: 'user'}
on:selected={async (e) => {
if (email == $userStore?.email) {
sendUserToast('You cannot demote yourself', true)
listUsers(activeOnly)
return
}
let role = e.detail
if (role === 'super_admin') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: true,
is_devops: false
}
})
}
if (role === 'devops') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: true
}
})
}
if (role === 'user') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: false
}
})
}
sendUserToast('User updated')
listUsers(activeOnly)
}}
>
{#snippet children({ item })}
<ToggleButton
value={'user'}
small
label="User"
disabled={role_source === 'instance_group' &&
(super_admin || devops)}
tooltip={role_source === 'instance_group' &&
(super_admin || devops)
? 'Role is set by an instance group. Remove the user from the group to demote to "User".'
: undefined}
showTooltipIcon={role_source === 'instance_group' &&
(super_admin || devops)}
{item}
/>
<ToggleButton
value={'devops'}
small
label="Devops"
tooltip="Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"
{item}
/>
<ToggleButton
value={'super_admin'}
small
label="Superadmin"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/key}
{#if role_source === 'instance_group' && (super_admin || devops)}
<a
href="{base}/groups"
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
title="Role set by instance group. You can upgrade to a higher role manually, but demoting to &quot;User&quot; requires removing them from the group."
onclick={() => closeDrawer?.()}
>
Set by instance group
</a>
{/if}
</div>
{/if}
{:else if groupRole}
<a
href="{base}/groups"
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
title={groupRoleTooltip}
onclick={() => closeDrawer?.()}
>
Set by instance group
</a>
{/if}
</div>
</Cell>
<Cell last>
<Cell last actions class={i % 2 === 0 ? 'bg-surface-tertiary' : ''}>
<div class="flex items-center justify-end">
{#if isServiceAccount}
{#if workspace_id}
<a
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
class="text-xs text-secondary hover:text-primary hover:underline"
title="Manage in workspace settings">Manage in workspace</a
>
<DropdownV2
items={[
{
displayName: 'Manage in workspace',
icon: Settings,
action: () => closeDrawer?.(),
href: `${base}/workspace_settings?tab=users&workspace=${workspace_id}`
}
]}
/>
{/if}
{:else}
<div
@@ -722,6 +751,29 @@
</Cell>
</tr>
{/each}
{#if filteredUsers.length > nbDisplayed}
{@const remaining = Math.min(50, filteredUsers.length - nbDisplayed)}
<!-- Last row rather than a footer under the scroller, the way the runs
list pages: the control scrolls with the rows it extends. -->
<tr>
<Cell
colspan={5 +
(automateUsernameCreation ? 1 : 0) +
(activeOnly ? 1 : 0)}
>
<Button
variant="subtle"
unifiedSize="xs"
wrapperClasses="w-full justify-center"
onClick={() => {
nbDisplayed += 50
}}
>
Load next {remaining} user{remaining !== 1 ? 's' : ''}
</Button>
</Cell>
</tr>
{/if}
{/if}
</tbody>
</DataTable>
@@ -174,7 +174,7 @@ export async function main(bucket: any, api_token: string) {
async function mintApiToken(): Promise<string> {
return await UserService.createToken({
requestBody: {
label: `test connection: ${resourceType}`,
label: `ephemeral-test-connection: ${resourceType}`,
expiration: new Date(Date.now() + API_TOKEN_TTL_MS).toISOString(),
scopes: ['settings:write']
}
@@ -16,6 +16,7 @@
type MenuItem = {
label: string
description?: string
onClick?: (e?: Event) => void
href?: string
icon?: any
@@ -146,6 +147,7 @@
const items = typeof menuItems === 'function' ? menuItems() : menuItems
return items.map((item) => ({
displayName: item.label,
description: item.description,
action: item.onClick ? (e) => item.onClick?.(e) : undefined,
icon: item.icon,
disabled: item.disabled ?? false,
@@ -35,6 +35,7 @@
HistoryIcon
} from 'lucide-svelte'
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
@@ -48,6 +49,9 @@
draft_path?: string
draft_users?: { username?: string | null }[]
canWrite: boolean
/** Projected from the flow value by the listing; a chat-input flow opens
* as a conversation and is badged as such. */
chat_input_enabled?: boolean
}
marked: string | undefined
shareModal: ShareModal
@@ -124,6 +128,10 @@
<FlowHistory bind:this={flowHistory} path={flow.path} />
{/if}
{#snippet chatBadge()}
<ChatFlowBadge />
{/snippet}
<Row
aiId={`flow-row-${flow.path}`}
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
@@ -140,6 +148,7 @@
canFavorite={!flow.draft_only}
{depth}
{rowSelection}
titleBadge={flow.chat_input_enabled ? chatBadge : undefined}
>
{#snippet badges()}
{#if flow.archived}
@@ -67,6 +67,9 @@
badges?: import('svelte').Snippet
actions?: import('svelte').Snippet
customSummary?: import('svelte').Snippet
/** Rendered inline right after the title, unlike `badges`, which sit in
* their own column and are hidden below `lg`. */
titleBadge?: import('svelte').Snippet
/** Overrides the secondary path line (e.g. to strike a renamed path).
* Falls back to the plain `path` string when not provided. */
pathDisplay?: import('svelte').Snippet
@@ -101,6 +104,7 @@
badges,
actions,
customSummary,
titleBadge,
pathDisplay,
onSelect = () => {}
}: Props = $props()
@@ -275,7 +279,12 @@
</div>
{/if}
<div class="grow min-w-0">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
<div
class={twMerge(
'text-emphasis flex-wrap text-left text-xs font-semibold',
titleBadge ? 'inline-flex items-center gap-2' : ''
)}
>
{#if customSummary}
{@render customSummary?.()}
{:else if marked}
@@ -283,6 +292,7 @@
{:else}
{!summary || summary.length == 0 ? displayPath : summary}
{/if}
{@render titleBadge?.()}
</div>
<div class="text-hint text-3xs truncate text-left font-normal" title={path}>
{#if pathDisplay}
@@ -7,6 +7,9 @@
interface Props {
label?: string | undefined
/** Shown instead of `label` below the `xl` breakpoint, for groups that must keep
* their width inside a narrow table cell. The full label stays the accessible name. */
shortLabel?: string | undefined
iconOnly?: boolean
tooltip?: string | undefined
icon?: any | undefined
@@ -30,6 +33,7 @@
let {
label = undefined,
shortLabel = undefined,
iconOnly = false,
tooltip = undefined,
icon = undefined,
@@ -68,6 +72,7 @@
<button
{id}
{disabled}
aria-label={shortLabel ? label : undefined}
class={twMerge(
'group rounded-md transition-all font-normal flex gap-1 flex-row items-center justify-center border text-xs',
horizontalPadding,
@@ -75,7 +80,7 @@
'text-primary data-[state=on]:text-primary',
'data-[state=on]:bg-surface-tertiary data-[state=off]:border-transparent data-[state=on]:border-border-normal/30',
'bg-surface-transparent hover:bg-surface-hover',
disabled ? '!shadow-none' : '',
disabled ? '!shadow-none !text-disabled' : '',
className
)}
use:melt={$item(value)}
@@ -102,7 +107,12 @@
/>
{/if}
{#if label && !iconOnly}
{label}
{#if shortLabel}
<span class="hidden xl:inline">{label}</span>
<span class="xl:hidden" aria-hidden="true">{shortLabel}</span>
{:else}
{label}
{/if}
{/if}
{#if showTooltipIcon}
<Info size={iconSize} class="text-gray-400" />
@@ -44,6 +44,7 @@
import Markdown from 'svelte-exmarkdown'
import { twMerge } from 'tailwind-merge'
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
import { getChatViewHost } from './chatViewHost'
import { getAiChatManager } from './aiChatManagerContext'
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -68,14 +69,19 @@
import { base } from '$lib/base'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const chatHost = getChatViewHost()
// The skill and MCP menus take an AIChatManager itself, which the seam deliberately
// doesn't carry. They render only under GLOBAL, which a non-copilot host never sets.
const aiChatManager = getAiChatManager()
// The free grant pays for the copilot's own model, so its banners belong only to a host
// that sends to that model. A flow chat's turn runs on the flow's provider.
const freeTier = $derived(chatHost.supportsModelSettings ? $copilotInfo.freeTier : undefined)
// The user spent their one-time free Windmill AI grant: there is no model left to send
// to, so say so in the thread itself rather than only failing on send.
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
let freeTierExhausted = $derived(freeTier?.exhausted === true)
// Still on the free grant: keep how much is left in view right above the composer, so
// running out isn't a surprise. Once spent, the exhausted banner replaces it.
let freeTier = $derived($copilotInfo.freeTier)
let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted)
@@ -174,8 +180,12 @@
wideLayout = false,
emptyHint,
inputPreface,
footerSettings,
initialInstructions = undefined,
onDraftChange = undefined
onDraftChange = undefined,
placeholder = undefined,
scrollElement = $bindable(),
onTranscriptScroll = undefined
}: {
messages: DisplayMessage[]
pastChats: { id: string; title: string }[]
@@ -202,9 +212,18 @@
wideLayout?: boolean
emptyHint?: Snippet
inputPreface?: Snippet
/** The settings control at the footer's right edge, where the copilot puts its
* model picker. A host that configures its turn elsewhere replaces it here. */
footerSettings?: Snippet
// Seed / observe the main composer's draft text (see AIChatInput).
initialInstructions?: string
onDraftChange?: (text: string) => void
/** Composer placeholder. Falls back to the per-AI-mode wording. */
placeholder?: string
/** The transcript's scroll container. A host that paginates older messages
* needs it to measure and restore the scroll position. */
scrollElement?: HTMLDivElement | undefined
onTranscriptScroll?: () => void
} = $props()
let aiChatInput: AIChatInput | undefined = $state()
@@ -223,7 +242,7 @@
let panelEl: HTMLDivElement | undefined = $state()
$effect(() => {
function onWindowKeydownCapture(e: KeyboardEvent) {
if (e.key !== 'Escape' || !aiChatManager.loading) return
if (e.key !== 'Escape' || !chatHost.loading) return
const active = document.activeElement
const focusOnChat =
!active || active === document.body || (panelEl?.contains(active) ?? false)
@@ -231,22 +250,21 @@
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
// row must not answer for the other.
if (aiChatManager.hasPendingRunForm) {
if (chatHost.hasPendingRunForm) {
const row = active?.closest('[data-run-form-actions]')
const toolCallId = row?.getAttribute('data-run-form-actions')
if (!toolCallId || !aiChatManager.isRunFormPending(toolCallId)) return
if (!toolCallId || !chatHost.isRunFormPending(toolCallId)) return
} else if (!focusOnChat) return
e.preventDefault()
// Immediate form: other chat panels' identical listeners must not
// also cancel on body focus, nor a drawer/modal close on this press.
e.stopImmediatePropagation()
aiChatManager.cancel()
chatHost.cancel()
}
window.addEventListener('keydown', onWindowKeydownCapture, true)
return () => window.removeEventListener('keydown', onWindowKeydownCapture, true)
})
let scrollEl: HTMLDivElement | undefined = $state()
// Programmatic-scroll guard. `scrollDown()` triggers an async `scroll`
// event; if a token-append between the scrollTo and the dispatch makes
// scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and
@@ -259,22 +277,23 @@
// Instant scroll — smooth would animate every token append, racing with
// the next scrollDown and confusing the onscroll bottom-detection below.
function scrollDown() {
if (!scrollEl) return
if (!scrollElement) return
programmaticScrollAt = Date.now()
scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'auto' })
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' })
}
let height = $state(0)
$effect(() => {
if (aiChatManager.automaticScroll && height) {
if (chatHost.automaticScroll && height) {
scrollDown()
}
// Recompute the scroll-to-latest visibility on every content-height
// change. `onScroll` only fires for actual scroll events, so without
// this the arrow can go stale when content grows past the threshold
// while auto-scroll is disabled (user scrolled up mid-stream).
if (scrollEl && height) {
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
if (scrollElement && height) {
const distance =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX
}
})
@@ -289,8 +308,9 @@
const SCROLL_TO_LATEST_THRESHOLD_PX = 200
let showScrollToLatest = $state(false)
function onScroll() {
if (!scrollEl) return
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
if (!scrollElement) return
const distance =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
// Always refresh the arrow visibility — even during the cooldown,
// because clicking the arrow itself triggers a programmatic scroll
// whose only event would otherwise be swallowed, leaving the arrow
@@ -303,14 +323,15 @@
return
}
if (distance <= STICK_TO_BOTTOM_PX) {
aiChatManager.enableAutomaticScroll()
chatHost.enableAutomaticScroll()
} else {
aiChatManager.disableAutomaticScroll()
chatHost.disableAutomaticScroll()
}
onTranscriptScroll?.()
}
function submitSuggestion(suggestion: string) {
aiChatManager.sendRequest({ instructions: suggestion })
chatHost.sendRequest({ instructions: suggestion })
}
export function focusInput() {
@@ -319,35 +340,31 @@
$effect(() => {
if (aiChatInput) {
aiChatManager.setAiChatInput(aiChatInput)
chatHost.setAiChatInput(aiChatInput)
}
return () => {
aiChatManager.setAiChatInput(null)
chatHost.setAiChatInput(null)
}
})
// Also shown for a run held by another tab, labeled with where it is: the
// dots say a turn is in flight even before the reader reaches the footer
// note. Remote runs pause nothing and offer no Stop — this tab can't cancel.
const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere)
const showTypingIndicator = $derived(chatHost.loading || chatHost.runHeldElsewhere)
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
// `@`-context is still invoked inline by typing `@` in the input, so the button
// is redundant. NAVIGATOR/ASK/API don't take @-context at all.
const showContextPicker = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.APP
chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP
)
// File attachment is GLOBAL-mode only.
const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled)
// Steers the OS file picker toward text + image formats (soft hint; both attach
// to the message — text files after a content sniff).
const TEXT_FILE_ACCEPT =
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
const canAttachFiles = $derived(chatHost.supportsMessageAttachments && !disabled)
// Folders are linked as session-wide assets, which only a host that reads files in
// the browser can do — a host running the turn server-side takes attachments only.
const canLinkFolders = $derived(chatHost.supportsLinkedFolders && !disabled)
let fileInputEl = $state<HTMLInputElement | null>(null)
let folderInputEl = $state<HTMLInputElement | null>(null)
let dragDepth = $state(0)
@@ -373,12 +390,12 @@
}
async function handleAddFiles(files: FileList | FileToAttach[]) {
const { added, rejected } = await aiChatManager.attachedFiles.addFiles(files)
const { added, rejected } = await chatHost.attachedFiles.addFiles(files)
reportAddResult(added, rejected)
}
async function addDirHandle(dir: FileSystemDirectoryHandle) {
const { added, rejected } = await aiChatManager.attachedFiles.addFolder(dir)
const { added, rejected } = await chatHost.attachedFiles.addFolder(dir)
reportAddResult(added, rejected)
}
@@ -471,8 +488,13 @@
const textFiles = looseFiles.filter((f) => !isImageFile(f))
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
// Folders link as a live handle.
for (const h of handles.filter(isDirectoryHandle)) {
await addDirHandle(h)
const dirs = handles.filter(isDirectoryHandle)
if (dirs.length > 0 && !canLinkFolders) {
sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
} else {
for (const h of dirs) {
await addDirHandle(h)
}
}
} else {
// Fallback (no File System Access API): snapshot dropped files AND folders by walking
@@ -497,7 +519,10 @@
topLevelText.push(file)
}
}
if (folderEntries.length > 0) await handleAddFiles(folderEntries)
if (folderEntries.length > 0) {
if (canLinkFolders) await handleAddFiles(folderEntries)
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
}
if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText)
}
}
@@ -524,9 +549,9 @@
input.value = ''
}
const autonomyAvailability = $derived({
autoAcceptEditsAvailable: aiChatManager.autoAcceptEditsAvailable,
autoAcceptToolConfirmationsAvailable: aiChatManager.autoAcceptToolConfirmationsAvailable,
planModeAvailable: aiChatManager.planModeAvailable
autoAcceptEditsAvailable: chatHost.autoAcceptEditsAvailable,
autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable,
planModeAvailable: chatHost.planModeAvailable
})
const availableAutonomyModeOptions = $derived(
autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
@@ -534,8 +559,8 @@
// Fall back to ask-permission when the persisted mode isn't applicable in the
// current AI mode (e.g. auto-accept edits while in a mode without edits).
const effectiveAutonomyMode = $derived(
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
? aiChatManager.autonomyMode
availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode)
? chatHost.autonomyMode
: AIAutonomyMode.DEFAULT
)
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
@@ -544,13 +569,13 @@
// The typing-dots indicator implies the AI is busy, which is misleading while
// the loop is parked on the user; surface a text pill instead so users know to
// act on the tool above.
const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
// Gated on `loading` because a card restored from history still looks parked:
// its resolver left with the old page, so the composer must not advertise an
// answer it cannot deliver.
const pendingQuestionToolCallId = $derived.by(() => {
if (!aiChatManager.loading) {
if (!chatHost.loading) {
return undefined
}
const pending = pendingUserActionDetail(messages)
@@ -559,14 +584,14 @@
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) {
if (chatHost.mode !== AIMode.APP || !chatHost.appAiChatHelpers) {
return undefined
}
return aiChatManager.appAiChatHelpers.getSelectedContext()
return chatHost.appAiChatHelpers.getSelectedContext()
})
const yoloBypassedTools = $derived.by(() => {
return aiChatManager.tools
return chatHost.tools
.filter((tool) => tool.requiresConfirmation === true || tool.bypassedByAutoAccept === true)
.map((tool) => ({
name: tool.def.function.name,
@@ -583,8 +608,7 @@
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
)
const showFlowPendingActionControls = $derived(
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
!aiChatManager.autoAcceptEditsActive
(chatHost.flowAiChatHelpers?.hasPendingChanges() ?? false) && !chatHost.autoAcceptEditsActive
)
// A disabled state with no message (a remote hold, a spent free grant) keeps
// the footer toolbar in place — swapping it for an empty strip would make
@@ -592,11 +616,15 @@
// a real message (archived, AI off) still shows it, hold or not, matching
// the precedence disabledMessage itself encodes.
const footerMessageShown = $derived(disabled && disabledMessage !== '')
// `canAttachFiles` belongs in the group too: in GLOBAL mode the `+` always has the
// context picker or the autonomy selector beside it, but a host with attachments and
// nothing else would lose the group and the `+` with it.
const showFooterLeftControls = $derived(
!footerMessageShown &&
(showContextPicker ||
(canAttachFiles ||
showContextPicker ||
showAutonomyModeSelector ||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
(chatHost.mode === AIMode.SCRIPT && hasDiff))
)
</script>
@@ -694,12 +722,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#each pastChats as chat (chat.id)}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
disabled={aiChatManager.loading ||
aiChatManager.sendInFlight ||
aiChatManager.runHeldElsewhere}
title={aiChatManager.runHeldElsewhere
disabled={chatHost.loading ||
chatHost.sendInFlight ||
chatHost.runHeldElsewhere}
title={chatHost.runHeldElsewhere
? 'Wait for the turn in the other tab to switch conversation'
: aiChatManager.loading || aiChatManager.sendInFlight
: chatHost.loading || chatHost.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
onclick={() => {
@@ -731,10 +759,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
<Button
title={aiChatManager.runHeldElsewhere
title={chatHost.runHeldElsewhere
? 'Wait for the turn in the other tab to start a new chat'
: 'New chat'}
disabled={aiChatManager.runHeldElsewhere}
disabled={chatHost.runHeldElsewhere}
on:click={() => {
saveAndClear()
}}
@@ -769,7 +797,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div class="flex-1 min-h-0 relative">
<div
class="absolute inset-0 overflow-y-scroll pt-2 scrollbar-subtle"
bind:this={scrollEl}
bind:this={scrollElement}
onscroll={onScroll}
>
<div
@@ -800,16 +828,16 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<ChatTypingIndicator
loading={showTypingIndicator}
paused={waitingForUserAction}
label={aiChatManager.runHeldElsewhere
label={chatHost.runHeldElsewhere
? 'Running in another tab'
: aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
: chatHost.loadingLabel
? chatHost.loadingLabel
: chatHost.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: chatHost.currentReasoningActive &&
!chatHost.currentReply &&
!chatHost.currentReasoning
? (chatHost.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
</div>
@@ -832,7 +860,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
aria-label="Scroll to latest message"
startIcon={{ icon: ArrowDown }}
on:click={() => {
aiChatManager.enableAutomaticScroll()
chatHost.enableAutomaticScroll()
scrollDown()
}}
/>
@@ -854,7 +882,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
variant="default"
btnClasses="bg-green-500 hover:bg-green-600 text-white hover:text-white"
onclick={() => {
aiChatManager.flowAiChatHelpers?.acceptAllModuleActions()
chatHost.flowAiChatHelpers?.acceptAllModuleActions()
}}
>
Accept all
@@ -866,7 +894,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
variant="default"
btnClasses="dark:opacity-50 opacity-60 hover:opacity-100"
onclick={() => {
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
chatHost.flowAiChatHelpers?.rejectAllModuleActions()
}}
>
Reject all
@@ -876,7 +904,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
<div>
<QueuedMessageChip />
{#if aiChatManager.mode === AIMode.GLOBAL && !aiChatManager.isSessionChat}
{#if chatHost.mode === AIMode.GLOBAL && !chatHost.isSessionChat}
<!-- Standalone Jobs bar for the global side-panel chat. In /sessions the
Jobs segment lives inside the session bar (SessionChangesBar). -->
<div class="mb-1">
@@ -898,9 +926,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={aiChatInput}
bind:selectedContext
{availableContext}
{placeholder}
{initialInstructions}
{onDraftChange}
showContext={aiChatManager.mode !== AIMode.GLOBAL}
showContext={chatHost.mode !== AIMode.GLOBAL}
{disabled}
{pendingQuestionToolCallId}
isFirstMessage={messages.length === 0}
@@ -925,7 +954,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
/>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
{#if chatHost.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
@@ -969,7 +998,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
// together: awaited inline they queue, and the whole menu —
// attachments included — waits out two round trips.
const closeMenu = () => (plusMenuOpen = false)
const inGlobal = aiChatManager.mode === AIMode.GLOBAL
const inGlobal = chatHost.mode === AIMode.GLOBAL
const [skillItems, mcpItems] = await Promise.all([
inGlobal ? skillsMenu.items(closeMenu) : undefined,
inGlobal ? mcpMenu.items(closeMenu) : undefined
@@ -983,19 +1012,23 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
linkFiles()
}
},
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
},
...(canLinkFolders
? [
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
}
]
: []),
...(skillItems
? [
{
@@ -1054,7 +1087,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={fileInputEl}
type="file"
multiple
accept={TEXT_FILE_ACCEPT}
accept={chatHost.attachmentAccept}
class="hidden no-default-style"
onchange={onFileInputChange}
/>
@@ -1075,7 +1108,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
availableAutonomyModeOptions.map((option) => ({
displayName: option.label,
selected: effectiveAutonomyMode === option.mode,
action: () => aiChatManager.setAutonomyMode(option.mode)
action: () => chatHost.setAutonomyMode(option.mode)
}))}
placement="bottom-start"
fixedHeight={false}
@@ -1102,18 +1135,18 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#if effectiveAutonomyMode === AIAutonomyMode.PLAN}
<span class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.modeNote}</span>
{/if}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && chatHost.autoAcceptToolConfirmationsAvailable}
<Tooltip small placement="top">
<AlertTriangle class="w-3 h-3 text-red-500" />
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">
{aiChatManager.autoAcceptEditsAvailable
{chatHost.autoAcceptEditsAvailable
? 'Bypass permissions auto-accepts edits and tool usage.'
: 'Bypass permissions auto-accepts tool usage.'}
</p>
<p class="mt-1">
{aiChatManager.autoAcceptEditsAvailable
{chatHost.autoAcceptEditsAvailable
? 'This can result in edits being applied or tools being called without user confirmation.'
: 'This can result in tools being called without user confirmation.'}
</p>
@@ -1134,7 +1167,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff && !disabled}
{#if chatHost.mode === AIMode.SCRIPT && hasDiff && !disabled}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
@@ -1145,25 +1178,27 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{:else}
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
{#if aiChatManager.mode === AIMode.GLOBAL}
{#if chatHost.mode === AIMode.GLOBAL}
<AttachedFilesBar />
{/if}
{#if !hideModeSelector}
<ChatMode />
{/if}
{#if aiChatManager.mode === AIMode.APP}
{#if chatHost.mode === AIMode.APP}
<DatatableCreationPolicy />
{/if}
<ContextUsageIndicator />
<!-- Unconditional: this composer mounts only via `AIChat` ← `SessionWrapper`,
and `sessionRuntime` locks a session to GLOBAL, where the settings
modal's Instructions section owns the prompt entries. -->
<AIChatModelSettings promptSettings={false} />
{#if aiChatManager.mode === AIMode.GLOBAL}
{#if chatHost.supportsModelSettings}
<!-- `promptSettings={false}`: in a session, GLOBAL, the settings modal's
Instructions section owns the prompt entries. -->
<AIChatModelSettings promptSettings={false} />
{/if}
{@render footerSettings?.()}
{#if chatHost.mode === AIMode.GLOBAL}
<AssistantSettingsModal bind:this={assistantSettings} />
{/if}
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if appContext.inspectorElement}
<div
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-2xs"
@@ -1210,7 +1245,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
</div>
</div>
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
{#if (chatHost.mode === AIMode.NAVIGATOR || chatHost.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
<div class="px-2 mt-4">
<div class="flex flex-col gap-2">
{#each suggestions as suggestion (suggestion)}
@@ -12,6 +12,8 @@
} from './context'
import { AIMode } from './AIChatManager.svelte'
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
import { formatMention } from './mention'
import { twMerge } from 'tailwind-merge'
import { tick, untrack, type Snippet } from 'svelte'
@@ -45,7 +47,10 @@
isImageViewerOpen
} from '$lib/components/common/image/ExpandableImage.svelte'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// Resolved here, not where it is used: getContext is only legal during component
// initialisation, and the mention consumer below runs inside the send gesture.
const chatManager = getAiChatManager()
interface Props {
availableContext: ContextElement[]
@@ -65,15 +70,15 @@
showContext?: boolean
bottomRightSnippet?: Snippet
onKeyDown?: (e: KeyboardEvent) => void
// When provided, overrides `aiChatManager.loading` for the send/stop
// When provided, overrides `chatHost.loading` for the send/stop
// button — useful for callers driving their own request lifecycle
// (e.g. the inline ⌘K widget runs requests outside the global
// `aiChatManager.loading` flag).
// `chatHost.loading` flag).
loading?: boolean
// Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`.
// Called when the user clicks Stop. Defaults to `chatHost.cancel()`.
onCancel?: () => void
// Observe the composer draft as it changes (the text is local state —
// `aiChatManager.instructions` only carries programmatic prompts). Used by
// `chatHost.instructions` only carries programmatic prompts). Used by
// sessions to persist the typed-but-unsent prompt with the session draft.
onDraftChange?: (text: string) => void
// tool_call_id of the askUserQuestion the turn is parked on, when it is. A
@@ -132,7 +137,7 @@
// The composer unlocks by itself when the other tab's turn ends, so the
// placeholder names what it is waiting on (the typing indicator says
// where the run is).
if (aiChatManager.runHeldElsewhere) {
if (chatHost.runHeldElsewhere) {
return 'Waiting for the turn in the other tab to finish'
}
if (pendingQuestionToolCallId !== undefined) {
@@ -147,7 +152,7 @@
return placeholder
}
switch (aiChatManager.mode) {
switch (chatHost.mode) {
case AIMode.SCRIPT:
return 'Modify this script...'
case AIMode.FLOW:
@@ -213,19 +218,24 @@
// against a concurrent drop.
let pendingImages = $state(0)
/** Attach dropped/pasted image files (downscaled + bounded). GLOBAL mode only. */
/** Attach dropped/pasted image files (downscaled + bounded). */
export async function addImages(files: (File | Blob)[]) {
if (aiChatManager.mode !== AIMode.GLOBAL) return
if (!chatHost.supportsMessageAttachments) return
const imageFiles = files.filter(isImageFile)
if (imageFiles.length === 0) return
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
// drop/paste handler that can't surface a rejection.
const model = tryGetCurrentModel()
// Only known text-only models fail this, so attaching would certainly 400 the
// next turn — refuse rather than warn and send it anyway.
if (model && !modelSupportsVision(model.provider, model.model)) {
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
return
// The vision check is about the model this composer's own turn will hit, so it
// only applies to a host that picks that model. Elsewhere the model is chosen
// in the flow and tryGetCurrentModel would answer for the wrong one.
if (chatHost.supportsModelSettings) {
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
// drop/paste handler that can't surface a rejection.
const model = tryGetCurrentModel()
// Only known text-only models fail this, so attaching would certainly 400 the
// next turn — refuse rather than warn and send it anyway.
if (model && !modelSupportsVision(model.provider, model.model)) {
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
return
}
}
// Count decodes already in flight: two drops that both read the image count
// before either resolves would each claim the same free slots and overshoot
@@ -308,13 +318,13 @@
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes
)
$effect(() => {
aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
})
$effect(() => () => aiChatManager.clearComposerStaged(composerKey))
$effect(() => () => chatHost.clearComposerStaged(composerKey))
/** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */
/** Attach dropped/picked text files (sniffed + bounded). */
export async function addTextFiles(candidates: File[]) {
if (aiChatManager.mode !== AIMode.GLOBAL) return
if (!chatHost.supportsMessageAttachments) return
if (candidates.length === 0) return
const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles
if (remaining <= 0) {
@@ -346,7 +356,7 @@
// stage stands in for it, so counting both would charge those bytes twice.
let budget =
MAX_CONVERSATION_FILE_BYTES -
aiChatManager.attachmentBytesExcluding(composerKey) -
chatHost.attachmentBytesExcluding(composerKey) -
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
pendingFileBytes
const withinBudget: File[] = []
@@ -388,7 +398,7 @@
// from the budget — the decoded sizes replace it.
const liveBudget =
MAX_CONVERSATION_FILE_BYTES -
aiChatManager.attachmentBytesExcluding(composerKey) -
chatHost.attachmentBytesExcluding(composerKey) -
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
(pendingFileBytes - reservedBytes)
const { droppedAtBudget } = draft.addFiles(reads, liveBudget)
@@ -420,9 +430,9 @@
// Modes that show the rich textarea with @-context support (workspace
// scripts, workspace flows, code blocks, DBs, etc.).
const isContextEnabledMode = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.GLOBAL
chatHost.mode === AIMode.SCRIPT ||
chatHost.mode === AIMode.FLOW ||
chatHost.mode === AIMode.GLOBAL
)
const domSelectorChips = $derived(
@@ -551,14 +561,14 @@
* the composer. The conversation is left untouched — resending creates a new
* message, unlike the bubble's edit pencil which rewinds the conversation. */
function recallLastSentMessage(): boolean {
const messages = aiChatManager.displayMessages
const messages = chatHost.displayMessages
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== 'user' || message.synthetic) continue
// Images come from the stored turn, never the bubble: a provider
// rejection strips them from history while the bubble keeps its copy,
// and recalling that copy would re-attach the refused image.
const images = aiChatManager.storedImages(i) ?? []
const images = chatHost.storedImages(i) ?? []
// Eligibility looks at the bubble, though: the last thing the user
// actually sent is the recall boundary, so a context-only turn (GLOBAL
// allows text-free sends with chips) recalls its chips, and a turn
@@ -582,8 +592,7 @@
// count against the conversation budget — re-admit them instead of
// copying, or resending would blow past MAX_CONVERSATION_FILE_BYTES.
if (message.files?.length) {
const budget =
MAX_CONVERSATION_FILE_BYTES - aiChatManager.attachmentBytesExcluding(composerKey)
const budget = MAX_CONVERSATION_FILE_BYTES - chatHost.attachmentBytesExcluding(composerKey)
const { droppedAtBudget } = draft.addFiles(message.files, budget)
if (droppedAtBudget > 0) {
const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000)
@@ -654,10 +663,10 @@
if (
contextElement.type === 'app_datatable' &&
aiChatManager.mode === AIMode.APP &&
aiChatManager.appAiChatHelpers
chatHost.mode === AIMode.APP &&
chatHost.appAiChatHelpers
) {
const appAiChatHelpers = aiChatManager.appAiChatHelpers
const appAiChatHelpers = chatHost.appAiChatHelpers
appAiChatHelpers.addTableToWhitelist(
contextElement.datatableName,
contextElement.schemaName,
@@ -699,8 +708,10 @@
* consuming past them would hand this message a mention the user picked for
* the next one. */
function consumeMentionsIfGlobal() {
if (aiChatManager.mode !== AIMode.GLOBAL) return
aiChatManager.contextManager?.consumeMentionContext()
if (chatHost.mode !== AIMode.GLOBAL) return
// The mention context belongs to the copilot's own ContextManager, which only
// the manager has — the GLOBAL guard above means this host is always it.
chatManager.contextManager?.consumeMentionContext()
}
function sendRequest() {
@@ -709,13 +720,18 @@
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) {
return
}
// A host whose consumer needs a message of its own refuses an attachment-only
// turn. Returning before `take()` keeps the chips where the user put them.
if (chatHost.requiresMessageText && draft.text.trim() === '') {
return
}
// Read before `take()` empties the draft the id derives from, and only take
// once the answer is delivered — an undelivered one would leave the user
// with neither their text nor a resumed turn.
const answeredQuestionId = questionAnsweredBySend
if (
answeredQuestionId &&
aiChatManager.handleUserQuestionAnswer(answeredQuestionId, [
chatHost.handleUserQuestionAnswer(answeredQuestionId, [
expanded(chatDraft(draft.text.trim(), draft.pastes))
])
) {
@@ -727,7 +743,7 @@
contextTextareaComponent?.clearForSend()
return
}
if (aiChatManager.loading) {
if (chatHost.loading) {
// Queue the message instead of silently discarding it — it is
// auto-sent when the streaming turn completes successfully.
// Editing-while-loading keeps the old discard behavior. Paste
@@ -738,10 +754,10 @@
// chips picked at press time.
if (
editingMessageIndex === null &&
(!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
(!draft.isEmpty || (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0))
) {
const sent = draft.take()
aiChatManager.queueMessage(
chatHost.queueMessage(
expanded(chatDraft(sent.text, sent.pastes)),
sent.images,
[...selectedContext],
@@ -758,7 +774,7 @@
// message's original chips), so send exactly what's shown — the user may
// have added or removed chips.
const sent = draft.take()
aiChatManager.restartGeneration(
chatHost.restartGeneration(
editingMessageIndex,
sent.text,
sent.pastes,
@@ -771,9 +787,9 @@
const sent = draft.take()
// Pin before consuming: the manager falls back to the live selection only
// when given no override, and the consume below empties it.
const carried = aiChatManager.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
consumeMentionsIfGlobal()
aiChatManager.sendRequest({
chatHost.sendRequest({
instructions: sent.text,
pastes: sent.pastes,
images: sent.images,
@@ -1005,31 +1021,40 @@
<!-- The turn stays `loading` while parked on a question, but a drafted answer
is what the button should ship then — otherwise the only pointer action on
a typed answer would be Stop. Anything else keeps Stop. -->
{@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend}
{@const isLoading = (loading ?? chatHost.loading) && !questionAnsweredBySend}
{@const emptyDraft = draft.isEmpty}
<!-- A text-free GLOBAL draft with context chips is a valid turn (Enter
already sends it), so the button stays enabled there for pointer/touch
parity — mirrors the sendRequest guard. Custom onSendRequest consumers
(inline ⌘K) and editor copilots need content. -->
{@const needsText = chatHost.requiresMessageText && draft.text.trim() === ''}
<!-- The wording is about the attachment, so it earns its place only once there is one:
an empty composer is the idle state, not a refusal. -->
{@const needsTextForAttachment = needsText && !emptyDraft}
{@const sendDisabled =
disabled ||
pendingImages > 0 ||
pendingFiles > 0 ||
ingestionHolds > 0 ||
needsText ||
(emptyDraft &&
(onSendRequest !== undefined ||
aiChatManager.mode !== AIMode.GLOBAL ||
chatHost.mode !== AIMode.GLOBAL ||
selectedContext.length === 0))}
<Button
variant="subtle"
unifiedSize="md"
iconOnly
title={isLoading ? 'Stop' : 'Send'}
title={isLoading
? 'Stop'
: needsTextForAttachment
? 'Write a message to send with the attachment'
: 'Send'}
startIcon={{ icon: isLoading ? Square : ArrowUp }}
disabled={!isLoading && sendDisabled}
on:click={() => {
if (isLoading) {
onCancel ? onCancel() : aiChatManager.cancel()
onCancel ? onCancel() : chatHost.cancel()
} else if (!sendDisabled) {
submitRequest()
}
@@ -1115,9 +1140,9 @@
class="relative mt-1"
role="presentation"
onkeydown={(e) => {
if (e.key === 'Escape' && aiChatManager.loading) {
if (e.key === 'Escape' && chatHost.loading) {
e.preventDefault()
aiChatManager.cancel()
chatHost.cancel()
} else if (
e.key === 'ArrowUp' &&
!e.defaultPrevented &&
@@ -1139,14 +1164,14 @@
// custom-send consumers (inline widget) have their own history
// semantics.
if (
aiChatManager.queuedMessage ||
aiChatManager.queuedImages.length > 0 ||
aiChatManager.queuedFiles.length > 0 ||
(aiChatManager.queuedContext?.length ?? 0) > 0
chatHost.queuedMessage ||
chatHost.queuedImages.length > 0 ||
chatHost.queuedFiles.length > 0 ||
(chatHost.queuedContext?.length ?? 0) > 0
) {
e.preventDefault()
aiChatManager.dequeueMessage()
} else if (!aiChatManager.sendInFlight && recallLastSentMessage()) {
chatHost.dequeueMessage()
} else if (!chatHost.sendInFlight && recallLastSentMessage()) {
// History recall waits for the in-flight turn: from the moment the
// composer clears, the turn's bubble, stored images and context land
// across several awaits, so recalling now would return an incomplete
@@ -1163,10 +1188,10 @@
bind:this={contextTextareaComponent}
bind:value={draft.text}
bind:pastes={draft.pastes}
onImageFiles={aiChatManager.mode === AIMode.GLOBAL
onImageFiles={chatHost.supportsMessageAttachments
? (pasted) => void addImages(pasted)
: undefined}
onTextFiles={aiChatManager.mode === AIMode.GLOBAL
onTextFiles={chatHost.supportsMessageAttachments
? (pasted) => void addTextFiles(pasted)
: undefined}
{availableContext}
@@ -1196,7 +1221,7 @@
</div>
{/if}
</div>
{:else if aiChatManager.mode === AIMode.APP}
{:else if chatHost.mode === AIMode.APP}
{#if showContext}
{@render badgeRow()}
{/if}
@@ -1264,30 +1289,38 @@
</Portal>
{/if}
{:else}
<div class={twMerge('relative w-full scroll-pb-2 pt-2', className)}>
<textarea
bind:this={instructionsTextareaComponent}
bind:value={draft.text}
use:autosize={{ maxHeight: '40vh' }}
onkeydown={(e) => {
if (onKeyDown) {
onKeyDown(e)
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendRequest()
}
}}
rows={1}
placeholder={modePlaceholder}
class={twMerge('resize-none', CHAT_INPUT_PADDING)}
{disabled}
></textarea>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
<!-- Same box as the rich composer above, so a host on the plain textarea shows
the identical chip rows inside the identical field. -->
<div class={composerBoxClass(disabled)}>
{@render badgeRow()}
{@render imageChipsRow()}
<div class={twMerge('relative w-full', className)}>
<textarea
bind:this={instructionsTextareaComponent}
bind:value={draft.text}
use:autosize={{ maxHeight: '40vh' }}
onkeydown={(e) => {
if (onKeyDown) {
onKeyDown(e)
}
// An Enter that confirms an IME composition (Japanese, Chinese) is not a
// send; it would ship the unfinished text.
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
sendRequest()
}
}}
rows={1}
placeholder={modePlaceholder}
class={twMerge('resize-none', COMPOSER_FIELD_RESET, CHAT_INPUT_PADDING)}
{disabled}
></textarea>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
</div>
</div>
{/if}
{#if bottomRightSnippet}
@@ -1,3 +1,4 @@
import type { ChatViewHost } from './chatViewHost'
import type { ScriptLang } from '$lib/gen/types.gen'
import { JobService, type CompletedJob } from '$lib/gen'
import type { FlowOptions, ScriptOptions } from './ContextManager.svelte'
@@ -445,9 +446,27 @@ function planModeHostFor(m: AIChatManager): PlanModeHost {
}
}
export class AIChatManager {
export class AIChatManager implements ChatViewHost {
contextManager = new ContextManager()
historyManager = new HistoryManager()
// The copilot owns its model choice and its own transcript, so both chat
// affordances apply here. See ChatViewHost for hosts where they don't.
supportsModelSettings = true
supportsMessageEditing = true
// The copilot turn is the attachments themselves when there is no text.
requiresMessageText = false
// Attachments and linked folders are GLOBAL-mode affordances. Declared as
// getters because `mode` changes under a mounted composer.
get supportsMessageAttachments() {
return this.mode === AIMode.GLOBAL
}
get supportsLinkedFolders() {
return this.mode === AIMode.GLOBAL
}
// Steers the OS file picker toward text + image formats (a soft hint; both attach to
// the message — text files after a content sniff).
attachmentAccept =
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
/** Files the user attached to the current GLOBAL-mode conversation. */
attachedFiles = new AttachedFilesStore()
/** Markdown artifacts the copilot created for the current session. */
@@ -3,7 +3,7 @@
import type { DisplayMessage, ToolDisplayMessage } from './shared'
import ContextElementBadge from './ContextElementBadge.svelte'
import AssistantMessage from './AssistantMessage.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { Button } from '$lib/components/common'
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -15,7 +15,7 @@
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
import { workspaceStore } from '$lib/stores'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// Paths in a message name items the chat's tools reach, so they resolve against the
// operating workspace, never `workspaceStore`: a fork session leaves the store on the
@@ -25,7 +25,7 @@
// Registers the dependency that `operatingWorkspace`'s own untracked
// `get(workspaceStore)` cannot.
void $workspaceStore
return aiChatManager.operatingWorkspace
return chatHost.operatingWorkspace
})
// Per-message expand/collapse state for paste chips shown in the bubble.
@@ -62,7 +62,12 @@
let editContext = $state<ContextElement[]>([])
function editMessage() {
if (message.role !== 'user' || editingMessageIndex !== null || aiChatManager.loading) {
if (
!chatHost.supportsMessageEditing ||
message.role !== 'user' ||
editingMessageIndex !== null ||
chatHost.loading
) {
return
}
editContext = [...(message.contextElements ?? [])]
@@ -79,7 +84,9 @@
message.role === 'tool' && 'mb-1',
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
isLast && '!mb-12',
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
message.role !== 'user' || !chatHost.supportsMessageEditing
? 'cursor-default'
: 'cursor-pointer'
)}
role="button"
tabindex="0"
@@ -116,7 +123,7 @@
bind:selectedContext={editContext}
initialInstructions={message.content}
initialPastes={message.pastes}
initialImages={aiChatManager.storedImages(messageIndex)}
initialImages={chatHost.storedImages(messageIndex)}
initialFiles={message.files}
{editingMessageIndex}
onClickOutside={() => (editingMessageIndex = null)}
@@ -185,9 +192,9 @@
on:click={() => {
if (message.snapshot) {
if (message.snapshot.type === 'flow') {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
chatHost.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
} else if (message.snapshot.type === 'app') {
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
chatHost.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
}
}
}}
@@ -206,7 +213,7 @@
variant="default"
title="Retry generation"
startIcon={{ icon: RefreshCwIcon }}
onclick={() => aiChatManager.retryRequest(messageIndex)}
onclick={() => chatHost.retryRequest(messageIndex)}
>
Retry
</Button>
@@ -4,7 +4,7 @@
import { CircleHelp, ArrowUp, Plus, Square, SquareCheck } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import type { UserQuestionDisplay } from './shared'
// Sessions inject a per-pane `AIChatManager` via context; outside of
@@ -12,7 +12,7 @@
// this, answers clicked inside a session would dispatch to the singleton's
// pending callbacks map (which doesn't have the session manager's question
// callback), and the AI loop would stall.
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
interface Props {
toolCallId: string
@@ -93,14 +93,14 @@
}
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [choice])
chatHost.handleUserQuestionAnswer(toolCallId, [choice])
}
function submitPicked() {
if (!multiSelect || picked.size === 0) {
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [...picked])
chatHost.handleUserQuestionAnswer(toolCallId, [...picked])
}
function submitCustomAnswer() {
@@ -119,7 +119,7 @@
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [answer])
chatHost.handleUserQuestionAnswer(toolCallId, [answer])
}
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
@@ -12,6 +12,7 @@
workspaceItemRegistry
} from './workspaceItems.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import DisplayResult from '$lib/components/DisplayResult.svelte'
interface Props {
message: DisplayMessage
@@ -60,6 +61,20 @@
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
}
const stepName = $derived(message.role === 'assistant' ? message.stepName : undefined)
// A flow step can return a file rather than text; the raw JSON would be
// unreadable, so hand it to the result viewer instead of the markdown renderer.
const s3Object = $derived.by(() => {
if (!message.content.startsWith('{')) return undefined
try {
const parsed = JSON.parse(message.content)
return parsed?.type === 'windmill_s3_object' && parsed?.s3 ? parsed : undefined
} catch {
return undefined
}
})
const candidatePaths = $derived(extractCandidatePaths(message.content))
const rendererPlugin = {
renderer: {
@@ -98,6 +113,12 @@
})
</script>
{#if stepName}
<div class="text-2xs text-tertiary font-medium mb-1 truncate" title="Answered by {stepName}">
{stepName}
</div>
{/if}
{#if reasoning}
<ChatCollapsibleCard
label={reasoningLabel}
@@ -112,7 +133,9 @@
</ChatCollapsibleCard>
{/if}
{#if message.content}
{#if s3Object}
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
{:else if message.content}
<div class="w-full space-y-2 {markdownProse.sm}">
<Markdown md={message.content} {plugins} />
</div>

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