From e2a91ca2b141a0bee64ddfafe3cbf3ff86b6fb35 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Fri, 18 Sep 2026 11:31:00 +0200 Subject: [PATCH 001/112] feat: flow chat job-backed detail, smooth streaming and answer chrome (#11186) * feat: flow chat job-backed detail, smooth streaming and answer chrome * chore: document setFlowPath, drop unused chatIdentity, tighten comments * fix: read a nested agent tool's call from its own job * fix: show only the model's arguments on a tool card read from its job * feat: add attachments to the chat sdk message type * refactor: read a flow chat message's files from the message, not its run * fix: keep every job-backed answer when reads finish out of order * refactor: build flow chat tool cards from the conversation row alone * feat: keep the model picker's current choice when a flow chat retry replays its run * fix: export ChatAttachment from the chat sdk entry point * fix: drop a conversation list for the flow path before setFlowPath * fix: return the current listing, not old-path rows, after setFlowPath * fix: treat a listing stale on kind or path as one stale listing * revert: drop setFlowPath and rebuild the chat when the flow path changes * fix: refuse a flow chat retry once any turn ran while it read the run * docs: say where a display message's createdAt comes from * style: drop the rule between New chat and the conversation list --- .../lib/components/FlowPreviewContent.svelte | 10 +- frontend/src/lib/components/ScrollFade.svelte | 66 ++++ .../copilot/chat/AIChatDisplay.svelte | 27 +- .../copilot/chat/AIChatManager.svelte.ts | 10 +- .../copilot/chat/AIChatMessage.svelte | 4 +- .../copilot/chat/AssistantMessage.svelte | 55 ++- .../copilot/chat/ToolExecutionDisplay.svelte | 19 +- .../src/lib/components/copilot/chat/shared.ts | 6 +- .../copilot/chat/typewriterReveal.ts | 7 + .../components/flows/content/FlowInput.svelte | 1 + .../flows/conversations/FlowChat.svelte | 50 ++- .../conversations/FlowChatInterface.svelte | 11 +- .../FlowConversationsSidebar.svelte | 2 +- .../flows/conversations/flowChatProps.ts | 19 + .../conversations/flowChatViewHost.svelte.ts | 292 +++++++++++---- .../conversations/flowChatViewHost.test.ts | 343 +++++++++++++++--- .../conversations/messageAttachments.test.ts | 25 ++ .../flows/conversations/messageAttachments.ts | 48 +++ .../(logged)/flows/get/[...path]/+page.svelte | 112 +++--- 19 files changed, 893 insertions(+), 214 deletions(-) create mode 100644 frontend/src/lib/components/ScrollFade.svelte create mode 100644 frontend/src/lib/components/flows/conversations/flowChatProps.ts create mode 100644 frontend/src/lib/components/flows/conversations/messageAttachments.test.ts create mode 100644 frontend/src/lib/components/flows/conversations/messageAttachments.ts diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 32b46cb2be..5d543ae24d 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -471,7 +471,9 @@ return jobId ?? '' }} conversationKind="test" + frame="boxed" path={$pathStore} + identity={$initialPathStore || fakeInitialPath} inputSchema={flowStore.val.schema} flowModules={flowStore.val.value?.modules} /> @@ -558,7 +560,13 @@ {/if} {/if} -
+ +
{#if flowHasChanged()}
+ /** + * A soft edge on a scroller, so content scrolling out of view fades instead of being + * cut against whatever borders it. + * + * Rendered as an overlay in the scroller's positioned ancestor rather than inside the + * scroller: `sticky` would resolve against the scroller's padding box and leave the + * first few pixels unfaded. It shows only when there is something hidden in that + * direction, so a transcript that fits shows no edge at all. + */ + import { twMerge } from 'tailwind-merge' + + interface Props { + /** The scrolling element this masks. */ + scroller: HTMLElement | undefined + edge?: 'top' | 'bottom' + /** Tailwind colour stop to fade from — the surface the scroller sits on. */ + from?: string + /** Tailwind height of the fade band. */ + height?: string + class?: string + } + + let { + scroller, + edge = 'top', + from = 'from-surface', + height = 'h-4', + class: className = '' + }: Props = $props() + + let hidden = $state(true) + + $effect(() => { + const el = scroller + if (!el) return + const update = () => { + // A pixel of slack: fractional scroll offsets otherwise leave the bottom edge + // showing on a scroller that is already at its end. + hidden = + edge === 'top' ? el.scrollTop <= 1 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1 + } + update() + el.addEventListener('scroll', update, { passive: true }) + // Content arriving or the pane resizing changes what is hidden without a scroll. + const observer = new ResizeObserver(update) + observer.observe(el) + if (el.firstElementChild) observer.observe(el.firstElementChild) + return () => { + el.removeEventListener('scroll', update) + observer.disconnect() + } + }) + + + diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 0085ca701f..b7f662b148 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -35,6 +35,7 @@ import ChatQuickActions from './ChatQuickActions.svelte' import ContextUsageIndicator from './ContextUsageIndicator.svelte' import AIChatModelSettings from './AIChatModelSettings.svelte' + import ScrollFade from '$lib/components/ScrollFade.svelte' import AssistantSettingsModal from './AssistantSettingsModal.svelte' import { SkillsMenu } from './skills/skillsMenu.svelte' import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte' @@ -591,6 +592,15 @@ // 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. + // A step name hangs its icon in the column's left padding (see AssistantMessage), so a + // transcript carrying one widens the padding, on both sides to keep the column centred. + const agentGutter = $derived(messages.some((m) => m.role === 'assistant' && m.stepName)) + const columnClass = $derived( + wideLayout + ? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}` + : `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}` + ) + const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages)) // Gated on `loading` because a card restored from history still looks parked: @@ -823,12 +833,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> bind:this={scrollElement} onscroll={onScroll} > -
+
{#each messages as message, messageIndex (messageIndex)} {/if}
+ + {#if showScrollToLatest}
{/if} -
+ +
{#if showFlowPendingActionControls}
+ {/if} +
+ {#if editable && row.id !== ADMIN_ROLE} + removeRole(row.id)} /> + {/if} +
+ +
+ {/each} +
+ + {#if editable && unusedRoles.length > 0} +
+ + dataTable.database.resource_type, - (resource_type) => { - dataTable.database = { - resource_type, - resource_path: - resource_type === 'instance' ? defaultInstanceDbName() : undefined + {#if dataTable.reference} +
+ Governed by + {dataTable.reference.workspace_id} + / + {dataTable.reference.datatable} + + This fork uses its parent's data table rather than a copy of it, so the database and + its roles are decided in that workspace. + +
+ {:else} +
+
+ {#if dataTable.database.resource_type === 'instance'} + + Use Windmill's PostgreSQL instance + + {/if} + does. // - // In "large mode" (viewport ≥ 1760px, where app.css bumps :root to 18px → + // In "large mode" (screen ≥ 1760px, where app.css bumps :root to 18px → // font 13.5px) headless-Chromium ink measurement showed the text sitting // ~1px low with the base leading. The residual is a fixed ~2px of line box, // so the exact centered value there is (content-box height − 2px). Scoped to - // the same 1760px breakpoint as the font-size bump; small mode is unchanged. + // the same query as the font-size bump; small mode is unchanged. export const inputLeadingClasses: Record = { - '2xs': 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem - xs: 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem - sm: 'leading-6 min-[1760px]:leading-[calc(1.5rem_-_2px)]', // h-7 − py-0.5 → 1.5rem - md: 'leading-8 min-[1760px]:leading-[calc(2rem_-_2px)]', // h-8, no py → 2rem - lg: 'leading-10 min-[1760px]:leading-[calc(2.5rem_-_2px)]' // h-10, no py → 2.5rem + '2xs': 'leading-4 [@media(min-device-width:1760px)]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem + xs: 'leading-4 [@media(min-device-width:1760px)]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem + sm: 'leading-6 [@media(min-device-width:1760px)]:leading-[calc(1.5rem_-_2px)]', // h-7 − py-0.5 → 1.5rem + md: 'leading-8 [@media(min-device-width:1760px)]:leading-[calc(2rem_-_2px)]', // h-8, no py → 2rem + lg: 'leading-10 [@media(min-device-width:1760px)]:leading-[calc(2.5rem_-_2px)]' // h-10, no py → 2.5rem } diff --git a/frontend/src/lib/editorFontSize.svelte.ts b/frontend/src/lib/editorFontSize.svelte.ts index 13d99915e3..96519b0aac 100644 --- a/frontend/src/lib/editorFontSize.svelte.ts +++ b/frontend/src/lib/editorFontSize.svelte.ts @@ -1,25 +1,25 @@ // Reactive Monaco font sizes that follow the global `:root` font-size -// breakpoint set in `frontend/src/lib/assets/app.css` (18px at ≥1760px). +// breakpoint set in `frontend/src/lib/assets/app.css` (18px on screens ≥1760px). // The values mirror Tailwind's `text-xs` (0.75rem) computed pixel size so // editors visually match the surrounding UI. -const LARGE_VIEWPORT_QUERY = '(min-width: 1760px)' +const LARGE_SCREEN_QUERY = '(min-device-width: 1760px)' -let isLargeViewport = $state(false) +let isLargeScreen = $state(false) if (typeof window !== 'undefined') { - const mq = window.matchMedia(LARGE_VIEWPORT_QUERY) - isLargeViewport = mq.matches + const mq = window.matchMedia(LARGE_SCREEN_QUERY) + isLargeScreen = mq.matches mq.addEventListener('change', (e) => { - isLargeViewport = e.matches + isLargeScreen = e.matches }) } export const editorFontSize = { get regular(): number { - return isLargeViewport ? 13.5 : 12 + return isLargeScreen ? 13.5 : 12 }, get small(): number { - return isLargeViewport ? 12 : 11 + return isLargeScreen ? 12 : 11 } } diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index d14cd2c2dc..58d852a2d5 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -139,8 +139,8 @@ let isCollapsed = $state(collapsePref.val) // Resizable desktop rail, sized in REM so it scales with the root font-size the - // same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px past 1760px - // wide (app.css), which grows the rem-based button content; a fixed-px rail would + // same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px on screens + // ≥1760px (app.css), which grows the rem-based button content; a fixed-px rail would // not grow with it and the content would overflow. SIDEBAR_MIN_REM is the default // expanded width (the old w-52); the handle only resizes when expanded and only // widens from there — collapsing is the toggle button's job, not the drag's. From 53afecd4588247bc1812d3e68a30db1f3c3b2724 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 18 Sep 2026 15:15:02 +0200 Subject: [PATCH 020/112] fix: register the job token with the sensitive log masking system (#10943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worker): register the job token with the log masking system The masking system covered secrets fetched through `get_value_internal` and `$encrypted:` args, but not the job's own token, so a script that echoed `$WM_TOKEN` wrote it verbatim into logs that are persisted to the database and, when configured, to object storage. `run_worker` now registers the token for the job it just pulled, alongside the existing `register_running_job` call, so it is redacted like any other registered secret. That makes every job carry at least one registered value, where before the per-batch mask snapshot was skipped entirely for the majority of jobs that touched no secret. Cache the compiled Aho-Corasick automaton per job and invalidate it when a new secret is registered, so a chatty job no longer rebuilds it once per log batch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3 * fix(nativets): mask secrets in the in-process log path NativeTS hands `console.log` output to a task that drains a channel into `append_logs`, so it never reaches the masking in `handle_child::write_lines` and a script logging `$WM_TOKEN` persisted the raw JWT. That drain can still be flushing after the job is unregistered, so a plain per-line `snapshot` would leave the tail unmasked. `JobMasker` keeps the last masks it saw for exactly that window, and refreshes while the job is alive so secrets fetched mid-run are covered too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3 * fix(nativets): seed the job masker at construction A `JobMasker` that only looked up its masks on the first `mask` call had the same hole at the head of the log that its retention closes at the tail: if the drain task's first productive poll landed after the job was unregistered, the registry was already gone and every line was written raw. `new` now takes the snapshot, and its callers construct it from the job's own execution while the job is still registered. Also cover the nativets sink with an integration test, gated on `deno_core` the way the CI test build is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3 * fix(nativets): mask on the producing side of the log channel Masking as the drain task wrote to `append_logs` left two holes, because that task is detached and outlives the job: a secret registered mid-run could still be queued when the job was unregistered and would then be written raw, and the `windmill:job_log` tracing emission that EE forwards job logs on never went through the mask at all. Mask where the line is produced instead. That loop is joined before the job completes, so the job's secrets are always still registered, and one call now covers both the tracing mirror and the channel. The result stream keeps reading the raw text, the way `handle_child` keeps its raw `line` for results. `JobMasker` is no longer load-bearing for the post-unregistration window, so it is documented for what it now does: keep the security notice to once per set of secrets for a sink that masks line by line. Also drop the nativets test's tag override — `DEFAULT_TAGS` does advertise `nativets`, so the comment justifying it was wrong — and pin the automaton cache invalidation, whose failure mode is an unmasked secret. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3 * fix(worker): hold the log-masking lifecycle at the job boundary Registering the job around the poller's call left every other way of running a job uncovered: the interactive worker shell and inline AI agent tools both call `handle_queued_job` directly, and a script logging `$WM_TOKEN` from either persisted the live credential. Register from inside `handle_queued_job` instead, under a drop guard, so each path is covered by construction rather than by remembering to add a call. Nothing is lost by unregistering earlier: the writes that follow go through `append_logs`, which never consulted the registry. In nativets, decide the stream/log routing before masking. `MaskSnapshot`'s notice is one-shot, so a secret-bearing `WM_STREAM:` chunk used to spend it on text that is then discarded, leaving later redactions in `job_logs` unexplained. Restore the masker's post-unregistration test: the memory-limit path never joins the producing loop, so that fallback is still load-bearing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3 * docs: correct the nativets masking comments The producer loop is not joined on the memory-limit path, so it does not "always" run while the job is registered — say normally, which is what `JobMasker`'s fallback is there for. Name the reason a stream chunk stays raw everywhere it goes, including the tracing mirror: it is result data that no log sink persists, so masking it would be masking a result. State the masker test's invariant without asserting a mechanism behind it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3 * fix(masking): keep the security notice on a line of its own `mask` appended the notice as a newline plus the notice text, which assumes the caller hands it a bare log line. nativets hands it a chunk that already ends in a newline, and its sink concatenates chunks verbatim, so the notice arrived after a blank line and the next log line was welded onto the end of it. Emit the notice as its own line for either shape. `handle_child` is unaffected: its input never ends in a newline, so it keeps the original path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3 --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/tests/job_token_log_masking.rs | 108 +++++++++ .../src/sensitive_log_masks.rs | 221 ++++++++++++++++-- backend/windmill-runtime-nativets/src/lib.rs | 23 +- backend/windmill-worker/src/worker.rs | 30 ++- 4 files changed, 350 insertions(+), 32 deletions(-) create mode 100644 backend/tests/job_token_log_masking.rs diff --git a/backend/tests/job_token_log_masking.rs b/backend/tests/job_token_log_masking.rs new file mode 100644 index 0000000000..5d7de27218 --- /dev/null +++ b/backend/tests/job_token_log_masking.rs @@ -0,0 +1,108 @@ +/* + * The job's own token (`$WM_TOKEN`) stays valid well past the job it was minted + * for, and job logs are persisted to `job_logs` and optionally to object storage, + * so a script that echoes the token would otherwise park a live credential in + * durable storage. `run_worker` registers the token with `sensitive_log_masks` + * for the job it pulled; this pins that the persisted log carries the masked form. + */ + +use sqlx::{Pool, Postgres}; +use windmill_common::{ + jobs::{JobPayload, RawCode}, + scripts::ScriptLang, +}; +use windmill_test_utils::*; + +/// Prefix of a serialized job token: `jwt_` plus the base64 of a JWT header. +/// The masked form keeps only `jwt` + the last three characters, so it never matches. +const RAW_TOKEN_PREFIX: &str = "jwt_ey"; + +#[sqlx::test(fixtures("base"))] +async fn test_job_token_masked_in_persisted_logs(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: "echo \"running with --token $WM_TOKEN\"".to_string(), + path: None, + lock: None, + language: ScriptLang::Bash, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + tag: None, + })) + .run_until_complete(&db, false, port) + .await; + assert!(job.success, "job should have succeeded"); + + let logs = + sqlx::query_scalar::<_, Option>("SELECT logs FROM job_logs WHERE job_id = $1") + .bind(job.id) + .fetch_one(&db) + .await? + .unwrap_or_default(); + + assert!( + !logs.contains(RAW_TOKEN_PREFIX), + "an unmasked job token reached the persisted logs: {logs}" + ); + assert!( + logs.contains("secret value was masked"), + "expected the masking notice in logs: {logs}" + ); + Ok(()) +} + +/// nativets runs V8 in-process and persists `console.log` output through its own +/// channel, so it is masked by a different mechanism than the bash case above and +/// needs its own guard. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_job_token_masked_in_nativets_logs(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: "export async function main() {\n console.log('running with --token ' + process.env.WM_TOKEN);\n return 'ok';\n}".to_string(), + path: None, + lock: None, + language: ScriptLang::Nativets, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + tag: None, + })) + .run_until_complete(&db, false, port) + .await; + assert!(job.success, "job should have succeeded"); + + let logs = + sqlx::query_scalar::<_, Option>("SELECT logs FROM job_logs WHERE job_id = $1") + .bind(job.id) + .fetch_one(&db) + .await? + .unwrap_or_default(); + + assert!( + !logs.contains(RAW_TOKEN_PREFIX), + "an unmasked job token reached the persisted logs: {logs}" + ); + assert!( + logs.contains("secret value was masked"), + "expected the masking notice in logs: {logs}" + ); + Ok(()) +} diff --git a/backend/windmill-common/src/sensitive_log_masks.rs b/backend/windmill-common/src/sensitive_log_masks.rs index b6f6262b77..c999297ec0 100644 --- a/backend/windmill-common/src/sensitive_log_masks.rs +++ b/backend/windmill-common/src/sensitive_log_masks.rs @@ -10,7 +10,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; -use std::sync::RwLock; +use std::sync::{Arc, RwLock}; use uuid::Uuid; /// Minimum length for a secret to be registered for masking. @@ -20,9 +20,27 @@ const MIN_SECRET_LENGTH: usize = 8; const MASKED_NOTICE: &str = "[windmill] secret value was masked for security reasons, use string transformations to display full value"; +/// The secrets registered for one job, plus the automaton compiled from them. +#[derive(Default)] +struct JobMasks { + secrets: HashSet, + /// Built on the first `snapshot` after a change and shared by every later + /// snapshot. Every job registers at least its own token, so without this + /// cache each log batch of each job would rebuild the automaton. + compiled: Option>, +} + +/// Aho-Corasick automaton for O(m) multi-pattern matching in a single pass, +/// regardless of the number of secrets registered, with the replacement +/// strings indexed to match the automaton's pattern order. +struct CompiledMasks { + ac: aho_corasick::AhoCorasick, + replacements: Vec, +} + lazy_static::lazy_static! { - /// Map of job_id -> set of secret values that should be masked in that job's logs. - static ref SENSITIVE_MASKS: RwLock>> = + /// Map of job_id -> secret values that should be masked in that job's logs. + static ref SENSITIVE_MASKS: RwLock> = RwLock::new(HashMap::new()); /// Set of currently running job IDs on this worker process. @@ -32,13 +50,8 @@ lazy_static::lazy_static! { } /// A lock-free snapshot of secrets for a job, taken once per log batch. -/// Uses Aho-Corasick for O(m) multi-pattern matching in a single pass, -/// regardless of the number of secrets registered. pub struct MaskSnapshot { - /// Aho-Corasick automaton for fast matching. - ac: aho_corasick::AhoCorasick, - /// Replacement strings, indexed to match the automaton's pattern order. - replacements: Vec, + compiled: Arc, /// Whether the security notice has already been appended for this snapshot. /// Tracked locally to avoid a global write lock on every masked line. notice_shown: std::cell::Cell, @@ -53,34 +66,104 @@ impl MaskSnapshot { } // Single-pass check + replace using the pre-built automaton - if !self.ac.is_match(text) { + if !self.compiled.ac.is_match(text) { return Cow::Borrowed(text); } - let mut result = self.ac.replace_all(text, &self.replacements); + let mut result = self + .compiled + .ac + .replace_all(text, &self.compiled.replacements); - // Append the notice only once per snapshot (i.e. per batch) + // Append the notice only once per snapshot (i.e. per batch), as its own line. + // Callers pass either a bare line (`handle_child`) or a chunk that already ends + // in a newline (nativets), and the sinks concatenate what they get verbatim: + // assuming either shape welds the notice onto a neighbouring line. if !self.notice_shown.get() { self.notice_shown.set(true); - result.push('\n'); - result.push_str(MASKED_NOTICE); + if result.ends_with('\n') { + result.push_str(MASKED_NOTICE); + result.push('\n'); + } else { + result.push('\n'); + result.push_str(MASKED_NOTICE); + } } Cow::Owned(result) } } +/// A masker for sinks that mask line by line rather than in batches, like nativets +/// masking each `console.log` chunk as V8 produces it. `snapshot` per line would +/// re-arm the security notice on every one; this keeps it to once per distinct set +/// of secrets while still picking up secrets registered mid-run. +/// +/// Masks by job id alone — the caller is the one that knows the text it passes +/// belongs to that job. +pub struct JobMasker { + job_id: Uuid, + snapshot: Option, +} + +impl JobMasker { + pub fn new(job_id: Uuid) -> Self { + JobMasker { job_id, snapshot: snapshot(&job_id) } + } + + /// Mask every secret registered for the job. Returns `Cow::Borrowed` when no match. + /// Falls back to the masks it last saw once the job is unregistered, so a sink + /// still draining past the end of a run does not start emitting secrets. + pub fn mask<'a>(&mut self, text: &'a str) -> Cow<'a, str> { + if let Some(fresh) = snapshot(&self.job_id) { + // Replacing an equivalent snapshot would re-arm the notice, so only take + // one built from a secret set we have not seen. + let unchanged = self + .snapshot + .as_ref() + .is_some_and(|cur| Arc::ptr_eq(&cur.compiled, &fresh.compiled)); + if !unchanged { + self.snapshot = Some(fresh); + } + } + match self.snapshot.as_ref() { + Some(snapshot) => snapshot.mask(text), + None => Cow::Borrowed(text), + } + } +} + /// Take a snapshot of the current secrets for a job. Returns `None` if no secrets /// are registered (the caller can then skip masking entirely for the whole batch). /// /// Call this once per log batch in `write_lines`, not per line. pub fn snapshot(job_id: &Uuid) -> Option { - let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner()); - let secrets = masks.get(job_id)?; - if secrets.is_empty() { - return None; + { + let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner()); + let job = masks.get(job_id)?; + if job.secrets.is_empty() { + return None; + } + if let Some(compiled) = job.compiled.as_ref() { + return Some(MaskSnapshot { + compiled: compiled.clone(), + notice_shown: std::cell::Cell::new(false), + }); + } } + let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); + let job = masks.get_mut(job_id)?; + if job.secrets.is_empty() { + return None; + } + let compiled = job + .compiled + .get_or_insert_with(|| Arc::new(compile(&job.secrets))); + Some(MaskSnapshot { compiled: compiled.clone(), notice_shown: std::cell::Cell::new(false) }) +} + +fn compile(secrets: &HashSet) -> CompiledMasks { // Sort longest-first so longer secrets are matched before shorter substrings let mut sorted: Vec<&String> = secrets.iter().collect(); sorted.sort_by(|a, b| b.len().cmp(&a.len())); @@ -106,7 +189,7 @@ pub fn snapshot(job_id: &Uuid) -> Option { .build(sorted.iter().map(|s| s.as_str())) .expect("failed to build aho-corasick automaton"); - Some(MaskSnapshot { ac, replacements, notice_shown: std::cell::Cell::new(false) }) + CompiledMasks { ac, replacements } } /// Register a job as currently running. Call this before `handle_queued_job`. @@ -148,20 +231,110 @@ pub fn register_secret_for_all_running_jobs(secret: &str) { let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); for job_id in job_ids { - if let Some(set) = masks.get_mut(&job_id) { - set.insert(secret.to_string()); + if let Some(job) = masks.get_mut(&job_id) { + if job.secrets.insert(secret.to_string()) { + job.compiled = None; + } } } } /// Register a secret value for a specific job. -/// Used for `$encrypted:` args where we know the job ID. +/// Used for the job's own token and for `$encrypted:` args, where we know the job ID. pub fn register_secret_for_job(job_id: Uuid, secret: &str) { if secret.len() < MIN_SECRET_LENGTH { return; } let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); - if let Some(set) = masks.get_mut(&job_id) { - set.insert(secret.to_string()); + if let Some(job) = masks.get_mut(&job_id) { + if job.secrets.insert(secret.to_string()) { + job.compiled = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The compiled automaton is cached per job, so a secret registered after the + /// first snapshot only gets masked if the cache is invalidated. + #[test] + fn snapshot_rebuilds_after_a_new_secret_is_registered() { + let job_id = Uuid::new_v4(); + register_running_job(job_id); + register_secret_for_job(job_id, "firstsecretvalue"); + let _ = snapshot(&job_id) + .expect("secret registered") + .mask("firstsecretvalue"); + + register_secret_for_job(job_id, "secondsecretvalue"); + + let snap = snapshot(&job_id).expect("secrets registered"); + let masked = snap.mask("firstsecretvalue then secondsecretvalue"); + assert!(!masked.contains("firstsecretvalue"), "{masked}"); + assert!(!masked.contains("secondsecretvalue"), "{masked}"); + unregister_running_job(job_id); + } + + /// A line-by-line sink must not repeat the notice on every line, and must still + /// pick up a secret registered after the masker was built. + #[test] + fn job_masker_notices_once_per_secret_set() { + let job_id = Uuid::new_v4(); + register_running_job(job_id); + register_secret_for_job(job_id, "firstsecretvalue"); + let mut masker = JobMasker::new(job_id); + + let first = masker.mask("saw firstsecretvalue").into_owned(); + assert!(!first.contains("firstsecretvalue"), "{first}"); + assert!(first.contains(MASKED_NOTICE), "{first}"); + + let second = masker.mask("saw firstsecretvalue again").into_owned(); + assert!(!second.contains("firstsecretvalue"), "{second}"); + assert!(!second.contains(MASKED_NOTICE), "{second}"); + + register_secret_for_job(job_id, "secondsecretvalue"); + let third = masker.mask("saw secondsecretvalue").into_owned(); + assert!(!third.contains("secondsecretvalue"), "{third}"); + unregister_running_job(job_id); + } + + /// Unregistration must not turn masking off under a sink that is still emitting: + /// the masker keeps working off the masks it last saw rather than going quiet. + #[test] + fn job_masker_masks_after_the_job_is_unregistered() { + let job_id = Uuid::new_v4(); + register_running_job(job_id); + register_secret_for_job(job_id, "supersecretvalue"); + let mut masker = JobMasker::new(job_id); + + unregister_running_job(job_id); + + let masked = masker.mask("logged supersecretvalue here"); + assert!(!masked.contains("supersecretvalue"), "{masked}"); + } + + /// The notice has to end up on a line of its own for both shapes callers pass: + /// a bare line (`handle_child`) and a newline-terminated chunk (nativets). The + /// sinks concatenate what they are given verbatim, so getting this wrong welds + /// the notice onto whichever line follows it. + #[test] + fn notice_lands_on_its_own_line_for_both_caller_shapes() { + let job_id = Uuid::new_v4(); + register_running_job(job_id); + register_secret_for_job(job_id, "supersecretvalue"); + + let line = snapshot(&job_id) + .expect("secret registered") + .mask("tok supersecretvalue"); + assert_eq!(line, format!("tok s*****e\n{MASKED_NOTICE}")); + + let chunk = snapshot(&job_id) + .expect("secret registered") + .mask("tok supersecretvalue\n"); + assert_eq!(chunk, format!("tok s*****e\n{MASKED_NOTICE}\n")); + + unregister_running_job(job_id); } } diff --git a/backend/windmill-runtime-nativets/src/lib.rs b/backend/windmill-runtime-nativets/src/lib.rs index d44213d3bc..750bf7ebac 100644 --- a/backend/windmill-runtime-nativets/src/lib.rs +++ b/backend/windmill-runtime-nativets/src/lib.rs @@ -838,6 +838,11 @@ pub async fn eval_fetch_timeout( } } let w_id_for_tracing = w_id_for_tracing; + // nativets delivers logs in-process, so they never reach the masking in + // `handle_child::write_lines` and a `console.log` of `$WM_TOKEN` would be + // persisted verbatim. Mask here rather than in the detached task draining into + // `append_logs`: this loop normally runs while the job is still registered. + let mut masker = windmill_common::sensitive_log_masks::JobMasker::new(job_id); let handle = tokio::spawn(async move { let mut result_stream = String::new(); let mut is_stream = false; @@ -845,10 +850,20 @@ pub async fn eval_fetch_timeout( use windmill_common::result_stream::extract_stream_from_logs; use windmill_common::tracing_init::{OTEL_JOB_LOGS, OTEL_PREFIX}; + let stream = extract_stream_from_logs(&log.trim_end_matches("\n")); + + // A stream chunk is result data, not a log line — it never reaches + // `job_logs`, and `merge_result_stream` can make it the job's result — + // so it stays raw wherever it goes, here and in the mirror below. + // Deliberately unlike `handle_child`, which streams the masked text. + // Routed before masking because the notice is one-shot: spent on a chunk + // no sink persists, a later redaction in `job_logs` would go unexplained. + let logged = stream.is_none().then(|| masker.mask(&log).into_owned()); + // Mirror `process_streaming_log_lines` (EE) + the OTEL_JOB_LOGS // hook from handle_child.rs, neither of which runs for nativets // since nativets delivers logs in-process via the log channel. - for line in log.lines() { + for line in logged.as_deref().unwrap_or(&log).lines() { tracing::info!( target: "windmill:job_log", job_id = ?job_id, @@ -862,7 +877,7 @@ pub async fn eval_fetch_timeout( } } - if let Some(stream) = extract_stream_from_logs(&log.trim_end_matches("\n")) { + if let Some(stream) = stream { if !is_stream { is_stream = true; if let Some(ref f) = stream_notifier_update { @@ -874,8 +889,8 @@ pub async fn eval_fetch_timeout( if let Err(e) = result_stream_sender.send(stream) { tracing::error!("failed to send result stream: {e}"); } - } else { - if let Err(e) = append_logs_sender.send(log) { + } else if let Some(logged) = logged { + if let Err(e) = append_logs_sender.send(logged) { tracing::error!("failed to send log: {e}"); } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 56b4b26fb0..55a698a9dd 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -3859,8 +3859,6 @@ pub async fn run_worker( let arc_job = Arc::new(job); - windmill_common::sensitive_log_masks::register_running_job(arc_job.id); - let span = create_span_with_name(&arc_job, &worker_name, Some(hostname), "job"); let log_ctx = log_context_for_job(&arc_job, &worker_name, Some(hostname)); @@ -3976,8 +3974,6 @@ pub async fn run_worker( _ => {} } - windmill_common::sensitive_log_masks::unregister_running_job(job_id); - #[cfg(feature = "prometheus")] if let Some(duration) = _timer.map(|x| x.stop_and_record()) { register_metric( @@ -4512,6 +4508,30 @@ async fn detect_and_store_runtime_assets_from_job_args( } } +/// Holds a job's entry in the log-masking registry for as long as it executes, so +/// that secrets it fetches can be registered against it, and masks the job's own +/// token from the start: `$WM_TOKEN` stays valid well past the run, and a script +/// that echoes it would otherwise leave a live credential in the persisted logs. +/// +/// Lives here rather than at the call sites so that every way of running a job — +/// the poller, the interactive worker shell, an inline AI agent tool — is covered +/// by construction. +struct RunningJobMasks(Uuid); + +impl RunningJobMasks { + fn register(job_id: Uuid, token: &str) -> Self { + windmill_common::sensitive_log_masks::register_running_job(job_id); + windmill_common::sensitive_log_masks::register_secret_for_job(job_id, token); + RunningJobMasks(job_id) + } +} + +impl Drop for RunningJobMasks { + fn drop(&mut self) { + windmill_common::sensitive_log_masks::unregister_running_job(self.0); + } +} + pub async fn handle_queued_job( job: Arc, raw_code: Option, @@ -4533,6 +4553,8 @@ pub async fn handle_queued_job( flow_runners: Option>, #[cfg(feature = "benchmark")] _bench: &mut BenchmarkIter, ) -> windmill_common::error::Result { + let _masks = RunningJobMasks::register(job.id, &client.token); + if job.canceled_by.is_some() { return Err(Error::JsonErr(canceled_job_to_result(&job))); } From 9d335de87a4dbaa51038d55afe8d980761dcdfaf Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 18 Sep 2026 15:15:35 +0200 Subject: [PATCH 021/112] feat: add a workspace toggle that adds its admins and developers to new forks (#11215) * feat: add a workspace toggle that adds its admins and developers to new forks Co-Authored-By: Claude Opus 5 * fix: add members copied into a fork as manual members, not instance-group ones Co-Authored-By: Claude Opus 5 * style: keep the fork members copy comment at the query and drop the raw spacer Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...83dce0c9986f3847227c2f66daa84d4109d7d.json | 15 ---- ...ce73279957d6a39fdc6b1ecb6e0a1e02f921f.json | 16 ++++ ...56ee979e4c392970278304e80368b770bb7b4.json | 22 ++++++ ...cc1011a49fc8d8ad046ac572e81cf8938995.json} | 4 +- ...d21d12fa1207b58bfc46249c250cdcdb5363.json} | 12 ++- ...78f7e6dfa9185056f7731547ff05b8176b271.json | 15 ++++ ...bf1ebed0192be267983e8fbd18f79997e6142.json | 15 ---- ...1b93db0406aaf278bccba07be00e9f937e2a.json} | 14 +++- ...52fb2b4cf7983049d2c490940df360c7e2b30.json | 15 ++++ ...dd_admins_and_developers_to_forks.down.sql | 1 + ..._add_admins_and_developers_to_forks.up.sql | 1 + backend/summarized_schema.txt | 2 +- .../tests/fork_members.rs | 75 ++++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 77 ++++++++++++++++++- .../src/workspaces_extra.rs | 2 +- backend/windmill-api/openapi.yaml | 40 ++++++++++ .../settings/WorkspaceUserSettings.svelte | 36 +++++++++ .../CreateWorkspaceInner.svelte | 19 +++++ 18 files changed, 336 insertions(+), 45 deletions(-) delete mode 100644 backend/.sqlx/query-1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d.json create mode 100644 backend/.sqlx/query-2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f.json create mode 100644 backend/.sqlx/query-2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4.json rename backend/.sqlx/{query-a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a.json => query-5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995.json} (63%) rename backend/.sqlx/{query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json => query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json} (93%) create mode 100644 backend/.sqlx/query-9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271.json delete mode 100644 backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json rename backend/.sqlx/{query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json => query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json} (78%) create mode 100644 backend/.sqlx/query-eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30.json create mode 100644 backend/migrations/20260918092041_add_admins_and_developers_to_forks.down.sql create mode 100644 backend/migrations/20260918092041_add_admins_and_developers_to_forks.up.sql create mode 100644 backend/windmill-api-integration-tests/tests/fork_members.rs diff --git a/backend/.sqlx/query-1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d.json b/backend/.sqlx/query-1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d.json deleted file mode 100644 index b9cbc6c382..0000000000 --- a/backend/.sqlx/query-1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d" -} diff --git a/backend/.sqlx/query-2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f.json b/backend/.sqlx/query-2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f.json new file mode 100644 index 0000000000..4b96519a06 --- /dev/null +++ b/backend/.sqlx/query-2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f" +} diff --git a/backend/.sqlx/query-2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4.json b/backend/.sqlx/query-2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4.json new file mode 100644 index 0000000000..99b1a9472d --- /dev/null +++ b/backend/.sqlx/query-2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "add_admins_and_developers_to_forks", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4" +} diff --git a/backend/.sqlx/query-a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a.json b/backend/.sqlx/query-5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995.json similarity index 63% rename from backend/.sqlx/query-a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a.json rename to backend/.sqlx/query-5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995.json index 0532a5d3a0..7cd917d623 100644 --- a/backend/.sqlx/query-a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a.json +++ b/backend/.sqlx/query-5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ", + "query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations,\n add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a" + "hash": "5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995" } diff --git a/backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json b/backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json similarity index 93% rename from backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json rename to backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json index 01c0fd19af..7a822a8d6a 100644 --- a/backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json +++ b/backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", "describe": { "columns": [ { @@ -177,6 +177,11 @@ "ordinal": 34, "name": "guest_jwt_jwks_url", "type_info": "Text" + }, + { + "ordinal": 35, + "name": "add_admins_and_developers_to_forks", + "type_info": "Bool" } ], "parameters": { @@ -219,8 +224,9 @@ false, false, true, - true + true, + false ] }, - "hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99" + "hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363" } diff --git a/backend/.sqlx/query-9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271.json b/backend/.sqlx/query-9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271.json new file mode 100644 index 0000000000..9c4d587c01 --- /dev/null +++ b/backend/.sqlx/query-9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271" +} diff --git a/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json b/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json deleted file mode 100644 index 94cf77ebc5..0000000000 --- a/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142" -} diff --git a/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json b/backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json similarity index 78% rename from backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json rename to backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json index 1c247ad5b5..7462224850 100644 --- a/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json +++ b/backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", "describe": { "columns": [ { @@ -45,16 +45,21 @@ }, { "ordinal": 8, + "name": "add_admins_and_developers_to_forks", + "type_info": "Bool" + }, + { + "ordinal": 9, "name": "deploy_ui", "type_info": "Jsonb" }, { - "ordinal": 9, + "ordinal": 10, "name": "large_file_storage", "type_info": "Jsonb" }, { - "ordinal": 10, + "ordinal": 11, "name": "datatable", "type_info": "Jsonb" } @@ -73,10 +78,11 @@ true, true, false, + false, true, true, true ] }, - "hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447" + "hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a" } diff --git a/backend/.sqlx/query-eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30.json b/backend/.sqlx/query-eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30.json new file mode 100644 index 0000000000..ec641bf32d --- /dev/null +++ b/backend/.sqlx/query-eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30" +} diff --git a/backend/migrations/20260918092041_add_admins_and_developers_to_forks.down.sql b/backend/migrations/20260918092041_add_admins_and_developers_to_forks.down.sql new file mode 100644 index 0000000000..4e8ac47c69 --- /dev/null +++ b/backend/migrations/20260918092041_add_admins_and_developers_to_forks.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings DROP COLUMN add_admins_and_developers_to_forks; diff --git a/backend/migrations/20260918092041_add_admins_and_developers_to_forks.up.sql b/backend/migrations/20260918092041_add_admins_and_developers_to_forks.up.sql new file mode 100644 index 0000000000..bb3502d362 --- /dev/null +++ b/backend/migrations/20260918092041_add_admins_and_developers_to_forks.up.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings ADD COLUMN add_admins_and_developers_to_forks BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index e27cb2893c..47f9faca68 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -234,7 +234,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr FK: (workspace_id) -> workspace(id) workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool) FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id) -workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int) +workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool) FK: (workspace_id) -> workspace(id) zombie_job_counter: job_id(uuid), counter(int) FK: (job_id) -> v2_job(id) diff --git a/backend/windmill-api-integration-tests/tests/fork_members.rs b/backend/windmill-api-integration-tests/tests/fork_members.rs new file mode 100644 index 0000000000..5f4baced1f --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fork_members.rs @@ -0,0 +1,75 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +/// With `add_admins_and_developers_to_forks` on, a fork starts with the parent's admins and +/// developers at their parent role, even when a developer forks it; operators are left out. The +/// copies are manual members: a parent membership that came from an instance group must not carry +/// that provenance into a fork that does not configure the group. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_adds_parent_admins_and_developers(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!( + "http://localhost:{}/api/w/test-workspace/workspaces", + server.addr.port() + ); + let client = reqwest::Client::new(); + + sqlx::query( + "UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'", + ) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO usr (workspace_id, email, username, is_admin, added_via) + VALUES ('test-workspace', 'test4@windmill.dev', 'test-user-4', false, + '{\"source\": \"instance_group\", \"group\": \"devs\"}')", + ) + .execute(&db) + .await?; + + let resp = client + .post(format!( + "{base_url}/edit_add_admins_and_developers_to_forks" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "add_admins_and_developers_to_forks": true })) + .send() + .await?; + assert!( + resp.status().is_success(), + "enabling the setting: {}", + resp.text().await? + ); + + let resp = client + .post(format!("{base_url}/create_fork")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ "id": "wm-fork-team", "name": "Team fork" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "creating the fork: {}", + resp.text().await? + ); + + let members: Vec<(String, bool, bool)> = sqlx::query_as( + "SELECT username, is_admin, added_via IS NULL FROM usr + WHERE workspace_id = 'wm-fork-team' ORDER BY username", + ) + .fetch_all(&db) + .await?; + assert_eq!( + members, + vec![ + ("test-user".to_string(), true, true), + ("test-user-2".to_string(), false, true), + ("test-user-4".to_string(), false, true), + ] + ); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index aafba577b6..eb709c49c2 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -155,6 +155,10 @@ pub fn workspaced_service() -> Router { .route("/edit_deploy_ui_config", post(edit_deploy_ui_config)) .route("/edit_default_app", post(edit_default_app)) .route("/edit_guest_access", post(edit_guest_access)) + .route( + "/edit_add_admins_and_developers_to_forks", + post(edit_add_admins_and_developers_to_forks), + ) .route("/edit_guest_jwt_key", post(edit_guest_jwt_key)) .route("/guest_usage", get(get_guest_usage)) .route("/default_app", get(get_default_app)) @@ -338,6 +342,7 @@ pub struct WorkspaceSettings { pub guest_jwt_public_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub guest_jwt_jwks_url: Option, + pub add_admins_and_developers_to_forks: bool, } /// Subset of `WorkspaceSettings` that is safe to return to any workspace @@ -363,6 +368,8 @@ pub struct WorkspacePublicSettings { /// Not sensitive, and the app editor needs it to say whether the guest rung is /// live -- an app can be set to `guest` while the workspace has guests off. pub guest_access_enabled: bool, + /// Read by the fork dialog, which tells the forker who else the fork will include. + pub add_admins_and_developers_to_forks: bool, #[serde(skip_serializing_if = "Option::is_none")] pub deploy_ui: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1126,7 +1133,8 @@ async fn get_settings( error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, - guest_jwt_jwks_url + guest_jwt_jwks_url, + add_admins_and_developers_to_forks FROM workspace_settings WHERE @@ -1168,6 +1176,7 @@ async fn get_public_settings( teams_team_guid, mute_critical_alerts, guest_access_enabled, + add_admins_and_developers_to_forks, deploy_ui, large_file_storage, datatable @@ -5052,6 +5061,47 @@ async fn edit_guest_access( )) } +#[derive(Deserialize)] +struct EditAddAdminsAndDevelopersToForks { + add_admins_and_developers_to_forks: bool, +} + +async fn edit_add_admins_and_developers_to_forks( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(EditAddAdminsAndDevelopersToForks { add_admins_and_developers_to_forks }): Json< + EditAddAdminsAndDevelopersToForks, + >, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2", + add_admins_and_developers_to_forks, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.edit_add_admins_and_developers_to_forks", + ActionKind::Update, + &w_id, + Some(&add_admins_and_developers_to_forks.to_string()), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!( + "Adding admins and developers to new forks set to {add_admins_and_developers_to_forks} for workspace {w_id}" + )) +} + #[derive(Deserialize)] struct EditGuestJwtKey { /// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the @@ -6712,7 +6762,8 @@ async fn update_workspace_settings( ducklake = source_ws.ducklake, dbt_warehouses = source_ws.dbt_warehouses, datatable = source_ws.datatable, - git_app_installations = source_ws.git_app_installations + git_app_installations = source_ws.git_app_installations, + add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks FROM workspace_settings source_ws WHERE source_ws.workspace_id = $1 AND workspace_settings.workspace_id = $2 @@ -6853,14 +6904,21 @@ async fn copy_workspace_members( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + admins_and_developers_only: bool, ) -> Result<()> { + // Admins and developers join as manual members: the fork does not inherit the source's + // instance-group config, so a copied `instance_group` provenance would let the fork's + // reconciliation delete them and their data. sqlx::query!( "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via) - SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via + SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, + CASE WHEN $3 THEN NULL ELSE added_via END FROM usr WHERE workspace_id = $2 + AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account)) ON CONFLICT DO NOTHING", target_workspace_id, source_workspace_id, + admins_and_developers_only, ) .execute(&mut **tx) .await?; @@ -8651,8 +8709,19 @@ async fn create_workspace_fork( // intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before // the explicit creator insert below so the creator (a parent member) is copied with full metadata // (operator/role/is_service_account/added_via), not the bare row the insert alone would leave. + // Independently, the parent's admins can have every fork of it start with its admins and + // developers; the forker cannot opt out, since the point is that those admins can review it. if nw.copy_members && nw.is_dev_workspace { - copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?; + copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, false).await?; + } else if sqlx::query_scalar!( + "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1", + parent_workspace_id + ) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(false) + { + copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, true).await?; } // Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 1cb5188e5b..45f82227f5 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id( // Duplicate workspace settings (keep copy in old workspace for reference) info!("Duplicating workspace_settings table"); sqlx::query!( - "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2", + "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2", &rw.new_id, &old_id ) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index cba085a7e3..23390e0c67 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4090,9 +4090,13 @@ paths: guest_access_enabled: type: boolean description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false. + add_admins_and_developers_to_forks: + type: boolean + description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role. required: - workspace_id - guest_access_enabled + - add_admins_and_developers_to_forks /w/{workspace}/workspaces/get_settings: get: @@ -4183,6 +4187,9 @@ paths: guest_jwt_jwks_url: type: string description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`. + add_admins_and_developers_to_forks: + type: boolean + description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role. /w/{workspace}/workspaces/get_deploy_to: get: @@ -6313,6 +6320,39 @@ paths: schema: type: string + /w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks: + post: + summary: choose whether new forks of this workspace start with its admins and developers + description: >- + When on, every fork created from this workspace gets the workspace's admins and + developers as members, with the role they hold here; operators, disabled users and + service accounts are left out. The setting is copied into each fork, so forks of a + fork follow it too. Off by default. Workspace-admin gated. + operationId: editAddAdminsAndDevelopersToForks + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: Whether new forks start with this workspace's admins and developers + required: true + content: + application/json: + schema: + type: object + properties: + add_admins_and_developers_to_forks: + type: boolean + required: + - add_admins_and_developers_to_forks + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/edit_guest_jwt_key: post: summary: set the key guest JWTs are verified against for this workspace diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 5c8af52683..449e9c7d31 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -46,6 +46,7 @@ let auto_invite_domain: string | undefined = $state() let operatorOnly: boolean | undefined = $state(undefined) let autoAdd: boolean | undefined = $state(false) + let addAdminsAndDevelopersToForks = $state(false) let nbDisplayed = $state(30) // Instance group auto-add settings @@ -122,6 +123,25 @@ autoAdd = autoInvite?.mode === 'add' autoAddInstanceGroups = autoInvite?.instance_groups || [] autoAddInstanceGroupsRoles = autoInvite?.instance_groups_roles || {} + addAdminsAndDevelopersToForks = settings.add_admins_and_developers_to_forks ?? false + } + + async function updateAddAdminsAndDevelopersToForks(enabled: boolean): Promise { + try { + await WorkspaceService.editAddAdminsAndDevelopersToForks({ + workspace: $workspaceStore!, + requestBody: { add_admins_and_developers_to_forks: enabled } + }) + sendUserToast( + enabled + ? 'New forks will start with the admins and developers of this workspace' + : 'New forks will start with their creator only' + ) + } catch (e) { + console.error('Failed to update the fork members setting:', e) + addAdminsAndDevelopersToForks = !enabled + sendUserToast(`Failed to update the fork members setting: ${e}`, true) + } } let getUsagePromise: CancelablePromise | undefined = undefined @@ -1067,6 +1087,22 @@
+
+ updateAddAdminsAndDevelopersToForks(e.detail)} + options={{ + right: 'Add admins and developers to new forks', + rightTooltip: + 'Admins and developers of this workspace join every new fork with the role they have here, so they can follow and review the work done in it. Forks of those forks follow the same setting.' + }} + /> +
+ {#if invites?.length > 0}
(isFork ? baseWorkspaceId : undefined), + async (ws, _prev, { signal }) => { + if (!ws) return undefined + const settings = await WorkspaceService.getPublicSettings({ workspace: ws }) + if (signal.aborted) throw new DOMException('superseded', 'AbortError') + return { ws, adds: settings.add_admins_and_developers_to_forks } + } + ) + let baseAddsAdminsAndDevelopers = $derived( + baseForkMembersResource.current?.ws === baseWorkspaceId && + !!baseForkMembersResource.current?.adds + ) // Ask the server whether a dev already exists: the caller may not be a member of this prod's dev, // so the client workspace list can't see it and would offer an invalid "create dev" action. const devWorkspaceResource = resource( @@ -929,6 +942,12 @@ disabled={createAsDevWorkspace} /> + {#if baseAddsAdminsAndDevelopers && !(createAsDevWorkspace && copyMembers)} + + {baseWorkspaceId} adds its admins and developers to every new fork, with the role they have + there. + + {/if} {/if} {#if isFork} Date: Fri, 18 Sep 2026 15:21:07 +0200 Subject: [PATCH 022/112] fix: re-encrypt git sync secrets on workspace key rotation (#11218) * fix: re-encrypt git sync credentials and webhook secrets on workspace key rotation Co-Authored-By: Claude Opus 5 * chore: point ee-repo-ref at the git sync key rotation companion Co-Authored-By: Claude Opus 5 * chore: update ee-repo-ref to bc3ef08c8e4233508c023e6ee847a3cd0b8be43b This commit updates the EE repository reference after PR #814 was merged in windmill-ee-private. Previous ee-repo-ref: 8121eac421c5d026f36e2edec140f3c79b23d2cd New ee-repo-ref: bc3ef08c8e4233508c023e6ee847a3cd0b8be43b Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../workspace_encryption_key_git_sync.rs | 61 +++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 86 ++++++++++++++++--- 3 files changed, 135 insertions(+), 14 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3d9d49412a..3dfd92f8c2 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f2fced19fcae81de7f6dac545010ce404c052e1b +bc3ef08c8e4233508c023e6ee847a3cd0b8be43b diff --git a/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs index 8edfd39d61..a22ad974c3 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs @@ -275,6 +275,67 @@ async fn test_encryption_key_rotation_dispatches_batched_git_sync( Ok(()) } +/// Stored repository tokens and webhook secrets are encrypted under the +/// workspace key but never synced, so a rotation has to carry them over even +/// when the caller skips re-encrypting variables. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_encryption_key_rotation_reencrypts_git_sync_secrets( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::variables::{build_crypt, crypt_from_key_with_suffix, decrypt, encrypt}; + initialize_tracing().await; + + create_folder(&db, "28103").await?; + create_git_repo_resource(&db).await?; + let sync_script_path = "f/28103/test_sync_script_git_secrets"; + create_sync_script(&db, sync_script_path).await?; + setup_git_sync_config(&db, sync_script_path).await?; + + let mc = build_crypt(&db, "test-workspace").await?; + sqlx::query( + r#" + UPDATE workspace_settings SET + git_credentials = jsonb_build_array(jsonb_build_object( + 'token', $1::text, 'repo_identity', 'https://gitlab.example.com/grp/proj')), + git_sync = jsonb_set(git_sync, '{repositories,0,auto_pull}', jsonb_build_object( + 'enabled', true, 'mode', 'webhook', 'webhook_id', 1, 'webhook_secret', $2::text)) + WHERE workspace_id = 'test-workspace' + "#, + ) + .bind(encrypt(&mc, "stored-token")) + .bind(encrypt(&mc, "hook-secret")) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + let new_key = "c".repeat(64); + let resp = authed(client().post(format!("{base}/encryption_key"))) + .json(&json!({"new_key": new_key, "skip_reencrypt": true})) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "set_encryption_key failed: {}", + resp.text().await? + ); + + let (token, secret): (String, String) = sqlx::query_as( + "SELECT git_credentials->0->>'token', git_sync#>>'{repositories,0,auto_pull,webhook_secret}' + FROM workspace_settings WHERE workspace_id = 'test-workspace'", + ) + .fetch_one(&db) + .await?; + let new_mc = crypt_from_key_with_suffix(&new_key, ""); + assert_eq!(decrypt(&new_mc, token)?, "stored-token"); + assert_eq!(decrypt(&new_mc, secret)?, "hook-secret"); + + Ok(()) +} + /// Regression test for the non-debouncing fallback: a workspace whose sync /// script predates hub version 28103 must still receive git-sync jobs for the /// encryption_key entry and every re-encrypted secret. Before the fallback was diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index eb709c49c2..879babff4f 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -38,7 +38,7 @@ use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; use windmill_common::query_builders::{render_db_quoted_identifier, DbType}; use windmill_common::users::username_to_permissioned_as; use windmill_common::variables::{ - build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, + crypt_from_key_with_suffix, decrypt, encrypt, WORKSPACE_CRYPT_CACHE, }; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; use windmill_common::workspaces::GitRepositorySettings; @@ -5690,9 +5690,6 @@ async fn set_encryption_key( )); } - // Build the previous cipher before the transaction (reads from cache/pool) - let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?; - let mut tx = db.begin().await?; // Under the row's lock, so two rotations racing serialize and each sees the key the @@ -5726,17 +5723,14 @@ async fn set_encryption_key( None }; + // From the keys read and written under the lock, never from `build_crypt`: its + // cache can still hold a key an earlier rotation replaced, and the git-sync + // secrets below are skipped rather than failed when they do not decrypt. + let previous_encryption_key = crypt_from_key_with_suffix(&previous_key, ""); + let new_encryption_key = crypt_from_key_with_suffix(&request.new_key, ""); + let mut reencrypted_secret_paths: Vec = Vec::new(); if !request.skip_reencrypt.unwrap_or(false) { - // Build the new cipher directly from the key string, since the transaction - // hasn't committed yet and build_crypt() would read the old key from the pool. - let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() { - format!("{}{}", request.new_key, salt) - } else { - request.new_key.clone() - }; - let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256); - let mut truncated_new_key = request.new_key.clone(); truncated_new_key.truncate(8); tracing::warn!( @@ -5776,6 +5770,14 @@ async fn set_encryption_key( } } + reencrypt_git_sync_secrets( + &mut tx, + &w_id, + &previous_encryption_key, + &new_encryption_key, + ) + .await?; + tx.commit().await?; // Invalidate the cache only after the transaction has committed @@ -5813,6 +5815,64 @@ async fn set_encryption_key( return Ok(()); } +/// Move the git-sync secrets the server keeps under the workspace key (stored +/// repository tokens, webhook secrets) to the new key. They are never synced, so +/// unlike variables they are still under the old key when the caller skips +/// re-encryption. +async fn reencrypt_git_sync_secrets( + conn: &mut sqlx::PgConnection, + w_id: &str, + old: &magic_crypt::MagicCrypt256, + new: &magic_crypt::MagicCrypt256, +) -> Result<()> { + let Some((mut credentials, mut git_sync)) = + sqlx::query_as::<_, (serde_json::Value, Option)>( + "SELECT git_credentials, git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", + ) + .bind(w_id) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(()); + }; + let reencrypt = |value: &mut serde_json::Value| { + let Some(ciphertext) = value.as_str() else { + return; + }; + match decrypt(old, ciphertext.to_string()) { + Ok(plain) => *value = serde_json::Value::String(encrypt(new, &plain)), + // Left by an earlier rotation and unrecoverable either way; failing here + // would block every later rotation of the workspace. + Err(e) => tracing::warn!( + "a git-sync secret of workspace {w_id} does not decrypt under its current key, leaving it as is: {e}" + ), + } + }; + for entry in credentials.as_array_mut().into_iter().flatten() { + if let Some(token) = entry.get_mut("token") { + reencrypt(token); + } + } + let repositories = git_sync + .as_mut() + .and_then(|g| g.get_mut("repositories")) + .and_then(|r| r.as_array_mut()); + for repo in repositories.into_iter().flatten() { + if let Some(secret) = repo.pointer_mut("/auto_pull/webhook_secret") { + reencrypt(secret); + } + } + sqlx::query( + "UPDATE workspace_settings SET git_credentials = $2, git_sync = $3 WHERE workspace_id = $1", + ) + .bind(w_id) + .bind(credentials) + .bind(git_sync) + .execute(&mut *conn) + .await?; + Ok(()) +} + #[derive(Serialize)] struct UsedTriggers { pub websocket_used: bool, From 37e493ae66ed5c000ecac492d60fc0fdf4bda71f Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 18 Sep 2026 15:24:00 +0200 Subject: [PATCH 023/112] feat: add an instance setting to refuse a token in MCP URLs (#11162) * feat: add an instance setting to refuse a token in MCP URLs MCP clients are commonly configured with the token in the URL (`/api/mcp/w/{workspace}/mcp?token=...`). A URL-borne credential ends up in browser history, proxy logs and referrers, so an instance can now turn that channel off with the `mcp_disable_token_query_param` global setting and leave the Authorization header as the only way in, which sends MCP clients through the OAuth flow the endpoints already advertise. The rejection is a middleware on both the workspaced and the gateway MCP mounts, layered outside everything that reads a token and inside the WWW-Authenticate layer, so the 401 carries the resource pointer a client needs to start OAuth discovery. Off by default. With it on, the token drawer and the home connect drawer stop offering to mint a token for an MCP URL and hand over the bare URL instead. Co-Authored-By: Claude Opus 5 * fix: read the MCP URL policy when a URL is asked for, and drop the all-workspaces option Two review findings on the token drawer: The policy was read once per page load and cached for the browser session, so a superadmin turning the setting on left every open tab handing out `?token=` URLs the server now refuses. Both entry points now read it when the user actually asks for an MCP URL: when MCP mode is entered, and when the connect drawer opens. The workspace picker offered "All workspaces / Multi-workspace", but the gateway's consent screen binds the token it issues to the one workspace picked there, so OAuth has no multi-workspace grant to hand out. That entry is now token-only. Co-Authored-By: Claude Opus 5 * fix: don't guess the MCP URL policy, and say where the switch lands on restart Review findings: The comment on the settings load claimed `MODE=mcp` as the target deployment, but that mode joins no monitor loop, so the startup pass is its only read and a change lands on restart. That is true of every global setting there, `base_url` included; the comment now says so, and the setting description tells an operator running dedicated MCP servers what to expect. A failed settings probe resolved to "tokens allowed", so with the switch on the drawer would mint a non-expiring token and hand over a URL the server refuses for as long as it exists. The probe now propagates its error and the panel reports it with a retry, creating nothing until the answer is known. The test passed a valid token, so it could not tell a rejection before authentication from one after it. It now also sends a token that was never valid and asserts the middleware's own message, which fails if the layer moves inward. Co-Authored-By: Claude Opus 5 * fix: withhold the MCP URL until a workspace is picked With no persisted workspace the store starts undefined, so opening the drawer from /user/workspaces before choosing one rendered a copyable `/api/mcp/w/undefined/mcp`. It reads like a real URL and a client pointed at it would never connect. The panel now asks for a workspace instead, matching the guard the token branch already has on its generate button. Co-Authored-By: Claude Opus 5 * docs: drop the coverage-status note from the MCP switch test It documented what the test does not reach rather than a constraint the next reader could break; that belongs in the PR, not the module doc. The layer-order rationale, which is what a future edit would break, stays. Co-Authored-By: Claude Opus 5 * refactor: fall back to the bare MCP URL instead of alerting on a failed read When the setting read fails, show the bare URL rather than an error with a retry. It works whichever way the setting is, so no alert is needed, and it still never mints a token for a URL the server may refuse. The connect drawer's wording falls back the same way so the blurb matches the panel. Co-Authored-By: Claude Opus 5 * refactor: move the MCP URL token setting to Core It sat in the Auth/OAuth/SAML list, which the settings sidebar shows under SSO, suggesting a dependency on SSO that does not exist: MCP OAuth has Windmill act as the authorization server, and any login method, password included, completes it. It is an instance-wide credential policy, so it now lives with the other ones in Core, kept out of quick setup like its neighbours. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- backend/src/main.rs | 9 +- backend/src/monitor.rs | 27 ++ .../tests/mcp_token_query_param.rs | 102 +++++++ backend/windmill-api-settings/src/lib.rs | 13 +- backend/windmill-api/src/lib.rs | 8 +- backend/windmill-api/src/mcp/core.rs | 30 ++- backend/windmill-api/src/mcp/mod.rs | 2 +- .../windmill-common/src/global_settings.rs | 5 + .../components/home/HomeConnectDrawer.svelte | 15 +- .../src/lib/components/instanceSettings.ts | 9 + .../components/settings/CreateToken.svelte | 255 +++++++++++------- frontend/src/lib/mcpAuth.ts | 20 ++ 12 files changed, 385 insertions(+), 110 deletions(-) create mode 100644 backend/windmill-api-integration-tests/tests/mcp_token_query_param.rs create mode 100644 frontend/src/lib/mcpAuth.ts diff --git a/backend/src/main.rs b/backend/src/main.rs index 316ed099f2..9e7a93cfcd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -52,7 +52,8 @@ use windmill_common::{ INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, - MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, + MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, + NO_DEFAULT_MAVEN_SETTING, NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, @@ -126,7 +127,8 @@ use windmill_worker::{ use crate::monitor::{ initial_load, load_concurrency_key_max_queued, load_disable_password_login, - load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled, + load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, + load_mcp_disable_token_query_param, load_metrics_debug_enabled, load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, load_workspace_fairness_enabled, @@ -2164,6 +2166,9 @@ async fn process_notify_event( DISABLE_PASSWORD_LOGIN_SETTING => { load_disable_password_login(db).await; } + MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => { + load_mcp_disable_token_query_param(db).await; + } EXPOSE_METRICS_SETTING => { tracing::info!("Metrics setting changed, restarting"); spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 6b752c0ab4..750bc40560 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -65,6 +65,7 @@ use windmill_common::{ FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, + MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING, @@ -288,6 +289,15 @@ pub async fn initial_load( ); if let Some(db) = conn.as_sql() { + // Outside the `server_mode` block below: a `MODE=mcp` process serves the MCP routes + // with `server_mode` false and would otherwise never read this at all. That mode + // joins no monitor loop, so there — as for every global setting, `base_url` + // included — this pass is the only read, and a change lands on restart. + pass.setting( + MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, + false, + |v| async move { apply_mcp_disable_token_query_param(v) }, + ); pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move { apply_tag_per_workspace_enabled(v) }); @@ -1617,6 +1627,23 @@ pub fn apply_disable_password_login(value: Option) { }; } +pub async fn load_mcp_disable_token_query_param(db: &DB) { + match load_value_from_global_settings(db, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING).await { + Ok(v) => apply_mcp_disable_token_query_param(v), + Err(e) => tracing::error!("Error loading mcp_disable_token_query_param setting: {e:#}"), + }; +} + +pub fn apply_mcp_disable_token_query_param(value: Option) { + match value { + Some(serde_json::Value::Bool(t)) => { + MCP_DISABLE_TOKEN_QUERY_PARAM.store(t, Ordering::Relaxed) + } + None => MCP_DISABLE_TOKEN_QUERY_PARAM.store(false, Ordering::Relaxed), + _ => (), + }; +} + struct LogFile { file_path: String, hostname: String, diff --git a/backend/windmill-api-integration-tests/tests/mcp_token_query_param.rs b/backend/windmill-api-integration-tests/tests/mcp_token_query_param.rs new file mode 100644 index 0000000000..d0d6e070f6 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/mcp_token_query_param.rs @@ -0,0 +1,102 @@ +//! The `mcp_disable_token_query_param` switch closes the URL-borne credential path. +//! +//! The rejection is a middleware layered between the `WWW-Authenticate` decorator and +//! everything that reads a token, on both the workspaced and the gateway mount. Each half of +//! that sandwich is pinned: the `WWW-Authenticate` header on the refusal catches the layer +//! being moved outward (a client would lose the pointer that starts OAuth discovery), and +//! refusing a token that was never valid catches it being moved inward past authentication +//! (the URL-borne token would be hashed and looked up before anything refused it). +#![cfg(feature = "mcp")] + +use std::sync::atomic::Ordering; + +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_common::global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM; +use windmill_test_utils::*; + +/// Workspace-less with an `mcp:` scope, which is what the gateway mount requires; the +/// workspaced mount takes its workspace from the path, so one token reaches both. +async fn insert_mcp_token(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) + VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])", + ) + .execute(db) + .await?; + Ok(()) +} + +/// A token that is not in `token` at all. Authentication would refuse it on its own, so a +/// refusal carrying the middleware's own wording is evidence nothing looked it up first. +const BOGUS_TOKEN: &str = "NOT_A_REAL_TOKEN"; + +async fn tools_list(url: &str) -> anyhow::Result { + Ok(reqwest::Client::new() + .post(url) + .header("Accept", "application/json, text/event-stream") + .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })) + .send() + .await?) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_mcp_token_query_param_switch(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + insert_mcp_token(&db).await?; + let server = ApiServer::start_mcp(db.clone()).await?; + let port = server.addr.port(); + let workspaced = + format!("http://localhost:{port}/api/mcp/w/test-workspace/mcp?token=MCP_TOKEN"); + let gateway = format!("http://localhost:{port}/api/mcp/gateway?token=MCP_TOKEN"); + + assert_eq!( + tools_list(&workspaced).await?.status(), + 200, + "a URL-borne token is the documented default and must keep working while the switch is off" + ); + assert_eq!(tools_list(&gateway).await?.status(), 200); + + MCP_DISABLE_TOKEN_QUERY_PARAM.store(true, Ordering::Relaxed); + + for url in [&workspaced, &gateway] { + let resp = tools_list(url).await?; + assert_eq!( + resp.status(), + 401, + "{url} still admitted a token in the URL" + ); + // What sends the client into the OAuth flow rather than leaving it stuck on a 401. + assert!( + resp.headers().contains_key("www-authenticate"), + "{url} rejected without pointing at the authorization server" + ); + } + + // Refused before authentication, not after: an invalid token gets the middleware's own + // message rather than the generic 401 that looking it up would produce. + let resp = tools_list(&format!( + "http://localhost:{port}/api/mcp/w/test-workspace/mcp?token={BOGUS_TOKEN}" + )) + .await?; + assert_eq!(resp.status(), 401); + assert!( + resp.text().await?.contains("does not accept a token in the MCP URL"), + "an invalid URL token was answered by authentication, so the token was read before \ + the switch refused it" + ); + + // The header stays open: it is the channel the OAuth flow itself hands tokens over on. + let resp = reqwest::Client::new() + .post(format!("http://localhost:{port}/api/mcp/gateway")) + .header("Accept", "application/json, text/event-stream") + .header("Authorization", "Bearer MCP_TOKEN") + .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + Ok(()) +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 55e22d912f..3bbaf888e2 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -64,10 +64,11 @@ use windmill_common::{ GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, - MAX_TOKEN_EXPIRATION_DAYS_SETTING, RETENTION_PERIOD_SECS_OVERRIDES_SETTING, - RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, - WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, - WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING, + MAX_TOKEN_EXPIRATION_DAYS_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, + RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING, + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, + WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -1364,6 +1365,10 @@ pub async fn get_global_setting( && key != INSTANCE_BANNER_SETTING // The token form reads it to stop offering expirations the server would shorten. && key != MAX_TOKEN_EXPIRATION_DAYS_SETTING + // Whoever is wiring up an MCP client reads it to know whether a URL-borne token + // would be refused, and they are usually not a superadmin. Not a secret: pointing + // any MCP client at the instance discovers the same answer. + && key != MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING { require_super_admin(&db, &authed).await?; } diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index b70d80140a..0fd5ac31b5 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -560,7 +560,7 @@ pub async fn run_server( if server_mode || mcp_mode { use mcp::{ add_www_authenticate_header, add_www_authenticate_header_gateway, - extract_workspace_from_token, + extract_workspace_from_token, reject_token_query_param, }; let (mcp_router, mcp_cancellation_token) = setup_mcp_server( db.clone(), @@ -573,15 +573,17 @@ pub async fn run_server( let workspaced_mcp_router = mcp_router .clone() .route_layer(from_extractor::()) + .layer(axum::middleware::from_fn(reject_token_query_param)) .layer(axum::middleware::from_fn(add_www_authenticate_header)) .layer(axum::middleware::from_fn(extract_and_store_workspace_id)); // Gateway MCP router — resolves workspace from token let gateway_mcp_router = mcp_router .route_layer(from_extractor::()) + .layer(axum::middleware::from_fn(extract_workspace_from_token)) + .layer(axum::middleware::from_fn(reject_token_query_param)) .layer(axum::middleware::from_fn( add_www_authenticate_header_gateway, - )) - .layer(axum::middleware::from_fn(extract_workspace_from_token)); + )); ( workspaced_mcp_router, gateway_mcp_router, diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 480e3c0841..86fbec1806 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -43,11 +43,14 @@ use axum::{ extract::{Extension, Path}, http::Request, middleware::Next, - response::Response, + response::{IntoResponse, Response}, routing::get, Json, Router, }; -use windmill_common::{auth::hash_token, db::GatewayWorkspaceId, error::JsonResult}; +use windmill_common::{ + auth::hash_token, db::GatewayWorkspaceId, error::JsonResult, + global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM, +}; // McpAuth impl for ApiAuthed is in windmill-api-auth (same crate as the type) @@ -446,6 +449,29 @@ pub async fn add_www_authenticate_header( } } +/// Middleware refusing a credential carried in the MCP URL once the instance sets +/// `mcp_disable_token_query_param`. Sits outside everything that reads the token, so neither +/// the gateway lookup nor `ApiAuthed` ever sees it, and inside the `WWW-Authenticate` layer, +/// whose header is what sends the client into the OAuth flow instead. Refused rather than +/// ignored: the URL leaked the token whether or not the request used it. +pub async fn reject_token_query_param(request: Request, next: Next) -> Response { + let carries_token = MCP_DISABLE_TOKEN_QUERY_PARAM.load(std::sync::atomic::Ordering::Relaxed) + && request + .uri() + .query() + .is_some_and(|q| url::form_urlencoded::parse(q.as_bytes()).any(|(k, _)| k == "token")); + if carries_token { + return ( + axum::http::StatusCode::UNAUTHORIZED, + "This instance does not accept a token in the MCP URL. Remove the token query \ + parameter and let your client sign in through OAuth, or send the token in an \ + Authorization header.", + ) + .into_response(); + } + next.run(request).await +} + /// Extract the bearer token from either the `Authorization` header or the /// `?token=` query parameter (MCP clients commonly pass it in the URL). fn extract_gateway_token(request: &Request) -> Option { diff --git a/backend/windmill-api/src/mcp/mod.rs b/backend/windmill-api/src/mcp/mod.rs index 5f6bd5edb5..5545d59a9f 100644 --- a/backend/windmill-api/src/mcp/mod.rs +++ b/backend/windmill-api/src/mcp/mod.rs @@ -12,5 +12,5 @@ pub mod oauth_server; pub use core::{ add_www_authenticate_header, add_www_authenticate_header_gateway, extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service, - setup_mcp_server, + reject_token_query_param, setup_mcp_server, }; diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index b022f9195e..6b9aae519d 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -102,6 +102,10 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation"; pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails"; pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login"; +/// Refuse `?token=` on the MCP endpoints, leaving the `Authorization` header as the only way +/// in. A URL-borne credential ends up in browser history, proxy logs and referrers, so an +/// instance that cares sends MCP clients through the OAuth flow instead. +pub const MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING: &str = "mcp_disable_token_query_param"; /// Ceiling, in days, on how far ahead a token minted through `POST /users/tokens/create` or /// `POST /users/tokens/impersonate` may expire; a request asking for more, or for no /// expiration at all, is shortened to it rather than refused. On those routes only: server-side @@ -407,6 +411,7 @@ use std::sync::atomic::AtomicBool; lazy_static::lazy_static! { pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false); pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false); + pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false); /// Origins HTTP routes allow cross-origin when they configure none of their /// own. Empty means unset, which keeps the historical `*`. pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap> = diff --git a/frontend/src/lib/components/home/HomeConnectDrawer.svelte b/frontend/src/lib/components/home/HomeConnectDrawer.svelte index e7e4a306d9..e08ac2ddea 100644 --- a/frontend/src/lib/components/home/HomeConnectDrawer.svelte +++ b/frontend/src/lib/components/home/HomeConnectDrawer.svelte @@ -5,10 +5,12 @@ import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte' import { Bot, ExternalLink, Terminal } from 'lucide-svelte' import { shell } from 'svelte-highlight/languages' + import { mcpTokenUrlDisabled } from '$lib/mcpAuth' type ConnectTab = 'cli' | 'mcp' let drawer: Drawer | undefined = $state() + let tokenUrlDisabled = $state(false) let selectedTab: ConnectTab = $state('cli') let openVersion = $state(0) @@ -24,6 +26,10 @@ wmill sync pull`) export function openDrawer(tab: ConnectTab = 'cli') { selectedTab = tab openVersion += 1 + // Falls back like CreateToken below, which shows the bare URL when the read fails. + void mcpTokenUrlDisabled() + .then((v) => (tokenUrlDisabled = v)) + .catch(() => (tokenUrlDisabled = true)) drawer?.openDrawer() } @@ -96,8 +102,13 @@ wmill sync pull`)

MCP URL

- Generate an MCP server URL for the current workspace and choose which - scripts, flows, and endpoints the client can access. + {#if tokenUrlDisabled} + The MCP server URL for the current workspace. Your client signs in to + Windmill to use it. + {:else} + Generate an MCP server URL for the current workspace and choose which + scripts, flows, and endpoints the client can access. + {/if}

diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 06e720a846..7335b33848 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -325,6 +325,15 @@ export const settings: Record = { value === null || value === '' || parseMaxTokenExpirationDays(value) !== undefined + }, + { + label: 'Disable token in MCP URLs', + description: + 'Reject the ?token= query parameter on the MCP endpoints, so MCP clients authenticate with an Authorization header or through the OAuth flow. A token in a URL is a credential that ends up in browser history, proxy logs and referrers. Existing MCP URLs carrying a token stop working. Servers and workers pick this up within a minute; dedicated MCP servers (MODE=mcp) apply it when they next restart.', + key: 'mcp_disable_token_query_param', + fieldType: 'boolean', + storage: 'setting', + hideInQuickSetup: true } ], Jobs: [ diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index acde0a557a..146f844b3f 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -6,12 +6,15 @@ workspaceStore, type UserWorkspace } from '$lib/stores' - import { Button } from '../common' + import { Alert, Button, Skeleton } from '../common' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' import Toggle from '../Toggle.svelte' import { SettingService, UserService, type NewToken } from '$lib/gen' + import { mcpTokenUrlDisabled } from '$lib/mcpAuth' import TokenDisplay from './TokenDisplay.svelte' import ScopesPicker from './ScopesPicker.svelte' + import CopyableCodeBlock from '../details/CopyableCodeBlock.svelte' + import { shell } from 'svelte-highlight/languages' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' import TextInput from '../text_input/TextInput.svelte' @@ -59,6 +62,22 @@ let pickedScopes = $state(null) let readOnly = $state(false) + // How this instance lets an MCP client in. `oauth` means it refuses `?token=`, so a + // generated token would not get a client in and the URL is handed over bare instead. + // A failed read lands on `oauth`: the bare URL works whichever way the setting is, + // whereas guessing `token` mints a non-expiring credential the server may refuse. + type McpUrlPolicy = 'loading' | 'token' | 'oauth' + let mcpUrlPolicy = $state('loading') + + async function loadMcpUrlPolicy() { + mcpUrlPolicy = 'loading' + try { + mcpUrlPolicy = (await mcpTokenUrlDisabled()) ? 'oauth' : 'token' + } catch (err) { + console.error('Failed to load the MCP token setting:', err) + mcpUrlPolicy = 'oauth' + } + } const DAY_SECS = 24 * 60 * 60 const EXPIRATION_CHOICES = [ @@ -117,6 +136,7 @@ function enterMcpMode() { mcpCreationMode = true + void loadMcpUrlPolicy() resetExpirationOnModeChange() newTokenWorkspace = defaultNewTokenWorkspace ?? $workspaceStore newToken = undefined @@ -224,11 +244,17 @@ const scopeWorkspaceId = $derived( isAllWorkspaces ? $workspaceStore || '' : newTokenWorkspace || $workspaceStore || '' ) - const mcpBaseUrl = $derived( + // Undefined wherever the workspace is: `/api/mcp/w/undefined/mcp` reads like a real URL + // and is copyable, so the OAuth panel withholds it rather than showing a broken one. The + // token branch guards the same case by disabling its generate button. + const mcpUrl = $derived( isAllWorkspaces - ? `${window.location.origin}/api/mcp/gateway?token=` - : `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp?token=` + ? `${window.location.origin}/api/mcp/gateway` + : newTokenWorkspace + ? `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp` + : undefined ) + const mcpBaseUrl = $derived(`${mcpUrl ?? ''}?token=`) $effect(() => { const requestedMcpMode = mcpOnly || openWithMcpMode @@ -256,7 +282,9 @@
-

{title}

+

+ {mcpCreationMode && mcpUrlPolicy !== 'token' ? 'MCP URL' : title} +

{#if showMcpMode && !mcpOnly}
{/if} - {#if scopes != undefined} -
- Scope - {#each scopes as scope (scope)} - - {/each} -
- + {:else if mcpCreationMode && mcpUrlPolicy === 'oauth'} + {#if !lockWorkspace} +
+ Workspace + + ({ label: w.name, value: w.id, subtitle: w.id })) - ]} +
+ + Paste this URL into your client. It opens a Windmill page where you approve the access + it asks for, and no token needs to be copied around. + +
+ {:else} +

Pick a workspace to get its MCP URL.

+ {/if} + + {#if !mcpOnly} +
+ +
+ {/if} + {:else} + {#if scopes != undefined} +
+ Scope + {#each scopes as scope (scope)} + + {/each} +
+ - {#if isAllWorkspaces} +
+
+ {/if} + + {#if !scopes || scopes.length === 0} + + {/if} + +
+ {#if mcpCreationMode} + {#if !lockWorkspace} +
+ Workspace + newTokenExpiration, (v) => (pickedExpiration = v)} + placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'} + inputClass="w-full" + items={expirationItems} + /> + {#if maxExpirationSecs != undefined}

- This token works across every workspace you can access. Tools take a - workspace_id argument; call list_workspaces to discover them. + This instance limits tokens to {maxExpirationLabel}.

{/if}
{/if} - {/if} +
- {#if !mcpOnly} -
- Label (optional) + {#if !mcpOnly} +
- {/if} - - {#if !mcpCreationMode || maxExpirationSecs != undefined} -
- - Expires In - {#if maxExpirationSecs == undefined} - (optional) - {/if} - - ({ value: s, label: s }))} + bind:value={ + () => schema, + (s) => { + schema = s + table = undefined + } + } + placeholder="The database itself" + clearable + loading={schemasLoading} + size="sm" + class="w-56" + /> + {#if schema} + info.owner, + (role) => { + // The select shows what the database says; a pick is a request, and only the + // applied change moves it. + if (role && role !== info.owner) { + confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`) + } + } + } + /> + {:else} + {info.owner} + {/if} +
+ {/if} + +
+
+ Grants + + {target.kind === 'database' + ? 'What each role may do on the database itself — CREATE is the right to create schemas in it — and what default privileges set database-wide give it on what is created later, in every schema. No schema can take those back.' + : 'What each role may do here, beyond what it owns.'} + +
+ {#if info.editable} + + confirm( + { type: 'grant', role, privileges, scope }, + `Granted ${privileges.join(', ')} to ${role}` + )} + /> + {/if} + {#if grantRows.length === 0} + No grants yet. + {:else} + + + + Role + Privileges + On + + + + + {#each grantRows as grant (grantKey(grant))} + {@const revokeScope = revokeScopeOf(grant)} + {@const revocable = revocablePrivileges(grant, target)} + {@const blocked = blockingSources(grant, revocable)} + {@const uncovered = uncoveredCreators(grant, info.roles)} + + {grant.grantee} + {grant.privileges.join(', ')} + + {grantScopeLabel(grant)} + {#if blocked.length > 0} + + from {blocked.join(', ')} + + {/if} + {#if uncovered.length > 0} + + · not for what {uncovered.join(', ')} + {uncovered.length === 1 ? 'creates' : 'create'} + + {/if} + + + + {#if info.editable && revokeScope && revocable.length > 0 && blocked.length === 0 && info.roles.includes(grant.grantee) && grant.grantee !== ADMIN_ROLE} +
+
+{/if} + + (pending = undefined)} +> +
+ {#if pendingCoversObjects} + + The same privileges on several objects read as one row, and are revoked together. + + {/if} + {#each pending?.warnings ?? [] as warning (warning)} + {warning} + {/each} + + Runs against {datatable} in a single transaction: + +
{(pending?.statements ?? []).join(';\n')};
+
+
diff --git a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte new file mode 100644 index 0000000000..5e5119abb6 --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte @@ -0,0 +1,95 @@ + + +
+
+ GRANT + ({ value: p, label: p }))} + placeholder="privileges" + {disabled} + size="sm" + class="min-w-48" + /> + ON + ({ value: r, label: r }))} + placeholder="role" + {disabled} + size="sm" + class="w-40" + /> + +
+ {#if statement} +
{statement}
+ {/if} +
diff --git a/frontend/src/lib/components/datatableAcl/aclScopes.test.ts b/frontend/src/lib/components/datatableAcl/aclScopes.test.ts new file mode 100644 index 0000000000..7c01836e89 --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/aclScopes.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import type { AclGrant } from '$lib/gen' +import { + blockingSources, + grantKey, + groupGrants, + revocablePrivileges, + revokeScopeOf, + uncoveredCreators +} from './aclScopes' + +const table = (name: string) => ({ name, kind: 'TABLE' }) +const by = (role: string, privileges: string[], reachable = true) => ({ + role, + privileges, + reachable +}) +const byAdmin = (grant: Omit): AclGrant => ({ + ...grant, + sources: [by('admin', grant.privileges)] +}) + +describe('grantKey', () => { + it('tells apart a table and a function of the same name', () => { + const row = (object: { name: string; kind: string; args?: string }) => ({ + grantee: 'analytics', + privileges: ['SELECT'], + objects: [object], + sources: [by('admin', ['SELECT'])] + }) + expect(grantKey(row(table('orders')))).not.toBe( + grantKey(row({ name: 'orders', kind: 'FUNCTION', args: '' })) + ) + }) +}) + +describe('groupGrants', () => { + // A row's revoke names every object in it, so a row must only hold what one revoke may take. + it('folds the same privileges on objects of one kind, and nothing else', () => { + const grants: AclGrant[] = [ + { grantee: 'analytics', privileges: ['SELECT'], object: table('orders') }, + { grantee: 'analytics', privileges: ['SELECT'], object: table('salaries') }, + { grantee: 'operator', privileges: ['SELECT'], object: table('orders') }, + { grantee: 'analytics', privileges: ['INSERT', 'SELECT'], object: table('events') }, + { grantee: 'analytics', privileges: ['SELECT'], object: { name: 's', kind: 'SEQUENCE' } }, + { grantee: 'analytics', privileges: ['SELECT'], future: 'TABLES' }, + { grantee: 'analytics', privileges: ['USAGE'] } + ].map(byAdmin) + const rows = groupGrants(grants) + expect(rows.map((r) => [r.grantee, r.privileges, r.objects, r.future])).toEqual([ + ['analytics', ['SELECT'], [table('orders'), table('salaries')], undefined], + ['operator', ['SELECT'], [table('orders')], undefined], + ['analytics', ['INSERT', 'SELECT'], [table('events')], undefined], + ['analytics', ['SELECT'], [{ name: 's', kind: 'SEQUENCE' }], undefined], + ['analytics', ['SELECT'], [], 'TABLES'], + ['analytics', ['USAGE'], [], undefined] + ]) + }) + + // A revoke takes the row back from every source, so the row must name them all, with what each + // gave. + it('keeps every source of the grants it folds', () => { + const grants: AclGrant[] = [ + { + grantee: 'analytics', + privileges: ['SELECT'], + object: table('orders'), + sources: [by('admin', ['SELECT'])] + }, + { + grantee: 'analytics', + privileges: ['SELECT'], + object: table('salaries'), + sources: [by('admin', ['SELECT']), by('operator', ['SELECT'])] + } + ] + expect(groupGrants(grants)[0].sources).toEqual([ + by('admin', ['SELECT']), + by('operator', ['SELECT']) + ]) + }) +}) + +describe('revoke of a row', () => { + const row = (future?: string) => ({ + grantee: 'analytics', + privileges: ['SELECT'], + objects: [], + future, + sources: [by('admin', ['SELECT'])] + }) + + it('takes back only what the editor may revoke on the database', () => { + const database = { ...row(), privileges: ['CONNECT', 'CREATE'] } + expect(revocablePrivileges(database, { kind: 'database' })).toEqual(['CREATE']) + expect(revocablePrivileges(database, { kind: 'schema', schema: 'public' })).toEqual([ + 'CONNECT', + 'CREATE' + ]) + // Set database-wide, so not one a schema's revoke could take back either. + const schemasLater = { ...row('SCHEMAS'), privileges: ['CREATE'] } + expect(revocablePrivileges(schemasLater, { kind: 'database' })).toEqual([]) + }) + + it('maps default privileges to their scope, and refuses the ones it has none for', () => { + expect(revokeScopeOf(row())).toBe('target') + expect(revokeScopeOf(row('TABLES'))).toBe('future_tables') + expect(revokeScopeOf(row('TYPES'))).toBeUndefined() + expect(revokeScopeOf({ ...row(), objects: [{ name: 'mood', kind: 'TYPE' }] })).toBeUndefined() + }) + + // Postgres takes a grant back only through its source: offering the revoke would promise what + // the plan then refuses. But only the sources of what is revoked count: the catalog's CONNECT + // on the database comes from its owner, out of reach, and must not hold back a CREATE the + // editor granted. + it('is held back only by a source out of reach for what it takes', () => { + const database = { + ...row(), + privileges: ['CONNECT', 'CREATE'], + sources: [by('postgres', ['CONNECT'], false), by('admin', ['CREATE'])] + } + const revocable = revocablePrivileges(database, { kind: 'database' }) + expect(blockingSources(database, revocable)).toEqual([]) + expect(blockingSources(database, ['CONNECT'])).toEqual(['postgres']) + const partly = { + ...row('TABLES'), + sources: [by('admin', ['SELECT']), by('postgres', ['SELECT'], false)] + } + expect(blockingSources(partly, ['SELECT'])).toEqual(['postgres']) + }) + + // Whether a grant can be taken back depends on its object, so a row folding several objects is + // only revocable if each of its grants is. + it('is held back by a source out of reach on any of the objects it folds', () => { + const grants: AclGrant[] = [ + { + grantee: 'analytics', + privileges: ['SELECT'], + object: table('orders'), + sources: [by('admin', ['SELECT'])] + }, + { + grantee: 'analytics', + privileges: ['SELECT'], + object: table('salaries'), + sources: [by('admin', ['SELECT'], false)] + } + ] + const [folded] = groupGrants(grants) + expect(folded.objects).toHaveLength(2) + expect(blockingSources(folded, ['SELECT'])).toEqual(['admin']) + // Folding reads the grants, never rewrites them. + expect(grants[0].sources[0].reachable).toBe(true) + }) +}) + +describe('uncoveredCreators', () => { + // A default privilege binds only the creating roles it was granted for: a role added since is + // left out until the grant is made again. + it('names the roles a created-later row leaves out', () => { + const future = { + grantee: 'analytics', + privileges: ['SELECT'], + objects: [], + future: 'TABLES', + sources: [by('admin', ['SELECT']), by('analytics', ['SELECT'])] + } + expect(uncoveredCreators(future, ['admin', 'analytics', 'late'])).toEqual(['late']) + expect(uncoveredCreators({ ...future, future: undefined }, ['late'])).toEqual([]) + // Set by a role outside the catalog, it was never meant to cover the catalog's roles. + expect( + uncoveredCreators({ ...future, sources: [by('postgres', ['SELECT'], false)] }, [ + 'admin', + 'late' + ]) + ).toEqual([]) + }) +}) diff --git a/frontend/src/lib/components/datatableAcl/aclScopes.ts b/frontend/src/lib/components/datatableAcl/aclScopes.ts new file mode 100644 index 0000000000..d5b8ad9d20 --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/aclScopes.ts @@ -0,0 +1,211 @@ +import type { AclGrant, AclSource, AclTarget } from '$lib/gen' + +/** The role a data table connects as without roles — `custom_instance_user` in Postgres. */ +export const ADMIN_ROLE = 'admin' + +/** Privileges Postgres accepts per kind of object. Mirrors the whitelist the backend validates + * against — a privilege missing here just cannot be built. */ +/** `CREATE` on a database is the right to create schemas in it, and the only database privilege + * handed out here: `CONNECT` is managed with the instance's role catalog. */ +export const DATABASE_PRIVILEGES = ['CREATE'] +export const SCHEMA_PRIVILEGES = ['USAGE', 'CREATE'] +export const TABLE_PRIVILEGES = [ + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'TRUNCATE', + 'REFERENCES', + 'TRIGGER' +] +/** Postgres 17 and later only, so it is offered from what the server reports. */ +export const MAINTAIN_PRIVILEGE = 'MAINTAIN' +export const SEQUENCE_PRIVILEGES = ['USAGE', 'SELECT', 'UPDATE'] +export const FUNCTION_PRIVILEGES = ['EXECUTE'] + +export type AclScope = + | 'target' + | 'all_tables' + | 'all_sequences' + | 'all_functions' + | 'future_tables' + | 'future_sequences' + | 'future_functions' + +export type AclTargetKind = AclTarget['kind'] + +/** The scopes a target can grant on, in the order the builder offers them. */ +export function scopesOf(kind: AclTargetKind): { value: AclScope; label: string }[] { + if (kind === 'database') return [{ value: 'target', label: 'the database itself' }] + if (kind === 'table') return [{ value: 'target', label: 'this table' }] + return [ + { value: 'target', label: 'the schema itself' }, + { value: 'all_tables', label: 'all tables in it' }, + { value: 'all_sequences', label: 'all sequences in it' }, + { value: 'all_functions', label: 'all functions in it' }, + { value: 'future_tables', label: 'tables created later' }, + { value: 'future_sequences', label: 'sequences created later' }, + { value: 'future_functions', label: 'functions created later' } + ] +} + +export function privilegesOf( + scope: AclScope, + kind: AclTargetKind, + supportsMaintain = false +): string[] { + const tablePrivileges = supportsMaintain + ? [...TABLE_PRIVILEGES, MAINTAIN_PRIVILEGE] + : TABLE_PRIVILEGES + switch (scope) { + case 'target': + if (kind === 'database') return DATABASE_PRIVILEGES + return kind === 'schema' ? SCHEMA_PRIVILEGES : tablePrivileges + case 'all_tables': + case 'future_tables': + return tablePrivileges + case 'all_sequences': + case 'future_sequences': + return SEQUENCE_PRIVILEGES + case 'all_functions': + case 'future_functions': + return FUNCTION_PRIVILEGES + } +} + +/** What a statement built at this scope reads as, for the builder's own preview. */ +export function scopeSql(scope: AclScope, target: AclTarget, dbname?: string): string { + if (target.kind === 'database') return `DATABASE ${dbname ?? ''}`.trim() + const schema = target.schema + switch (scope) { + case 'target': + return target.kind === 'schema' ? `SCHEMA ${schema}` : `TABLE ${schema}.${target.table}` + case 'all_tables': + return `ALL TABLES IN SCHEMA ${schema}` + case 'all_sequences': + return `ALL SEQUENCES IN SCHEMA ${schema}` + case 'all_functions': + return `ALL FUNCTIONS IN SCHEMA ${schema}` + case 'future_tables': + return `TABLES (default privileges in ${schema})` + case 'future_sequences': + return `SEQUENCES (default privileges in ${schema})` + case 'future_functions': + return `FUNCTIONS (default privileges in ${schema})` + } +} + +/** One row of the grants table: the same privileges on several objects read as one line, since + * granting them per object is what `ON ALL TABLES` does. */ +export type GroupedGrant = { + grantee: string + privileges: string[] + objects: NonNullable[] + future?: string + /** Every role the row's grants come from, each once, with what it gave. */ + sources: AclSource[] +} + +export function groupGrants(grants: AclGrant[]): GroupedGrant[] { + const rows: GroupedGrant[] = [] + for (const grant of grants) { + const existing = grant.object + ? rows.find( + (r) => + r.grantee === grant.grantee && + r.future === grant.future && + r.objects[0]?.kind === grant.object?.kind && + r.privileges.join() === grant.privileges.join() + ) + : undefined + if (existing) { + existing.objects.push(grant.object!) + for (const source of grant.sources) { + const known = existing.sources.find((s) => s.role === source.role) + // Whether a role's grant can be taken back depends on the object it is on, so a row + // holds a source as reachable only if it is on every object the row folds. + if (known) { + known.reachable &&= source.reachable + known.privileges = [...new Set([...known.privileges, ...source.privileges])].sort() + } else { + existing.sources.push({ ...source, privileges: [...source.privileges] }) + } + } + } else { + rows.push({ + grantee: grant.grantee, + privileges: grant.privileges, + objects: grant.object ? [grant.object] : [], + future: grant.future, + sources: grant.sources.map((s) => ({ ...s, privileges: [...s.privileges] })) + }) + } + } + return rows +} + +/** The roles that gave some of `privileges` and that this data table's connection cannot act for. + * Only they can take those grants back, so a revoke of `privileges` is not offered. */ +export function blockingSources(grant: GroupedGrant, privileges: string[]): string[] { + return grant.sources + .filter((s) => !s.reachable && s.privileges.some((p) => privileges.includes(p))) + .map((s) => s.role) +} + +/** Which of `roles` a "created later" row granted for some of them does not cover. A default + * privilege binds only the creating roles it was granted for, so what the others create stays out + * of it. A row none of `roles` set — the instance's own, say — was never meant to cover them, and + * names none. */ +export function uncoveredCreators(grant: GroupedGrant, roles: string[]): string[] { + if (!grant.future || !grant.sources.some((s) => roles.includes(s.role))) return [] + return roles.filter((r) => !grant.sources.some((s) => s.role === r)) +} + +/** A row's identity. Two rows may share a grantee and an object name — a table `orders` and a + * function `orders()` — so the kind and the privileges are part of it too. */ +export function grantKey(grant: GroupedGrant): string { + return [ + grant.grantee, + grant.future ?? '', + grant.privileges.join(','), + ...grant.objects.map((o) => `${o.kind}:${o.name}(${o.args ?? ''})`) + ].join('|') +} + +/** The scope a revoke of this row takes, or `undefined` when the builder cannot express it — + * Postgres also records privileges on types, present and default, which nothing here grants and + * the API has no scope for. */ +export function revokeScopeOf(grant: GroupedGrant): AclScope | undefined { + if (!grant.future) return grant.objects.some((o) => o.kind === 'TYPE') ? undefined : 'target' + const scope = `future_${grant.future.toLowerCase()}` + return (['future_tables', 'future_sequences', 'future_functions'] as const).find( + (s) => s === scope + ) +} + +/** The privileges of a row a revoke may take back. On the database that is `CREATE` alone: + * `CONNECT` belongs to the role catalog, which would grant it again, and `TEMPORARY` is not one + * the editor hands out — a row holding only those has nothing to revoke here. A database's rows + * "created later" are default privileges set database-wide, which nothing here revokes. */ +export function revocablePrivileges(grant: GroupedGrant, target: AclTarget): string[] { + if (target.kind === 'database' && grant.objects.length === 0) { + if (grant.future) return [] + return grant.privileges.filter((p) => DATABASE_PRIVILEGES.includes(p)) + } + return grant.privileges +} + +/** How a row reads back: what it covers, in one phrase. */ +export function grantScopeLabel(grant: GroupedGrant): string { + if (grant.future) return `${grant.future.toLowerCase()} created later` + if (grant.objects.length === 1) { + const object = grant.objects[0] + // A routine's arguments are part of what it is, so two of the same name would otherwise + // read as one row twice. + const args = object.args !== undefined ? `(${object.args})` : '' + return `${object.kind.toLowerCase()} ${object.name}${args}` + } + if (grant.objects.length > 1) + return `${grant.objects.length} ${grant.objects[0].kind.toLowerCase()}s` + return 'itself' +} diff --git a/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte index 17ba0edd09..c5c4171150 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte @@ -11,10 +11,14 @@ GroupService, UserService, WorkspaceService, + type AclTarget, + type DatatableAclInfo, type DatatablePermissions, type InstanceDatatableRole } from '$lib/gen' import { sendUserToast } from '$lib/toast' + import AclTargetPicker from '../datatableAcl/AclTargetPicker.svelte' + import PgAclEditor from '../datatableAcl/PgAclEditor.svelte' const ADMIN_ROLE = 'admin' @@ -49,11 +53,38 @@ const availableRoles: InstanceDatatableRole[] = $derived(info?.available_roles ?? []) const unusedRoles = $derived(availableRoles.filter((r) => !rows.some((row) => row.id === r.id))) + let aclSchema = $state(undefined) + let aclTable = $state(undefined) + const aclTarget: AclTarget = $derived( + aclSchema + ? aclTable + ? { kind: 'table', schema: aclSchema, table: aclTable } + : { kind: 'schema', schema: aclSchema } + : { kind: 'database' } + ) + let aclSchemas = $state([]) + let aclSchemasLoaded = $state(false) + let aclTables = $state([]) + + // The editor's read of a database lists its schemas, and of a schema its tables — which is what + // the picker offers, so the picker reads nothing of its own. A read for a target since left + // behind is dropped. + function onAclLoaded(target: AclTarget, loaded: DatatableAclInfo) { + if (JSON.stringify(target) !== JSON.stringify(aclTarget)) return + if (target.kind === 'database') { + aclSchemas = loaded.children + aclSchemasLoaded = true + } else if (target.kind === 'schema') aclTables = loaded.children + } + async function load() { loading = true loadError = undefined try { - const res = await WorkspaceService.getDatatablePermissions({ workspace, datatableName: datatable }) + const res = await WorkspaceService.getDatatablePermissions({ + workspace, + datatableName: datatable + }) info = res permissioned = res.permissioned defaultRole = res.default_role @@ -124,6 +155,11 @@ } export function open() { + aclSchema = undefined + aclTable = undefined + aclSchemas = [] + aclSchemasLoaded = false + aclTables = [] drawer?.openDrawer() load() } @@ -143,8 +179,11 @@ drawer?.closeDrawer()} - tooltip="A data table role is a Postgres login. A job that names one connects as it, and Postgres decides what it may touch — grant privileges with SQL. Roles are defined for the whole instance; here you say who may use each one on this data table." + tooltip="A data table role is a Postgres login. A job that names one connects as it, and Postgres decides what it may touch — grant it privileges under Access. Roles are defined for the whole instance; here you say who may use each one on this data table." > + {#snippet titleExtra()} + Beta + {/snippet} {#if loading}

Loading…

{:else if loadError} @@ -173,7 +212,7 @@ These data tables point at the same database with their own entry, so what you set here does not reach them:
    - {#each info.ungoverned_reachers as reacher} + {#each info.ungoverned_reachers as reacher (`${reacher.workspace_id}/${reacher.datatable}`)}
  • {reacher.workspace_id} / {reacher.datatable}
  • {/each}
@@ -194,7 +233,7 @@ {#if availableRoles.length === 0} Only admin can be used until a superadmin adds a data table - role in the data table settings page. + role, from Instance roles at the top of the data tables settings page. {/if} @@ -271,6 +310,34 @@
{/if} + + {#if info?.supported} +
+
+ Access + + What each role may do in Postgres, on the database, a schema or a table. Every + change shows the SQL it runs before running it. + +
+ aclSchema, + (s) => { + aclSchema = s + aclTables = [] + } + } + bind:table={aclTable} + /> + {#key JSON.stringify(aclTarget)} + + {/key} +
+ {/if}
{/if} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte b/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte index e46b578597..d100b1681a 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte @@ -3,7 +3,6 @@ import CloseButton from '../common/CloseButton.svelte' import TextInput from '../text_input/TextInput.svelte' import Toggle from '../Toggle.svelte' - import Tooltip from '../Tooltip.svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' import Cell from '../table/Cell.svelte' @@ -83,16 +82,6 @@
-
-

Instance roles

- - A data table role is a real Postgres login on this instance, shared by every instance - database. A job that names one connects as it, and Postgres decides what it may touch — grant - it privileges with SQL. Which people may use a role on a given data table is set per data - table, in its roles drawer. - -
- {#if loadError} {loadError} {:else} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index 867cc58d87..53b4819689 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -92,8 +92,7 @@ type GetSettingsResponse, type TestDataTableConnectionResponse } from '$lib/gen' - // `superadmin` gates the commented-out roles section at the bottom; restore it there. - import { workspaceStore } from '$lib/stores' + import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { resource } from 'runed' @@ -101,10 +100,8 @@ import { Popover } from '../meltComponents' import ExploreAssetButton from '../ExploreAssetButton.svelte' import DataTableMigrationsButton from './DataTableMigrationsButton.svelte' - // Both components are complete and reviewed; their call sites in this file are commented - // out until the ACL editor lands. Uncomment these with them. - // import DataTablePermissionsButton from './DataTablePermissionsButton.svelte' - // import DataTableRolesSection from './DataTableRolesSection.svelte' + import DataTablePermissionsButton from './DataTablePermissionsButton.svelte' + import InstanceRolesButton from './InstanceRolesButton.svelte' import { deepEqual } from 'fast-equals' import { clone } from '$lib/utils' import SettingsFooter from './SettingsFooter.svelte' @@ -318,7 +315,13 @@ title="Data tables" description="Relational storage the whole workspace shares under one name. Scripts, flows and apps address it as datatable://main instead of picking a PostgreSQL resource, so nobody needs access to the credentials to query it, and you can point that name at another database without touching a line of code. Browse and edit tables, and version schema changes as migrations, from here." link="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables" -/> +> + {#snippet actions()} + {#if $superadmin && $enterpriseLicense && !isCloudHosted()} + + {/if} + {/snippet} + {#if isCloudHosted()} @@ -472,15 +475,6 @@ datatable={dataTable.name} disabled={!!dirtyMap[dataTable.name]} /> -
-{/if} ---> - + import { Badge, Button, Drawer, DrawerContent } from '../common' + import { Users } from 'lucide-svelte' + import DataTableRolesSection from './DataTableRolesSection.svelte' + + let drawer: Drawer | undefined = $state(undefined) + + + + + + drawer?.closeDrawer()} + tooltip="A data table role is a real Postgres login on this instance, shared by every instance database. A job that names one connects as it, and Postgres decides what it may touch. Which people may use a role on a given data table, and what it may do there, is set per data table, in its roles drawer." + > + {#snippet titleExtra()} + Beta + {/snippet} + + + From 9e0e835c6876305d3a44902fb1432fd9c2348a7c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 19 Sep 2026 23:26:28 +0200 Subject: [PATCH 031/112] fix: bump git sync hub scripts to cli 1.815.1 (#11239) Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-common/src/workspaces.rs | 4 ++-- frontend/src/lib/hubPaths.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 711b4dac29..df741a9394 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -184,7 +184,7 @@ pub enum ObjectType { DatatableMigration, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28969/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28971/sync-script-to-git-repo-windmill"; /// Hub script that applies a repository's state back into a workspace /// (the repo → Windmill / "pull" direction). Same script the UI runs from @@ -192,7 +192,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28969/sync-script-to-git-repo /// ignores the slug, so the slug is kept free of characters that would be /// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened /// reverse proxies reject as double-encoding when the client re-encodes it). -pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28957/git-sync-init-repository-windmill"; +pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28970/git-sync-init-repository-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index dfa0be57c1..831380748e 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,6 +1,6 @@ { "gitSyncTest": "hub/28950/git-repo-test-read-write-windmill", - "gitInitRepo": "hub/28957/git-sync-init-repository-windmill", + "gitInitRepo": "hub/28970/git-sync-init-repository-windmill", "slackErrorHandler": "hub/28794/workspace-or-schedule-error-handler-slack", "emailErrorHandler": "hub/19795/workspace-or-error-handler-email", "slackRecoveryHandler": "hub/28791/slack/schedule-recovery-handler-slack", From 6186a0645d3f3e92e06b666a6b5774b8af2b488f Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:29:14 +0200 Subject: [PATCH 032/112] feat: edit variables, resources and triggers in their own session tab (#11206) * feat: edit variables, resources and triggers in their own session tab Co-Authored-By: Claude Opus 5 (1M context) * fix: take a trigger's flow kind from its config and name the written trigger Co-Authored-By: Claude Opus 5 (1M context) * refactor: one operating-workspace context for editors acting on a session's workspace Co-Authored-By: Claude Opus 5 (1M context) * fix: reload page item tabs only in sessions acting on the tool's workspace Co-Authored-By: Claude Opus 5 (1M context) * fix: resolve open in workspace for every preview tab kind in one place Co-Authored-By: Claude Opus 5 (1M context) * fix: guard every component under a session editor against the navigation workspace Co-Authored-By: Claude Opus 5 (1M context) * fix: show a page item tab whose item is gone instead of mounting its editor Co-Authored-By: Claude Opus 5 (1M context) * fix: follow a saved page item in the tab's own workspace and restore in-frame rows Co-Authored-By: Claude Opus 5 (1M context) * feat: close a page item tab from its editor header Co-Authored-By: Claude Opus 5 (1M context) * fix: judge trigger permissions by the user acting in the session workspace Co-Authored-By: Claude Opus 5 (1M context) * fix: judge every session-editor permission by the user acting in that workspace Co-Authored-By: Claude Opus 5 (1M context) * fix: keep session permissions reactive to the acting role and restore tabs as last seen Co-Authored-By: Claude Opus 5 (1M context) * fix: default Path's owner controls to the user acting in its workspace Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ruben Fiszel --- .../lib/components/AIProviderPicker.svelte | 6 +- frontend/src/lib/components/AgentTrace.svelte | 8 +- .../src/lib/components/ApiConnectForm.svelte | 6 +- .../src/lib/components/AppConnectInner.svelte | 7 +- frontend/src/lib/components/ArgInfo.svelte | 8 +- frontend/src/lib/components/ArgInput.svelte | 6 +- .../components/BedrockCredentialsCheck.svelte | 12 +- .../src/lib/components/ChannelSelector.svelte | 10 +- frontend/src/lib/components/CliHelpBox.svelte | 6 +- frontend/src/lib/components/DBManager.svelte | 8 +- .../lib/components/DBManagerContent.svelte | 12 +- frontend/src/lib/components/DBTable.svelte | 14 +- .../src/lib/components/DatatablePicker.svelte | 6 +- .../src/lib/components/DefaultScripts.svelte | 7 +- .../lib/components/DefaultScriptsInner.svelte | 7 +- .../src/lib/components/DucklakePicker.svelte | 12 +- .../lib/components/EditableSchemaForm.svelte | 6 +- frontend/src/lib/components/Editor.svelte | 22 +- frontend/src/lib/components/EditorBar.svelte | 11 +- .../src/lib/components/EditorHeader.svelte | 17 +- .../components/ErrorOrRecoveryHandler.svelte | 21 +- .../lib/components/ExploreAssetButton.svelte | 19 +- .../src/lib/components/FlowBuilder.svelte | 26 +- .../src/lib/components/FlowGraphViewer.svelte | 6 +- .../lib/components/FlowGraphViewerStep.svelte | 7 +- .../components/FlowHistoryJobPicker.svelte | 8 +- .../src/lib/components/FlowLogViewer.svelte | 6 +- .../FlowLoopIterationPreview.svelte | 6 +- .../lib/components/FlowPreviewContent.svelte | 6 +- .../lib/components/FlowRestartButton.svelte | 8 +- .../lib/components/FlowStatusViewer.svelte | 7 +- .../components/FlowStatusViewerInner.svelte | 20 +- frontend/src/lib/components/FlowViewer.svelte | 6 +- .../src/lib/components/FolderEditor.svelte | 34 ++- .../src/lib/components/FolderPicker.svelte | 10 +- .../components/GitHubAppIntegration.svelte | 54 ++-- .../lib/components/GitLabIntegration.svelte | 14 +- .../components/GitRepoPopoverPicker.svelte | 10 +- .../components/GitRepoResourcePicker.svelte | 6 +- .../src/lib/components/GitRepoViewer.svelte | 6 +- .../src/lib/components/GroupEditor.svelte | 43 +++- .../src/lib/components/HistoricInputs.svelte | 6 +- .../src/lib/components/HistoricList.svelte | 14 +- .../components/InputTransformPickers.svelte | 6 +- .../InputTransformSchemaForm.svelte | 6 +- frontend/src/lib/components/JobLoader.svelte | 6 +- .../src/lib/components/JobOtelTraces.svelte | 43 ++-- .../src/lib/components/LabelsInput.svelte | 6 +- .../LightweightResourcePicker.svelte | 8 +- frontend/src/lib/components/LogViewer.svelte | 10 +- .../lib/components/ModulePreviewForm.svelte | 6 +- frontend/src/lib/components/ModuleTest.svelte | 6 +- .../components/ParqetCsvTableRenderer.svelte | 9 +- .../lib/components/PasswordArgInput.svelte | 7 +- frontend/src/lib/components/Path.svelte | 56 +++-- .../components/PathNameAutocomplete.svelte | 11 +- .../lib/components/PermissionHistory.svelte | 14 +- .../src/lib/components/QueuePosition.svelte | 6 +- .../src/lib/components/ResourceEditor.svelte | 12 +- .../components/ResourceEditorDrawer.svelte | 71 ++++-- .../src/lib/components/ResourceForm.svelte | 7 +- .../src/lib/components/ResourcePicker.svelte | 6 +- .../lib/components/ResourceTypePicker.svelte | 8 +- .../components/ResourceVersionHistory.svelte | 6 +- .../src/lib/components/S3FilePicker.svelte | 6 +- .../lib/components/S3FilePickerInner.svelte | 6 +- .../src/lib/components/S3FilePreview.svelte | 6 +- .../lib/components/SaveInputsButton.svelte | 6 +- .../lib/components/SavedInputsPicker.svelte | 16 +- frontend/src/lib/components/SchemaForm.svelte | 6 +- .../src/lib/components/ScriptBuilder.svelte | 17 +- .../src/lib/components/ScriptEditor.svelte | 13 +- .../src/lib/components/ScriptPicker.svelte | 18 +- .../components/ScriptVersionHistory.svelte | 8 +- frontend/src/lib/components/SqlRepl.svelte | 6 +- .../lib/components/SummaryPathDisplay.svelte | 24 +- .../src/lib/components/TestConnection.svelte | 6 +- .../src/lib/components/VariableEditor.svelte | 43 +++- .../src/lib/components/VariableForm.svelte | 7 +- .../src/lib/components/WorkerTagPicker.svelte | 12 +- .../src/lib/components/WorkerTagSelect.svelte | 14 +- .../lib/components/WorkflowTimeline.svelte | 18 +- .../WorkspaceItemDrillPicker.svelte | 11 +- .../lib/components/aiEvals/EvalsPane.svelte | 6 +- .../apps/editor/AppEditorHeaderDeploy.svelte | 36 +-- .../apps/editor/AppJobsDrawer.svelte | 12 +- .../apps/editor/DeploymentHistory.svelte | 14 +- .../InlineScriptRunnableByPath.svelte | 12 +- .../mainInput/RunnableSelector.svelte | 13 +- .../mainInput/WorkspaceFlowList.svelte | 6 +- .../mainInput/WorkspaceScriptList.svelte | 6 +- .../AssetGraph/AssetGraphDetailsPane.svelte | 7 +- .../assets/AssetGraph/AssetNode.svelte | 15 +- .../assets/AssetGraph/AssetRunsPanel.svelte | 18 +- .../assets/AssetGraph/DataTablePreview.svelte | 14 +- .../AssetGraph/DucklakeResultPreview.svelte | 12 +- .../AssetGraph/DucklakeVersionPreview.svelte | 13 +- .../AssetGraph/PipelineScriptView.svelte | 6 +- .../AssetGraph/PipelineTriggerEditors.svelte | 13 +- .../assets/AssetGraph/RunnableNode.svelte | 6 +- .../assets/AssetsDropdownButton.svelte | 10 +- .../components/assets/JobAssetsViewer.svelte | 16 +- .../DraftChangesConfirmationModal.svelte | 9 +- .../common/drawer/DrawerContent.svelte | 34 ++- .../common/fileDownload/FileDownload.svelte | 6 +- .../common/fileUpload/FileUpload.svelte | 16 +- .../components/copilot/StepGenQuick.svelte | 6 +- .../copilot/chat/AIChatMessage.svelte | 8 +- .../copilot/chat/AIChatModelSettings.svelte | 18 +- .../chat/AssistantInstructionsSection.svelte | 10 +- .../chat/AssistantSettingsModal.svelte | 6 +- .../copilot/chat/ChatContextPicker.svelte | 6 +- .../chat/DatatableCreationPolicy.svelte | 6 +- .../copilot/chat/LinkRenderer.svelte | 8 +- .../copilot/chat/ToolMessageActions.svelte | 5 +- .../chat/createdResourceActions.svelte.ts | 15 +- .../components/copilot/chat/global/core.ts | 12 +- .../src/lib/components/copilot/chat/shared.ts | 10 +- .../src/lib/components/dbt/DbtEditor.svelte | 6 +- .../details/ErrorHandlerToggleButtonV2.svelte | 10 +- .../components/flows/FlowAssetsHandler.svelte | 6 +- .../lib/components/flows/FlowEditor.svelte | 15 +- .../components/flows/FlowHistoryInner.svelte | 6 +- .../flows/common/FlowCardHeader.svelte | 7 +- .../flows/content/AgentEditorModal.svelte | 6 +- .../flows/content/AgentResourceBar.svelte | 8 +- .../flows/content/AiAgentStepInputs.svelte | 6 +- .../flows/content/ExpandedSubflowStep.svelte | 6 +- .../flows/content/FlowEditorDrawer.svelte | 6 +- .../content/FlowEnvironmentVariables.svelte | 6 +- .../components/flows/content/FlowInput.svelte | 6 +- .../flows/content/FlowInputsFlow.svelte | 6 +- .../flows/content/FlowInputsQuick.svelte | 24 +- .../flows/content/FlowModuleComponent.svelte | 6 +- .../flows/content/FlowModuleScript.svelte | 6 +- .../flows/content/FlowModuleSuspend.svelte | 9 +- .../content/FlowModuleWorkerTagSelect.svelte | 4 +- .../flows/content/FlowPathViewer.svelte | 8 +- .../flows/content/FlowSettings.svelte | 16 +- .../flows/content/McpToolEditor.svelte | 5 +- .../flows/content/ScriptEditorDrawer.svelte | 6 +- .../WorkspaceScriptSettingsDrawer.svelte | 6 +- .../flows/conversations/FlowChat.svelte | 7 +- .../flows/map/FlowModuleSchemaMap.svelte | 6 +- .../flows/map/InsertModuleInner.svelte | 6 +- .../flows/pickers/PickHubScript.svelte | 7 +- .../flows/pickers/PickHubScriptQuick.svelte | 7 +- .../pickers/WorkspaceScriptPicker.svelte | 10 +- .../pickers/WorkspaceScriptPickerQuick.svelte | 11 +- .../flows/propPicker/StepHistory.svelte | 6 +- .../lib/components/graph/FlowGraphV2.svelte | 6 +- .../graph/renderers/nodes/AssetNode.svelte | 7 +- .../jobs/MissingWorkerTagAlert.svelte | 6 +- .../components/metrics/MetricsDrawer.svelte | 6 +- .../components/operatingWorkspace.svelte.ts | 48 ++++ .../lib/components/operatingWorkspace.test.ts | 119 +++++++++ .../propertyPicker/ObjectViewer.svelte | 14 +- .../propertyPicker/PropPicker.svelte | 8 +- .../raw_apps/DefaultDatabaseSelector.svelte | 7 +- .../raw_apps/RawAppDataTableDrawer.svelte | 7 +- .../components/raw_apps/RawAppEditor.svelte | 15 +- .../raw_apps/RawAppEditorHeader.svelte | 26 +- .../raw_apps/RawAppInlineScriptEditor.svelte | 7 +- .../RawAppInlineScriptRunnable.svelte | 15 +- .../raw_apps/RawAppInlineScriptsPanel.svelte | 7 +- .../raw_apps/RawAppInputsSpecEditor.svelte | 15 +- .../raw_apps/RawAppSharedUiDrawer.svelte | 7 +- .../raw_apps/RawAppTemplatePicker.svelte | 8 +- .../components/raw_apps/rawAppWorkspace.ts | 22 -- .../components/runs/JobDetailHeader.svelte | 24 +- .../runs/NoWorkerWithTagWarning.svelte | 6 +- .../src/lib/components/runs/RunBadges.svelte | 6 +- .../components/schema/JobSchemaPicker.svelte | 6 +- .../schema/RunningJobSchemaPicker.svelte | 62 +++-- .../components/scriptEditor/LogPanel.svelte | 12 +- .../sessions/OpenInSessionButton.svelte | 7 +- .../sessions/PageItemEditorView.svelte | 174 +++++++++++++ .../components/sessions/PreviewTabHost.svelte | 23 ++ .../sessions/SessionChangesBar.svelte | 4 +- .../sessions/SessionEditorTarget.svelte | 2 + .../components/sessions/pageDrawerSession.ts | 35 ++- .../lib/components/sessions/pageItemLookup.ts | 62 +++++ .../lib/components/sessions/previewPaths.ts | 81 ++++++ .../components/sessions/previewReload.test.ts | 21 ++ .../lib/components/sessions/previewReload.ts | 74 ++++-- .../components/sessions/previewRouter.test.ts | 21 +- .../lib/components/sessions/previewRouter.ts | 70 +++++- .../components/sessions/sessionMode.svelte.ts | 11 + .../sessions/sessionPreviewTabs.svelte.ts | 136 ++++++---- .../sessions/sessionPreviewTabs.test.ts | 236 +++++++----------- .../sessions/sessionRuntime.svelte.ts | 20 +- .../components/settings/CreateToken.svelte | 18 +- .../settings/EditTokenScopesModal.svelte | 6 +- .../settings/UserAIPromptsSettings.svelte | 7 +- .../triggers/AddTriggersButton.svelte | 10 +- .../components/triggers/CaptureSection.svelte | 10 +- .../components/triggers/CaptureTable.svelte | 11 +- .../components/triggers/CaptureWrapper.svelte | 10 +- .../triggers/PermissionedAsLine.svelte | 15 +- .../triggers/TestTriggerConnection.svelte | 7 +- .../triggers/TriggerEditorToolbar.svelte | 6 +- .../triggers/TriggerHistoryButton.svelte | 7 +- .../triggers/TriggerSuspendedJobsModal.svelte | 7 +- .../components/triggers/TriggerTokens.svelte | 8 +- .../components/triggers/TriggersEditor.svelte | 39 +-- .../amqp/AmqpEditorConfigSection.svelte | 7 +- .../amqp/AmqpTriggerEditorInner.svelte | 96 ++++--- .../AzureTriggerEditorConfigSection.svelte | 18 +- .../azure/AzureTriggerEditorInner.svelte | 113 ++++++--- .../triggers/email/DefaultEmailCapture.svelte | 6 +- .../email/DefaultEmailConfigSection.svelte | 9 +- .../triggers/email/DefaultEmailPanel.svelte | 16 +- .../triggers/email/EmailCapture.svelte | 6 +- .../EmailTriggerEditorConfigSection.svelte | 14 +- .../email/EmailTriggerEditorInner.svelte | 102 +++++--- .../triggers/email/EmailTriggerPanel.svelte | 8 +- .../components/triggers/gcp/GcpCapture.svelte | 6 +- .../gcp/GcpTriggerEditorConfigSection.svelte | 21 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 102 +++++--- .../triggers/http/OpenAPISpecGenerator.svelte | 9 +- .../triggers/http/RouteCapture.svelte | 6 +- .../triggers/http/RouteCorsOption.svelte | 4 +- .../http/RouteEditorConfigSection.svelte | 24 +- .../triggers/http/RouteEditorInner.svelte | 121 +++++---- .../triggers/http/RoutesGenerator.svelte | 9 +- .../triggers/http/RoutesPanel.svelte | 7 +- .../kafka/KafkaTriggerEditorInner.svelte | 102 +++++--- .../kafka/KafkaTriggersConfigSection.svelte | 8 +- .../mqtt/MqttEditorConfigSection.svelte | 7 +- .../mqtt/MqttTriggerEditorInner.svelte | 102 +++++--- .../native/NativeTriggerEditor.svelte | 41 ++- .../triggers/native/NativeTriggerTable.svelte | 9 +- .../services/github/GitHubTriggerForm.svelte | 10 +- .../google/GoogleCalendarPicker.svelte | 16 +- .../services/google/GoogleDrivePicker.svelte | 74 +++--- .../nextcloud/NextcloudTriggerForm.svelte | 10 +- .../nats/NatsTriggerEditorInner.svelte | 102 +++++--- .../nats/NatsTriggersConfigSection.svelte | 7 +- .../postgres/CheckPostgresRequirement.svelte | 15 +- .../PostgresTriggerEditorInner.svelte | 96 ++++--- .../postgres/PublicationPicker.svelte | 27 +- .../triggers/postgres/SlotPicker.svelte | 17 +- .../schedules/ScheduleEditorInner.svelte | 80 +++--- .../sqs/SqsTriggerEditorConfigSection.svelte | 7 +- .../triggers/sqs/SqsTriggerEditorInner.svelte | 102 +++++--- .../components/triggers/triggerWorkspace.ts | 29 --- .../triggers/webhook/WebhooksCapture.svelte | 6 +- .../webhook/WebhooksConfigSection.svelte | 8 +- .../websocket/WebsocketCapture.svelte | 26 +- .../WebsocketEditorConfigSection.svelte | 8 +- .../WebsocketTriggerEditorInner.svelte | 111 +++++--- .../lib/components/wizards/AppPicker.svelte | 33 ++- .../DataTableMigrationsButton.svelte | 8 +- .../src/routes/(root)/(logged)/+layout.svelte | 14 +- .../(root)/(logged)/resources/+page.svelte | 6 +- .../(root)/(logged)/sessions/+page.svelte | 93 ++++--- 256 files changed, 3455 insertions(+), 1774 deletions(-) create mode 100644 frontend/src/lib/components/operatingWorkspace.svelte.ts create mode 100644 frontend/src/lib/components/operatingWorkspace.test.ts delete mode 100644 frontend/src/lib/components/raw_apps/rawAppWorkspace.ts create mode 100644 frontend/src/lib/components/sessions/PageItemEditorView.svelte create mode 100644 frontend/src/lib/components/sessions/pageItemLookup.ts delete mode 100644 frontend/src/lib/components/triggers/triggerWorkspace.ts diff --git a/frontend/src/lib/components/AIProviderPicker.svelte b/frontend/src/lib/components/AIProviderPicker.svelte index 19b1346b84..f69bb6e8f4 100644 --- a/frontend/src/lib/components/AIProviderPicker.svelte +++ b/frontend/src/lib/components/AIProviderPicker.svelte @@ -3,11 +3,13 @@ import Select from './select/Select.svelte' import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib' import type { AIProvider, ProviderConfig } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import ResourcePicker from './ResourcePicker.svelte' import Toggle from './Toggle.svelte' import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage' import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { value: ProviderConfig | undefined @@ -26,7 +28,7 @@ workspace = undefined }: Props = $props() - let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '') + let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace ?? '') let value = $derived.by(() => { if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined diff --git a/frontend/src/lib/components/AgentTrace.svelte b/frontend/src/lib/components/AgentTrace.svelte index 8c151e192c..91b87fea67 100644 --- a/frontend/src/lib/components/AgentTrace.svelte +++ b/frontend/src/lib/components/AgentTrace.svelte @@ -2,7 +2,6 @@ import { ExternalLink, Globe } from 'lucide-svelte' import { JobService, type Job } from '$lib/gen' import { base } from '$lib/base' - import { workspaceStore } from '$lib/stores' import { msToReadableTimeShort } from '$lib/utils' import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte' import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte' @@ -10,6 +9,9 @@ import GfmMarkdown from './GfmMarkdown.svelte' import type { AgentTraceEntry } from './agentTrace' import { SvelteMap, SvelteSet } from 'svelte/reactivity' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { entries: AgentTraceEntry[] @@ -40,7 +42,7 @@ try { jobs.set( jobId, - await JobService.getJob({ id: jobId, workspace: workspaceId ?? $workspaceStore! }) + await JobService.getJob({ id: jobId, workspace: workspaceId ?? $operatingWorkspace! }) ) } catch { // A tool job can be gone (retention) or unreadable. The row still has its @@ -121,7 +123,7 @@ {:else if entry.jobId} diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index dedeee90e2..2255817c5e 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -1,7 +1,6 @@ @@ -20,7 +22,7 @@ Setup the wmill cli for this workspace & remote:
diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 7520cc5013..fc1bbb9752 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -1,5 +1,5 @@ -{#if $userStore?.is_admin || $userStore?.is_super_admin} +{#if actingUser?.is_admin || actingUser?.is_super_admin} diff --git a/frontend/src/lib/components/DefaultScriptsInner.svelte b/frontend/src/lib/components/DefaultScriptsInner.svelte index eaa3d989a0..b062fb4c49 100644 --- a/frontend/src/lib/components/DefaultScriptsInner.svelte +++ b/frontend/src/lib/components/DefaultScriptsInner.svelte @@ -1,10 +1,13 @@
@@ -46,9 +47,6 @@ {onClear} /> {#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'ducklake', path: value })} - + {/if}
diff --git a/frontend/src/lib/components/EditableSchemaForm.svelte b/frontend/src/lib/components/EditableSchemaForm.svelte index bd3399d9d2..79173424d9 100644 --- a/frontend/src/lib/components/EditableSchemaForm.svelte +++ b/frontend/src/lib/components/EditableSchemaForm.svelte @@ -4,7 +4,6 @@ const bubble = createBubbler() import type { Schema } from '$lib/common' import { VariableService, type ScriptLang } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { Button } from './common' import ItemPicker from './ItemPicker.svelte' import VariableEditor from './VariableEditor.svelte' @@ -36,6 +35,9 @@ import Section from '$lib/components/Section.svelte' import Editor from './Editor.svelte' import AddPropertyV2 from './schema/AddPropertyV2.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() // export let openEditTab: () => void = () => {} const dispatch = createEventDispatcher() @@ -128,7 +130,7 @@ workspace = undefined }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? $operatingWorkspace) $effect.pre(() => { if (args == undefined) { diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 987aa937eb..6aabc30c2b 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -42,7 +42,6 @@ import { editorConfig, registerWebviewPaste, updateOptions } from '$lib/editorUtils' import { editorFontSize } from '$lib/editorFontSize.svelte' import { createHash as randomHash } from '$lib/editorLangUtils' - import { workspaceStore } from '$lib/stores' import DdlMigrationGuard from './DdlMigrationGuard.svelte' import { type Preview, @@ -120,6 +119,9 @@ import { rawAppLintStore, type MonacoLintError } from './raw_apps/lintStore' import { MarkerSeverity } from 'monaco-editor' import { resource, useDebounce, watch } from 'runed' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() // import EditorTheme from './EditorTheme.svelte' let divEl: HTMLDivElement | null = $state(null) @@ -684,7 +686,7 @@ // via a short-TTL cache — macros are late-bound, so mild staleness is fine. async function addWorkspaceMacroCompletions() { workspaceMacroCompletor?.dispose() - const workspace = $workspaceStore + const workspace = $operatingWorkspace if (!workspace) return let macros: Awaited> = [] try { @@ -739,7 +741,7 @@ provideCompletionItems: async function (model, position) { // Read the store per request, not at registration — the provider // outlives a workspace switch. - const workspace = $workspaceStore + const workspace = $operatingWorkspace if (!workspace) return { suggestions: [] } const before = model.getLineContent(position.lineNumber).slice(0, position.column - 1) if (!/^\s*(\/\/|--|#)\s*(column|data_test|on|materialize)\b/.test(before)) { @@ -780,7 +782,7 @@ $dbSchemas[resourcePath] = await getDbSchemas( lang === 'graphql' ? 'graphql' : (scriptLang ?? ''), resourcePath, - $workspaceStore, + $operatingWorkspace, (e) => console.error(`error getting ${lang} (${scriptLang}) db schema`, e), { customTag } ) @@ -1778,9 +1780,9 @@ let customTsTypesData = resource([() => lang], async () => { if (lang !== 'typescript') return undefined let datatables = ( - await WorkspaceService.listDataTables({ workspace: $workspaceStore ?? '' }) + await WorkspaceService.listDataTables({ workspace: $operatingWorkspace ?? '' }) ).map((d) => d.name) - let ducklakes = await WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' }) + let ducklakes = await WorkspaceService.listDucklakes({ workspace: $operatingWorkspace ?? '' }) return { datatables, ducklakes } }) function setTypescriptCustomTypes() { @@ -1822,7 +1824,7 @@ scriptLang === 'nativets') ) { const resourceTypes = await ResourceService.listResourceType({ - workspace: $workspaceStore ?? '' + workspace: $operatingWorkspace ?? '' }) const namespace = formatResourceTypes( @@ -2023,7 +2025,7 @@ $lspTokenStore = newToken token = newToken } - let root = hostname + '/api/scripts_u/tokened_raw/' + $workspaceStore + '/' + token + let root = hostname + '/api/scripts_u/tokened_raw/' + $operatingWorkspace + '/' + token return root } @@ -2274,10 +2276,10 @@ -{#if datatableForMigrations && $workspaceStore} +{#if datatableForMigrations && $operatingWorkspace} {/if} diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index 91516843ab..81ef6c1c96 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -22,7 +22,6 @@ {/snippet}
-
+{/snippet} diff --git a/frontend/src/lib/components/ResourceForm.svelte b/frontend/src/lib/components/ResourceForm.svelte index df878280a7..1d4f091236 100644 --- a/frontend/src/lib/components/ResourceForm.svelte +++ b/frontend/src/lib/components/ResourceForm.svelte @@ -8,7 +8,7 @@ import { Alert, Skeleton } from './common' import Path from './Path.svelte' import LabelsInput from './LabelsInput.svelte' - import { workspaceStore, type UserExt } from '$lib/stores' + import { type UserExt } from '$lib/stores' import SchemaForm from './SchemaForm.svelte' import SimpleEditor from './SimpleEditor.svelte' import FilesetEditor from './FilesetEditor.svelte' @@ -22,6 +22,9 @@ import SyncResourceTypes from './SyncResourceTypes.svelte' import Label from './Label.svelte' import ResourcePathHint from './ResourcePathHint.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { path: string @@ -81,7 +84,7 @@ onCredentialStored }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? $operatingWorkspace) let rawCode: string | undefined = $state(undefined) let textFileContent: string = $state('') diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index 06023b1bb4..778c1d2614 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -1,6 +1,5 @@ - clearPageDrawerAnchor(VARIABLES_PATH)}> +{#if inline} + {@render content()} +{:else} + clearPageDrawerAnchor(VARIABLES_PATH)}> + {@render content()} + +{/if} + +{#snippet content()} (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet banner()} {/snippet} - +{/snippet} diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte index 61945eef08..63fdbf7425 100644 --- a/frontend/src/lib/components/VariableForm.svelte +++ b/frontend/src/lib/components/VariableForm.svelte @@ -10,10 +10,13 @@ import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { Loader2, RotateCcw } from 'lucide-svelte' import autosize from '$lib/autosize' - import { workspaceStore, type UserExt } from '$lib/stores' + import { type UserExt } from '$lib/stores' import { isOwner } from '$lib/utils' import { isEncryptedDraftValue } from '$lib/encryptedDraft' import EncryptedDraftField from './EncryptedDraftField.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Variable { value: string @@ -56,7 +59,7 @@ actingUser }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? $operatingWorkspace) // Loading the deployed secret overwrites the draft row this form shares with the AI // chat, so every path that would trigger it has to be blocked while that row stages a diff --git a/frontend/src/lib/components/WorkerTagPicker.svelte b/frontend/src/lib/components/WorkerTagPicker.svelte index ccc3266ced..dae4967e05 100644 --- a/frontend/src/lib/components/WorkerTagPicker.svelte +++ b/frontend/src/lib/components/WorkerTagPicker.svelte @@ -2,6 +2,7 @@ import { Button } from '$lib/components/common' import { ExternalLink, RotateCw, Loader2 } from 'lucide-svelte' import { workerTags, workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import AssignableTags from './AssignableTags.svelte' import { WorkerService } from '$lib/gen' import WorkerTagSelect from './WorkerTagSelect.svelte' @@ -11,8 +12,8 @@ popupPlacement?: 'bottom-end' | 'top-end' disabled?: boolean placeholder?: string - // Workspace to read tags from; defaults to $workspaceStore. A fork-scoped - // session passes its effective workspace so the picker matches the deploy target. + // Workspace to read tags from; defaults to the operating workspace (see + // `useOperatingWorkspace`). workspaceId?: string } @@ -26,8 +27,11 @@ // See WorkerTagSelect: the shared `workerTags` cache is navigation-scoped, so a // different target workspace reads/writes a local list to avoid clobbering it. - let effectiveWorkspace = $derived(workspaceId ?? $workspaceStore) - let usesLocal = $derived(workspaceId != undefined && workspaceId !== $workspaceStore) + const operatingWorkspace = useOperatingWorkspace() + let effectiveWorkspace = $derived(workspaceId ?? $operatingWorkspace) + let usesLocal = $derived( + effectiveWorkspace != undefined && effectiveWorkspace !== $workspaceStore + ) let localWorkerTags = $state(undefined) let currentTags = $derived(usesLocal ? localWorkerTags : $workerTags) diff --git a/frontend/src/lib/components/WorkerTagSelect.svelte b/frontend/src/lib/components/WorkerTagSelect.svelte index fe00f07f9d..624aeb1bcd 100644 --- a/frontend/src/lib/components/WorkerTagSelect.svelte +++ b/frontend/src/lib/components/WorkerTagSelect.svelte @@ -1,5 +1,6 @@ + +{#snippet loading()} +
+ +
+{/snippet} + +
+ {#if eeLocked} +
This trigger requires an enterprise license.
+ {:else if lookup.loading || !lookup.current} + {@render loading()} + {:else if !lookup.current.exists} +
+

+ {pageItemKindLabel(item)} {item.path} no longer exists in this + workspace. +

+ +
+ {:else} + {#key `${item.kind}:${triggerKey}:${item.path}:${workspaceId}:${reloadNonce}:${savedNonce}`} + {#if item.kind === 'variable'} + + {:else if item.kind === 'resource'} + { + if (path !== undefined) onSaved(path) + }} + onRestored={() => savedNonce++} + /> + {:else if triggerKey} + {#await TRIGGER_EDITORS[triggerKey]()} + {@render loading()} + {:then Module} + onSaved(path)} + /> + {/await} + {/if} + {/key} + {/if} +
diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 3dee7dd1b3..b88907bf02 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -103,12 +103,19 @@ applyPageIframeTheme(darkMode) }) + // A page item's editor reads its draft only when it loads, so a reload remounts it. + let pageItemReloadNonce = $state(0) + export function reload() { // A live editor shares the runtime store the chat mutates, so generic chat // edits are already reflected — no reload needed. Deploys refresh it via // each editor view's onDeploy → runtime.syncPreviewWithDeployed. So only the // iframe fallback (a separate page) has to be told to refresh. if (slot.kind === 'editor') return + if (slot.kind === 'pageitem') { + pageItemReloadNonce++ + return + } try { const win = frame?.contentWindow if (!win) return @@ -311,6 +318,22 @@ {/await} {/if}
+{:else if slot.kind === 'pageitem' && mounted && runtime} +
+ + {#if overlayHostEl} + {#await import('./PageItemEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {/if} +
{:else if slot.kind === 'artifact' && mounted}
workspaceId) // This tab's storage path, for the editor below: several tabs are mounted at // once and only this one knows which item each is open on. diff --git a/frontend/src/lib/components/sessions/pageDrawerSession.ts b/frontend/src/lib/components/sessions/pageDrawerSession.ts index ca7379be20..16d8d9204a 100644 --- a/frontend/src/lib/components/sessions/pageDrawerSession.ts +++ b/frontend/src/lib/components/sessions/pageDrawerSession.ts @@ -11,6 +11,7 @@ import type { UserDraftItemKind } from '$lib/gen' // flow editors, where pulling the filter schemas that module reads views from would make // every trigger's save utils eager. import { + drawerHashFor, pageHref, stripBase, TRIGGER_PAGES, @@ -20,6 +21,7 @@ import { type TriggerKind } from './previewPaths' import type { OpenInSessionSource } from './OpenInSessionButton.svelte' +import { isSessionPreviewFrame } from './sessionMode.svelte' // The draft each page's drawer edits. The preview loads the page in its own // document and reads the draft back from the server, so opening a session has to @@ -63,11 +65,6 @@ async function flushOrRefuse(query: Parameters[0 } } -// How each page addresses a row in its hash. Resources route theirs through an extra -// segment; every other page names the path directly. -const drawerHashFor = (pagePath: string, itemPath: string) => - pagePath === RESOURCES_PATH ? `/resource/${itemPath}` : itemPath - /** * Deep-link the row whose drawer just opened, so the location says what is on screen — a * drawer opened from a row's Edit button is as open as one reached by link, and the chat @@ -87,6 +84,34 @@ export function setPageDrawerAnchor(pagePath: string, itemPath: string | undefin history.replaceState(history.state, '', `${pathname}${search}${anchor}`) } +/** + * Inside a session preview frame, hand a list page row up to the session, which edits it in + * a tab of its own. True when handed off: the caller must then not open its drawer. False + * off that page, and outside a preview frame, where the drawer is how the row is edited. + */ +export function handOffPageDrawer(pagePath: string, itemPath: string | undefined): boolean { + if (!itemPath || !isSessionPreviewFrame()) return false + if (stripBase(window.location.pathname) !== pagePath) return false + try { + window.parent.postMessage( + { type: 'wm.session.openPageItem', pagePath, path: itemPath }, + window.location.origin + ) + } catch { + return false + } + // A frame left on a row's hash claims a row nobody has open here, and reopens its tab on + // every reload. Not through the router: these pages open their drawer from the hash. Once + // more after the event: a row link's `href="#"` lands after its click handler. + const dropAnchor = () => { + const { pathname, search, hash } = window.location + if (hash) history.replaceState(history.state, '', `${pathname}${search}`) + } + dropAnchor() + setTimeout(dropAnchor, 0) + return true +} + /** * Drop the row a list page deep-links, once its drawer closes. The hash is how the row was * requested; leaving it behind makes the location claim a drawer that is no longer open — diff --git a/frontend/src/lib/components/sessions/pageItemLookup.ts b/frontend/src/lib/components/sessions/pageItemLookup.ts new file mode 100644 index 0000000000..bee3fc41c1 --- /dev/null +++ b/frontend/src/lib/components/sessions/pageItemLookup.ts @@ -0,0 +1,62 @@ +import { + AmqpTriggerService, + ApiError, + AzureTriggerService, + EmailTriggerService, + GcpTriggerService, + HttpTriggerService, + KafkaTriggerService, + MqttTriggerService, + NatsTriggerService, + PostgresTriggerService, + ResourceService, + ScheduleService, + SqsTriggerService, + VariableService, + WebsocketTriggerService +} from '$lib/gen' +import type { PageItemRef, TriggerKind } from './previewPaths' + +type Get = (args: { workspace: string; path: string; getDraft: boolean }) => Promise + +const TRIGGER_GETS: Record = { + http: (a) => HttpTriggerService.getHttpTrigger(a), + websocket: (a) => WebsocketTriggerService.getWebsocketTrigger(a), + postgres: (a) => PostgresTriggerService.getPostgresTrigger(a), + kafka: (a) => KafkaTriggerService.getKafkaTrigger(a), + nats: (a) => NatsTriggerService.getNatsTrigger(a), + mqtt: (a) => MqttTriggerService.getMqttTrigger(a), + amqp: (a) => AmqpTriggerService.getAmqpTrigger(a), + sqs: (a) => SqsTriggerService.getSqsTrigger(a), + gcp: (a) => GcpTriggerService.getGcpTrigger(a), + azure: (a) => AzureTriggerService.getAzureTrigger(a), + email: (a) => EmailTriggerService.getEmailTrigger(a) +} + +function getFor(ref: PageItemRef): Get { + switch (ref.kind) { + case 'variable': + return (a) => VariableService.getVariable(a) + case 'resource': + return (a) => ResourceService.getResource(a) + case 'schedule': + return (a) => ScheduleService.getSchedule(a) + case 'trigger': + return TRIGGER_GETS[ref.triggerKind] + } +} + +/** A page item as its editor would load it — deployed, or only a draft — or undefined when it + * is neither: deleted, or a draft that was discarded. Any other failure is thrown, for the + * caller to leave to the editor rather than report as a missing item. */ +export async function lookupPageItem( + ref: PageItemRef, + workspace: string +): Promise | undefined> { + try { + return (await getFor(ref)({ workspace, path: ref.path, getDraft: true })) as Record + } catch (e) { + if (e instanceof ApiError && e.status === 404) return undefined + throw e + } +} diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 8d10dc1a32..13d5abca33 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -48,6 +48,87 @@ export const TRIGGER_PAGES: Record + pagePath === RESOURCES_PATH ? `/resource/${itemPath}` : itemPath + +/** The full page a page item is edited on: its list page, with the row's drawer open. */ +export function pageItemPageHref(ref: PageItemRef): string { + const listPath = pageItemListPath(ref) + return `${pageHref(listPath)}#${drawerHashFor(listPath, ref.path)}` +} + +/** The list page a page item is edited from. */ +export function pageItemListPath(ref: PageItemRef): string { + switch (ref.kind) { + case 'variable': + return VARIABLES_PATH + case 'resource': + return RESOURCES_PATH + case 'schedule': + return SCHEDULES_PATH + case 'trigger': + return TRIGGER_PAGES[ref.triggerKind].path + } +} + +/** The page item a list page's row names, or undefined for a page that lists none. */ +export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined { + const clean = stripBase(pagePath) + if (clean === VARIABLES_PATH) return { kind: 'variable', path } + if (clean === RESOURCES_PATH) return { kind: 'resource', path } + if (clean === SCHEDULES_PATH) return { kind: 'schedule', path } + const trigger = Object.entries(TRIGGER_PAGES).find(([, p]) => p.path === clean) + return trigger ? { kind: 'trigger', triggerKind: trigger[0] as TriggerKind, path } : undefined +} + +const PAGE_ITEM_ROUTE = /^pageitem:(variable|resource|schedule|trigger\.([a-z]+))\/([^?#]+)$/ + +// A scheme rather than a path, like artifacts: the tab mounts the item's editor in process, +// so there is no page a frame could load. The path is encoded whole, so its slashes cannot +// be read as part of the scheme. +export function pageItemUrl(ref: PageItemRef): string { + const kind = ref.kind === 'trigger' ? `trigger.${ref.triggerKind}` : ref.kind + return `pageitem:${kind}/${encodeURIComponent(ref.path)}` +} + +export function parsePageItemRoute(url: string): PageItemRef | null { + const m = url.match(PAGE_ITEM_ROUTE) + if (!m) return null + let path: string + try { + path = decodeURIComponent(m[3]) + } catch { + return null + } + if (m[2] !== undefined) { + if (!(m[2] in TRIGGER_PAGES)) return null + return { kind: 'trigger', triggerKind: m[2] as TriggerKind, path } + } + return { kind: m[1] as 'variable' | 'resource' | 'schedule', path } +} + +/** Singular human name of a page item's kind, e.g. "Kafka trigger". */ +export function pageItemKindLabel(ref: PageItemRef): string { + switch (ref.kind) { + case 'variable': + return 'Variable' + case 'resource': + return 'Resource' + case 'schedule': + return 'Schedule' + case 'trigger': + return TRIGGER_PAGES[ref.triggerKind].label.replace(/s$/, '') + } +} + /** Label a trigger list page from its (base-stripped) pathname, or undefined. */ export function triggerLabelForPath(path: string): string | undefined { const clean = stripBase(path) diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index de11805b9a..ce9491d334 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { toolReloadEffect, tabsToReload } from './previewReload' import type { SessionPreviewTab } from './sessionState.svelte' +import { pageItemUrl } from './previewPaths' describe('toolReloadEffect', () => { it('maps a non-item mutation to its own list page only', () => { @@ -52,6 +53,26 @@ describe('toolReloadEffect', () => { }) }) +describe('page item tabs', () => { + const tab = (url: string): SessionPreviewTab => ({ id: url, url, loc: url }) + const kafkaA = tab('pageitem:trigger.kafka/u%2Fme%2Fa') + const kafkaB = tab('pageitem:trigger.kafka/u%2Fme%2Fb') + const list = tab('/kafka_triggers') + + it('reloads only the trigger a write names, and its list page', () => { + const { pages, items } = toolReloadEffect('write_trigger', { + kind: 'kafka', + config: { path: 'u/me/a' } + }) + const named = new Set(items.map((i) => pageItemUrl(i))) + expect(tabsToReload([kafkaA, kafkaB, list], new Set(pages), named)).toEqual([kafkaA, list]) + }) + + it('reloads every tab of the kind when the tool names no item', () => { + expect(tabsToReload([kafkaA, kafkaB], new Set(['/kafka_triggers']))).toEqual([kafkaA, kafkaB]) + }) +}) + describe('tabsToReload', () => { const scheduleTab: SessionPreviewTab = { id: 's', url: '/schedules', loc: '/schedules' } const resourceTab: SessionPreviewTab = { id: 'r', url: '/resources', loc: '/resources' } diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 6e9d750a1f..bdfbeaa3ff 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -1,6 +1,14 @@ import type { SessionPreviewTab } from './sessionState.svelte' import { whereIs } from './sessionPreviewTabs.svelte' -import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' +import { + pageItemListPath, + pageItemUrl, + parsePageItemRoute, + stripBase, + TRIGGER_PAGES, + type PageItemRef, + type TriggerKind +} from './previewPaths' // Which list pages a completed chat tool can change, as base-stripped paths // (e.g. `/schedules`). This allowlist is the single source of truth for "does @@ -13,23 +21,27 @@ import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' // deliberately absent: every editable item is a live in-process editor that // self-syncs from the store the chat mutates, so its tab needs no reload — and // no list page we preview lists open drafts. They fall through to NO_RELOAD. -// This "live editors self-sync, only list pages reload" invariant is the reason -// the callers below and in the sessions page reload nothing for item tabs. -export type ToolReloadEffect = { pages: string[] } -const NO_RELOAD: ToolReloadEffect = { pages: [] } +// +// Page items (variables, resources, schedules, triggers) are the exception among +// in-process tabs: their editors read a draft only when they open, so a write to +// one reloads its tab too. `items` names it when the tool's args do; without a +// path, every tab of that kind reloads. +export type ToolReloadEffect = { pages: string[]; items: PageItemRef[] } +const NO_RELOAD: ToolReloadEffect = { pages: [], items: [] } export function toolReloadEffect(name: string, args: any): ToolReloadEffect { switch (name) { case 'write_schedule': - return { pages: ['/schedules'] } + return withItem(['/schedules'], itemRef('schedule', args)) case 'write_trigger': - return { pages: triggerPages(args?.kind) } + // Its path sits in the trigger's own config, not beside `kind`. + return withItem(triggerPages(args?.kind), itemRef('trigger', args?.config, args?.kind)) case 'write_resource': - return { pages: ['/resources'] } + return withItem(['/resources'], itemRef('resource', args)) case 'write_variable': - return { pages: ['/variables'] } + return withItem(['/variables'], itemRef('variable', args)) case 'create_folder': - return { pages: ['/folders'] } + return { pages: ['/folders'], items: [] } // Generic item tools carry a workspace-item `type`; refresh its list page // when it lives on one (schedule/resource/variable/trigger). script/flow/app // have their own live editor tab and no previewed list page → nothing. @@ -37,12 +49,31 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect { case 'discard_local_draft': case 'deploy_workspace_item': case 'rebase_draft': - return { pages: pagesForItemType(args?.type, args) } + return withItem( + pagesForItemType(args?.type, args), + itemRef(args?.type, args, args?.trigger_kind) + ) default: return NO_RELOAD } } +function withItem(pages: string[], item: PageItemRef | undefined): ToolReloadEffect { + return { pages, items: item && pages.length ? [item] : [] } +} + +function itemRef(type: unknown, args: any, triggerKind?: unknown): PageItemRef | undefined { + const path = args?.path + if (typeof path !== 'string' || !path) return undefined + if (type === 'variable' || type === 'resource' || type === 'schedule') { + return { kind: type, path } + } + if (type === 'trigger' && (triggerKind as string) in TRIGGER_PAGES) { + return { kind: 'trigger', triggerKind: triggerKind as TriggerKind, path } + } + return undefined +} + function pagesForItemType(type: unknown, args: any): string[] { switch (type) { case 'schedule': @@ -63,14 +94,23 @@ function triggerPages(kind: unknown): string[] { return page ? [page.path] : [] } -// The open tabs a page-reload should refresh: those whose observed page path is -// in `pages`. Item-editor and pipeline tab routes are never list pages, so they -// never match (see the self-sync invariant above). Pure over a tab snapshot so -// the sessions page can reload by id and this stays unit-testable. +// The open tabs a reload should refresh: list-page tabs whose observed page path is +// in `pages`, and page item tabs on those pages — only the named ones when a tool +// named its item. Item-editor and pipeline tab routes are never list pages, so they +// never match (see the self-sync invariant above). Pure over a tab snapshot so the +// sessions page can reload by id and this stays unit-testable. export function tabsToReload( tabs: SessionPreviewTab[], - pages: ReadonlySet + pages: ReadonlySet, + items: ReadonlySet = new Set() ): SessionPreviewTab[] { if (pages.size === 0) return [] - return tabs.filter((t) => pages.has(stripBase(whereIs(t)))) + return tabs.filter((t) => { + const pageItem = parsePageItemRoute(t.url) + if (!pageItem) return pages.has(stripBase(whereIs(t))) + const listPath = pageItemListPath(pageItem) + if (!pages.has(listPath)) return false + const named = [...items].some((u) => pageItemListPath(parsePageItemRoute(u)!) === listPath) + return !named || items.has(pageItemUrl(pageItem)) + }) } diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index 7d56f9e717..3bc76882e5 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -14,8 +14,27 @@ import { previewLocationContext, previewLocationLabel, resolvePreviewTab, - runFormUrl + runFormUrl, + workspacePageHref } from './previewRouter' +import { pageItemUrl } from './previewPaths' + +describe('workspacePageHref', () => { + it('sends a page item tab to its list page with the row open, never to its scheme', () => { + expect(workspacePageHref(pageItemUrl({ kind: 'resource', path: 'u/me/db' }))).toBe( + '/resources#/resource/u/me/db' + ) + expect( + workspacePageHref(pageItemUrl({ kind: 'trigger', triggerKind: 'kafka', path: 'f/a/b' })) + ).toBe('/kafka_triggers#f/a/b') + }) + + it('has no page for a tab that belongs to the chat', () => { + expect(workspacePageHref(artifactUrl('a1', 'Plan'))).toBeUndefined() + expect(workspacePageHref(runFormUrl('call_1', 'Run'))).toBeUndefined() + expect(workspacePageHref('/runs?path=u/me/x')).toBe('/runs?path=u/me/x') + }) +}) describe('drawerAnchorFor', () => { it('reads the anchored row on the pages that deep-link one', () => { diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 9f312ea389..13e7626e70 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -3,8 +3,13 @@ import { AUDIT_LOGS_PATH, FOLDERS_PATH, GROUPS_PATH, + pageItemForListPath, + pageItemListPath, + pageItemPageHref, + pageItemUrl, pageKey, pageHref, + parsePageItemRoute, parsePreviewItemRoute, RESOURCES_PATH, RUNS_PATH, @@ -14,17 +19,22 @@ import { WORKSPACE_SETTINGS_PATH, triggerLabelForPath, TRIGGER_PAGES, + type PageItemRef, type PreviewItemRoute, type TriggerKind } from './previewPaths' // Re-exported so the preview code that already reads locations through this module keeps // one import, while a caller needing only a path can reach for the leaf instead. export { + pageItemListPath, + pageItemUrl, pageKey, pageHref, + parsePageItemRoute, parsePreviewItemRoute, stripBase, TRIGGER_PAGES, + type PageItemRef, type PreviewItemRoute, type TriggerKind } @@ -68,6 +78,7 @@ export type PreviewTarget = | { type: 'item'; item: WorkspaceItem } | { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget } | { type: 'runform'; toolCallId: string; label: string } + | { type: 'pageitem'; ref: PageItemRef } export type PreviewPage = { label: string; path: string; icon: DrillIcon } @@ -117,6 +128,26 @@ export function drawerAnchorFor(location: string): string | undefined { return location.slice(hashAt + 1).replace(/^\/resource\//, '') || undefined } +/** The item a list-page location deep-links, as a tab of its own: a session edits these + * in process, so the list page's drawer is never where one belongs. */ +export function pageItemForLocation(location: string): PageItemRef | undefined { + const anchor = drawerAnchorFor(location) + if (!anchor) return undefined + let path: string + try { + path = decodeURIComponent(anchor) + } catch { + return undefined + } + return pageItemForListPath(location, path) +} + +/** A location with a deep-linked row replaced by that row's own tab; any other unchanged. */ +export function pageItemLocation(location: string): string { + const ref = pageItemForLocation(location) + return ref ? pageItemUrl(ref) : location +} + // Query params the preview host injects into an iframe URL (`nomenubar` hides the nav, // `workspace` scopes the page). Never part of what a location means. const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const @@ -126,7 +157,7 @@ const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const export function canonicalizeObservedLoc(loc: string): string { // An artifact or a run form is a scheme, not a path — `new URL` would happily parse it // and hand back a pathname with the scheme gone. - if (parseArtifactRoute(loc) || parseRunFormRoute(loc)) return loc + if (parseArtifactRoute(loc) || parseRunFormRoute(loc) || parsePageItemRoute(loc)) return loc try { const u = new URL(loc, 'http://_') for (const p of INJECTED_PARAMS) u.searchParams.delete(p) @@ -201,6 +232,8 @@ export function describeLocation(loc: string): PreviewLocation { // Identity is the call, never the label: that carries the script's summary, so folding it // in would open a second tab for the same form whenever the summary differed. if (runForm) return { identity: `runform:${runForm.toolCallId}`, view: '', anchor: '' } + const pageItem = parsePageItemRoute(loc) + if (pageItem) return { identity: pageItemUrl(pageItem), view: '', anchor: '' } const canonical = canonicalizeObservedLoc(loc) const path = stripBase(canonical) const bare = canonical.split('#')[0] @@ -322,6 +355,15 @@ export function previewLocationContext(loc: string): { location: string open?: string } { + // Told as its list page with the item open, the shape the model already reads for a row + // whose drawer is open — which is all a page item tab is to it. + const pageItem = parsePageItemRoute(loc) + if (pageItem) { + return { + ...previewLocationContext(pageItemListPath(pageItem)), + open: promptSafe(pageItem.path) + } + } const { identity, anchor } = describeLocation(loc) const bare = canonicalizeObservedLoc(loc).split('#')[0] const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : '' @@ -370,6 +412,8 @@ export function previewLocationLabel(url: string): string { if (artifact) return artifact.name || 'Artifact' const runForm = parseRunFormRoute(url) if (runForm) return runForm.label || 'Run form' + const pageItem = parsePageItemRoute(url) + if (pageItem) return pageItem.path.split('/').pop() || pageItem.path const page = matchReusablePage(url) if (page) return page.label const trigger = triggerLabelForPath(url) @@ -492,6 +536,7 @@ export type PreviewSlot = | { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string } | { kind: 'artifact'; id: string; version?: number } | { kind: 'runform'; toolCallId: string } + | { kind: 'pageitem'; ref: PageItemRef } | { kind: 'iframe' } export function resolvePreviewTab(url: string): PreviewSlot { @@ -499,6 +544,8 @@ export function resolvePreviewTab(url: string): PreviewSlot { if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version } const runForm = parseRunFormRoute(url) if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId } + const pageItem = parsePageItemRoute(url) + if (pageItem) return { kind: 'pageitem', ref: pageItem } const pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) { return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder } @@ -516,3 +563,24 @@ export function resolvePreviewTab(url: string): PreviewSlot { if (!editorKind) return { kind: 'iframe' } return { kind: 'editor', editorKind, path: route.itemPath } } + +/** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when + * there is none. Every tab kind answers here, so a new one cannot fall through to its url being + * navigated as a path — an artifact or page item url is a scheme, not a route. */ +export function workspacePageHref(location: string): string | undefined { + const slot = resolvePreviewTab(location) + switch (slot.kind) { + case 'artifact': + case 'runform': + return undefined + case 'pageitem': + return pageItemPageHref(slot.ref) + case 'editor': + case 'iframe': + return location + default: { + const unhandled: never = slot + return unhandled + } + } +} diff --git a/frontend/src/lib/components/sessions/sessionMode.svelte.ts b/frontend/src/lib/components/sessions/sessionMode.svelte.ts index a4972a9afb..a2c20e08a8 100644 --- a/frontend/src/lib/components/sessions/sessionMode.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMode.svelte.ts @@ -47,6 +47,17 @@ export function withMenuHidden(url: string, workspaceId?: string): string { } } +// True when this window is a sessions-preview iframe: embedded, with the `nomenubar` flag +// the preview always sets and the logged layout stickies into sessionStorage. +export function isSessionPreviewFrame(): boolean { + if (typeof window === 'undefined' || window.self === window.top) return false + try { + return sessionStorage.getItem('nomenubar_embedded') === 'true' + } catch { + return false + } +} + // Append `?workspace=` to a canonical route so a full-page navigation (e.g. // "Open in workspace") lands on the session's effective workspace instead of // the navigation workspace. Unlike withMenuHidden, the menu is kept visible — diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index a9db01e0fa..a90eb83cc4 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -8,7 +8,10 @@ import { describeLocation, matchPreviewPage, showsView, + pageItemLocation, + pageItemUrl, parseArtifactRoute, + parsePageItemRoute, parsePipelineRoute, previewLocationContext, promptSafe, @@ -24,6 +27,12 @@ import { import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte' import type { Kind } from '$lib/utils_deployable' import { pipelineFolderFromBundlePath } from '$lib/pipelinePaths' +import { + pageItemKindLabel, + TRIGGER_PAGES, + type PageItemRef, + type TriggerKind +} from './previewPaths' // The single live owner of a session's preview tabs. Runs behind a small // interface both the sessions page (renderer) and the `open_preview` tool cross, @@ -78,7 +87,10 @@ function keptVersion( // scheme. `onto` is the tab about to be written, passed wherever one is being re-pointed so // that every such path keeps its pin. function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string { - if (target.type === 'page') return target.href + // A list page asked for with a row anchored is that row's own tab: its drawer would only + // open the editor a page item tab already hosts, inside a frame of its own. + if (target.type === 'page') return pageItemLocation(target.href) + if (target.type === 'pageitem') return pageItemUrl(target.ref) if (target.type === 'artifact') { return artifactUrl(target.id, target.name, keptVersion(target, onto)) } @@ -152,10 +164,9 @@ export function previewTargetForSessionTarget( // Adapt a deployable item's layout kind (the session review dock speaks `Kind`, // not SessionTarget) to a preview destination: the three live editors, data -// pipelines, plus legacy drag-and-drop apps, which the panel hosts as an iframe -// over their edit route. Every other kind maps to undefined — not for lack of any -// route (a variable or trigger has a list page the panel can host) but because -// there is no item editor to preview, so their row falls back to the diff. The +// pipelines, page items (variables, resources, schedules, triggers), plus legacy +// drag-and-drop apps, which the panel hosts as an iframe over their edit route. +// Every other kind maps to undefined, and its row falls back to the diff. The // undefined is also the caller's test for "can this row be previewed?". export function previewTargetForDeployKind(kind: Kind, path: string): PreviewTarget | undefined { if (kind === 'app') { @@ -164,6 +175,16 @@ export function previewTargetForDeployKind(kind: Kind, path: string): PreviewTar if (kind === 'script' || kind === 'flow' || kind === 'raw_app') { return previewTargetForSessionTarget(kind, path) } + if (kind === 'variable' || kind === 'resource' || kind === 'schedule') { + return { type: 'pageitem', ref: { kind, path } } + } + const triggerKind = kind.endsWith('_trigger') ? kind.slice(0, -'_trigger'.length) : undefined + if (triggerKind && triggerKind in TRIGGER_PAGES) { + return { + type: 'pageitem', + ref: { kind: 'trigger', triggerKind: triggerKind as TriggerKind, path } + } + } // A pipeline's editor is its folder's graph view, not its bundle path. if (kind === 'data_pipeline') { const folder = pipelineFolderFromBundlePath(path) @@ -189,7 +210,14 @@ export function hydratePreviewTabs(session: { seen.add(t.id) // Rebuilt field-by-field so stray properties on old saved records (e.g. the // retired `pinned` flag) don't survive hydration and get persisted back. - tabs.push({ id: t.id, url: t.url, loc: t.loc || t.url }) + // A list page saved with a row's drawer open comes back as that row's own tab. Read from + // where the frame was, not from what was last commanded: the user may have moved to + // another row since, or closed the drawer, and the observation is what they saw. + const loc = t.loc || t.url + const item = pageItemLocation(t.loc ? loc : t.url) + tabs.push( + parsePageItemRoute(item) ? { id: t.id, url: item, loc: item } : { id: t.id, url: t.url, loc } + ) } if (tabs.length > 0) { const wantActive = session.activePreviewTabId @@ -290,22 +318,14 @@ export class SessionPreviewTabs { // Drift is a change of what the frame *shows*, not of its URL string: a page // writing its own filter defaults back is not the user navigating away. const drifted = !showsView(tab.loc, url) - // Both cases the browser will not act on, decided here because this is where the - // old and new commands are both in hand: re-commanding the URL a drifted frame - // already carries moves nothing, and moving to another fragment resolves within the - // same document — so a list page never re-runs the `#` read that opens a row. - // Dropping the fragment is not one of them: the same-document path applies only to a - // target that has one, so the browser loads the page — closing the drawer by itself — - // and forcing a second load races that one back onto the row. - const fragmentOnly = - !commandUnchanged && url.includes('#') && tab.url.split('#')[0] === url.split('#')[0] + // Decided here because this is where the old and new commands are both in hand: + // re-commanding the URL a drifted frame already carries moves nothing. retargetTab(tab, url) - if ((commandUnchanged && drifted) || fragmentOnly) this.pulseReload(tab.id) + if (commandUnchanged && drifted) this.pulseReload(tab.id) } - // Force the host to reload the iframe. A navigation onto the tab's exact current URL - // changes nothing, so URL-driven behavior — a `#` opening a drawer the user has - // since closed — would never re-fire. + // Force the host to reload the tab. A navigation onto the tab's exact current URL + // changes nothing, so URL-driven behavior would never re-fire. pulseReload(id: string): void { this.#reloadPulse = { id, nonce: this.#reloadPulse.nonce + 1 } } @@ -435,21 +455,21 @@ export class SessionPreviewTabs { // shows this. The tab on this exact view wins over any other on the page — // `new_tab` puts two views side by side, and retargeting whichever sits first // would overwrite the other and leave both on the same row. - const shown = opts?.forceNewTab - ? undefined - : (this.#tabs.find((t) => showsView(t.loc, url)) ?? - this.#tabs.find((t) => describeLocation(t.loc).identity === describeLocation(url).identity)) + // A page item stays one tab whatever the opener asks: two would hold two drafts of it. + const shown = + opts?.forceNewTab && !parsePageItemRoute(url) + ? undefined + : (this.#tabs.find((t) => showsView(t.loc, url)) ?? + this.#tabs.find( + (t) => describeLocation(t.loc).identity === describeLocation(url).identity + )) if (shown) { const same = showsView(shown.loc, url) if (same) { // The frame is already here, but record what was asked for: `url` is what the // tab persists and remounts from, so leaving it on where the frame started - // sends a refresh back to the row the user has since moved off. + // sends a refresh back to the view the user has since moved off. recordCommand(shown, url) - // Nothing to navigate to, so nothing would re-run: the list pages read their - // `#` once per document, and the drawer it opens may since have been - // closed. Only a forced load can bring it back. - if (describeLocation(url).anchor) this.pulseReload(shown.id) } else { this.#retarget(shown, url) } @@ -512,7 +532,17 @@ export class SessionPreviewTabs { return } } - this.#retarget(t, targetUrl(target, t)) + // One tab per page item, as for editors: two would hold two drafts of one item. + const url = targetUrl(target, t) + if (parsePageItemRoute(url)) { + const existing = this.#tabs.find((x) => x.url === url) + if (existing && existing.id !== t.id) { + this.#activeId = existing.id + this.#flush() + return + } + } + this.#retarget(t, url) this.#flush() } @@ -574,6 +604,17 @@ export class SessionPreviewTabs { this.#flush() } + /** Follow a page item its editor saved under a new path, in place. */ + retargetPageItem(from: PageItemRef, to: PageItemRef): void { + const fromUrl = pageItemUrl(from) + const toUrl = pageItemUrl(to) + if (fromUrl === toUrl) return + const tab = this.#tabs.find((t) => t.url === fromUrl) + if (!tab) return + retargetTab(tab, toUrl) + this.#flush() + } + closeArtifact(artifactId: string): void { const tab = this.#tabs.find((t) => parseArtifactRoute(t.url)?.id === artifactId) if (tab) this.close(tab.id) @@ -606,23 +647,14 @@ export class SessionPreviewTabs { } // Feed back the location an iframe reported on load (only the page can read - // contentWindow.location). Updates the observed `loc`; `url` follows only when a - // drawer closed (below), and the host navigates on a command it isn't already at, - // so that write does not move the frame. + // contentWindow.location). Updates the observed `loc` only: the host navigates on a + // command it isn't already at, and an in-frame move is the user browsing. observeLocation(id: string, loc: string): void { const t = this.#tabs.find((x) => x.id === id) if (!t) return const canonical = canonicalizeObservedLoc(loc) if (t.loc === canonical) return t.loc = canonical - // Closing a drawer drops the row from the frame's URL. The command has to follow, or - // the tab reopens it on the next mount — the iframe loads `url`, not `loc`. Only the - // anchor: any other in-frame move is the user browsing, which must not re-command. - const commanded = describeLocation(t.url) - const observed = describeLocation(canonical) - if (commanded.anchor && !observed.anchor && commanded.identity === observed.identity) { - t.url = t.url.split('#')[0] - } this.#flush() } @@ -730,6 +762,7 @@ export function describePreview( const lines = tabs.map((t) => { const where = whereIs(t) const artifact = parseArtifactRoute(where) + const pageItem = parsePageItemRoute(where) const page = matchPreviewPage(where) const pipelineFolder = parsePipelineRoute(where) const route = parsePreviewItemRoute(where) @@ -737,16 +770,19 @@ export function describePreview( ? // A pinned tab is not showing what the assistant last wrote, and nothing else in this // summary would tell it so. `artifact "${artifact.name || 'Artifact'}"${artifact.version ? ` (pinned to v${artifact.version})` : ''}` - : page - ? `page "${page.label}"${previewLocationDetail(where)}` - : pipelineFolder - ? `pipeline "${pipelineFolder}"` - : route - ? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"` - : // Trigger list pages land here (they're outside PREVIEW_PAGES), and - // their `#` is the trigger the drawer has open. - `${stripBase(where)}${previewLocationDetail(where)}` - const live = resolvePreviewTab(t.url).kind === 'editor' ? ', live editor' : '' + : pageItem + ? `${pageItemKindLabel(pageItem).toLowerCase()} "${pageItem.path}"` + : page + ? `page "${page.label}"${previewLocationDetail(where)}` + : pipelineFolder + ? `pipeline "${pipelineFolder}"` + : route + ? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"` + : // Trigger list pages land here (they're outside PREVIEW_PAGES), and + // their `#` is the trigger the drawer has open. + `${stripBase(where)}${previewLocationDetail(where)}` + const slotKind = resolvePreviewTab(t.url).kind + const live = slotKind === 'editor' || slotKind === 'pageitem' ? ', live editor' : '' const active = t.id === activeId ? ', active' : '' // One list entry per tab: an artifact's name, a pipeline folder and an item path // all arrive decoded from a URL, so any of them could otherwise write a line here. diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts index b71de62c23..8b1ba27ea0 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts @@ -177,10 +177,88 @@ describe('previewTargetForDeployKind', () => { pipelineTarget ) }) + it('routes variables, resources, schedules and triggers to their own tab', () => { + expect(previewTargetForDeployKind('schedule', 'u/me/s')).toEqual({ + type: 'pageitem', + ref: { kind: 'schedule', path: 'u/me/s' } + }) + expect(previewTargetForDeployKind('http_trigger', 'u/me/t')).toEqual({ + type: 'pageitem', + ref: { kind: 'trigger', triggerKind: 'http', path: 'u/me/t' } + }) + expect(previewTargetForDeployKind('variable', 'u/me/v')).toEqual({ + type: 'pageitem', + ref: { kind: 'variable', path: 'u/me/v' } + }) + }) + it('has no destination for kinds the preview panel cannot host', () => { - expect(previewTargetForDeployKind('schedule', 'u/me/s')).toBeUndefined() - expect(previewTargetForDeployKind('http_trigger', 'u/me/t')).toBeUndefined() - expect(previewTargetForDeployKind('variable', 'u/me/v')).toBeUndefined() + expect(previewTargetForDeployKind('folder', 'f/x')).toBeUndefined() + expect(previewTargetForDeployKind('resource_type', 'x')).toBeUndefined() + }) +}) + +describe('page item tabs', () => { + const variable: PreviewTarget = { + type: 'pageitem', + ref: { kind: 'variable', path: 'u/me/token' } + } + + it('opens a list page anchored at a row as that row’s own tab, beside the list', () => { + const o = owner() + o.open({ type: 'page', href: '/routes', label: 'HTTP routes' }) + o.open({ type: 'page', href: '/routes#u/me/a', label: 'HTTP routes' }) + expect(o.tabs.map((t) => t.url)).toEqual(['/routes', 'pageitem:trigger.http/u%2Fme%2Fa']) + + // Resources address their row through an extra segment. + o.open({ type: 'page', href: '/resources?owner=u#/resource/u/me/db', label: 'Resources' }) + expect(o.tabs.at(-1)!.url).toBe('pageitem:resource/u%2Fme%2Fdb') + }) + + it('keeps one tab per item, whatever the opener asks', () => { + const o = owner() + o.open(variable) + o.open({ type: 'page', href: '/runs', label: 'Runs' }) + expect(o.open({ type: 'page', href: '/variables#u/me/token', label: 'V' }).status).toBe( + 'focused' + ) + expect(o.open(variable, { forceNewTab: true }).status).toBe('focused') + o.navigate(variable) + expect(o.tabs).toHaveLength(2) + expect(o.activeId).toBe(o.tabs[0].id) + }) + + it('follows an item saved under a new path in place', () => { + const o = owner() + o.open(variable) + const id = o.tabs[0].id + o.retargetPageItem( + { kind: 'variable', path: 'u/me/token' }, + { kind: 'variable', path: 'f/x/token' } + ) + expect(o.tabs).toEqual([ + { id, url: 'pageitem:variable/f%2Fx%2Ftoken', loc: 'pageitem:variable/f%2Fx%2Ftoken' } + ]) + }) + + it('restores a tab saved on a row’s drawer as the row it was last on', () => { + const snap = hydratePreviewTabs({ + previewTabs: [ + { id: 'a', url: '/schedules#u/me/daily', loc: '/schedules?path=u#u/me/daily' }, + // A drawer opened inside the frame: the command is still the bare list. + { id: 'b', url: '/variables', loc: '/variables#u/me/token' }, + // The user moved on to another row inside the frame… + { id: 'c', url: '/variables#u/me/token', loc: '/variables#u/me/other' }, + // …or closed the drawer, leaving no row to restore. + { id: 'd', url: '/variables#u/me/token', loc: '/variables' } + ] + }) + expect(snap.tabs).toEqual([ + { id: 'a', url: 'pageitem:schedule/u%2Fme%2Fdaily', loc: 'pageitem:schedule/u%2Fme%2Fdaily' }, + { id: 'b', url: 'pageitem:variable/u%2Fme%2Ftoken', loc: 'pageitem:variable/u%2Fme%2Ftoken' }, + { id: 'c', url: 'pageitem:variable/u%2Fme%2Fother', loc: 'pageitem:variable/u%2Fme%2Fother' }, + { id: 'd', url: '/variables#u/me/token', loc: '/variables' } + ]) }) }) @@ -232,93 +310,34 @@ describe('SessionPreviewTabs.open', () => { expect(o.activeId).toBe(firstId) }) - // A trigger list page is not a `matchReusablePage`, so the runtime's - // navigate-in-place path doesn't cover it: re-pointing the tab has to happen - // here or the panel keeps showing the previously opened row. - it('re-points a page tab whose hash target changed instead of only focusing it', () => { - const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const firstId = o.activeId - - // 'retargeted', not 'opened': the tab count is unchanged, and the caller - // reports that to the model. - const res = o.open(routes('/routes#u/me/b')) - expect(res.status).toBe('retargeted') - expect(o.tabs).toHaveLength(1) - expect(o.activeId).toBe(firstId) - expect(o.tabs[0].url).toBe('/routes#u/me/b') - - // Back to the bare list: still the same tab, no longer anchored at a row. - expect(o.open(routes('/routes')).status).toBe('retargeted') - expect(o.tabs).toHaveLength(1) - expect(o.tabs[0].url).toBe('/routes') - - // ...and asking for the view it already shows is a plain focus. - expect(o.open(routes('/routes')).status).toBe('focused') - }) - // The list pages rewrite their own filter defaults into the URL after mount, // and `loc` follows that rewrite. Matching on anything but the path made a tab // stop recognizing itself, so every later open spawned a duplicate. it('still recognizes a tab after the page rewrote its own filter params', () => { const o = owner() const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const id = o.tabs[0].id - o.observeLocation(id, '/routes?filter_path_of=trigger#u/me/a') + o.open(routes('/routes')) + o.observeLocation(o.tabs[0].id, '/routes?filter_path_of=trigger') - const res = o.open(routes('/routes#u/me/b')) - expect(res.status).toBe('retargeted') + expect(o.open(routes('/routes')).status).toBe('focused') expect(o.tabs).toHaveLength(1) - expect(o.tabs[0].url).toBe('/routes#u/me/b') }) // `new_tab` deliberately keeps two views of one page side by side. Reopening one of // them must focus the tab already showing it, not retarget whichever tab happens to - // sit first in the strip — that would overwrite the other view and leave two tabs - // on the same row. + // sit first in the strip — that would overwrite the other view. it('focuses the tab already showing the exact location before retargeting by path', () => { const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) + const runs = (href: string) => ({ type: 'page' as const, href, label: 'Runs' }) + o.open(runs('/runs?path=u/me/a')) const first = o.tabs[0].id - o.open(routes('/routes#u/me/b'), { forceNewTab: true }) + o.open(runs('/runs?path=u/me/b'), { forceNewTab: true }) const second = o.tabs[1].id - expect(o.open(routes('/routes#u/me/b')).status).toBe('focused') + expect(o.open(runs('/runs?path=u/me/b')).status).toBe('focused') expect(o.activeId).toBe(second) expect(o.tabs).toHaveLength(2) - expect(o.tabs.find((t) => t.id === first)?.url).toBe('/routes#u/me/a') - }) - - // The list pages read their `#` once per document, so a drawer the user closed - // inside the frame only comes back on a forced load — and re-commanding the location - // the tab already shows produces no navigation the host could act on. - it('forces a load when the requested row is the one the tab already shows', () => { - const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const id = o.tabs[0].id - o.observeLocation(id, '/routes?filter_path_of=trigger#u/me/a') - const before = o.reloadPulse.nonce - - expect(o.open(routes('/routes#u/me/a')).status).toBe('focused') - expect(o.reloadPulse).toEqual({ id, nonce: before + 1 }) - }) - - // Dropping the fragment is a load in itself, so the forced one lands on top of a - // navigation still in flight — and reloads the row the command asked to leave. - it('does not force a load when the requested location drops the row', () => { - const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const id = o.tabs[0].id - const before = o.reloadPulse.nonce - - o.navigate(routes('/routes')) - expect(o.tabs.find((t) => t.id === id)?.url).toBe('/routes') - expect(o.reloadPulse.nonce).toBe(before) + expect(o.tabs.find((t) => t.id === first)?.url).toBe('/runs?path=u/me/a') }) // Runs restores the user's "hide schedules" preference into the URL whenever a load @@ -365,29 +384,11 @@ describe('SessionPreviewTabs.open', () => { expect(o.tabs[0].url).toBe('/apps/edit/u/me/dash') }) - // Re-commanding the URL a tab is already pointed at changes nothing the host can - // see, so the frame would stay wherever the user navigated it inside the page. - it('forces a reload when the request matches the command but the frame drifted', () => { - const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const id = o.tabs[0].id - // The user clicked another trigger inside the iframe. - o.observeLocation(id, '/routes#u/me/b') - const before = o.reloadPulse.nonce - - const res = o.open(routes('/routes#u/me/a')) - expect(res.status).toBe('retargeted') - expect(o.tabs).toHaveLength(1) - expect(o.tabs[0].loc).toBe('/routes#u/me/a') - expect(o.reloadPulse.nonce).toBe(before + 1) - }) - it('forceNewTab opts a page out of the location dedupe', () => { const o = owner() const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const res = o.open(routes('/routes#u/me/b'), { forceNewTab: true }) + o.open(routes('/routes')) + const res = o.open(routes('/routes'), { forceNewTab: true }) expect(res.status).toBe('opened') expect(o.tabs).toHaveLength(2) }) @@ -512,59 +513,6 @@ describe('SessionPreviewTabs.open', () => { }) }) -describe('SessionPreviewTabs.open — commanded url', () => { - it('records the requested row even when the frame is already showing it', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - // The user moves to another row inside the frame. - o.observeLocation(o.tabs[0].id, '/routes#u/me/b') - o.open({ type: 'page', href: '/routes#u/me/b', label: 'R' }) - // `url` is what a refresh and a remount reload from, so it has to follow. - expect(o.tabs[0].url).toBe('/routes#u/me/b') - expect(o.tabs).toHaveLength(1) - }) -}) - -describe('SessionPreviewTabs.observeLocation', () => { - it('drops the row from the command when the frame closes its drawer', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - // The page clears its own hash when the drawer closes. - o.observeLocation(o.tabs[0].id, '/routes?filter_path_of=trigger') - // The iframe mounts from `url`, so a remount would otherwise reopen the drawer. - expect(o.tabs[0].url).toBe('/routes') - }) - - it('leaves the command alone when the user just browses inside the frame', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - o.observeLocation(o.tabs[0].id, '/routes#u/me/b') - expect(o.tabs[0].url).toBe('/routes#u/me/a') - }) -}) - -describe('SessionPreviewTabs.open — forced loads', () => { - it('pulses when only the fragment changes, since the browser would not load', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - const before = o.reloadPulse.nonce - o.open({ type: 'page', href: '/routes#u/me/b', label: 'R' }) - // Same document: the browser resolves the new fragment without a load, so the - // list page never re-runs the `#` read that opens the row. - expect(o.reloadPulse.nonce).toBeGreaterThan(before) - expect(o.tabs).toHaveLength(1) - }) - - it('does not pulse when the document itself changes', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - const before = o.reloadPulse.nonce - o.open({ type: 'page', href: '/schedules#u/me/a', label: 'S' }) - // Different page: src changes, the browser loads it, nothing to force. - expect(o.reloadPulse.nonce).toBe(before) - }) -}) - describe('SessionPreviewTabs.navigate', () => { it('retargets the active tab to an editor item', () => { const o = owner() diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index bd899c8208..640b484478 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -56,7 +56,10 @@ import { selectPreviewTabsToClose, whereIs } from './sessionPreviewTabs.svelte' +import { pageItemKindLabel } from './previewPaths' import { + pageItemForLocation, + pageItemLocation, parsePreviewItemRoute, previewLocationContext, previewLocationLabel, @@ -387,7 +390,8 @@ function createRuntime(session: Session): SessionRuntime { // What the side panel is showing, stamped on each user message so the chat // knows the page (and the row whose drawer is open) without spending a // get_preview_status round-trip. Live editors are skipped: they register - // themselves as the ACTIVE EDITOR through UserDraft's live-draft registry. + // themselves as the ACTIVE EDITOR through UserDraft's live-draft registry. A page + // item tab is not one of them, and reads as its list page with the item open. manager.activePreviewResolver = () => { const owner = getRuntime(session.id)?.previewTabs // What is on screen, not merely which tab is selected: the rule tells the model @@ -395,7 +399,8 @@ function createRuntime(session: Session): SessionRuntime { // point those at a page the user cannot see. const tab = owner?.displayedTab if (!tab) return undefined - if (resolvePreviewTab(tab.url).kind !== 'iframe') return undefined + const slotKind = resolvePreviewTab(tab.url).kind + if (slotKind !== 'iframe' && slotKind !== 'pageitem') return undefined return previewLocationContext(whereIs(tab)) } // Pre-flight: materialise the (still-transient) session, then commit @@ -1101,7 +1106,9 @@ async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise { // open_page dispatches here to show a workspace page (Runs/Schedules) as a page // tab in the calling session's preview panel. Returns undefined when there is no // session so open_page can fall back to browser navigation. -setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label, newTab }) => { +setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label: pageLabel, newTab }) => { const sessionId = callerSessionId ?? sessionState.currentSessionId if (!sessionId) return undefined const session = sessionState.sessions.find((s) => s.id === sessionId) if (!session) return undefined const owner = getOrCreateRuntime(session).previewTabs + // A page opened on one item is that item's tab, and the report has to name what opened. + const pageItem = pageItemForLocation(href) + const label = pageItem + ? `the ${pageItemKindLabel(pageItem).toLowerCase()} ${promptSafe(pageItem.path)}` + : pageLabel // open() owns the whole decision — which tab already shows this page, whether the // requested view differs from what it shows, and whether a forced load is needed to // re-fire a drawer. Deciding any of that again here means two predicates for one diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 146f844b3f..8ee5391443 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -1,11 +1,6 @@ {#if captureInfo} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte index 3a60d3be71..4a44e01b30 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte @@ -2,8 +2,6 @@ import { Alert } from '$lib/components/common' import Required from '$lib/components/Required.svelte' import Section from '$lib/components/Section.svelte' - import { userStore, workspaceStore } from '$lib/stores' - import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' // import { page } from '$app/state' import { getEmailAddress, getEmailDomain } from './utils' import { isCloudHosted } from '$lib/cloud' @@ -12,6 +10,10 @@ import { untrack } from 'svelte' import { EmailTriggerService } from '$lib/gen' import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' interface Props { initialTriggerPath?: string | undefined dirtyLocalPart?: boolean @@ -35,8 +37,10 @@ isDraftOnly = true, showTestingBadge = false }: Props = $props() - const triggerWs = getTriggerWorkspace() - const wsId = $derived(triggerWs?.() ?? $workspaceStore) + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) + const wsId = $derived($operatingWorkspace) let validateTimeout: number | undefined = undefined @@ -95,7 +99,7 @@ local_part === undefined && (local_part = '') }) - let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) + let userIsAdmin = $derived(actingUser?.is_admin || actingUser?.is_super_admin) let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index d2133cf865..30f848004f 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -2,6 +2,7 @@ import { Button } from '$lib/components/common' import { clearPageDrawerAnchor, + handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -17,8 +18,7 @@ type Retry, type TriggerMode } from '$lib/gen' - import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' - import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' + import { usedTriggerKinds } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' @@ -38,9 +38,16 @@ import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' + import { + useOperatingUser, + useOperatingWorkspace, + useOperatingWorkspaceHref + } from '$lib/components/operatingWorkspace.svelte' let { useDrawer = true, + inline = false, + onClose = undefined, hideTarget = false, description = undefined, isEditor = false, @@ -55,8 +62,11 @@ trigger = undefined, customSaveBehavior = undefined } = $props() - const triggerWs = getTriggerWorkspace() - const wsId = $derived(triggerWs?.() ?? $workspaceStore) + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) + const operatingHref = useOperatingWorkspaceHref() + const wsId = $derived($operatingWorkspace) // Form data state let initialPath = $state('') @@ -75,7 +85,13 @@ let workspaced_local_part = $state(false) let drawerLoading = $state(true) let showLoader = $state(false) - let can_write = $state(true) + let permsPath = $state(undefined) + let permsForWrite = $state | undefined>(undefined) + // The acting user in the operating workspace arrives asynchronously, and an unknown user + // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. + const can_write = $derived( + permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser) + ) let extraPerms = $state | undefined>(undefined) let error_handler_path: string | undefined = $state() let error_handler_args: Record = $state({}) @@ -95,7 +111,7 @@ let originalConfig = $state(undefined) let hasChanged = $derived(!deepEqual(getEmailTriggerConfig(), originalConfig ?? {})) - const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) + const isAdmin = $derived(actingUser?.is_admin || actingUser?.is_super_admin) const emailConfig = $derived.by(getEmailTriggerConfig) const draftSync = useTriggerDraftSync({ @@ -127,6 +143,9 @@ defaultConfig?: Partial, fixedScriptPath_?: string ) { + if (handOffPageDrawer(TRIGGER_PAGES.email.path, ePath)) return + // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". + operatingUser.forgetFailures() drawerLoading = true let loader = setTimeout(() => { showLoader = true @@ -206,6 +225,8 @@ } function loadTriggerConfig(cfg?: Partial): void { + // The loaded trigger says what it runs; an opener's `isFlow` is only its guess. + if (cfg?.is_flow !== undefined) itemKind = cfg.is_flow ? 'flow' : 'script' script_path = cfg?.script_path ?? '' initialScriptPath = cfg?.script_path ?? '' is_flow = cfg?.is_flow ?? false @@ -213,7 +234,8 @@ local_part = cfg?.local_part ?? '' workspaced_local_part = cfg?.workspaced_local_part ?? false extraPerms = cfg?.extra_perms ?? undefined - can_write = canWrite(path, cfg?.extra_perms ?? {}, $userStore) + permsPath = path + permsForWrite = cfg?.extra_perms ?? {} error_handler_path = cfg?.error_handler_path error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry @@ -265,7 +287,7 @@ saveCfg, edit, wsId!, - !!$userStore?.is_admin || !!$userStore?.is_super_admin, + !!actingUser?.is_admin || !!actingUser?.is_super_admin, usedTriggerKinds ) if (isSaved) { @@ -405,7 +427,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!$userStore?.operator} + isOperator={!!actingUser?.operator} promptClass="text-xs mt-3 mb-1 text-primary" > {#snippet createButton()} @@ -414,7 +436,9 @@ btnClasses="ml-4" variant="accent" size="xs" - href={itemKind === 'flow' ? '/flows/add?hub=72' : '/scripts/add?hub=hub%2F19813'} + href={operatingHref( + itemKind === 'flow' ? '/flows/add?hub=72' : '/scripts/add?hub=hub%2F19813' + )} target="_blank">Create from template {/if} @@ -486,36 +510,44 @@ {/if} {/snippet} -{#if useDrawer} +{#snippet drawerBody()} + (inline ? onClose?.() : drawer?.closeDrawer())} + > + {#snippet actions()} + {@render saveButton()} + {/snippet} + {#snippet banner()} + draftSync.deployed} + reserveSpace={draftSync.hasBaseline} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} + {@render config()} + +{/snippet} + +{#if useDrawer && inline} + {@render drawerBody()} +{:else if useDrawer} clearPageDrawerAnchor(TRIGGER_PAGES.email.path)} > - drawer?.closeDrawer()} - > - {#snippet actions()} - {@render saveButton()} - {/snippet} - {#snippet banner()} - draftSync.deployed} - reserveSpace={draftSync.hasBaseline} - getCurrent={() => draftSync.current} - onDiscard={() => draftSync.resetToDeployed(initialPath)} - disabled={!can_write} - /> - {/snippet} - {@render config()} - + {@render drawerBody()} {:else}
diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte index 12ec403aeb..7eac6a8971 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte @@ -1,11 +1,15 @@
{#if resourceId} -
+
Selected: {resourceName || resourceId}
{:else} -
+
No file selected
@@ -198,21 +196,27 @@
-
- - {#each schemaTables as tableKey} - {@const isDisabled = isTableDisabled(schemaKey, tableKey)} - {@const isChecked = isTableSelected(schemaKey, tableKey) || isDisabled} - {@const isCurrentPreview = - selected.schemaKey === schemaKey && selected.tableKey === tableKey} -
{ - selectTable(schemaKey, tableKey) - toggleTableSelection(schemaKey, tableKey) - }} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - selectTable(schemaKey, tableKey) - toggleTableSelection(schemaKey, tableKey) - } - }} - > - - e.stopPropagation()} - onchange={() => toggleTableSelection(schemaKey, tableKey)} - /> - - -

{tableKey}

- + {#if dtOpen} + {#if root.error} +

{root.error}

+ {/if} + {#each root.schemas as sc (sc.schemaKey)} + {@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)} + {@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'} + {#if dbSupportsSchemas} -
- {/each} - - - {/each} - {:else} - - {#each filteredTableKeys as tableKey} - - - {/each} - {/if} + ]} + btnId={'db-manager-schema-actions-' + onlyAlphaNumAndUnderscore(sc.schemaKey)} + /> + {/if} +
+ + {/if} + + + {#if schemaOpen || !dbSupportsSchemas} + {@const tableIndent = dbSupportsSchemas + ? root.datatable !== undefined + ? 'pl-11' + : 'pl-7' + : root.datatable !== undefined + ? 'pl-7' + : 'pl-3'} + {#each sc.tables as tableKey (tableKey)} + {@const entry = { + datatable: root.datatable, + schema: sc.schemaKey, + table: tableKey + }} + {@const hasMenu = !multiSelectMode} + {@const isSelected = + root.datatable === currentDatatable && + selected.schemaKey === sc.schemaKey && + selected.tableKey === tableKey} + + {/each} + {#if canCreateTableIn(root.datatable, sc.schemaKey)} + + {/if} + {/if} + + {/each} + {#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)} + + {/if} + {/if} + {/each}
- {#if !multiSelectMode} - - {/if} - {#if tableKey && colDefs?.[tableKey]?.length} + {#if mainPane} + {@render mainPane()} + {:else if tableKey && colDefs?.[tableKey]?.length} {@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey, whereClause })} + (aclDrawer = undefined)}> + (aclDrawer = undefined)} + tooltip="Who owns this, and what each role may do with it." + > + {#if aclDrawer && workspace} + {@const dt = aclDrawer.datatable ?? currentDatatable} + {#if dt} + {#key `${dt}~${JSON.stringify(aclDrawer.target)}`} + + {/key} + {/if} + {/if} + + + (askingForConfirmation = undefined)} @@ -754,20 +1164,12 @@ - { - newSchemaDialogOpen = false - newSchemaName = '' - }} -> + { - newSchemaDialogOpen = false - newSchemaName = '' - }} - title="Create a new schema" + on:close={closeSchemaDialog} + title={schemaDialog?.mode === 'rename' + ? `Rename ${schemaDialog.schema}` + : 'Create a new schema'} >
@@ -779,27 +1181,7 @@ placeholder="Enter schema name..." autofocus on:keydown={(e) => { - if (e.key === 'Enter' && sanitizedNewSchemaName && !schemaAlreadyExists) { - askingForConfirmation = { - confirmationText: `Create ${sanitizedNewSchemaName}`, - type: 'reload', - title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`, - open: true, - id: 'db-create-schema-confirmation-modal', - onConfirm: async () => { - askingForConfirmation && (askingForConfirmation.loading = true) - try { - await dbSchemaOps.onCreateSchema({ schema: sanitizedNewSchemaName }) - refresh?.() - selected.schemaKey = sanitizedNewSchemaName - newSchemaDialogOpen = false - newSchemaName = '' - } finally { - askingForConfirmation = undefined - } - } - } - } + if (e.key === 'Enter') submitSchemaName() }} /> {#if schemaAlreadyExists} @@ -814,32 +1196,8 @@
{#snippet actions()} - {/snippet}
diff --git a/frontend/src/lib/components/DBManagerContent.svelte b/frontend/src/lib/components/DBManagerContent.svelte index 3ee98538ba..d4e00ce30b 100644 --- a/frontend/src/lib/components/DBManagerContent.svelte +++ b/frontend/src/lib/components/DBManagerContent.svelte @@ -1,16 +1,21 @@ + + (open = false) }} +> + { + // The row underneath folds on click, and picking a role is not that. + e.stopPropagation() + open = !open + }} + > + + {role} + + + anchorEl!.getBoundingClientRect())} + onSelectValue={(item) => { + open = false + if (item.value !== role) onSelect(item.value) + }} + /> + diff --git a/frontend/src/lib/components/DdlMigrationGuard.svelte b/frontend/src/lib/components/DdlMigrationGuard.svelte index 3bda6aa216..0dab262fd0 100644 --- a/frontend/src/lib/components/DdlMigrationGuard.svelte +++ b/frontend/src/lib/components/DdlMigrationGuard.svelte @@ -6,8 +6,18 @@ import { joinSqlStatements, splitSqlRuns } from './sqlDdl' import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry' import { CornerDownLeft } from 'lucide-svelte' + import { withMigrationRole } from './datatableMigrationRole' - let { workspace, datatable }: { workspace: string; datatable: string } = $props() + let { + workspace, + datatable, + role + }: { + workspace: string + datatable: string + /** The role the editor runs as. The migration declares it, or it would run as admin. */ + role?: string + } = $props() type Choice = 'run' | 'migrate' | 'cancel' @@ -73,7 +83,7 @@ function openMigrationModal(sql: string): Promise { return new Promise((resolve) => { resolveMigrationClosed = (created: boolean) => resolve(created) - newMigrationModal?.open({ codeUp: sql }) + newMigrationModal?.open({ codeUp: withMigrationRole(sql, role) }) }) } @@ -145,6 +155,11 @@ migrations rather than run ad-hoc. Create a migration for it instead? {/if}

+ {#if role} +

+ It will run as role {role}. +

+ {/if}
{promptSql}
  • feature adoption (counts of which flow, script, trigger, worker and data table @@ -1163,9 +1164,10 @@ the home page’s create menu and hub-project picker are opened and from which entry point, the name of any public hub project imported from the home page and how far that import got, whether a pre-approved trial offer was opened, whether data tables are put - under roles and whether callers name a role or take the default, and which kinds of - access change (grant, revoke, ownership, default privileges) are applied to data - tables, last 30 days)
  • feature adoption (counts of which flow, script, trigger, worker and data table diff --git a/frontend/src/lib/components/SqlRepl.svelte b/frontend/src/lib/components/SqlRepl.svelte index d6c2d288e6..d58ac65898 100644 --- a/frontend/src/lib/components/SqlRepl.svelte +++ b/frontend/src/lib/components/SqlRepl.svelte @@ -225,5 +225,10 @@ {#if datatableName && ws} - + {/if} diff --git a/frontend/src/lib/components/Star.svelte b/frontend/src/lib/components/Star.svelte index dd91f671c8..4f0f169109 100644 --- a/frontend/src/lib/components/Star.svelte +++ b/frontend/src/lib/components/Star.svelte @@ -9,9 +9,10 @@ kind: FavoriteKind summary?: string workspaceId?: string + size?: number } - let { path, kind, workspaceId, summary }: Props = $props() + let { path, kind, workspaceId, summary, size = 16 }: Props = $props() let buttonHover = $state(false) let starred = $derived(favoriteManager.isStarred(path, kind)) @@ -31,14 +32,14 @@ > {#if starred} {#if buttonHover} - + {:else} - + {/if} {:else} {/if} diff --git a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts index c0477e8408..f2337d699b 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts @@ -282,7 +282,8 @@ const scriptsV2: typeof legacyScripts = { ...legacyScripts.postgresql, code: ` SELECT table_name, column_name, udt_name, column_default, is_nullable, nsp.nspname AS table_schema FROM information_schema.columns -RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')` +RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') +AND NOT starts_with(nsp.nspname, 'pg_') AND has_schema_privilege(nsp.oid, 'USAGE')` } } diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index bfba6a6a82..524817ba34 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -197,6 +197,7 @@ one long unbreakable string (a path list, a URL) sizes this column by that string and pushes it out of the panel — and any `truncate` inside never engages. --> +

    {title} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 71c06d6194..21c3d24770 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -732,12 +732,14 @@ export class AIChatManager implements ChatViewHost { /** Every mounted flow editor. */ #flowEditors = new Set() appAiChatHelpers = $state(undefined) - /** Datatable creation policy: enabled flag, datatable name, and optional schema */ + /** Datatable creation policy: enabled flag, datatable name, optional schema, and the role the + * app uses each data table through */ datatableCreationPolicy = $state<{ enabled: boolean datatable: string | undefined schema: string | undefined - }>({ enabled: false, datatable: undefined, schema: undefined }) + roles?: Record + }>({ enabled: false, datatable: undefined, schema: undefined, roles: undefined }) pendingNewCode = $state(undefined) apiTools = $state[]>([]) aiChatInput = $state(null) diff --git a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte index 3aec0fa2b6..6b4cbbd710 100644 --- a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte +++ b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte @@ -69,6 +69,7 @@ diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index 5ab271cc6f..634341b966 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -18,6 +18,7 @@ import { type AppCodeSelectionElement, type AppDatatableElement } from '../context' +import { appDatatableRole, sdkDatatableCall } from '$lib/components/raw_apps/dataTableRefUtils' // Backend runnable types export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'inline' @@ -921,9 +922,20 @@ export function prepareAppSystemMessage(customPrompt?: string): ChatCompletionSy const policy = aiChatManager.datatableCreationPolicy const datatableName = policy.datatable ?? 'main' const schemaPrefix = policy.schema ? `${policy.schema}.` : '' - // Use wmill.datatable() for 'main' (default), otherwise wmill.datatable('name') - const datatableCall = - datatableName === 'main' ? 'wmill.datatable()' : `wmill.datatable('${datatableName}')` + // A role names the privileges the app's queries run with, so it has to be in the code the + // model writes. + const datatableRole = appDatatableRole(policy.roles, datatableName) + const tsDatatableCall = sdkDatatableCall(datatableName, datatableRole, 'typescript') + const pyDatatableCall = sdkDatatableCall(datatableName, datatableRole, 'python') + const roleEntries = Object.entries(policy.roles ?? {}) + const rolesNote = + roleEntries.length > 0 + ? `\n\nThis app uses these data tables through a role: ${roleEntries + .map(([dt, role]) => `\`${dt}\` as \`${role}\``) + .join( + ', ' + )}. Always pass that role when calling \`wmill.datatable\` on them, as in the examples. The role only reaches what it was granted, so a query on a table it lacks privileges on fails with \`permission denied\`.` + : '' let content = `You are a helpful assistant that creates and edits apps on the Windmill platform. Apps are defined as a collection of files that contains both the frontend and the backend. @@ -1024,7 +1036,7 @@ Backend runnables should only perform **data operations** (SELECT, INSERT, UPDAT import * as wmill from 'windmill-client'; export async function main(user_id: string) { - const sql = ${datatableCall}; + const sql = ${tsDatatableCall}; const user = await sql\`SELECT * FROM ${schemaPrefix}users WHERE id = \${user_id}\`.fetchOne(); return user; } @@ -1035,12 +1047,12 @@ export async function main(user_id: string) { import wmill def main(user_id: str): - db = ${datatableCall} + db = ${pyDatatableCall} user = db.query('SELECT * FROM ${schemaPrefix}users WHERE id = $1', user_id).fetch_one() return user \`\`\` -Use these examples for normal datatable access. +Use these examples for normal datatable access.${rolesNote} ### Schema Modifications (DDL) - Use exec_datatable_sql tool ONLY diff --git a/frontend/src/lib/components/copilot/chat/datatableTools.ts b/frontend/src/lib/components/copilot/chat/datatableTools.ts index 7232500898..bb7976d396 100644 --- a/frontend/src/lib/components/copilot/chat/datatableTools.ts +++ b/frontend/src/lib/components/copilot/chat/datatableTools.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { WorkspaceService, type CompletedJob } from '$lib/gen' import type { DataTableTables } from '$lib/gen/types.gen' import { runScript } from '$lib/components/jobs/utils' +import { datatableReference } from '$lib/components/dbTypes' import { createToolDef, executeTestRun, @@ -15,9 +16,9 @@ import { * * Datatables are workspace-level managed PostgreSQL databases. The backend * endpoints used here (`list_datatable_tables`, `get_datatable_table_schema`) - * and SQL execution (`datatable://`) are gated only by workspace - * membership, so these tools need no app context and operate directly on the - * workspace. This is the unrestricted counterpart to the app-mode datatable + * and SQL execution (`datatable://`) need no app context: the server + * decides what the caller reaches, as the datatable role they name or its + * default. This is the unrestricted counterpart to the app-mode datatable * tools in `app/core.ts`, which additionally filter by the app's whitelist. */ @@ -31,9 +32,19 @@ const memo = (factory: () => T): (() => T) => { // ============= Pure workspace-scoped operations ============= -/** List all datatables configured in the workspace, with their schema/table names. */ -export async function listDatatables(workspace: string): Promise { - return await WorkspaceService.listDataTableTables({ workspace }) +/** List the datatables configured in the workspace, with their schema/table names: all of them as + * their default role, or only `datatableName`, as `role` when one is given. */ +export async function listDatatables( + workspace: string, + datatableName?: string, + role?: string +): Promise { + if (datatableName === undefined) return await WorkspaceService.listDataTableTables({ workspace }) + return await WorkspaceService.listDataTableTables({ + workspace, + datatableName, + ...(role !== undefined && { roleFor: datatableName, role }) + }) } /** Get the columns (column_name -> compact_type) of one datatable table. */ @@ -41,13 +52,15 @@ export async function getDatatableColumns( workspace: string, datatableName: string, schemaName: string, - tableName: string + tableName: string, + role?: string ): Promise> { const schema = await WorkspaceService.getDataTableTableSchema({ workspace, datatableName, schemaName, - tableName + tableName, + role }) return schema.columns } @@ -81,7 +94,26 @@ const NO_DATATABLES_CONFIGURED_MESSAGE = // ============= Tool definitions ============= -const getListDatatablesSchema = memo(() => z.object({})) +// The same rule the server applies to `-- role `; a name it would refuse fails here instead. +const getRoleSchema = memo(() => + z + .string() + .regex(/^[A-Za-z0-9_-]{1,63}$/) + .optional() + .describe( + "The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role." + ) +) + +const getListDatatablesSchema = memo(() => + z.object({ + datatable_name: z + .string() + .optional() + .describe('List only this datatable. Required with `role`.'), + role: getRoleSchema() + }) +) const getListDatatablesToolDef = memo(() => createToolDef( getListDatatablesSchema(), @@ -94,7 +126,8 @@ const getGetDatatableTableSchemaSchema = memo(() => z.object({ datatable_name: z.string().describe('The datatable name to inspect, e.g. "main".'), schema_name: z.string().describe('The schema name, e.g. "public".'), - table_name: z.string().describe('The table name to inspect.') + table_name: z.string().describe('The table name to inspect.'), + role: getRoleSchema() }) ) const getGetDatatableTableSchemaToolDef = memo(() => @@ -117,6 +150,7 @@ const getExecDatatableSqlSchema = memo(() => .describe( 'The SQL query to execute. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, ALTER TABLE, DROP TABLE, etc. For SELECT queries, results are returned as an array of objects. A newly created table will appear in list_datatables automatically.' ), + role: getRoleSchema(), background: z .boolean() .optional() @@ -217,10 +251,18 @@ export function getDatatableTools(): Tool<{}>[] { { def: getListDatatablesToolDef(), planModeSafe: true, - fn: async ({ workspace, toolId, toolCallbacks }) => { + fn: async ({ args, workspace, toolId, toolCallbacks }) => { toolCallbacks.setToolStatus(toolId, { content: 'Listing datatables...' }) try { - const metadata = await listDatatables(workspace) + const parsedArgs = getListDatatablesSchema().parse(args ?? {}) + if (parsedArgs.role !== undefined && parsedArgs.datatable_name === undefined) { + throw new Error('`role` needs `datatable_name`, the datatable it is a role of') + } + const metadata = await listDatatables( + workspace, + parsedArgs.datatable_name, + parsedArgs.role + ) if (metadata.length === 0) { toolCallbacks.setToolStatus(toolId, { content: 'No datatables configured — set one up in workspace settings' @@ -236,7 +278,18 @@ export function getDatatableTools(): Tool<{}>[] { toolCallbacks.setToolStatus(toolId, { content: `Listed ${metadata.length} datatable(s) with ${totalTables} table(s)` }) - return JSON.stringify(metadata, null, 2) + // Only what the model acts on: the roles it may pass, not the creation privileges + // the manager's UI gates on. + return JSON.stringify( + metadata.map((d) => ({ + datatable_name: d.datatable_name, + schemas: d.schemas, + ...(d.error && { error: d.error }), + ...(d.permissioned && { usable_roles: d.usable_roles, default_role: d.default_role }) + })), + null, + 2 + ) } catch (e) { const errorMsg = `Error listing datatables: ${e instanceof Error ? e.message : String(e)}` toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg }) @@ -257,7 +310,8 @@ export function getDatatableTools(): Tool<{}>[] { workspace, parsedArgs.datatable_name, parsedArgs.schema_name, - parsedArgs.table_name + parsedArgs.table_name, + parsedArgs.role ) toolCallbacks.setToolStatus(toolId, { content: `Retrieved schema for ${parsedArgs.schema_name}.${parsedArgs.table_name}` @@ -300,7 +354,7 @@ export function getDatatableTools(): Tool<{}>[] { requestBody: { language: 'postgresql', content: parsedArgs.sql, - args: { database: `datatable://${name}` } + args: { database: datatableReference(name, parsedArgs.role) } } }), workspace, diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 2e68b74fe1..6c85a75a4e 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -1434,7 +1434,12 @@ Data Tables: - Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL. - Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists or call exec_datatable_sql. - Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries. -- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step. +- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.${ + isCloudHosted() + ? '' + : ` +- A raw app may use a datatable through a role (\`data.roles\` in its raw_app.yaml). When working on such an app, pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can.` + } - When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.${ skills.length > 0 ? ` diff --git a/frontend/src/lib/components/datatableAcl/AclTargetPicker.svelte b/frontend/src/lib/components/datatableAcl/AclTargetPicker.svelte deleted file mode 100644 index cbfb43ad39..0000000000 --- a/frontend/src/lib/components/datatableAcl/AclTargetPicker.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - -
    - ({ value: t, label: t }))} - bind:value={table} - placeholder="The whole schema" - clearable - size="sm" - class="w-56" - /> - {/if} -
    diff --git a/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte b/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte index e0ae596a3d..a3a765014a 100644 --- a/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte +++ b/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte @@ -25,30 +25,24 @@ let { workspace, datatable, - target, - onLoaded + target }: { workspace: string datatable: string /** What owner and grants are read and written for. */ target: AclTarget - /** Each read, with the target it was made for: it also lists what the target holds. */ - onLoaded?: (target: AclTarget, info: DatatableAclInfo) => void } = $props() const acl = resource( () => [workspace, datatable, target] as const, - async ([ws, dt, t]) => { - const loaded = await WorkspaceService.getDatatableAcl({ + async ([ws, dt, t]) => + await WorkspaceService.getDatatableAcl({ workspace: ws, datatableName: dt, kind: t.kind, schema: t.kind === 'database' ? undefined : t.schema, table: t.kind === 'table' ? t.table : undefined }) - onLoaded?.(t, loaded) - return loaded - } ) // Nothing is written before its SQL has been shown, and the apply runs exactly that SQL: the @@ -120,7 +114,12 @@ Loading… {:else}
    - {#if !info.editable} + {#if info.clone} + + Read only: this data table is a clone, and its owners and grants stay as they were copied + from the data table it was cloned from. + + {:else if !info.editable} Read only: access is changed by the admins of the workspace that governs this data table, on Windmill Enterprise Edition. diff --git a/frontend/src/lib/components/datatableMigrationRole.test.ts b/frontend/src/lib/components/datatableMigrationRole.test.ts new file mode 100644 index 0000000000..6d136b8371 --- /dev/null +++ b/frontend/src/lib/components/datatableMigrationRole.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from 'vitest' +import { parseMigrationRole, withMigrationRole } from './datatableMigrationRole' + +describe('parseMigrationRole', () => { + test('reads every spelling the server accepts from the leading comment block', () => { + for (const line of [ + '-- role analyst', + '-- Role: analyst', + '-- role=analyst', + '-- role analyst;' + ]) { + expect(parseMigrationRole(`\n${line}\nBEGIN;\nEND;`)).toEqual({ + kind: 'role', + role: 'analyst' + }) + } + }) + + test('an annotation below BEGIN is not one', () => { + expect(parseMigrationRole('BEGIN;\n-- role analyst\nEND;')).toEqual({ kind: 'none' }) + }) + + test('a malformed attempt is an error, not the default', () => { + for (const line of [ + '-- role based access below', + '-- role', + '-- role:', + '-- role an;alytics' + ]) { + expect(parseMigrationRole(`${line}\nBEGIN;`)).toEqual({ kind: 'malformed', line }) + } + }) + + test('comments that do not start with the word role are ignored', () => { + expect(parseMigrationRole('-- roles analyst\n-- rolex\nBEGIN;')).toEqual({ kind: 'none' }) + }) +}) + +describe('withMigrationRole', () => { + test('leads above BEGIN, so the server reads it', () => { + const out = withMigrationRole('BEGIN;\nSELECT 1;\nEND;', 'analyst') + expect(out).toBe('-- role analyst\nBEGIN;\nSELECT 1;\nEND;') + }) + + test('replaces any attempt rather than stacking, malformed ones included', () => { + const out = withMigrationRole( + '-- Role: auditor\n-- role oops no\n-- keep me\nBEGIN;', + 'analyst' + ) + expect(out).toBe('-- role analyst\n-- keep me\nBEGIN;') + }) + + test('undefined strips the annotation, so it runs as admin', () => { + expect(withMigrationRole('-- role analyst\n\nBEGIN;\nEND;', undefined)).toBe('BEGIN;\nEND;') + }) + + test('refuses a name the server would refuse', () => { + expect(() => withMigrationRole('BEGIN;', 'bad;name')).toThrow() + }) +}) diff --git a/frontend/src/lib/components/datatableMigrationRole.ts b/frontend/src/lib/components/datatableMigrationRole.ts new file mode 100644 index 0000000000..5475bf2fd7 --- /dev/null +++ b/frontend/src/lib/components/datatableMigrationRole.ts @@ -0,0 +1,70 @@ +import { isDatatableRoleName } from './dbTypes' + +/** + * A migration carries the data table role it runs as in its own SQL, as a `-- role ` + * annotation. There is no separate field: the annotation is what the server reads, and keeping + * it in the SQL is what lets it survive a `wmill sync` round-trip. + * + * Mirrors `SqlAnnotations::datatable_role` on the backend. It is only read from the leading + * comment block, so an annotation below `BEGIN;` is ignored and the migration runs as admin. A + * leading comment whose first word is `role` is an annotation attempt, and a malformed one is an + * error there, so it is one here too. + */ + +export type MigrationRole = + | { kind: 'none' } + | { kind: 'role'; role: string } + | { kind: 'malformed'; line: string } + +/** The body of a leading comment line that attempts a role annotation, or undefined. */ +function roleAttempt(line: string): string | undefined { + if (!line.startsWith('--')) return undefined + const body = line.slice(2).trimStart() + if (body.slice(0, 4).toLowerCase() !== 'role') return undefined + const after = body.slice(4) + if (after !== '' && !/^[\s:=]/.test(after)) return undefined + return after +} + +function parseAttempt(after: string): string | undefined { + let rest = after.trimStart() + if (rest.startsWith(':') || rest.startsWith('=')) rest = rest.slice(1) + const tokens = rest.split(/\s+/).filter((t) => t !== '') + if (tokens.length !== 1) return undefined + const role = tokens[0].endsWith(';') ? tokens[0].slice(0, -1) : tokens[0] + return isDatatableRoleName(role) ? role : undefined +} + +export function parseMigrationRole(sql: string): MigrationRole { + for (const raw of sql.split('\n')) { + const line = raw.trim() + if (line === '') continue + if (!line.startsWith('--')) break + const after = roleAttempt(line) + if (after === undefined) continue + const role = parseAttempt(after) + return role === undefined ? { kind: 'malformed', line } : { kind: 'role', role } + } + return { kind: 'none' } +} + +/** + * `sql` declaring `role`: any role annotation attempt in the leading comment block is removed, + * and `-- role ` is prepended above everything, or nothing when `role` is undefined. + */ +export function withMigrationRole(sql: string, role: string | undefined): string { + if (role !== undefined && !isDatatableRoleName(role)) { + throw new Error(`Invalid data table role '${role}'`) + } + const lines = sql.split('\n') + const kept: string[] = [] + let i = 0 + for (; i < lines.length; i++) { + const line = lines[i].trim() + if (line !== '' && !line.startsWith('--')) break + if (roleAttempt(line) === undefined) kept.push(lines[i]) + } + const rest = [...kept, ...lines.slice(i)] + while (rest.length > 0 && rest[0].trim() === '') rest.shift() + return role === undefined ? rest.join('\n') : [`-- role ${role}`, ...rest].join('\n') +} diff --git a/frontend/src/lib/components/datatableUsableRoles.test.ts b/frontend/src/lib/components/datatableUsableRoles.test.ts new file mode 100644 index 0000000000..cca4ab7607 --- /dev/null +++ b/frontend/src/lib/components/datatableUsableRoles.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' + +const request = vi.fn() +vi.mock('$lib/gen/core/request', () => ({ request: (...args: unknown[]) => request(...args) })) +vi.mock('$lib/gen', () => ({ OpenAPI: {} })) +vi.mock('$lib/cloud', () => ({ isCloudHosted: () => false })) + +import { listUsableDatatableRoles } from './datatableUsableRoles' + +const refuse = (body: string) => () => + Promise.reject(Object.assign(new Error('Bad Request'), { body })) + +describe('listUsableDatatableRoles', () => { + // The refusal is recognised by its sentence, so rewording it on one side alone would turn every + // data table on a build without the Enterprise Edition into a failed lookup rather than one + // that is not under roles. + it('reads the enterprise refusal as not under roles, and rethrows anything else', async () => { + request.mockImplementation(refuse('Data table roles are a Windmill Enterprise Edition feature')) + expect(await listUsableDatatableRoles('ws', 'main')).toEqual({ + permissioned: false, + roles: [], + default_role: 'admin' + }) + + request.mockImplementation(refuse('Data table not found')) + const rethrown = await listUsableDatatableRoles('ws', 'main').catch((e) => e) + expect(rethrown).toBeInstanceOf(Error) + }) +}) diff --git a/frontend/src/lib/components/datatableUsableRoles.ts b/frontend/src/lib/components/datatableUsableRoles.ts new file mode 100644 index 0000000000..84e52f29a8 --- /dev/null +++ b/frontend/src/lib/components/datatableUsableRoles.ts @@ -0,0 +1,44 @@ +import { OpenAPI, type ListUsableDatatableRolesResponse } from '$lib/gen' +import { request } from '$lib/gen/core/request' +import { isCloudHosted } from '$lib/cloud' +import { ADMIN_DATATABLE_ROLE } from './dbTypes' + +// `datatable_roles_unavailable` on the server, which is a plain 400: rewording it there without +// here makes every role picker on a non-Enterprise build fail instead of reading "not under roles". +const ROLES_UNAVAILABLE = 'Data table roles are a Windmill Enterprise Edition feature' + +const NOT_UNDER_ROLES: ListUsableDatatableRolesResponse = { + permissioned: false, + roles: [], + default_role: ADMIN_DATATABLE_ROLE +} + +/** + * The roles the caller may connect as on a data table. Cloud has no instance database, so no data + * table there is under roles, and none of the role pickers show. Without the Enterprise Edition + * every roles route refuses, which reads the same way: the data table is then used the way it was + * before roles, and one that is under roles is refused when something connects to it. + */ +export async function listUsableDatatableRoles( + workspace: string, + datatableName: string +): Promise { + if (isCloudHosted()) return NOT_UNDER_ROLES + try { + // The generated client encodes path params with `encodeURI`, which leaves a '?' in a + // data table name created before names were restricted to cut the path short. + return await request( + { ...OpenAPI, ENCODE_PATH: encodeURIComponent }, + { + method: 'GET', + url: '/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}', + path: { workspace, datatable_name: datatableName } + } + ) + } catch (e) { + const body = (e as { body?: unknown })?.body + const detail = `${typeof body === 'string' ? body : JSON.stringify(body ?? '')} ${(e as Error)?.message ?? e}` + if (detail.includes(ROLES_UNAVAILABLE)) return NOT_UNDER_ROLES + throw e + } +} diff --git a/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts b/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts index 59345c6ae7..890635dcf8 100644 --- a/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts +++ b/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts @@ -5,7 +5,7 @@ import { isDbType } from './dbTypes' /** * Single URL param `dbm` encodes the full DB manager state: - * firstSegment~path~schema.table + * firstSegment~path~schema.table~role=name * * firstSegment: * datatable – database with datatable:// resource (resourceType always postgresql) @@ -26,28 +26,46 @@ import { isDbType } from './dbTypes' * datatable~main~.customers (schema "public" implied) * ducklake~main~.orders (schema "main" implied) * postgresql~$res:u/user/my_pg~public.customers + * datatable~main~.customers~role=analyst + * datatable~main~role=analyst (no schema/table selected) + * + * role=name (last segment, optional, data tables only): the data table role to connect as. + * Omitted means the data table's default role. A trailing segment starting with `role=` is always + * the role, whatever follows. The name is kept as written, even when invalid (a `.` included), so + * the connection refuses it visibly instead of falling back to the default. */ const dbManagerSchema = z.object({ dbm: z.string().nullable() }) -interface ParsedDbm { +export interface ParsedDbm { type: 'database' | 'datatable' | 'ducklake' path: string resType?: string schema?: string table?: string + role?: string } -function parseDbm(raw: unknown): ParsedDbm | null { +const ROLE_SEGMENT_PREFIX = 'role=' + +function isRoleSegment(segment: string | undefined): segment is string { + return !!segment && segment.startsWith(ROLE_SEGMENT_PREFIX) +} + +export function parseDbm(raw: unknown): ParsedDbm | null { if (!raw || typeof raw !== 'string') return null const parts = raw.split('~') if (parts.length < 2 || !parts[1]) return null const firstSeg = parts[0] const path = parts[1] - const schemaTable = parts[2] ?? '' + const rest = parts.slice(2) + const role = isRoleSegment(rest.at(-1)) + ? rest.pop()!.slice(ROLE_SEGMENT_PREFIX.length) + : undefined + const schemaTable = rest[0] ?? '' let type: ParsedDbm['type'] let resType: string | undefined @@ -81,12 +99,12 @@ function parseDbm(raw: unknown): ParsedDbm | null { schema = defaultSchemas[type] } - return { type, path, resType, schema, table } + return { type, path, resType, schema, table, role: type === 'datatable' ? role : undefined } } const defaultSchemas: Record = { datatable: 'public', ducklake: 'main' } -function buildDbm(p: ParsedDbm): string { +export function buildDbm(p: ParsedDbm): string { const firstSeg = p.type === 'database' ? p.resType! : p.type const schema = p.schema === defaultSchemas[p.type] ? undefined : p.schema let schemaTable = '' @@ -97,7 +115,12 @@ function buildDbm(p: ParsedDbm): string { } else if (schema) { schemaTable = `${schema}.` } - return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}` + const segments = [firstSeg, p.path] + if (schemaTable) segments.push(schemaTable) + if (p.type === 'datatable' && p.role !== undefined) { + segments.push(`${ROLE_SEGMENT_PREFIX}${p.role}`) + } + return segments.join('~') } export interface DbManagerUriState { @@ -105,6 +128,8 @@ export interface DbManagerUriState { readonly effectiveInput: DbInput | undefined readonly isDatatableInput: boolean selectedDatatable: string | undefined + /** The data table role the drawer connects as; undefined means its default. */ + selectedRole: string | undefined selectedSchema: string | undefined selectedTable: string | undefined readonly open: boolean @@ -137,6 +162,7 @@ export function useDbManagerUriState(): DbManagerUriState { type: 'database' as const, resourceType: resType as DbType, resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path, + role: parsed.role, specificSchema: parsed.schema, specificTable: parsed.table } @@ -163,6 +189,7 @@ export function useDbManagerUriState(): DbManagerUriState { type: isDatatable ? 'datatable' : 'database', path: isDatatable ? nInput.resourcePath.slice('datatable://'.length) : nInput.resourcePath, resType: isDatatable ? undefined : nInput.resourceType, + role: isDatatable ? nInput.role : undefined, schema: nInput.specificSchema, table: nInput.specificTable }) @@ -194,7 +221,14 @@ export function useDbManagerUriState(): DbManagerUriState { return parsed?.type === 'datatable' ? parsed.path : undefined }, set selectedDatatable(v: string | undefined) { - if (v) updateField({ path: v }) + // A role belongs to one data table, so it cannot carry over to another. + if (v) updateField({ path: v, role: undefined }) + }, + get selectedRole() { + return parsed?.role + }, + set selectedRole(v: string | undefined) { + updateField({ role: v }) }, get selectedSchema() { return parsed?.schema diff --git a/frontend/src/lib/components/dbManagerRole.test.ts b/frontend/src/lib/components/dbManagerRole.test.ts new file mode 100644 index 0000000000..6c45d0ed30 --- /dev/null +++ b/frontend/src/lib/components/dbManagerRole.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { buildDbm, parseDbm } from './dbManagerDrawerModel.svelte' +import { schemaCacheKey } from './dbSchemaCache' +import { datatableReference, type DbInput } from './dbTypes' + +describe('dbm role segment', () => { + it('round-trips a role, with and without a table', () => { + for (const dbm of ['datatable~main~.orders~role=p4_analytics', 'datatable~main~role=p4-op']) { + expect(buildDbm(parseDbm(dbm)!)).toBe(dbm) + } + expect(parseDbm('datatable~main~sales.orders~role=analyst')).toMatchObject({ + path: 'main', + schema: 'sales', + table: 'orders', + role: 'analyst' + }) + }) + + it('reads a link without a role as the default role', () => { + const parsed = parseDbm('datatable~main~.orders')! + expect(parsed.role).toBeUndefined() + expect(parsed).toMatchObject({ schema: 'public', table: 'orders' }) + expect(buildDbm(parsed)).toBe('datatable~main~.orders') + }) + + it('keeps an invalid role as written, so the connection refuses it', () => { + expect(parseDbm('datatable~main~role=a;b')?.role).toBe('a;b') + // A dot does not turn it into a schema.table selection read as the default role. + expect(parseDbm('datatable~main~role=bad.name')).toMatchObject({ + role: 'bad.name', + schema: undefined, + table: undefined + }) + }) +}) + +describe('connecting as a role', () => { + const input = (role?: string): DbInput => ({ + type: 'database', + resourceType: 'postgresql', + resourcePath: 'datatable://main', + role + }) + + // What `getDatabaseArg` builds every DB manager connection from. + it('appends the role to the data table reference', () => { + expect(datatableReference('main', 'p4_analytics')).toBe('datatable://main?role=p4_analytics') + expect(datatableReference('main', undefined)).toBe('datatable://main') + }) + + it('never appends a role to a name containing ?', () => { + // The server reads such a whole reference as the stored name first, so `?role=` would + // be taken as part of the name or refused instead of picking the role. + expect(datatableReference('legacy?x', undefined)).toBe('datatable://legacy?x') + expect(() => datatableReference('sales?role=analytics', 'admin')).toThrow(/'\?' in its name/) + }) + + it('refuses a role name the server would not accept', () => { + expect(() => datatableReference('main', 'a&role=admin')).toThrow(/Invalid data table role/) + expect(() => datatableReference('main', '')).toThrow(/Invalid data table role/) + }) + + it('keys the schema cache by role', () => { + expect(schemaCacheKey('ws', input('a'))).not.toBe(schemaCacheKey('ws', input('b'))) + expect(schemaCacheKey('ws', input('a'))).not.toBe(schemaCacheKey('ws', input())) + }) +}) diff --git a/frontend/src/lib/components/dbOps.ts b/frontend/src/lib/components/dbOps.ts index 5005cc931c..e239a9e957 100644 --- a/frontend/src/lib/components/dbOps.ts +++ b/frontend/src/lib/components/dbOps.ts @@ -8,7 +8,8 @@ import { runScriptAndPollResult } from './jobs/utils' import { writingJobOptions } from './jobs/writingJob' import type { DBSchema, SQLSchema } from '$lib/stores' import { stringifySchema } from './copilot/lib' -import type { DbInput, DbType } from './dbTypes' +import { datatableReference, type DbInput, type DbType } from './dbTypes' +import { withMigrationRole } from './datatableMigrationRole' import { assert } from '$lib/utils' import { WorkspaceService } from '$lib/gen' import { pendingMigrations } from './workspaceSettings/datatableMigrationUtils' @@ -70,7 +71,9 @@ export function dbTableOpsWithPreviewScripts({ }): IDbTableOps { const dbType = getDbType(input) const language = getLanguageByResourceType(dbType) - const dbArg = getDatabaseArg(input) + // Built per call: an invalid role throws there, as that operation's error, rather than while + // the manager renders. + const dbArg = () => getDatabaseArg(input) const ducklake = input.type === 'ducklake' ? input.ducklake : undefined function makeMarker(op: string, payload: Record): string { @@ -91,7 +94,7 @@ export function dbTableOpsWithPreviewScripts({ }) const result = await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg, quicksearch }, language, content, tag } + requestBody: { args: { ...dbArg(), quicksearch }, language, content, tag } }) const count = result?.[0].count as number return count @@ -106,7 +109,7 @@ export function dbTableOpsWithPreviewScripts({ }) let items = (await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg, ...params }, language, content, tag } + requestBody: { args: { ...dbArg(), ...params }, language, content, tag } })) as unknown[] if (!items || !Array.isArray(items)) { throw 'items is not an array' @@ -123,7 +126,7 @@ export function dbTableOpsWithPreviewScripts({ { workspace, requestBody: { - args: { ...dbArg, value_to_update: newValue, ...values }, + args: { ...dbArg(), value_to_update: newValue, ...values }, language, content, tag @@ -135,14 +138,14 @@ export function dbTableOpsWithPreviewScripts({ onDelete: async ({ values }) => { const content = makeMarker('DELETE', { table: tableKey, columns: colDefs }) await runScriptAndPollResult( - { workspace, requestBody: { args: { ...dbArg, ...values }, language, content, tag } }, + { workspace, requestBody: { args: { ...dbArg(), ...values }, language, content, tag } }, writingJobOptions ) }, onInsert: async ({ values }) => { const content = makeMarker('INSERT', { table: tableKey, columns: colDefs }) await runScriptAndPollResult( - { workspace, requestBody: { args: { ...dbArg, ...values }, language, content, tag } }, + { workspace, requestBody: { args: { ...dbArg(), ...values }, language, content, tag } }, writingJobOptions ) } @@ -246,6 +249,7 @@ export type IDbSchemaOps = { previewAlterSql: (params: { values: AlterTableValues; schema?: string }) => Promise onCreateSchema: (params: { schema: string }) => Promise onDeleteSchema: (params: { schema: string }) => Promise + onRenameSchema: (params: { schema: string; newSchema: string }) => Promise onFetchTableEditorDefinition: (params: { table: string schema?: string @@ -283,7 +287,8 @@ export function dbSchemaOpsWithPreviewScripts({ tag?: string }): IDbSchemaOps { const dbType = getDbType(input) - const dbArg = getDatabaseArg(input) + // Built per call, for the same reason as in the table ops above. + const dbArg = () => getDatabaseArg(input) const language = getLanguageByResourceType(dbType) const ducklake = input.type === 'ducklake' ? input.ducklake : undefined @@ -293,6 +298,8 @@ export function dbSchemaOpsWithPreviewScripts({ input.type === 'database' && input.resourcePath.startsWith('datatable://') ? input.resourcePath.slice('datatable://'.length) : undefined + // A migration declaring no role runs as admin, whatever role the manager connects as. + const migrationRole = input.type === 'database' ? (input.role ?? input.migrationRole) : undefined function makeMarker(op: string, payload: Record): string { if (ducklake) payload.ducklake = ducklake @@ -359,7 +366,7 @@ export function dbSchemaOpsWithPreviewScripts({ : undefined if (!datatableName || !status?.enabled) { await runScriptAndPollResult( - { workspace, requestBody: { args: dbArg, content, language, tag } }, + { workspace, requestBody: { args: dbArg(), content, language, tag } }, writingJobOptions ) return @@ -373,12 +380,16 @@ export function dbSchemaOpsWithPreviewScripts({ throw new MigrationRunCancelled() } } - const codeUp = wrapMigration(await expandMarker(workspace, language, content)) + // Wrapped before annotating: the annotation must lead, above `BEGIN;`. + const codeUp = withMigrationRole( + wrapMigration(await expandMarker(workspace, language, content)), + migrationRole + ) // Down migrations are only generated for Postgres for now. let codeDown: string | undefined if (downContent && dbType === 'postgresql') { const downSql = (await expandMarker(workspace, language, downContent)).trim() - if (downSql) codeDown = wrapMigration(downSql) + if (downSql) codeDown = withMigrationRole(wrapMigration(downSql), migrationRole) } const created = await WorkspaceService.createDatatableMigration({ workspace, @@ -415,7 +426,7 @@ export function dbSchemaOpsWithPreviewScripts({ const fkContent = makeMarker('FOREIGN_KEYS', { table, schema }) const fkResult = await runScriptAndPollResult({ workspace, - requestBody: { args: dbArg, content: fkContent, language, tag } + requestBody: { args: dbArg(), content: fkContent, language, tag } }) let rawForeignKeys: RawForeignKey[] @@ -501,6 +512,11 @@ export function dbSchemaOpsWithPreviewScripts({ const downContent = makeMarker('CREATE_SCHEMA', { schema }) await applyDdl(migrationName('drop_schema', schema), content, downContent) }, + onRenameSchema: async ({ schema, newSchema }) => { + const content = makeMarker('RENAME_SCHEMA', { schema, new_schema: newSchema }) + const downContent = makeMarker('RENAME_SCHEMA', { schema: newSchema, new_schema: schema }) + await applyDdl(migrationName('rename_schema', schema), content, downContent) + }, onFetchForeignKeys: fetchForeignKeys, onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => { const foreignKeys = await fetchForeignKeys({ table, schema }) @@ -512,7 +528,7 @@ export function dbSchemaOpsWithPreviewScripts({ const pkContent = makeMarker('PRIMARY_KEY_CONSTRAINT', { table, schema }) const pkResult = (await runScriptAndPollResult({ workspace, - requestBody: { args: dbArg, content: pkContent, language, tag } + requestBody: { args: dbArg(), content: pkContent, language, tag } })) as { constraint_name?: string; CONSTRAINT_NAME?: string }[] if (pkResult && Array.isArray(pkResult) && pkResult.length > 0) { @@ -611,7 +627,9 @@ export function getDefaultDbTag(input: DbInput): string { export function getDatabaseArg(input: DbInput | undefined) { if (input?.type === 'database') { if (input.resourcePath.startsWith('datatable://')) { - return { database: input.resourcePath } + return { + database: datatableReference(input.resourcePath.slice('datatable://'.length), input.role) + } } else { return { database: '$res:' + input.resourcePath } } diff --git a/frontend/src/lib/components/dbSchemaCache.ts b/frontend/src/lib/components/dbSchemaCache.ts new file mode 100644 index 0000000000..0421c0c033 --- /dev/null +++ b/frontend/src/lib/components/dbSchemaCache.ts @@ -0,0 +1,21 @@ +import type { DbInput } from './dbTypes' + +/** What identifies a database's schema, role included: two roles on one data table may reach + * different schemas, so they cannot share a cache entry. Never throws, since it keys derived + * state; the connection itself is what refuses an invalid role. */ +export function getDbSchemasPath(input: DbInput): string { + switch (input.type) { + case 'database': + return input.role !== undefined && input.resourcePath.startsWith('datatable://') + ? `${input.resourcePath}?role=${input.role}` + : input.resourcePath + case 'ducklake': + return 'ducklake://' + input.ducklake + } +} + +/** Scoped by the acting workspace: a data table of the same name can exist in both the nav and + * the acting workspace, and one's schema must not be reused for the other. */ +export function schemaCacheKey(workspace: string | undefined, input: DbInput): string { + return `${workspace}:${getDbSchemasPath(input)}` +} diff --git a/frontend/src/lib/components/dbTypes.ts b/frontend/src/lib/components/dbTypes.ts index 6a85617110..e6d3af12e8 100644 --- a/frontend/src/lib/components/dbTypes.ts +++ b/frontend/src/lib/components/dbTypes.ts @@ -3,6 +3,12 @@ export type DbInput = type: 'database' resourceType: DbType resourcePath: string + /** The data table role to connect as; the data table's default when unset. Only + * meaningful for a `datatable://` path. */ + role?: string + /** The role migrations written through this input declare when `role` is unset. A + * migration declaring none runs as admin, not as the role the manager connects as. */ + migrationRole?: string specificSchema?: string specificTable?: string } @@ -23,3 +29,47 @@ export const dbTypes = [ 'duckdb' ] as const export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType) + +/** The role every data table has: the one it connects as when it is not under roles. */ +export const ADMIN_DATATABLE_ROLE = 'admin' + +/** What the server accepts in `-- role ` and `?role=`. */ +export function isDatatableRoleName(name: string): boolean { + return /^[A-Za-z0-9_-]{1,63}$/.test(name) +} + +/** Whether a role can be named in a reference to this data table. A name stored before names were + * restricted may contain `?`, and the server reads such a whole reference as that name first, so + * `?role=` after it would be taken as part of the name or refused. */ +export function datatableNameTakesRole(name: string): boolean { + return !name.includes('?') +} + +/** The `migrationRole` of a data table that cannot name a role in its reference: it connects as + * its default role, which its migrations must then declare. */ +export function defaultMigrationRole( + name: string, + permissioned: boolean | undefined, + defaultRole: string | undefined +): string | undefined { + return permissioned && !datatableNameTakesRole(name) ? defaultRole : undefined +} + +/** `datatable://`, with `?role=` when a role is named. Throws rather than build a + * reference the executor would refuse, or one that would silently mean another role. */ +export function datatableReference(name: string, role: string | undefined): string { + if (role === undefined) return `datatable://${name}` + if (!isDatatableRoleName(role)) { + throw new Error( + `Invalid data table role '${role}': only letters, digits, '_' and '-' are allowed` + ) + } + if (!datatableNameTakesRole(name)) { + throw new Error( + `Data table '${name}' has a '?' in its name, so it can only be used here as its default role. Rename it to connect as role '${role}'.` + ) + } + return `datatable://${name}?role=${role}` +} + +export type DatatableRowAction = 'migrations' | 'roles' | 'export' | 'import' diff --git a/frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte b/frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte index 57f01dddc1..c4d6d47269 100644 --- a/frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte +++ b/frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte @@ -3,13 +3,14 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import Select from '$lib/components/select/Select.svelte' import { + createDatatableAccessResource, createDatatablesResource, - createSchemasResource, toDatatableItems, toSchemaItems } from './datatableUtils.svelte' import { Button } from '../common' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { appDatatableRole } from './dataTableRefUtils' const operatingWorkspace = useOperatingWorkspace() let opWs = $derived($operatingWorkspace) @@ -19,6 +20,8 @@ datatable: string | undefined /** Currently selected schema */ schema: string | undefined + /** The role the app uses each data table through: schemas are listed as that role. */ + roles?: Record /** Callback when either value changes */ onChange?: (datatable: string | undefined, schema: string | undefined) => void /** Description text to show in the popover */ @@ -28,19 +31,31 @@ let { datatable, schema, + roles, onChange, description = 'Set the default datatable and schema for new tables. This is where AI will create new tables when needed.' }: Props = $props() + const role = $derived(datatable ? appDatatableRole(roles, datatable) : undefined) + // Load available datatables and schemas using shared utilities const datatables = createDatatablesResource(() => opWs) - const schemas = createSchemasResource( + const access = createDatatableAccessResource( () => datatable, + () => role, () => opWs ) const datatableItems = $derived(toDatatableItems(datatables.current)) - const schemaItems = $derived(toSchemaItems(schemas.current)) + // Until the answer is for this workspace, data table and role, the schemas in hand belong to + // another. + const schemaItems = $derived( + access.current.workspace === opWs && + access.current.datatable === datatable && + access.current.role === role + ? toSchemaItems(access.current.schemas) + : [] + ) // Track datatable changes to reset schema let previousDatatable = $state(undefined) @@ -81,6 +96,9 @@ placeholder="Select database" size="sm" /> + {#if role} + Used as role {role} + {/if}
    diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte index 6befd85579..7ccf89a275 100644 --- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte @@ -1,34 +1,50 @@ - - {/if} -
    - {#if editable && row.id !== ADMIN_ROLE} - removeRole(row.id)} /> - {/if} -
    - -

    - {/each} -
  • - - {#if editable && unusedRoles.length > 0} -
    - - ({ + value: r.id, + label: r.enabled ? r.name : `${r.name} (disabled)` + }))} + placeholder="+ Add a role" + bind:value={ + () => undefined, + (id) => { + if (id) addRole(id) + } + } + onCreateItem={addRoleByName} + class="w-64" + /> +
    + + + {/if} + + {/if}
    + + {#if info?.supported && !hasUnsavedChanges} +
    + +
    + {/if} {/if} + + {#snippet actions()} + {#if editable} + + {/if} + {/snippet} + +{#if $superadmin} + +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte b/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte index d100b1681a..b863dac06b 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte @@ -13,11 +13,22 @@ import { SettingService, type InstanceDatatableRole } from '$lib/gen' import { sendUserToast } from '$lib/toast' + let { + initialName = '', + onChanged + }: { + /** Prefills the name of the role to add. */ + initialName?: string + /** Called after every change to the catalog, whether or not it went through. */ + onChanged?: () => void + } = $props() + let roles = $state([]) let loading = $state(true) let loadError = $state(undefined) let busy = $state(false) - let newName = $state('') + // svelte-ignore state_referenced_locally + let newName = $state(initialName) /** Which role's name is being edited, and to what. */ let renaming = $state<{ id: string; name: string } | undefined>(undefined) @@ -48,6 +59,7 @@ // holds, so a failed flip has to snap back rather than sit there claiming it landed. await load() busy = false + onChanged?.() } } diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index 53b4819689..ebc4bd8a7f 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -64,12 +64,11 @@ - +{#if !hideTrigger} + +{/if} Beta {/snippet} - + {#key openCount} + + {/key} diff --git a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte index d16f948b88..04cf841b28 100644 --- a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte @@ -8,12 +8,17 @@ import TextInput from '../text_input/TextInput.svelte' import SimpleEditor from '../SimpleEditor.svelte' import { WorkspaceService, type DatatableMigration } from '$lib/gen' + import { listUsableDatatableRoles } from '../datatableUsableRoles' import { sendUserToast } from '$lib/toast' import { tick } from 'svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' import Portal from '$lib/components/Portal.svelte' import { fetchPendingMigrations, outOfOrderRunMessage } from './datatableMigrationUtils' + import Select from '../select/Select.svelte' + import { parseMigrationRole, withMigrationRole } from '../datatableMigrationRole' + import { ADMIN_DATATABLE_ROLE } from '../dbTypes' + import { resource } from 'runed' let { workspace, @@ -50,6 +55,8 @@ let tab = $state('up') let name = $state('') let nameInput = $state() + let upEditor = $state() + let downEditor = $state() // A valid migration name is non-empty and limited to letters, digits, '_' and '-'. const MIGRATION_NAME_RE = /^[a-zA-Z0-9_-]+$/ let nameInvalid = $derived(!MIGRATION_NAME_RE.test(name.trim())) @@ -60,6 +67,96 @@ const confirmationModal = createAsyncConfirmationModal() + // The role lives in the SQL as its `-- role ` annotation, so the code is the single + // source of truth and the Select is a view onto it: reading parses, writing rewrites the + // annotation. + const usableRoles = resource( + () => [workspace, datatable] as const, + async ([ws, dt]) => { + try { + return await listUsableDatatableRoles(ws, dt) + } catch (e) { + console.error('Failed to load data table roles:', e) + return null + } + } + ) + // Not a valid role name, so it cannot collide with one. + const NO_ROLE = '(no role)' + let declaredUp = $derived(parseMigrationRole(codeUp)) + let declaredDown = $derived(enableDown ? parseMigrationRole(codeDown) : undefined) + let malformedLine = $derived( + declaredUp.kind === 'malformed' + ? declaredUp.line + : declaredDown?.kind === 'malformed' + ? declaredDown.line + : undefined + ) + const roleOf = (d: typeof declaredUp) => (d.kind === 'role' ? d.role : undefined) + // A rollback runs as the role its own SQL names: under another role than the up migration it + // typically cannot touch what the up created. + let sqlProblem = $derived( + malformedLine !== undefined + ? malformedMessage(malformedLine) + : declaredDown !== undefined && roleOf(declaredDown) !== roleOf(declaredUp) + ? `The down migration runs as ${roleOf(declaredDown) ?? 'admin (no role)'} but the up migration as ${roleOf(declaredUp) ?? 'admin (no role)'}: make their role annotations match` + : undefined + ) + let selectedRole = $derived(declaredUp.kind === 'role' ? declaredUp.role : NO_ROLE) + let permissioned = $derived(!!usableRoles.current?.permissioned) + // No annotation runs as admin, which the server allows exactly to those who may use `admin`. + let adminUsable = $derived(!!usableRoles.current?.roles.includes(ADMIN_DATATABLE_ROLE)) + let roleItems = $derived.by(() => { + const usable = usableRoles.current + if (!usable?.permissioned) return [] + const names = usable.roles.filter((r) => r !== ADMIN_DATATABLE_ROLE) + // A role the SQL names but the caller cannot use is still shown, or the picker would + // misreport what the migration runs as. + if (declaredUp.kind === 'role' && !names.includes(declaredUp.role)) { + names.push(declaredUp.role) + } + const items = names.map((r) => ({ + value: r, + label: r === usable.default_role ? `${r} (default)` : r + })) + if (adminUsable || declaredUp.kind === 'none') { + items.push({ + value: NO_ROLE, + label: 'No role — runs as admin with full access' + }) + } + return items + }) + + function setRole(value: string | undefined) { + const role = value === NO_ROLE ? undefined : value + codeUp = withMigrationRole(codeUp, role) + // Up and down agree: a rollback run as another role could fail on objects it does not own. + if (enableDown) codeDown = withMigrationRole(codeDown, role) + // Assigning the bound value does not repaint the editor, and its next keystroke would write + // the stale text back. + upEditor?.setCode(codeUp) + if (enableDown) downEditor?.setCode(codeDown) + } + + // Set by `open` when the SQL names no role yet: the data table's default is written once its + // roles are known. + let applyDefaultRole = $state(false) + $effect(() => { + // `undefined` until the first answer lands; `null` when it failed. + const usable = usableRoles.current + if (!applyDefaultRole || !isOpen || usableRoles.loading || usable === undefined) return + applyDefaultRole = false + if (!usable?.permissioned || declaredUp.kind !== 'none') return + const role = + usable.default_role !== ADMIN_DATATABLE_ROLE && usable.roles.includes(usable.default_role) + ? usable.default_role + : usable.default_role === ADMIN_DATATABLE_ROLE && adminUsable + ? undefined + : usable.roles.find((r) => r !== ADMIN_DATATABLE_ROLE) + if (role !== undefined) setRole(role) + }) + // Frame the migration body in an explicit transaction so it applies atomically. function wrapInTransaction(body: string): string { return `BEGIN;\n\n${body}\n\nEND;` @@ -72,15 +169,35 @@ } const PLACEHOLDER = wrapInTransaction('-- Add your migration here') + function malformedMessage(line: string): string { + return `Malformed role annotation \`${line}\`: write it as \`-- role \`, or pick the role above` + } + export function open(prefill?: { name?: string; codeUp?: string; codeDown?: string }) { + // Roles and the default can have changed since the last open (the roles drawer sits next + // to this modal), and the default role is written from this answer. + usableRoles.refetch() name = prefill?.name ?? '' // Start from the transaction template; when prefilled from detected DDL, - // wrap that DDL in the same BEGIN; ... END; frame. - codeUp = prefill?.codeUp - ? wrapInTransaction(ensureTrailingSemicolon(prefill.codeUp)) - : PLACEHOLDER + // wrap that DDL in the same BEGIN; ... END; frame. A role the prefill declares is taken + // out first and put back on top: below `BEGIN;` it would not be read. + const prefillRole = prefill?.codeUp ? parseMigrationRole(prefill.codeUp) : undefined + if (prefill?.codeUp) { + const wrapped = wrapInTransaction( + ensureTrailingSemicolon(withMigrationRole(prefill.codeUp, undefined)) + ) + codeUp = + prefillRole?.kind === 'role' + ? withMigrationRole(wrapped, prefillRole.role) + : prefillRole?.kind === 'malformed' + ? `${prefillRole.line}\n${wrapped}` + : wrapped + } else { + codeUp = PLACEHOLDER + } codeDown = prefill?.codeDown ?? PLACEHOLDER enableDown = (prefill?.codeDown ?? '') !== '' + applyDefaultRole = prefillRole === undefined || prefillRole.kind === 'none' tab = 'up' isOpen = true // Focus the name field once the modal content has rendered. @@ -96,6 +213,10 @@ sendUserToast("Invalid migration name: use only letters, digits, '_' and '-'", true) return } + if (sqlProblem !== undefined) { + sendUserToast(sqlProblem, true) + return + } if (run) { // A new migration gets the highest timestamp, so any still-pending // migration is earlier: running only this one applies it out of order. @@ -176,29 +297,65 @@ closeOnOutsideClick={false} >
    - +
    + + {#if permissioned} + { - const value = parseInt(e.currentTarget.value) - if (!isNaN(value)) { - updateTokensForModel(provider as AIProvider, model, value) - } - }} - class="w-20 px-2 py-1 text-xs text-center border border-gray-200 dark:border-gray-700 rounded bg-surface focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> +
    + { + if (e.currentTarget.value === '') { + resetModel(provider as AIProvider, model) + return + } + const value = Number(e.currentTarget.value) + if (!isNaN(value)) { + updateTokens(provider as AIProvider, model, value) + } + }, + onblur: (e: Event & { currentTarget: HTMLInputElement }) => { + // Resync a value the state refused, so what is shown is what is stored. + e.currentTarget.value = String(limits[key] ?? '') + errors[key] = '' + } + }} + /> +
    tokens
    - {#if !isAtDefault} -
    - Default: {defaultTokens} tokens + {#if overridden} +
    + + {fallback.assumed ? 'Assumed' : 'Default'}: {fallback.tokens.toLocaleString()} + tokens +
    - {#if errors[getModelKey(provider as AIProvider, model)]} -
    {errors[getModelKey(provider as AIProvider, model)]}
    - {/if} + {/if} + {#if errors[key]} +
    {errors[key]}
    {/if}
    {/each} diff --git a/frontend/src/lib/components/workspaceSettings/perModelSettings.ts b/frontend/src/lib/components/workspaceSettings/perModelSettings.ts new file mode 100644 index 0000000000..a83da75540 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/perModelSettings.ts @@ -0,0 +1,24 @@ +import type { AIConfig, AIProvider } from '$lib/gen' +import { stripLegacyThinkingSuffix } from '../copilot/reasoningRegistry' + +/** + * The rows of a per-model setting, grouped by provider. Settings are keyed by the id + * the chat reads them under, and `setCopilotInfo` strips the deprecated `/thinking` + * suffix before the chat ever sees a model. A row built from the raw config would + * save a value under a key nothing reads, so it would sit in settings looking applied + * and never take effect. Stripping can collapse two configured slots onto one model, + * hence the dedupe. + */ +export function chatModelsByProvider( + aiProviders: Exclude +): Record> { + return Object.fromEntries( + Object.entries(aiProviders).map(([provider, config]) => [ + provider, + [...new Set(config.models.map(stripLegacyThinkingSuffix))].map((model) => ({ + provider: provider as AIProvider, + model + })) + ]) + ) +} From d340bec9994e12fcb652f17586ceeaec6a5bf49d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:46:03 +0000 Subject: [PATCH 044/112] chore(deps): bump actions/setup-node from 3 to 7 (#11260) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v3...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ai-agent-tests.yml | 2 +- .github/workflows/ai-evals-test.yml | 2 +- .github/workflows/backend-test-windows.yml | 2 +- .github/workflows/backend-test.yml | 2 +- .github/workflows/check-docs-links.yml | 2 +- .github/workflows/cli-tests.yml | 6 +++--- .github/workflows/codex-pr-review.yml | 2 +- .github/workflows/frontend-check.yml | 2 +- .github/workflows/git-sync-test.yml | 2 +- .github/workflows/npm_on_release.yml | 6 +++--- .github/workflows/pi-pr-review.yml | 2 +- .github/workflows/sdk-tests.yml | 2 +- .github/workflows/validate-openapi.yml | 2 +- .github/workflows/yaml-validator-tests.yml | 2 +- 14 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ai-agent-tests.yml b/.github/workflows/ai-agent-tests.yml index cd07602109..2527339699 100644 --- a/.github/workflows/ai-agent-tests.yml +++ b/.github/workflows/ai-agent-tests.yml @@ -63,7 +63,7 @@ jobs: with: bun-version: 1.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20" diff --git a/.github/workflows/ai-evals-test.yml b/.github/workflows/ai-evals-test.yml index 87eefab0f3..573bc77df9 100644 --- a/.github/workflows/ai-evals-test.yml +++ b/.github/workflows/ai-evals-test.yml @@ -77,7 +77,7 @@ jobs: with: bun-version: 1.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: # Node 24 ships npm 11, which frontend/package-lock.json is authored # with; npm 10 rejects it ("Missing: picomatch@4.0.5 from lock file"). diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index f9153c0ce0..e0c731e41f 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -75,7 +75,7 @@ jobs: with: bun-version: 1.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20" diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 9f65c2ae47..d5465d035d 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -59,7 +59,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "24" - uses: astral-sh/setup-uv@v6.2.1 diff --git a/.github/workflows/check-docs-links.yml b/.github/workflows/check-docs-links.yml index ec17c06771..e32e976f0c 100644 --- a/.github/workflows/check-docs-links.yml +++ b/.github/workflows/check-docs-links.yml @@ -16,7 +16,7 @@ jobs: sparse-checkout: | frontend/src .github/scripts - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "22.x" - name: Verify docs links are not 404 diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index e26231eda4..19afa777ae 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "20" @@ -89,7 +89,7 @@ jobs: cache-workspaces: backend - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "20" @@ -143,7 +143,7 @@ jobs: cache-workspaces: backend - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "20" diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 7a3882df7c..354e8a60ca 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -213,7 +213,7 @@ jobs: - name: Set up Node.js if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 22 diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 178ed53dc5..6347614e16 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v7 with: node-version: 24 cache: "npm" diff --git a/.github/workflows/git-sync-test.yml b/.github/workflows/git-sync-test.yml index bd15ec8876..b19f899a20 100644 --- a/.github/workflows/git-sync-test.yml +++ b/.github/workflows/git-sync-test.yml @@ -141,7 +141,7 @@ jobs: with: deno-version: v2.x - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20" diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 7d30265c74..631ab53153 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v7 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" @@ -21,7 +21,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v7 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" @@ -32,7 +32,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v7 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index 9b536b6ed5..a89275cfb5 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -206,7 +206,7 @@ jobs: - name: Set up Node.js if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 22 diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 6f7d79c6af..c6078a9e7f 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -43,7 +43,7 @@ jobs: with: bun-version: latest - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20.x" diff --git a/.github/workflows/validate-openapi.yml b/.github/workflows/validate-openapi.yml index 5bc59ebc4c..846621b503 100644 --- a/.github/workflows/validate-openapi.yml +++ b/.github/workflows/validate-openapi.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '20' diff --git a/.github/workflows/yaml-validator-tests.yml b/.github/workflows/yaml-validator-tests.yml index 04d31451d4..c5524312cb 100644 --- a/.github/workflows/yaml-validator-tests.yml +++ b/.github/workflows/yaml-validator-tests.yml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20" From 49e0974c0787c10818b7c7923e09af7234630d01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:46:16 +0000 Subject: [PATCH 045/112] chore(deps): bump astral-sh/setup-uv from 5 to 7 (#11258) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 5 to 7. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v5...v7) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/backend-test-windows.yml | 2 +- .github/workflows/backend-test.yml | 2 +- .github/workflows/sdk-tests.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index e0c731e41f..d7c29e5d0c 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -79,7 +79,7 @@ jobs: with: node-version: "20" - - uses: astral-sh/setup-uv@v6.2.1 + - uses: astral-sh/setup-uv@v7 with: version: "0.11.24" diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index d5465d035d..00659fef5f 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -62,7 +62,7 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "24" - - uses: astral-sh/setup-uv@v6.2.1 + - uses: astral-sh/setup-uv@v7 with: version: "0.11.24" - uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index c6078a9e7f..260fbb3a02 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -58,7 +58,7 @@ jobs: uses: actions/checkout@v4 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 # The interpreter is named explicitly: on a clean checkout uv picks the # runner's system python and stops with "not compatible with the locked From dbac7cce9921085618961d98fccc682fe01ba876 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:46:50 +0200 Subject: [PATCH 046/112] refactor(ai-evals): drop two unreachable job routes from the fetch stub (#11251) Co-authored-by: Claude Opus 5 (1M context) --- ai_evals/adapters/frontend/mockBackend.ts | 53 --------------- .../adapters/frontend/mockBackendApi.test.ts | 66 ++++--------------- 2 files changed, 12 insertions(+), 107 deletions(-) diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 07ea82a58f..90df0cf870 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -1178,27 +1178,6 @@ const BENCHMARK_WORKERS = [ } ] -const BENCHMARK_JOB_GET_PATH = /^\/api\/w\/([^/]+)\/jobs_u\/get\/([^/]+)$/ -const BENCHMARK_RUN_BY_PATH = /^\/api\/w\/([^/]+)\/jobs\/run\/(p|f)\/([^/]+)$/ - -/** An intercepted request carries its args as a JSON string; anything else means none - * were supplied. */ -function parseBenchmarkRequestBody( - body: BodyInit | null | undefined -): Record | undefined { - if (typeof body !== 'string') { - return undefined - } - try { - const parsed = JSON.parse(body) - return typeof parsed === 'object' && parsed !== null - ? (parsed as Record) - : undefined - } catch { - return undefined - } -} - /** True when `handleBenchmarkApiFetch` has an answer for this `/api/...` url. * Any other relative fetch must keep its normal (non-benchmark) behavior — * intercepting it with a synthetic 404 sends the model into retry loops. */ @@ -1210,8 +1189,6 @@ export function hasBenchmarkApiHandler(url: string): boolean { const path = url.split('?')[0] return ( path === '/api/workers/list' || - BENCHMARK_JOB_GET_PATH.test(path) || - BENCHMARK_RUN_BY_PATH.test(path) || path === '/api/embeddings/query_hub_scripts' || path.startsWith('/api/scripts/hub/get_full/') || BENCHMARK_AI_MODELS_PATH.test(path) @@ -1235,36 +1212,6 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon ?.aiProviders?.find((entry) => entry.path === resourcePath) return Response.json({ data: (seed?.models ?? []).map((id) => ({ id })) }) } - const jobGet = BENCHMARK_JOB_GET_PATH.exec(path) - if (jobGet) { - const id = decodeURIComponent(jobGet[2]) - const job = getBenchmarkCompletedJob(decodeURIComponent(jobGet[1]), id) - if (!job) { - return Response.json({ error: `Job not found for "${id}"` }, { status: 404 }) - } - // The real endpoint lets a caller drop the bulky fields. Ignoring that here would - // size the model's context off a payload it explicitly asked to shrink. - const query = new URLSearchParams(url.split('?')[1] ?? '') - if (query.get('no_logs') === 'true') { - delete job.logs - } - if (query.get('no_code') === 'true') { - delete job.raw_code - } - return Response.json(job) - } - const runByPath = BENCHMARK_RUN_BY_PATH.exec(path) - if (runByPath) { - const workspace = decodeURIComponent(runByPath[1]) - const runnablePath = decodeURIComponent(runByPath[3]) - const args = parseBenchmarkRequestBody(init?.body) - // The real endpoint answers with the bare job id as text, not JSON. - return new Response( - runByPath[2] === 'f' - ? runBenchmarkFlowByPath({ workspace, path: runnablePath, args }) - : runBenchmarkScriptByPath({ workspace, path: runnablePath, args }) - ) - } if (path === '/api/embeddings/query_hub_scripts') { const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? '' return Response.json(searchBenchmarkHubScripts(text)) diff --git a/ai_evals/adapters/frontend/mockBackendApi.test.ts b/ai_evals/adapters/frontend/mockBackendApi.test.ts index 0e489a4408..85bd1f3320 100644 --- a/ai_evals/adapters/frontend/mockBackendApi.test.ts +++ b/ai_evals/adapters/frontend/mockBackendApi.test.ts @@ -1,60 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { - createBenchmarkCompletedJob, - getBenchmarkCompletedJob, - handleBenchmarkApiFetch, - hasBenchmarkApiHandler, - resetBenchmarkMockBackend, - registerBenchmarkWorkspaceRunnables -} from './mockBackend' - -const WORKSPACE = 'benchmark-api-ws' +import { describe, expect, it } from 'bun:test' +import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend' +// `list_workers`'s client call is not faked, so its request really does leave as a relative +// `/api/...` url that node's fetch cannot resolve. Without this route the tool throws instead +// of answering. describe('benchmark API fetch handlers', () => { - beforeEach(() => resetBenchmarkMockBackend()) - afterEach(() => resetBenchmarkMockBackend()) + it('lists workers, the way list_workers reaches them', async () => { + const url = '/api/workers/list?per_page=100' - it('runs a deployed script by path', async () => { - registerBenchmarkWorkspaceRunnables(WORKSPACE, { - scripts: [ - { - path: 'f/evals/greet', - summary: 'Greet', - language: 'bun', - content: 'export async function main() {}' - } - ] - }) + expect(hasBenchmarkApiHandler(url)).toBe(true) + const body = await handleBenchmarkApiFetch(url).json() - const res = handleBenchmarkApiFetch( - `/api/w/${WORKSPACE}/jobs/run/p/${encodeURIComponent('f/evals/greet')}`, - { method: 'POST', body: JSON.stringify({ name: 'ada' }) } - ) - - expect(res.status).toBe(200) - const job = getBenchmarkCompletedJob(WORKSPACE, (await res.text()).trim()) - expect(job).toMatchObject({ success: true, args: { name: 'ada' } }) - }) - - it('serves a recorded job so a model can check the run it just started', async () => { - const id = createBenchmarkCompletedJob({ - workspace: WORKSPACE, - jobKind: 'preview', - result: 'Hello, World!' - }) - - const res = handleBenchmarkApiFetch(`/api/w/${WORKSPACE}/jobs_u/get/${id}`) - - expect(res.status).toBe(200) - expect(await res.json()).toMatchObject({ - id, - success: true, - result: 'Hello, World!' - }) - }) - - it('404s an unknown job id instead of letting the fetch fall through', () => { - expect(hasBenchmarkApiHandler(`/api/w/${WORKSPACE}/jobs_u/get/missing`)).toBe(true) - expect(handleBenchmarkApiFetch(`/api/w/${WORKSPACE}/jobs_u/get/missing`).status).toBe(404) + // The tool counts the list and maps over it, so it needs a bare array, not an envelope. + expect(Array.isArray(body)).toBe(true) + expect(body[0]).toMatchObject({ worker: expect.any(String), worker_group: expect.any(String) }) }) }) From 6dc78049ba2a29dbb34ff8a231d5742b23308177 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:47:09 +0000 Subject: [PATCH 047/112] chore(deps): bump actions/setup-go from 2 to 7 (#11257) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 2 to 7. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v2...v7) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/backend-test-windows.yml | 2 +- .github/workflows/backend-test.yml | 2 +- .github/workflows/go_on_release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index d7c29e5d0c..54a62ec495 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -67,7 +67,7 @@ jobs: # tests what production runs, instead of floating on the latest v2.x. deno-version: 2.2.1 - - uses: actions/setup-go@v2 + - uses: actions/setup-go@v7 with: go-version: 1.21.5 diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 00659fef5f..fea84e7781 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -53,7 +53,7 @@ jobs: # Pin to the Deno version shipped in the runtime image (Dockerfile) so CI # tests what production runs, instead of floating on the latest v2.x. deno-version: 2.2.1 - - uses: actions/setup-go@v2 + - uses: actions/setup-go@v7 with: go-version: 1.21.5 - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/go_on_release.yml b/.github/workflows/go_on_release.yml index 78ce12c6c3..6e53db09e7 100644 --- a/.github/workflows/go_on_release.yml +++ b/.github/workflows/go_on_release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v7 - name: install_jq run: | sudo apt-get update From f39742ae324a412704f1da84a502a3d21d109203 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:47:20 +0000 Subject: [PATCH 048/112] chore(deps): bump actions/setup-python from 5 to 7 (#11256) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 7. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v7) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ai-agent-tests.yml | 2 +- .github/workflows/check-system-prompts.yml | 2 +- .github/workflows/publish-cli-docs.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ai-agent-tests.yml b/.github/workflows/ai-agent-tests.yml index 2527339699..8a3e9e6dbc 100644 --- a/.github/workflows/ai-agent-tests.yml +++ b/.github/workflows/ai-agent-tests.yml @@ -67,7 +67,7 @@ jobs: with: node-version: "20" - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.11" diff --git a/.github/workflows/check-system-prompts.yml b/.github/workflows/check-system-prompts.yml index 3e684c82f9..370a177716 100644 --- a/.github/workflows/check-system-prompts.yml +++ b/.github/workflows/check-system-prompts.yml @@ -28,7 +28,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" diff --git a/.github/workflows/publish-cli-docs.yml b/.github/workflows/publish-cli-docs.yml index 9e76117eb5..d6a2bdb332 100644 --- a/.github/workflows/publish-cli-docs.yml +++ b/.github/workflows/publish-cli-docs.yml @@ -38,7 +38,7 @@ jobs: ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }} fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" From b7425443e1e08be029fc676047f727f84b646323 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 21 Sep 2026 16:47:47 +0200 Subject: [PATCH 049/112] fix: guard data table migration routes against operators and unauthorized authors (#11243) * fix: refuse operators and unauthorized authors on data table migration routes Co-Authored-By: Claude Opus 5 (1M context) * fix: check the replaced definition on migration upsert and skip unchanged writes Co-Authored-By: Claude Opus 5 (1M context) * fix: let data table admins edit migrations whose annotation no longer parses Co-Authored-By: Claude Opus 5 (1M context) * fix: keep migration delete idempotent when a concurrent delete wins Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> --- ...ce891a85daa00e037aba4b65b60f87243bd3.json} | 5 +- ...97462276930f0a83ffb458f7cb26035c8c3d.json} | 8 +- backend/tests/datatable_migration_add_down.rs | 32 +++ .../tests/datatable_roles.rs | 152 ++++++++++++ .../src/datatable_migrations.rs | 231 +++++++++++++----- 5 files changed, 363 insertions(+), 65 deletions(-) rename backend/.sqlx/{query-2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d.json => query-0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3.json} (65%) rename backend/.sqlx/{query-3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8.json => query-91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d.json} (59%) diff --git a/backend/.sqlx/query-2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d.json b/backend/.sqlx/query-0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3.json similarity index 65% rename from backend/.sqlx/query-2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d.json rename to backend/.sqlx/query-0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3.json index b82fcf2e17..2ff7820d86 100644 --- a/backend/.sqlx/query-2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d.json +++ b/backend/.sqlx/query-0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down WHERE NOT $7 OR (datatable_migrations.name = $8::text AND datatable_migrations.code_up = $9::text AND datatable_migrations.code_down IS NOT DISTINCT FROM $10::text)", + "query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down WHERE datatable_migrations.name = $7::text AND datatable_migrations.code_up = $8::text AND datatable_migrations.code_down IS NOT DISTINCT FROM $9::text", "describe": { "columns": [], "parameters": { @@ -11,7 +11,6 @@ "Varchar", "Text", "Text", - "Bool", "Text", "Text", "Text" @@ -19,5 +18,5 @@ }, "nullable": [] }, - "hash": "2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d" + "hash": "0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3" } diff --git a/backend/.sqlx/query-3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8.json b/backend/.sqlx/query-91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d.json similarity index 59% rename from backend/.sqlx/query-3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8.json rename to backend/.sqlx/query-91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d.json index 3206ba4433..57dbc41a9f 100644 --- a/backend/.sqlx/query-3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8.json +++ b/backend/.sqlx/query-91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 RETURNING name", + "query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 AND code_up = $4 AND code_down IS NOT DISTINCT FROM $5 RETURNING name", "describe": { "columns": [ { @@ -13,12 +13,14 @@ "Left": [ "Text", "Text", - "Int8" + "Int8", + "Text", + "Text" ] }, "nullable": [ false ] }, - "hash": "3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8" + "hash": "91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d" } diff --git a/backend/tests/datatable_migration_add_down.rs b/backend/tests/datatable_migration_add_down.rs index 3cd39e0fa3..47ea4c253b 100644 --- a/backend/tests/datatable_migration_add_down.rs +++ b/backend/tests/datatable_migration_add_down.rs @@ -142,3 +142,35 @@ async fn test_add_down_to_an_applied_migration(db: Pool) -> anyhow::Re Ok(()) } + +/// A definition saved before `-- role` annotations were parsed can open with a comment that now +/// reads as a malformed one. It never gets through a role check again, so an admin must still be +/// able to finish it rather than be refused on its annotation. +#[sqlx::test(fixtures("datatable_migrations_grants"))] +async fn test_add_down_to_a_migration_whose_comment_predates_role_annotations( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + setup_applied_migration_without_down(&db).await?; + let code_up = format!("-- Role table for widgets\n{CODE_UP}"); + sqlx::query("UPDATE datatable_migrations SET code_up = $1 WHERE workspace_id = 'dtmig-ws'") + .bind(&code_up) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let resp = authed(reqwest::Client::new().post(format!( + "http://localhost:{}/api/w/dtmig-ws/workspaces/upsert_datatable_migration/main", + server.addr.port() + ))) + .json(&json!({ + "timestamp": VERSION, + "name": "create_widgets", + "code_up": code_up, + "code_down": CODE_DOWN, + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 3d2853284d..4948a0098a 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -1414,6 +1414,158 @@ async fn a_fork_waits_for_a_rename_of_the_data_table_it_keeps( Ok(()) } +async fn enable_migrations_on_main(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, '{datatables,main,migrations_enabled}', 'true') + WHERE workspace_id = 'test-workspace'", + ) + .execute(db) + .await?; + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_migration_is_written_only_by_who_may_run_it(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + enable_migrations_on_main(&db).await?; + let server = ApiServer::start(db.clone()).await?; + let base = format!( + "http://localhost:{}/api/w/test-workspace/workspaces", + server.addr.port() + ); + let create = |token: &str, body: Value| { + authed( + client() + .post(format!("{base}/create_datatable_migration/main")) + .json(&body), + token, + ) + .send() + }; + let upsert = |token: &str, code_up: &str| { + authed( + client() + .post(format!("{base}/upsert_datatable_migration/main")) + .json(&json!({"timestamp": 20260101000000i64, "name": "m", "code_up": code_up})), + token, + ) + .send() + }; + + // `test-user-2` holds `analytics` and nothing else, so of a migration they may store only + // what they could run: SQL that declares no role runs as admin, in the up or the down. + for body in [ + json!({"name": "m", "code_up": "DROP TABLE t"}), + json!({"name": "m", "code_up": "-- role analytics\nSELECT 1", "code_down": "DROP TABLE t"}), + ] { + let resp = create("SECRET_TOKEN_2", body.clone()).await?; + assert_eq!(resp.status(), 401, "{body}: {}", resp.text().await?); + } + let resp = create( + "SECRET_TOKEN_2", + json!({"name": "m", "code_up": "-- role analytics\nSELECT 1"}), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + // An admin's migration re-pushed as stored writes nothing, so it passes. Replacing it removes + // SQL they could not run, even with SQL they could, so it is refused like deleting it. + let resp = upsert("SECRET_TOKEN", "DROP TABLE t").await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let resp = upsert("SECRET_TOKEN_2", "DROP TABLE t").await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + for code_up in ["DROP TABLE u", "-- role analytics\nSELECT 1"] { + let resp = upsert("SECRET_TOKEN_2", code_up).await?; + assert_eq!(resp.status(), 401, "{code_up}: {}", resp.text().await?); + } + let resp = authed( + client().delete(format!( + "{base}/delete_datatable_migration/main/20260101000000" + )), + "SECRET_TOKEN_2", + ) + .send() + .await?; + assert_eq!(resp.status(), 401, "{}", resp.text().await?); + + let code_up: String = sqlx::query_scalar( + "SELECT code_up FROM datatable_migrations + WHERE workspace_id = 'test-workspace' AND timestamp = 20260101000000", + ) + .fetch_one(&db) + .await?; + assert_eq!(code_up, "DROP TABLE t"); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn an_operator_neither_writes_nor_runs_a_migration(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + enable_migrations_on_main(&db).await?; + // Off roles, where every role check passes everyone, so only the operator guard is left to + // refuse. + sqlx::query( + "UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + // A token of its own: the auth cache is process-wide, and the other tests here authenticate + // `SECRET_TOKEN_3` in this workspace as a non-operator. + sqlx::raw_sql( + "UPDATE usr SET operator = true, role = 'Operator' + WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'; + INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('DT_OPERATOR_TOKEN'::bytea), 'hex'), 'DT_OPERATO', + 'DT_OPERATOR_TOKEN', 'test3@windmill.dev', 'operator', false);", + ) + .execute(&db) + .await?; + let server = ApiServer::start(db.clone()).await?; + let base = format!( + "http://localhost:{}/api/w/test-workspace/workspaces", + server.addr.port() + ); + let migration = json!({"timestamp": 20260101000000i64, "name": "m", "code_up": "SELECT 1"}); + + for request in [ + client() + .post(format!("{base}/create_datatable_migration/main")) + .json(&migration), + client() + .post(format!("{base}/upsert_datatable_migration/main")) + .json(&migration), + client().delete(format!( + "{base}/delete_datatable_migration/main/20260101000000" + )), + client().post(format!("{base}/run_datatable_migrations/main")), + client().post(format!("{base}/rollback_datatable_migrations/main")), + client().post(format!("{base}/generate_initial_datatable_migration/main")), + ] { + let resp = authed(request, "DT_OPERATOR_TOKEN").send().await?; + let status = resp.status(); + let text = resp.text().await?; + assert!( + status == 401 && text.contains("Operators cannot"), + "{status}: {text}" + ); + } + + // The same write from a developer is accepted: what refused the operator is theirs alone. + let resp = authed( + client() + .post(format!("{base}/upsert_datatable_migration/main")) + .json(&migration), + "SECRET_TOKEN_2", + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + Ok(()) +} + #[cfg(not(all(feature = "private", feature = "enterprise")))] const ENTERPRISE_REFUSAL: &str = "Data table roles are a Windmill Enterprise Edition feature"; diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index fa433b2cbc..df1fac203b 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -107,26 +107,63 @@ async fn ensure_migration_role_allowed( datatable_name: &str, authed: &ApiAuthed, sql: &str, - timestamp: i64, - name: &str, + migration: &str, ) -> Result<()> { - let context = format!("Migration {timestamp} ({name})"); let access = DatatableAccess::Authed(authed.to_authed_ref()); match SqlAnnotations::datatable_role(sql)? { Some(role) => { - ensure_can_use_datatable_role(db, w_id, datatable_name, Some(&role), &access, &context) + ensure_can_use_datatable_role(db, w_id, datatable_name, Some(&role), &access, migration) .await } None => ensure_datatable_admin_access(db, w_id, datatable_name, &access) .await .map_err(|e| { Error::NotAuthorized(format!( - "{context} declares no role, so it would run as admin. {e}" + "{migration} declares no role, so it would run as admin. {e}" )) }), } } +/// Refuse writing or deleting a migration definition unless this caller may run both its up and its +/// down. A definition is run later by whoever applies or rolls back pending migrations, as the role +/// it declares, so gating only the run would let anyone plant SQL for a data table's admin to run. +async fn ensure_may_edit_migration( + db: &DB, + w_id: &str, + datatable_name: &str, + authed: &ApiAuthed, + migration: &str, + code_up: &str, + code_down: Option<&str>, +) -> Result<()> { + // Admin access reaches everything a role can, so it edits any definition without reading its + // annotation, which one saved before annotations were parsed may no longer pass. A refusal + // there would leave such a definition impossible to delete or fix. + let access = DatatableAccess::Authed(authed.to_authed_ref()); + if ensure_datatable_admin_access(db, w_id, datatable_name, &access) + .await + .is_ok() + { + return Ok(()); + } + for sql in std::iter::once(code_up).chain(code_down) { + ensure_migration_role_allowed(db, w_id, datatable_name, authed, sql, migration).await?; + } + Ok(()) +} + +/// Operators run deployed code only, and a migration is SQL written or run by hand. Checked before +/// anything else, including the role checks: on a data table not under roles those pass everyone. +fn ensure_not_operator(authed: &ApiAuthed) -> Result<()> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot manage or run data table migrations".to_string(), + )); + } + Ok(()) +} + #[derive(Serialize)] struct AppliedMigration { version: i64, @@ -436,6 +473,7 @@ async fn run_datatable_migrations( Path((w_id, datatable_name)): Path<(String, String)>, Query(query): Query, ) -> JsonResult { + ensure_not_operator(&authed)?; // Before the admin connection is opened at all: the bookkeeping below is created and read // through it, so a caller no role covers must be refused here rather than after the fact. crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed) @@ -503,8 +541,7 @@ async fn run_datatable_migrations( &datatable_name, &authed, &m.code_up, - m.timestamp, - &m.name, + &format!("Migration {} ({})", m.timestamp, m.name), ) .await?; run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &m.code_up) @@ -573,6 +610,7 @@ async fn rollback_datatable_migrations( Path((w_id, datatable_name)): Path<(String, String)>, Query(query): Query, ) -> JsonResult { + ensure_not_operator(&authed)?; // Before the admin connection is opened at all: the bookkeeping below is created and read // through it, so a caller no role covers must be refused here rather than after the fact. crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed) @@ -666,8 +704,7 @@ async fn rollback_datatable_migrations( &datatable_name, &authed, &code_down, - version, - &definition.name, + &format!("Migration {} ({})", version, definition.name), ) .await?; @@ -1213,9 +1250,20 @@ async fn create_datatable_migration( Path((w_id, datatable_name)): Path<(String, String)>, Json(payload): Json, ) -> JsonResult { + ensure_not_operator(&authed)?; validate_datatable_path_segment(&datatable_name)?; validate_migration_name(&payload.name)?; ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?; + ensure_may_edit_migration( + &db, + &w_id, + &datatable_name, + &authed, + &format!("Migration '{}'", payload.name), + &payload.code_up, + payload.code_down.as_deref(), + ) + .await?; let mut tx = db.begin().await?; let timestamp = insert_datatable_migration_def( @@ -1267,6 +1315,34 @@ async fn delete_datatable_migration( Extension(db): Extension, Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>, ) -> Result { + ensure_not_operator(&authed)?; + let deleted_message = format!("Deleted migration {} from {}", timestamp, datatable_name); + + // A definition already gone is not an error: `wmill sync push` deletes whatever the repo + // dropped, which may no longer exist here. + let Some(definition) = sqlx::query!( + "SELECT name, code_up, code_down FROM datatable_migrations \ + WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3", + &w_id, + &datatable_name, + timestamp, + ) + .fetch_optional(&db) + .await? + else { + return Ok(deleted_message); + }; + ensure_may_edit_migration( + &db, + &w_id, + &datatable_name, + &authed, + &format!("Migration {} ({})", timestamp, definition.name), + &definition.code_up, + definition.code_down.as_deref(), + ) + .await?; + // Hold the run-serialization lock across the applied-check and the delete: a // run snapshots a migration's SQL before recording its version, so an // unserialized delete could race it and leave `_wm_migrations` pointing at a @@ -1294,19 +1370,46 @@ async fn delete_datatable_migration( ))); } + // Delete only the SQL the role check above cleared: it read the definition without the lock, + // and a rewrite that landed since was never checked. let deleted_name = sqlx::query_scalar!( "DELETE FROM datatable_migrations \ WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 \ + AND code_up = $4 AND code_down IS NOT DISTINCT FROM $5 \ RETURNING name", &w_id, &datatable_name, timestamp, + &definition.code_up, + definition.code_down.as_deref(), ) .fetch_optional(&db) .await?; // The definition is removed; runs may resume (audit/deploy metadata below // don't need the lock). drop(lock_client); + let Some(name) = deleted_name else { + // Nothing matched either because another delete got there first, which is this one's + // outcome too, or because the definition was rewritten. + let still_defined = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM datatable_migrations \ + WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3)", + &w_id, + &datatable_name, + timestamp, + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !still_defined { + return Ok(deleted_message); + } + return Err(Error::BadRequest(format!( + "Migration {} on data table '{}' changed while it was being deleted. Reload it and \ + retry.", + timestamp, datatable_name + ))); + }; audit_log( &db, @@ -1319,23 +1422,10 @@ async fn delete_datatable_migration( ) .await?; - // Only tally a change if a migration was actually deleted. - if let Some(name) = deleted_name { - record_datatable_migration_deployment( - &authed, - &db, - &w_id, - &datatable_name, - timestamp, - &name, - ) + record_datatable_migration_deployment(&authed, &db, &w_id, &datatable_name, timestamp, &name) .await?; - } - Ok(format!( - "Deleted migration {} from {}", - timestamp, datatable_name - )) + Ok(deleted_message) } #[derive(Deserialize)] @@ -1356,6 +1446,7 @@ async fn upsert_datatable_migration( Path((w_id, datatable_name)): Path<(String, String)>, Json(payload): Json, ) -> Result { + ensure_not_operator(&authed)?; validate_datatable_path_segment(&datatable_name)?; validate_migration_name(&payload.name)?; ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?; @@ -1381,9 +1472,9 @@ async fn upsert_datatable_migration( // run-serialization lock across the applied-check and the write below so an // in-flight run can't record a version for the SQL we're about to overwrite. // Held until the end of the handler (well past the write). The exempt - // upserts need no lock: a new or unchanged one overwrites nothing, and one - // that only adds a down leaves the `code_up` a concurrent run is recording - // a version for untouched. + // upserts need no lock: a new one overwrites nothing, and one that only adds + // a down leaves the `code_up` a concurrent run is recording a version for + // untouched. let only_adds_down = existing.as_ref().is_some_and(|existing| { existing.name == payload.name && existing.code_up == payload.code_up @@ -1395,8 +1486,40 @@ async fn upsert_datatable_migration( && existing.code_up == payload.code_up && existing.code_down == payload.code_down }); + let upserted_message = format!( + "Upserted migration {} in {}", + payload.timestamp, datatable_name + ); + // A re-push of the definition as stored changes nothing, so it is not checked, written, audited, + // synced or counted. Writing it anyway would re-create a definition deleted since the read above. + if unchanged { + return Ok(upserted_message); + } + ensure_may_edit_migration( + &db, + &w_id, + &datatable_name, + &authed, + &format!("Migration {} ({})", payload.timestamp, payload.name), + &payload.code_up, + payload.code_down.as_deref(), + ) + .await?; + // Replacing a definition removes the one stored, so it answers to the same check as a delete. + if let Some(existing) = existing.as_ref() { + ensure_may_edit_migration( + &db, + &w_id, + &datatable_name, + &authed, + &format!("Migration {} ({})", payload.timestamp, existing.name), + &existing.code_up, + existing.code_down.as_deref(), + ) + .await?; + } let _run_lock = match existing.as_ref() { - Some(_) if !only_adds_down && !unchanged => { + Some(_) if !only_adds_down => { // Fail closed: if we can't lock/read the applied set (e.g. the // data-table database is temporarily unreachable), refuse the change // rather than risk overwriting a migration that has already run. @@ -1426,31 +1549,27 @@ async fn upsert_datatable_migration( _ => None, }; - // An exempt upsert judged the row from an unlocked read and then writes - // without the lock, so that whole read is re-tested here, where `ON CONFLICT - // DO UPDATE` re-reads the row under a row lock. Otherwise a request working - // from a stale definition silently reverts whatever changed in between — a - // second addition's down, or a locked rewrite of the up whose new SQL a run - // may already have recorded a version for. Reading no row at all is part of - // the premise: the equality is NULL when `$8` is, so a version created in the - // meantime is refused rather than overwritten. - let recheck_observed = _run_lock.is_none(); + // The role checks, and the exemptions from the lock, all judged the row from the unlocked read + // above, so that whole read is re-tested here, where `ON CONFLICT DO UPDATE` re-reads the row + // under a row lock. Otherwise a request working from a stale definition silently replaces + // whatever changed in between: SQL the role checks never saw, or a locked rewrite of the up + // whose new SQL a run may already have recorded a version for. Reading no row at all is part of + // the premise: the equality is NULL when `$7` is, so a version created in the meantime is + // refused rather than overwritten. let written = sqlx::query!( "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) \ VALUES ($1, $2, $3, $4, $5, $6) \ ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE \ SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down \ - WHERE NOT $7 \ - OR (datatable_migrations.name = $8::text \ - AND datatable_migrations.code_up = $9::text \ - AND datatable_migrations.code_down IS NOT DISTINCT FROM $10::text)", + WHERE datatable_migrations.name = $7::text \ + AND datatable_migrations.code_up = $8::text \ + AND datatable_migrations.code_down IS NOT DISTINCT FROM $9::text", &w_id, &datatable_name, payload.timestamp, &payload.name, &payload.code_up, payload.code_down.as_deref(), - recheck_observed, existing.as_ref().map(|e| e.name.as_str()), existing.as_ref().map(|e| e.code_up.as_str()), existing.as_ref().and_then(|e| e.code_down.as_deref()), @@ -1489,24 +1608,17 @@ async fn upsert_datatable_migration( ) .await?; - // An unchanged re-push is not counted: `wmill sync push` sends every migration - // on every sync, so counting those would swamp the definitions people write. - if !unchanged { - windmill_common::feature_usage::log_feature_usage( - "datatable", - "migration_created", - if existing.is_none() { - "synced" - } else { - "edited" - }, - ); - } + windmill_common::feature_usage::log_feature_usage( + "datatable", + "migration_created", + if existing.is_none() { + "synced" + } else { + "edited" + }, + ); - Ok(format!( - "Upserted migration {} in {}", - payload.timestamp, datatable_name - )) + Ok(upserted_message) } /// Generate the first migration for a data table by snapshotting its current @@ -1519,6 +1631,7 @@ async fn generate_initial_datatable_migration( Extension(db): Extension, Path((w_id, datatable_name)): Path<(String, String)>, ) -> JsonResult { + ensure_not_operator(&authed)?; // Returns a `pg_dump` of the whole schema and writes into the data table's own bookkeeping, so // it answers to the workspace that governs it rather than to whoever is asking. ensure_datatable_admin_access( From 4cbc944c0bcb938300c964437d745d15a3ea608f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 21 Sep 2026 17:04:56 +0200 Subject: [PATCH 050/112] perf: keep the sessions page fast with many ai sessions (#11224) * perf: skip whole-document menu lookup on pointerdown when closed Co-Authored-By: Claude Opus 5 (1M context) * perf: load only a session's own chats in its chat history Co-Authored-By: Claude Opus 5 (1M context) * perf: stop mounting a chat per listed session on the sessions page Co-Authored-By: Claude Opus 5 (1M context) * fix: keep unsent input, init and chat rotation safe from runtime eviction Co-Authored-By: Claude Opus 5 (1M context) * refactor: cap mounted chat panes instead of evicting session runtimes Co-Authored-By: Claude Opus 5 (1M context) * refactor: read only message count and error for sessions without a runtime Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a chat pane mounted while its composer reads attachments Co-Authored-By: Claude Opus 5 (1M context) * fix: hold a session's first send until its chat has been restored Co-Authored-By: Claude Opus 5 (1M context) * fix: queue a second send behind one waiting on the chat restore Co-Authored-By: Claude Opus 5 (1M context) * fix: queue raw-app inline prompts behind a send waiting on the restore Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a chat pane mounted while its send waits on the restore Co-Authored-By: Claude Opus 5 (1M context) * fix: preserve session chat drafts across async updates --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/components/DropdownV2.svelte | 3 + .../copilot/chat/AIChatInput.svelte | 12 +- .../copilot/chat/AIChatManager.svelte.ts | 51 +++++++- .../copilot/chat/AIChatManager.test.ts | 19 +++ .../copilot/chat/HistoryManager.svelte.ts | 16 ++- .../copilot/chat/HistoryManager.test.ts | 33 +++++ .../components/copilot/chat/chatViewHost.ts | 4 + .../conversations/flowChatViewHost.svelte.ts | 3 + .../lib/components/meltComponents/Menu.svelte | 3 + .../components/meltComponents/Popover.svelte | 3 + .../sessions/RawAppEditorView.svelte | 9 +- .../components/sessions/SessionPicker.svelte | 23 ++-- .../sessions/sessionRuntime.svelte.ts | 115 +++++++++++++++++- .../sessions/sessionUnread.svelte.ts | 17 ++- .../(root)/(logged)/sessions/+page.svelte | 34 ++++-- 15 files changed, 309 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index 6738e7ace8..5061636835 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -145,6 +145,9 @@ } } async function getMenuElements(): Promise { + // Runs on every pointerdown anywhere, for every mounted dropdown. Skip the + // whole-document query when the outside handler below cannot act anyway. + if (!usePointerDownOutside || !open) return [] return Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[] } diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index d3cc2c8bd9..6f15937cf2 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -378,6 +378,16 @@ ) $effect(() => { chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes) + chatHost.setComposerHasDraft( + composerKey, + !draft.isEmpty || + (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0) || + pendingFiles > 0 || + pendingFileBytes > 0 || + pendingImages > 0 || + pendingBlobs > 0 || + ingestionHolds > 0 + ) }) $effect(() => () => chatHost.clearComposerStaged(composerKey)) @@ -889,7 +899,7 @@ contextTextareaComponent?.clearForSend() return } - if (chatHost.loading) { + if (chatHost.loading || chatHost.sendInFlight || chatHost.sendPending) { // 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 diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 97854a166e..922f64ad93 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2224,7 +2224,7 @@ export class AIChatManager implements ChatViewHost { * Gated on `sendInFlight` as well as `loading`: `loading` only rises after a * send's attachment upkeep, so between the two a click would slip past. */ sendOrQueue(text: string) { - if (this.loading || this.sendInFlight) { + if (this.loading || this.sendInFlight || this.sendPending) { this.queueMessage(text) return } @@ -2819,6 +2819,23 @@ export class AIChatManager implements ChatViewHost { clearComposerStaged(key: string) { this.#composerStaged.delete(key) + this.#composersWithDraft.delete(key) + } + + // Composers whose draft is non-empty. The draft itself is component-local, so + // this is the only way to know an unmount would lose unsent input. + #composersWithDraft = new SvelteSet() + + setComposerHasDraft(key: string, hasDraft: boolean) { + if (hasDraft) this.#composersWithDraft.add(key) + else this.#composersWithDraft.delete(key) + } + + /** Unsent input an unmount of this chat would lose: a composer draft, or a + * queued message (whose text may be empty when it carries only attachments + * or context). */ + get hasUnsentInput(): boolean { + return this.#composersWithDraft.size > 0 || this.#hasQueuedMessage() } /** Release the outgoing-files reservation identified by `key` (a per-send token). @@ -3174,7 +3191,39 @@ export class AIChatManager implements ChatViewHost { return this.#sendsInFlight > 0 } + // Resolves once this chat's stored transcript has been restored. A session + // runtime hands its manager out before that finishes, and `loadPastChat` + // refuses to swap a transcript under a send, so a turn started first lands in + // a chat of its own and is lost when the session's real one loads. + // + // Cleared once awaited, and only awaited when set: every later send — and + // every send of the chats that have no gate — reaches the `sendInFlight` + // counter below synchronously, which is what makes a second Enter queue + // behind the first instead of starting a turn of its own. + #ready: Promise | undefined + setReadyGate(ready: Promise | undefined) { + this.#ready = ready + } + + #sendsAwaitingReady = $state(0) + /** A send parked on the gate above. It has not reached `sendInFlight` yet — + * and must not, since `loadPastChat` reads that flag and would then skip the + * restore this send is waiting for — so every guard that decides between + * sending and queueing tests this as well. */ + get sendPending(): boolean { + return this.#sendsAwaitingReady > 0 + } + sendRequest = async (options: Parameters[0] = {}) => { + if (this.#ready) { + this.#sendsAwaitingReady++ + try { + await this.#ready + } finally { + this.#sendsAwaitingReady-- + this.#ready = undefined + } + } // A turn with nowhere to render still streams, spends tokens and applies // tool calls — entirely off-screen. Refuse instead. `sendInlineRequest` is // exempt: the ⌘K widget renders its own composer inside Monaco. diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 35ac65253c..ef15df4e8d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -514,6 +514,25 @@ describe('AIChatManager.sendOrQueue', () => { releaseUpkeep?.() await sending }) + + // A session's chat is restored after its manager is handed out, so the first + // send waits on that restore — before `sendInFlight`, which the restore reads + // to decide whether to bail. + it('queues while a send waits on the chat restore gate', async () => { + const manager = new AIChatManager() + let openGate: (() => void) | undefined + manager.setReadyGate(new Promise((resolve) => (openGate = resolve))) + manager.instructions = 'first turn' + const sending = manager.sendRequest() + await vi.waitFor(() => expect(manager.sendPending).toBe(true)) + expect(manager.sendInFlight).toBe(false) + + manager.sendOrQueue('fix the failing run') + expect(manager.queuedMessage).toBe('fix the failing run') + + openGate?.() + await sending + }) }) describe('AIChatManager request errors', () => { diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index d956b4c0c0..7843169dd9 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -394,7 +394,10 @@ export default class HistoryManager { .sort((a, b) => b.lastModified - a.lastModified) ) - async init() { + /** With `session`, loads only that session's chats plus `session.chatId`, which + * may predate session tagging. Loading every chat instead costs one full copy + * of the user's history per session runtime. */ + async init(session?: { id: string; chatId?: string }) { // (Re)initializing adopts a new identity's history: drop the previous // identity's cached blob ids with it. this.imageIdByUrl.clear() @@ -404,7 +407,16 @@ export default class HistoryManager { const db = await this.dbh.whenReady() if (!db) return try { - const chats = await db.getAll('chats') + let chats: StoredChat[] + if (session) { + chats = await db.getAllFromIndex('chats', 'by-session', session.id) + if (session.chatId && !chats.some((c) => c.id === session.chatId)) { + const current = await db.get('chats', session.chatId) + if (current) chats.push(current) + } + } else { + chats = await db.getAll('chats') + } this.savedChats = chats.reduce( (acc, chat) => { acc[chat.id] = chat diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts index cdbf6f2020..3ee9edad39 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts @@ -816,3 +816,36 @@ describe('HistoryManager.reloadChat', () => { expect(await hm.reloadChat(hm.getCurrentChatId())).toBe('unavailable') }) }) + +describe('HistoryManager.init for a session', () => { + it("loads the session's chats and its current chat, not other sessions'", async () => { + const setup = new HistoryManager() + await setup.init() + setup.close() + const chat = (id: string, sessionId?: string) => ({ + id, + actualMessages: [], + displayMessages: [], + title: id, + lastModified: 1, + ...(sessionId ? { sessionId } : {}) + }) + const db = await openDB('copilot-chat-history::admin@test') + await db.put('chats' as never, chat('mine', 's1') as never) + await db.put('chats' as never, chat('other-session', 's2') as never) + await db.put('chats' as never, chat('untagged-current') as never) + await db.put('chats' as never, chat('untagged-other') as never) + db.close() + + const hm = new HistoryManager() + await hm.init({ id: 's1', chatId: 'untagged-current' }) + + // The current chat can predate session tagging, so it is read by key. + expect( + hm + .getAllSavedChats() + .map((c) => c.id) + .sort() + ).toEqual(['mine', 'untagged-current']) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts index 4af23337e2..5c4dceb7f1 100644 --- a/frontend/src/lib/components/copilot/chat/chatViewHost.ts +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -52,6 +52,8 @@ export interface ChatViewHost { * pins away from the navigated one. */ readonly operatingWorkspace: string | undefined loading: boolean + /** A send waiting for the chat's transcript to be restored; queue behind it. */ + sendPending: boolean /** A turn this tab can neither follow nor stop, held by another tab on the same chat. */ readonly runHeldElsewhere: boolean loadingLabel: string | undefined @@ -85,6 +87,8 @@ export interface ChatViewHost { ) => void dequeueMessage: () => void setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void + /** Whether this composer holds anything unsent (text, pastes, attachments, reads in flight). */ + setComposerHasDraft: (key: string, hasDraft: boolean) => void clearComposerStaged: (key: string) => void attachmentBytesExcluding: (selfKey: string) => number diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 37bb868982..6ef18b7212 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -398,6 +398,8 @@ export class FlowChatViewHost implements ChatViewHost { get loading(): boolean { return isBusy(this.#state.status) } + // No transcript restore to wait for: a flow chat renders from its rows. + sendPending = false runHeldElsewhere = false loadingLabel = undefined compacting = false @@ -580,6 +582,7 @@ export class FlowChatViewHost implements ChatViewHost { return taken } setComposerStaged = () => {} + setComposerHasDraft = () => {} clearComposerStaged = () => {} attachmentBytesExcluding = () => 0 diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index 4a821ceacc..ab6de64123 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -176,6 +176,9 @@ } async function getMenuElements(): Promise { + // Runs on every pointerdown anywhere, for every mounted menu. Skip the + // whole-document query when the outside handler below cannot act anyway. + if (!usePointerDownOutside || !open) return [] // Tooltip content counts as menu territory for the same reason as the // onOutsideClick veto above. return Array.from( diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index 6a6b7d681b..dd69d16917 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -194,6 +194,9 @@ } async function getMenuElements(): Promise { + // Runs on every pointerdown anywhere, for every mounted popover. Skip the + // whole-document query when the outside handler below cannot act anyway. + if (!usePointerDownOutside || (!isOpen && !fullScreen)) return [] const selector = excludeSelectors ? `[data-popover], ${excludeSelectors}` : '[data-popover]' return Array.from(document.querySelectorAll(selector)) as HTMLElement[] } diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 4e2fb2bbd4..e95bc142e5 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -172,9 +172,10 @@ // The inline mini-composer over a selected element sends a chat turn; the // element is already an app_dom_selector chip, so it rides along as context. - // Mirror the composer: while a turn is streaming, queue it (a second concurrent - // sendRequest would race the shared abortController / streaming buffers) — it - // auto-sends when the current turn completes. + // Mirror the composer: while another send holds the chat — streaming, in + // preflight, or waiting for the transcript to be restored — queue it (a second + // concurrent sendRequest would race the shared abortController / streaming + // buffers) — it auto-sends when the current turn completes. function onInlinePrompt(selector: string, prompt: string) { // Snapshot the selection synchronously at submit time so a re-selection // during the async send preflight (immediate path) or before the queue @@ -190,7 +191,7 @@ .filter( (c) => c.type !== 'app_dom_selector' || (c.selector === selector && c.appPath === path) ) - if (runtime.manager.loading) { + if (runtime.manager.loading || runtime.manager.sendInFlight || runtime.manager.sendPending) { runtime.manager.queueMessage(prompt, [], snapshot) } else { void runtime.manager.sendRequest({ instructions: prompt, contextOverride: snapshot }) diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index e5b62da0eb..81ce3ff9af 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -44,8 +44,9 @@ import { unreadCountFor } from './sessionUnread.svelte' import Toggle from '$lib/components/Toggle.svelte' import { - getOrCreateRuntime, + ensureSessionChatPeek, getRuntime, + getSessionChatPeek, getSessionChatStatus, removeSession, resetSessionPreviewTabs @@ -87,7 +88,7 @@ // displayMessages array vs. the localStorage-backed lastSeen map; // both are reactive so the badge updates without polling. function unreadFor(session: Session): number { - return unreadCountFor(session.id, getRuntime(session.id)) + return unreadCountFor(session.id, getRuntime(session.id), getSessionChatPeek(session.id)) } // Whether the composer for a session holds non-whitespace text. We @@ -378,14 +379,12 @@ } }) - // Eagerly create a runtime per VISIBLE session so the status dot reflects - // the persisted chat (last message, pending confirmation, etc.) without - // requiring the user to open the session first. Sessions outside the - // current workspace scope are left cold to avoid opening IDB connections - // for unrelated work. + // Read each VISIBLE session's stored chat so the status dot and unread count + // reflect it without the user opening the session. A runtime per listed + // session would hold every listed chat in memory for as long as the page lives. $effect(() => { for (const session of visibleSessions) { - getOrCreateRuntime(session) + if (!getRuntime(session.id)) void ensureSessionChatPeek(session) } }) @@ -768,7 +767,9 @@ {/if} {#each group.sessions as session (session.id)} {@const runtime = getRuntime(session.id)} - {@const status = runtime ? getSessionChatStatus(runtime) : 'idle'} + {@const status = runtime + ? getSessionChatStatus(runtime) + : (getSessionChatPeek(session.id)?.status ?? 'idle')} {@const isSelected = sessionActive && session.id === sessionState.currentSessionId} {@const unread = unreadFor(session)} @@ -986,7 +987,9 @@ {/snippet} {#snippet sessionRow(session, indented, treeDepth)} {@const runtime = getRuntime(session.id)} - {@const status = runtime ? getSessionChatStatus(runtime) : 'idle'} + {@const status = runtime + ? getSessionChatStatus(runtime) + : (getSessionChatPeek(session.id)?.status ?? 'idle')} {@const isSelected = sessionActive && session.id === sessionState.currentSessionId} {@const isEditing = editingId === session.id} {@const unread = unreadFor(session)} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 640b484478..fbb84e1431 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -99,7 +99,13 @@ import type { RawAppDomResult } from '$lib/components/raw_apps/rawAppDom' import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib' -import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared' +import { + pendingUserAction, + type ChatJob, + type DisplayMessage +} from '$lib/components/copilot/chat/shared' +import { readStoredChat } from '$lib/components/copilot/chat/HistoryManager.svelte' +import { getCurrentUserEmail } from '$lib/userScopedStorage' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' // Per-kind load state for a session's editor target. Pure state container the @@ -985,7 +991,7 @@ function createRuntime(session: Session): SessionRuntime { async function initRuntime(runtime: SessionRuntime, session: Session) { const { manager } = runtime - await manager.historyManager.init() + await manager.historyManager.init(session) manager.historyManager.setSessionId(session.id) // Restore linked files persisted for this session (live handles re-grant on send; // snapshots restore directly). Non-transient sessions persist immediately. @@ -1000,7 +1006,9 @@ async function initRuntime(runtime: SessionRuntime, session: Session) { if (session.chatId) { manager.historyManager.setCurrentChatId(session.chatId) await manager.historyManager.tagChatWithSession(session.chatId, session.id) - await manager.loadPastChat(session.chatId) + // preserveQueue: restoring a session's own chat is not a conversation switch. + // A message queued while the restore ran is unsent user input waiting for it. + await manager.loadPastChat(session.chatId, { preserveQueue: true }) // loadPastChat only seeds the mask when the chat exists in history; a chatId // pointing at a chat not yet persisted (no turn saved) would leave it // undefined, and the Edits surface would then show every workspace draft. @@ -1020,7 +1028,11 @@ export function getOrCreateRuntime(session: Session): SessionRuntime { if (!runtime) { runtime = createRuntime(session) runtimes.set(session.id, runtime) - initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e)) + // The gate opens even when the restore fails: a session that cannot read its + // history is still usable, and a send that waits forever is not. + runtime.manager.setReadyGate( + initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e)) + ) } return runtime } @@ -1031,6 +1043,9 @@ export function disposeRuntime(sessionId: string) { runtime.manager.cancel('runtime disposed') runtime.manager.historyManager.close() runtimes.delete(sessionId) + // The sidebar reads the stored chat again rather than trust a read that + // predates everything this runtime saw. + peeks.delete(sessionId) } export function listRuntimes(): SessionRuntime[] { @@ -1064,7 +1079,13 @@ onRemoteTurnEnd((sessionId, chatId) => { async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise { const runtime = runtimes.get(sessionId) - if (!runtime) return + if (!runtime) { + // The driving tab may have rotated to a new chat; follow it, or the next + // read and a later runtime would open the old one. + setSessionChatId(sessionId, chatId) + refreshSessionChatPeek(sessionId) + return + } const m = runtime.manager // Two transient states get a short retry rather than a skip, because the // composer unlocks when this promise settles and a skip would unlock it on @@ -1405,3 +1426,87 @@ export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus if (last && (last.role === 'assistant' || last.role === 'tool')) return 'awaiting-user' return 'idle' } + +// --------------------------------------------------------------------------- +// Sessions without a runtime +// --------------------------------------------------------------------------- + +// A runtime costs a chat manager and its loaded transcript, and lives until its +// session is deleted, so the sidebar does not create one per listed session. It +// reads the session's one stored chat instead, once. Without a runtime nothing +// runs in this tab, so only two facts of that chat can matter: its length (for +// the unread count) and whether its last message failed. Every other status is +// a live one. +export interface SessionChatPeek { + status: 'error' | 'idle' + messageCount: number +} + +const peeks = new SvelteMap() +const peeksInFlight = new Set() +const peeksPendingRefresh = new Set() + +// Not gated on `detached`: a chat saved mid-wait stores its job undetached, and +// loadPastChat resumes polling every unfinished job either way. +function isLiveJob(j: ChatJob): boolean { + return ( + j.status === 'queued' || + j.status === 'running' || + j.status === 'suspended' || + j.status === 'scheduled' + ) +} + +function sessionById(sessionId: string): Session | undefined { + return sessionState.sessions.find((s) => s.id === sessionId) +} + +function refreshSessionChatPeek(sessionId: string): void { + peeks.delete(sessionId) + if (peeksInFlight.has(sessionId)) { + peeksPendingRefresh.add(sessionId) + return + } + const session = sessionById(sessionId) + if (session) void ensureSessionChatPeek(session) +} + +export function getSessionChatPeek(sessionId: string): SessionChatPeek | undefined { + return peeks.get(sessionId) +} + +/** Read the stored chat of a session that has no runtime. A chat with a detached + * job still running gets its runtime instead: only a runtime polls the job and + * resumes the conversation when it finishes. */ +export async function ensureSessionChatPeek(session: Session): Promise { + const id = session.id + const chatId = session.chatId + if (runtimes.has(id) || peeks.has(id) || peeksInFlight.has(id) || !chatId) return + const email = getCurrentUserEmail() + if (!email) return + peeksInFlight.add(id) + try { + const chat = await readStoredChat(chatId, email) + if (sessionById(id)?.chatId !== chatId) { + refreshSessionChatPeek(id) + return + } + if (!chat || runtimes.has(id)) return + if (chat.backgroundJobs?.some(isLiveJob)) { + getOrCreateRuntime(session) + return + } + const last = chat.displayMessages[chat.displayMessages.length - 1] + peeks.set(id, { + status: last?.role === 'user' && last.error ? 'error' : 'idle', + messageCount: chat.displayMessages.length + }) + } catch (e) { + console.error('Failed to read session chat', e) + } finally { + peeksInFlight.delete(id) + if (peeksPendingRefresh.delete(id)) { + refreshSessionChatPeek(id) + } + } +} diff --git a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts index 281c6571bc..26ce3f205b 100644 --- a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts @@ -1,4 +1,4 @@ -import type { SessionRuntime } from './sessionRuntime.svelte' +import type { SessionChatPeek, SessionRuntime } from './sessionRuntime.svelte' import { sessionState, putSession } from './sessionState.svelte' // Per-session "last seen" watermark — the displayMessages count the last time @@ -20,11 +20,16 @@ export function markSessionSeen(sessionId: string, count: number) { void putSession(s) } -// Number of unread messages for a session. Undefined / unloaded runtime -// returns 0 — until messages are hydrated we don't know what's new. -export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number { - if (!runtime) return 0 +// Number of unread messages for a session, from its runtime when it has one, else +// from the sidebar's read of its stored chat. Neither loaded returns 0 — until +// messages are read we don't know what's new. +export function unreadCountFor( + sessionId: string, + runtime: SessionRuntime | undefined, + peek: SessionChatPeek | undefined +): number { + const total = runtime ? runtime.manager.displayMessages.length : peek?.messageCount + if (total === undefined) return 0 const seen = sessionState.sessions.find((x) => x.id === sessionId)?.lastSeenCount ?? 0 - const total = runtime.manager.displayMessages.length return Math.max(0, total - seen) } diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index a3712f3f24..e773e7603f 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -220,19 +220,39 @@ // deep links the same way they react to picker clicks. selectSession(session.id) getOrCreateRuntime(session) + mountChat(session.id) }) }) - // Warm = sessions with a live runtime. The picker eagerly creates runtimes - // for its visible sessions, so this tracks whatever it shows. Keeping warm - // chats mounted (stacked, visibility-toggled) preserves their scroll/draft - // state across switches. + // Warm = sessions with a live runtime: the ones visited this page load plus + // any a chat tool or a running job started. const warmSessions = $derived( listRuntimes() .map((r) => sessionState.sessions.find((s) => s.id === r.sessionId)) .filter((s): s is NonNullable => s != null) ) + // Chat columns stay mounted (stacked, visibility-toggled) so switching back + // keeps scroll position, MRU-capped like preview tabs: every mounted column + // is a full transcript in the DOM. Unmounting drops only the column — the + // runtime keeps the chat — so a column is kept while its composer holds + // unsent input (component-local) or its turn is running. + const MAX_MOUNTED_CHATS = 5 + const mountedChatIds = new SvelteSet() + function keepsChatMounted(id: string): boolean { + const m = getRuntime(id)?.manager + return !!m && (m.loading || m.sendInFlight || m.sendPending || m.hasUnsentInput) + } + function mountChat(id: string) { + mountedChatIds.delete(id) + mountedChatIds.add(id) + for (const oldest of [...mountedChatIds]) { + if (mountedChatIds.size <= MAX_MOUNTED_CHATS) break + if (oldest !== id && !keepsChatMounted(oldest)) mountedChatIds.delete(oldest) + } + } + const mountedChatSessions = $derived(warmSessions.filter((s) => mountedChatIds.has(s.id))) + // Mark the active session "seen" up to its current message count: arrive → // clear unread; AI streams a new message while we're here → clear again. The // effect depends only on the length, not the array contents, so token-by-token @@ -915,11 +935,11 @@ class="flex-1 min-h-0 session-splitter {previewCollapsed ? 'splitter-off' : ''}" > {#if !fullscreen} - +
    - {#each warmSessions as s (s.id)} + {#each mountedChatSessions as s (s.id)} diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 7749310b5b..31fd9426f5 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -13,9 +13,10 @@ items?: Item[] | (() => Item[]) | (() => Promise) meltItem: MenubarMenuElements['item'] builders?: ReturnType['builders'] + close?: (afterClose?: () => void) => void } - let { aiId, items = [], meltItem, builders }: Props = $props() + let { aiId, items = [], meltItem, builders, close }: Props = $props() let computedItems: Item[] | undefined = $state(undefined) async function computeItems() { @@ -102,8 +103,8 @@ {#if item.separatorTop}
    {/if} - {#if item.submenuItems && builders} - + {#if (item.submenuItems || item.customSubmenu) && builders} + {})} /> {:else if item.disabled && item.tooltip} diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index cf27a45d89..17d752ae39 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -3,6 +3,7 @@ import AIChatMessage from './AIChatMessage.svelte' import AppAvailableContextList from './AppAvailableContextList.svelte' import ChatContextPicker from './ChatContextPicker.svelte' + import WorkspaceMentionPicker from './WorkspaceMentionPicker.svelte' import { type Snippet } from 'svelte' import { AlertTriangle, @@ -548,6 +549,11 @@ await imageWork } + function mentionWorkspaceItem(element: ContextElement) { + void aiChatInput?.addContextToSelection(element) + aiChatInput?.insertMention(element.title) + } + function onFolderInputChange(e: Event) { const input = e.currentTarget as HTMLInputElement // webkitdirectory files carry webkitRelativePath (`folder/sub/file`); addFiles groups @@ -1055,6 +1061,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> } ] : []), + ...(inGlobal + ? [ + { + displayName: 'Mention file', + icon: AtSign, + customSubmenu: WorkspaceMentionPicker, + customSubmenuProps: { + onSelect: mentionWorkspaceItem, + onAfterClose: () => aiChatInput?.focusInput() + } + } + ] + : []), ...(skillItems ? [ { diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 6f15937cf2..bcea3a26d6 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -14,7 +14,7 @@ import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext' import { getChatViewHost } from './chatViewHost' import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox' - import { formatMention } from './mention' + import { formatMention, hasMention, removeMentionFromText } from './mention' import { twMerge } from 'tailwind-merge' import { tick, untrack, type Snippet } from 'svelte' import Portal from '$lib/components/Portal.svelte' @@ -588,40 +588,33 @@ const contextKey = contextElementKey - /** Append `@title` to the textarea so the button-picker path stays in - * sync with the inline `@` mention path — both leave a visible - * token tied to the selectedContext entry, which the textarea diffs on - * to auto-remove items when the user deletes them. No-op when the - * mention is already present so re-picking the same item doesn't - * leave duplicate tokens. */ + /** Insert `@title` so button/menu picker paths stay in sync with + * the inline `@` mention path — both leave a visible token tied + * to the selectedContext entry, which the textarea diffs on to + * auto-remove items when the user deletes them. No-op when the same + * standalone mention is already present, so picking the same item + * again doesn't leave duplicate tokens. */ export function insertMention(title: string) { - const target = `@${title}` - if (draft.text.split(/\s+/).includes(target)) return + if (hasMention(draft.text, title)) return + if (contextTextareaComponent) { + void contextTextareaComponent.insertMention(title) + return + } const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' ' - draft.text = `${draft.text}${sep}${target} ` + draft.text = `${draft.text}${sep}${formatMention(title)} ` } /** Strip every `@title` token from the textarea — used when the user * deletes the corresponding badge so the badge X-button mirrors the - * inverse (text-delete-to-badge-remove) sync. Only matches `@title` as a - * standalone token (boundary on both sides) so substring matches don't - * bleed into other words; only the whitespace adjacent to the removed - * mention is collapsed so unrelated double-spaces stay intact. */ + * inverse (text-delete-to-badge-remove) sync. Only matches tokens whose + * `@` starts a word, so embedded text like `owner@app.ts` is left alone + * while punctuation and non-spacing text after the token still work. */ export function removeMention(title: string) { // Pre-zap the textarea's mention diff snapshot so the upcoming strip // doesn't refire the removal effect on a same-title sibling — the host // has already mutated `selectedContext` to drop the targeted entry. contextTextareaComponent?.unsyncMention(title) - const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const re = new RegExp(`(^|\\s)@${escaped}(\\s|$)`, 'g') - draft.text = draft.text.replace(re, (_m, lead, trail) => { - // Boundary on at least one side → drop the mention entirely. - if (!lead || !trail) return '' - // Middle of text: keep ONE of the bracketing whitespace chars so - // the surviving tokens are still separated; preserve the leading - // one verbatim so newlines/tabs aren't downgraded to spaces. - return lead - }) + draft.text = removeMentionFromText(draft.text, title) } export function focusInput() { diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 976b969b3f..dd3a011da2 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -10,7 +10,13 @@ import { zIndexes } from '$lib/zIndexes' import { twMerge } from 'tailwind-merge' import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext' - import { MENTION_RE, mentionTitle, formatMention } from './mention' + import { + MENTION_RE, + mentionTitle, + formatMention, + hasMentionLeadingBoundary, + mentionTitlesInText + } from './mention' import { createFloatingActions, createVirtualElement } from 'svelte-floating-ui' import { flip, offset, shift } from 'svelte-floating-ui/dom' import { @@ -65,17 +71,11 @@ const aiChatManager = getAiChatManager() - function extractMentions(text: string): Set { - const out = new Set() - for (const m of text.matchAll(MENTION_RE)) out.add(mentionTitle(m[0])) - return out - } - // Titles currently appearing as `@title` mentions in the textarea. Compared // against the previous snapshot in a $effect (NOT inside handleInput — // the picker mutates `value` programmatically via `updateInstructionsWithContext`, // which doesn't fire `oninput`, so a handleInput-only diff goes stale). - const mentionedTitles = $derived(extractMentions(value)) + const mentionedTitles = $derived(mentionTitlesInText(value)) let prevMentionedTitles = $state>(new Set()) let showContextTooltip = $state(false) @@ -268,7 +268,11 @@ if (!att) return match return `${match}` }) - html = html.replace(MENTION_RE, (match) => { + html = html.replace(MENTION_RE, (match, ...args) => { + const offset = args[args.length - 2] + if (typeof offset !== 'number' || !hasMentionLeadingBoundary(html, offset)) { + return match + } const title = unescapeHtml(mentionTitle(match)) const inContext = availableContext.find((c) => c.title === title) || @@ -566,6 +570,23 @@ showContextTooltip = false } + export async function insertMention(title: string) { + const token = formatMention(title) + const selectionStart = textarea?.selectionStart ?? value.length + const selectionEnd = textarea?.selectionEnd ?? selectionStart + const { from, to, ids } = tokensOverlapping(selectionStart, selectionEnd) + const before = value.slice(0, from) + const after = value.slice(to) + const prefix = before.length === 0 || /\s$/.test(before) ? '' : ' ' + const suffix = after.length === 0 ? ' ' : /^\s/.test(after) ? '' : ' ' + const inserted = `${prefix}${token}${suffix}` + replacePasteRange(from, to, ids, inserted) + await tick() + const pos = from + inserted.length + textarea?.setSelectionRange(pos, pos) + textarea?.focus() + } + function refreshCommandSkills() { if (commandSkillsRefreshInFlight) return commandSkillsRefreshInFlight = true diff --git a/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte b/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte new file mode 100644 index 0000000000..0fb21f684f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte @@ -0,0 +1,148 @@ + + + +{#snippet leafIcon(leaf: DrillLeaf)} + +{/snippet} + +{#snippet branchIcon(branch: DrillBranch)} + {#if branch.icon} + {@const Icon = branch.icon} + + {:else} + + {/if} +{/snippet} + + +
    + relativizeWorkspacePath(workspaceItemDisplayPath(leaf.data), scope)} + onScopeChange={handleScopeChange} + onFilterChange={loader.onFilterChange} + rootLoading={WORKSPACE_KINDS.some((k) => !loader.loaded[k] && loader.loadingKind[k])} + /> +
    diff --git a/frontend/src/lib/components/copilot/chat/mention.test.ts b/frontend/src/lib/components/copilot/chat/mention.test.ts index 8d379ce3b4..ae021bf1de 100644 --- a/frontend/src/lib/components/copilot/chat/mention.test.ts +++ b/frontend/src/lib/components/copilot/chat/mention.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { MENTION_RE, mentionTitle, formatMention } from './mention' +import { + MENTION_RE, + mentionTitle, + formatMention, + hasMention, + mentionTitlesInText, + removeMentionFromText +} from './mention' describe('formatMention', () => { it('leaves a simple name bare', () => { @@ -52,3 +59,83 @@ describe('MENTION_RE', () => { expect(mentionTitle(m[0])).toBe(name) }) }) + +describe('removeMentionFromText', () => { + it('removes a bare mention and keeps neighboring words separated', () => { + expect(removeMentionFromText('before @app.ts after', 'app.ts')).toBe('before after') + }) + + it('removes a bracketed mention', () => { + expect(removeMentionFromText('before @[my folder/a b.ts] after', 'my folder/a b.ts')).toBe( + 'before after' + ) + }) + + it('removes an escaped bracketed mention', () => { + expect(removeMentionFromText('before @[notes \\] draft.md] after', 'notes ] draft.md')).toBe( + 'before after' + ) + }) + + it('removes edge mentions without leaving extra whitespace', () => { + expect(removeMentionFromText('@app.ts after', 'app.ts')).toBe('after') + expect(removeMentionFromText('before @app.ts', 'app.ts')).toBe('before') + }) + + it('keeps embedded mention-like text intact', () => { + expect(removeMentionFromText('Contact owner@app.ts about @app.ts', 'app.ts')).toBe( + 'Contact owner@app.ts about' + ) + }) + + it('keeps Unicode-prefixed embedded mention-like text intact', () => { + expect(removeMentionFromText('Contact 用户@app.ts about @app.ts', 'app.ts')).toBe( + 'Contact 用户@app.ts about' + ) + expect(removeMentionFromText('Contact हिंदी@app.ts about @app.ts', 'app.ts')).toBe( + 'Contact हिंदी@app.ts about' + ) + expect(removeMentionFromText('Contact 𐐀@app.ts about @app.ts', 'app.ts')).toBe( + 'Contact 𐐀@app.ts about' + ) + }) +}) + +describe('hasMention', () => { + it('does not treat embedded mention-like text as a selected context mention', () => { + expect(hasMention('Contact owner@app.ts', 'app.ts')).toBe(false) + expect(hasMention('Contact owner@app.ts about @app.ts', 'app.ts')).toBe(true) + }) + + it('does not treat Unicode-prefixed embedded text as a mention', () => { + expect(hasMention('Contact 用户@app.ts', 'app.ts')).toBe(false) + expect(hasMention('Contact हिंदी@app.ts', 'app.ts')).toBe(false) + expect(hasMention('Contact 𐐀@app.ts', 'app.ts')).toBe(false) + }) + + it('treats punctuation next to a mention as a boundary', () => { + expect(hasMention('use @app.ts, then compare', 'app.ts')).toBe(true) + expect(hasMention('open (@app.ts)', 'app.ts')).toBe(true) + }) +}) + +describe('mentionTitlesInText', () => { + it('extracts only standalone mentions for context synchronization', () => { + expect([...mentionTitlesInText('Contact owner@app.ts about @app.ts')]).toEqual(['app.ts']) + expect([...mentionTitlesInText('Contact owner@app.ts')]).toEqual([]) + }) + + it('ignores Unicode-prefixed embedded text for context synchronization', () => { + expect([...mentionTitlesInText('Contact 用户@app.ts')]).toEqual([]) + }) + + it('keeps punctuation-adjacent mentions synchronized', () => { + expect([...mentionTitlesInText('use @app.ts, then compare')]).toEqual(['app.ts']) + expect([...mentionTitlesInText('open (@app.ts)')]).toEqual(['app.ts']) + }) + + it('ignores combining-mark and astral-letter embedded text', () => { + expect([...mentionTitlesInText('Contact हिंदी@app.ts')]).toEqual([]) + expect([...mentionTitlesInText('Contact 𐐀@app.ts')]).toEqual([]) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/mention.ts b/frontend/src/lib/components/copilot/chat/mention.ts index 65a2b3b7c6..d1d42696b0 100644 --- a/frontend/src/lib/components/copilot/chat/mention.ts +++ b/frontend/src/lib/components/copilot/chat/mention.ts @@ -29,3 +29,59 @@ const BARE_SAFE = /^[\w/.\-]+$/ export function formatMention(name: string): string { return BARE_SAFE.test(name) ? `@${name}` : `@[${name.replace(/[\\\]]/g, '\\$&')}]` } + +function isWordChar(char: string | undefined): boolean { + return char !== undefined && /[\p{L}\p{N}\p{M}_]/u.test(char) +} + +function codePointBefore(text: string, index: number): string | undefined { + if (index <= 0) return undefined + const prev = text.charCodeAt(index - 1) + if (prev >= 0xdc00 && prev <= 0xdfff && index > 1) return text.slice(index - 2, index) + return text[index - 1] +} + +// A mention is owned by this parser when the `@` starts a token. The trailing side is +// intentionally open: non-spacing scripts often continue immediately after a mention, +// while embedded forms like `owner@app.ts` are rejected by the leading boundary. +export function hasMentionLeadingBoundary(text: string, index: number): boolean { + return !isWordChar(codePointBefore(text, index)) +} + +export function isStandaloneMention(text: string, match: RegExpMatchArray): boolean { + if (match.index === undefined) return false + return hasMentionLeadingBoundary(text, match.index) +} + +export function hasMention(text: string, title: string): boolean { + return [...text.matchAll(MENTION_RE)].some( + (m) => mentionTitle(m[0]) === title && isStandaloneMention(text, m) + ) +} + +export function mentionTitlesInText(text: string): Set { + const out = new Set() + for (const m of text.matchAll(MENTION_RE)) { + if (isStandaloneMention(text, m)) out.add(mentionTitle(m[0])) + } + return out +} + +export function removeMentionFromText(text: string, title: string): string { + let out = '' + let last = 0 + for (const m of text.matchAll(MENTION_RE)) { + if (m.index === undefined || mentionTitle(m[0]) !== title) continue + let start = m.index + let end = m.index + m[0].length + if (!isStandaloneMention(text, m)) continue + const hasLead = start > 0 && /\s/.test(text[start - 1]) + const hasTrail = end < text.length && /\s/.test(text[end]) + if (hasLead && !hasTrail) start -= 1 + if (!hasLead && hasTrail) end += 1 + if (hasLead && hasTrail) end += 1 + out += text.slice(last, start) + last = end + } + return out + text.slice(last) +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0f9eb78da4..b90751b9d3 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1677,6 +1677,8 @@ export type Item = { iconColor?: string /** Extra props for `icon`, for an icon that does not take lucide's `size`. */ iconProps?: Record + customSubmenu?: import('svelte').Component + customSubmenuProps?: Record href?: string hrefTarget?: '_blank' | '_self' | '_parent' | '_top' disabled?: boolean From efa7a0a70af43f8827542059dd450326a9b88d62 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 22 Sep 2026 10:48:55 +0200 Subject: [PATCH 069/112] feat: allow workspace-scoped dynamic custom tags (#11283) * feat: check dynamic worker tags on the tag they resolve to Co-Authored-By: Claude Opus 5 (1M context) * test: pin the tag check after a flow preprocessor resolves the tag Co-Authored-By: Claude Opus 5 (1M context) * fix: keep tied tag placeholders exact and drop an unresolvable flow tag Co-Authored-By: Claude Opus 5 (1M context) * fix: describe tied tag placeholders in the custom tags editor Co-Authored-By: Claude Opus 5 (1M context) * fix: check workflow-as-code task tags on the tag they resolve to Co-Authored-By: Claude Opus 5 (1M context) * fix: judge a preprocessed flow's tag as written when it is run Co-Authored-By: Claude Opus 5 (1M context) * fix: check a preprocessed flow's own tag, as written only where it reads args Co-Authored-By: Claude Opus 5 (1M context) * feat: explain dynamic tags in the worker tag picker Co-Authored-By: Claude Opus 5 (1M context) * fix: look up worker availability for typed static tags Co-Authored-By: Claude Opus 5 (1M context) * fix: poll worker availability with the tag list as it is at each run Co-Authored-By: Claude Opus 5 (1M context) * feat: allow workspace-scoped dynamic custom tags Co-Authored-By: Claude Opus 5 (1M context) * test: pin the $workspace lookup for scoped custom tag patterns Co-Authored-By: Claude Opus 5 (1M context) * feat: keep a tag listed with its own scope inside it past matching patterns Co-Authored-By: Claude Opus 5 (1M context) * fix: warn when a scoped custom tag is also listed for every workspace Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/tests/worker.rs | 116 ++++++++++++++++++ backend/windmill-common/src/jobs.rs | 69 ++++++++--- backend/windmill-common/src/worker.rs | 94 +++++++++++--- .../lib/components/AssignableTagsInner.svelte | 113 +++++++++++------ 4 files changed, 322 insertions(+), 70 deletions(-) diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 74a10488e6..d7fb992280 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -5719,6 +5719,122 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool) -> a Ok(()) } +/// A workspace-scoped pattern admits the tags in its range only from the workspaces its scope +/// allows, and a tag listed by its own name with a scope stays inside that scope whatever pattern +/// it fits. The fork lineage and `$workspace` are resolved for a matching pattern. +#[sqlx::test(fixtures("base"))] +#[serial] +async fn test_scoped_custom_tag_pattern_admission(db: Pool) -> anyhow::Result<()> { + use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; + + initialize_tracing().await; + + // For a non-superadmin caller (a superadmin would bypass the scope check entirely). + async fn check(db: &Pool, w_id: &str, tag: &str, size: &str) -> Result<(), String> { + let args = std::collections::HashMap::from([( + "size".to_string(), + serde_json::value::to_raw_value(size).unwrap(), + )]); + let args = windmill_queue::PushArgs::from(&args); + windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None) + .await + .map_err(|e| e.to_string()) + } + async fn allowed(db: &Pool, w_id: &str, tag: &str, size: &str) -> bool { + check(db, w_id, tag, size).await.is_ok() + } + async fn allowed_as_written(db: &Pool, w_id: &str, tag: &str) -> bool { + windmill_common::jobs::check_tag_available_for_workspace_internal( + db, + w_id, + tag, + None, + std::future::ready(w_id.to_string()), + false, + None, + ) + .await + .is_ok() + } + + // The ancestor chain is cached process-wide by workspace id, so use one no other test takes. + let fork = "wm-fork-tagpattern"; + sqlx::query!( + "INSERT INTO workspace (id, name, owner, parent_workspace_id) + VALUES ($1, $1, 'test-user', 'test-workspace')", + fork + ) + .execute(&db) + .await?; + + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![ + "gpu-$args[size]".to_string(), + "$args[size]-secret(test-workspace)".to_string(), + "gpu-secret(other)".to_string(), + "$workspace-$args[size](test-workspace*)".to_string(), + "urgent".to_string(), + "urgent(other)".to_string(), + ]))); + + let confined = check(&db, "test-workspace", "gpu-$args[size]", "secret").await; + let checks = [ + ( + "scoped pattern from its workspace", + allowed(&db, "test-workspace", "cpu-secret", "").await, + ), + ( + "scoped pattern refused elsewhere", + !allowed(&db, "other", "cpu-secret", "").await, + ), + ("confined tag refused past the patterns it fits", confined.is_err()), + ( + "global pattern for the tags nothing confines", + allowed(&db, "test-workspace", "gpu-large", "").await, + ), + ( + "confined tag from its workspace", + allowed(&db, "other", "gpu-secret", "").await, + ), + // Listing the tag by name without a scope as well keeps it open everywhere. + ( + "tag listed both globally and scoped", + allowed(&db, "test-workspace", "urgent", "").await, + ), + // The fork's `$workspace` is its parent's, whose tags its lineage reaches. Written as the + // resolved tag, not as the entry, so only the pattern can admit it. + ( + "fork-scoped `$workspace` pattern from a fork", + allowed(&db, fork, "test-workspace-large", "").await, + ), + ( + "fork-scoped `$workspace` pattern refused elsewhere", + !allowed(&db, "other", "$workspace-$args[size]", "large").await, + ), + ( + "as written from its workspace", + allowed_as_written(&db, "test-workspace", "$args[size]-secret").await, + ), + ( + "as written refused elsewhere", + !allowed_as_written(&db, "other", "$args[size]-secret").await, + ), + ]; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default())); + + for (what, ok) in checks { + assert!(ok, "{what}"); + } + let confined = confined.unwrap_err(); + assert!( + confined.contains("the custom tag gpu-secret(other) restricts it to other workspaces") + && confined.contains("gpu-$args[size]") + && confined.contains("$args[size]-secret(test-workspace)"), + "got {confined}" + ); + + Ok(()) +} + #[cfg(all(feature = "quickjs", feature = "python"))] #[sqlx::test(fixtures("base"))] async fn test_whileloop_propagates_inner_iterator_eval_failure( diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index f2b0e16dac..796d1c999a 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -363,6 +363,7 @@ pub async fn check_tag_available_for_workspace_internal( && custom_tags_per_w .global .iter() + .chain(custom_tags_per_w.specific.keys()) .any(|t| t.contains("$workspace")) { tag_workspace.await @@ -371,21 +372,35 @@ pub async fn check_tag_available_for_workspace_internal( }; // A job whose tag is written exactly as an entry resolves inside what that entry admits, which // is the only way in for an entry `custom_tag_matches` cannot turn into a pattern. - let mut is_tag_in_workspace_custom_tags = custom_tags_per_w.global.iter().any(|entry| { + let admits = |entry: &str| { entry == tag || resolved_tag.is_some_and(|r| custom_tag_matches(entry, r, &tag_workspace)) - }); - if !is_tag_in_workspace_custom_tags { - if let Some(specific_tag) = custom_tags_per_w.specific.get(resolved_tag.unwrap_or(tag)) { - // Only a fork-scoped tag can match through the lineage, so every other tag keeps the - // ancestor lookup off the push path entirely. - let chain = if specific_tag.is_fork_scoped() { - workspace_with_fork_ancestors(db, w_id).await? - } else { - vec![w_id.to_string()] - }; - is_tag_in_workspace_custom_tags = specific_tag.applies_to_workspace(&chain); - } - } + }; + let scoped = custom_tags_per_w + .specific + .iter() + .filter(|(name, _)| admits(name)) + .collect::>(); + // Only a fork-scoped tag can match through the lineage, so every other tag keeps the + // ancestor lookup off the push path entirely. + let chain = if scoped.iter().any(|(_, t)| t.is_fork_scoped()) { + workspace_with_fork_ancestors(db, w_id).await? + } else { + vec![w_id.to_string()] + }; + // A tag listed by its own name with a scope stays inside that scope: a pattern it fits does + // not open it to other workspaces. Only listing it by name without a scope does. + let lands_on = resolved_tag.unwrap_or(tag); + let confined_by = custom_tags_per_w + .specific + .get_key_value(lands_on) + .filter(|(name, t)| { + !name.contains('$') + && !t.applies_to_workspace(&chain) + && !custom_tags_per_w.global.iter().any(|e| e == lands_on) + }); + let is_tag_in_workspace_custom_tags = confined_by.is_none() + && (custom_tags_per_w.global.iter().any(|entry| admits(entry)) + || scoped.iter().any(|(_, t)| t.applies_to_workspace(&chain))); match is_tag_in_scope_tags { Some(true) | None => { @@ -411,6 +426,32 @@ pub async fn check_tag_available_for_workspace_internal( if *TAGS_ARE_SENSITIVE { return Err(Error::BadRequest(format!("{tag} is not available to you"))); + } else if let Some((name, confining)) = confined_by { + let overridden = custom_tags_per_w + .global + .iter() + .filter(|entry| admits(entry)) + .cloned() + .chain( + scoped + .iter() + .filter(|(_, t)| t.applies_to_workspace(&chain)) + .map(|(n, t)| t.authored(n)), + ) + .collect::>(); + let overridden = if overridden.is_empty() { + String::new() + } else { + format!( + ", which takes precedence over the custom tags it also fits: {}", + overridden.join(", ") + ) + }; + return Err(Error::BadRequest(format!( + "Tag {tag} is not available in workspace {w_id}: the custom tag {} restricts it \ + to other workspaces{overridden}", + confining.authored(name) + ))); } else { return Err(error::Error::BadRequest(format!( "Tag {tag} is not included in the allowed CUSTOM_TAGS, which only super admins can go beyond: {:?}", diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 548dbe07b4..ad0a5068e8 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -41,6 +41,8 @@ use crate::{ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct CustomTags { pub global: Vec, + /// Keyed by the entry's name, which may hold placeholders like a global entry: only a name + /// without any can be looked up by the tag it admits, the others through [`custom_tag_matches`]. pub specific: HashMap, } @@ -83,20 +85,7 @@ impl CustomTags { } else { self.specific .iter() - .map(|(tag, tag_data)| { - let separator = tag_data.tag_type.corresponding_separator(); - let mut workspaces = tag_data - .workspaces - .iter() - .map(|w| w.to_string()) - .collect::>() - .join(&*separator.to_string()); - if tag_data.tag_type == SpecificTagType::AllExcluding { - // the AllExcluding tag syntax has a leading separator - workspaces.insert(0, separator); - } - format!("{}({})", tag, workspaces) - }) + .map(|(tag, tag_data)| tag_data.authored(tag)) .collect::>() }; let all_tags = self.global.clone(); @@ -261,6 +250,22 @@ impl SpecificTagData { pub fn is_fork_scoped(&self) -> bool { self.workspaces.iter().any(|w| w.include_forks) } + + /// The entry named `name` with this scope, as written in the custom tags: `tag(ws1+ws2)`. + pub fn authored(&self, name: &str) -> String { + let separator = self.tag_type.corresponding_separator(); + let mut workspaces = self + .workspaces + .iter() + .map(|w| w.to_string()) + .collect::>() + .join(&*separator.to_string()); + if self.tag_type == SpecificTagType::AllExcluding { + // the AllExcluding tag syntax has a leading separator + workspaces.insert(0, separator); + } + format!("{}({})", name, workspaces) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum SpecificTagType { @@ -542,7 +547,9 @@ lazy_static::lazy_static! { - // ^([\w-]+) # Group 1: tag name + // ^( # Group 1: tag name, a pattern when it holds placeholders + // (?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+ + // ) // \( # Literal '(' // ( # Group 2: the full workspace list // (?:[\w-]+\*?\+)*[\w-]+\*? # NoneExcept pattern: ws1+ws2* @@ -551,8 +558,9 @@ lazy_static::lazy_static! { // ) // \)$ # Closing ')' // - // The optional `*` after each workspace id is the fork marker, see [`WorkspaceMatcher`]. - static ref CUSTOM_TAG_REGEX: Regex = Regex::new(r"^([\w-]+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$").unwrap(); + // The placeholders are CUSTOM_TAG_PLACEHOLDER's. The optional `*` after each workspace id is + // the fork marker, see [`WorkspaceMatcher`]. + static ref CUSTOM_TAG_REGEX: Regex = Regex::new(r"^((?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$").unwrap(); // The placeholders a job's tag is resolved from when it is pushed, see [`custom_tag_matches`]. static ref CUSTOM_TAG_PLACEHOLDER: Regex = Regex::new(r"\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\]").unwrap(); @@ -3185,6 +3193,58 @@ mod tests { assert!(!matches("$workspace-$args[size]", "ws2-large")); } + #[test] + fn test_scoped_custom_tag_patterns_parse_and_round_trip() { + let input = vec![ + "gpu-$args[size](ws1+ws2)".to_string(), + "cpu-$flow_expr[results.a.size](^ws1)".to_string(), + "$workspace-$args[x](prod*)".to_string(), + "t-$args[id]-$args[id](ws1)".to_string(), + ]; + let tags = CustomTags::from(input.clone()); + + assert!(tags.global.is_empty()); + assert_eq!( + tags.specific["gpu-$args[size]"], + SpecificTagData { + tag_type: SpecificTagType::NoneExcept, + workspaces: vec![matcher("ws1"), matcher("ws2")], + } + ); + assert_eq!( + tags.specific["cpu-$flow_expr[results.a.size]"], + SpecificTagData { + tag_type: SpecificTagType::AllExcluding, + workspaces: vec![matcher("ws1")], + } + ); + assert!(tags.specific["$workspace-$args[x]"].is_fork_scoped()); + assert!(tags.specific.contains_key("t-$args[id]-$args[id]")); + + let mut result = tags.to_string_vec(None); + result.sort(); + let mut expected = input; + expected.sort(); + assert_eq!(result, expected); + + let mut for_ws1 = tags.to_string_vec(Some(&chain(&["ws1"]))); + for_ws1.sort(); + assert_eq!(for_ws1, vec!["gpu-$args[size]", "t-$args[id]-$args[id]"]); + + // Only the placeholders a tag resolves from make a name a pattern: anything else is no + // scoped entry at all. + for literal in [ + "gpu-$foo(ws1)", + "gpu-$args[](ws1)", + "gpu-$args[a.](ws1)", + "gpu.x(ws1)", + ] { + let tags = CustomTags::from(vec![literal.to_string()]); + assert_eq!(tags.global, vec![literal]); + assert!(tags.specific.is_empty()); + } + } + #[test] fn test_fork_marker_parses_and_round_trips() { let tags = CustomTags::from(vec![ diff --git a/frontend/src/lib/components/AssignableTagsInner.svelte b/frontend/src/lib/components/AssignableTagsInner.svelte index f385ff2b49..fd5859b784 100644 --- a/frontend/src/lib/components/AssignableTagsInner.svelte +++ b/frontend/src/lib/components/AssignableTagsInner.svelte @@ -39,7 +39,8 @@ const dispatch = createEventDispatcher() // Mirrors CUSTOM_TAG_REGEX in backend/windmill-common/src/worker.rs — keep both in sync. - const customTagRegex = /^([\w-]+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$/ + const customTagRegex = + /^((?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$/ // Mirrors RE_ARG_TAG and RE_FLOW_EXPR_TAG in backend/windmill-queue/src/jobs.rs. const dynamicTagRegex = /\$(args|flow_expr)\[((?:\w+\.)*\w+)\]/ @@ -47,10 +48,16 @@ return w.includeForks ? `${w.id} (and its forks)` : w.id } + // What an entry matches job tags against: a workspace specific entry's name, which may be + // dynamic like any other entry. + function nameOf(entry: string) { + return entry.match(customTagRegex)?.[1] ?? entry + } + let tagName = $derived(nameOf(newTag.trim())) + let dynamicTag = $derived.by(() => { - let r = newTag.trim() - if (r == '') return undefined - let matched = r.match(dynamicTagRegex) + if (tagName == '') return undefined + let matched = tagName.match(dynamicTagRegex) return matched ? { kind: matched[1], path: matched[2] } : undefined }) @@ -88,8 +95,8 @@ placeholders[placeholders.length - 1].end == entry.length ) } - let dynamicTagTied = $derived(isTied(newTag.trim())) - let dynamicTagReachesAnyTag = $derived(reachesAnyTag(newTag.trim())) + let dynamicTagTied = $derived(isTied(tagName)) + let dynamicTagReachesAnyTag = $derived(reachesAnyTag(tagName)) let extractedCustomTag = $derived.by(() => { let r = newTag.trim() @@ -164,10 +171,40 @@ -{#snippet reachesAnyTagWarning()} - Nothing fixed at its start or its end, so it allows anyone to reach almost any tag. We highly - recommend a prefix or a suffix, ideally both, to limit its reach: for instance - gpu-$args[size] or gpu-$args[size]-eu. +{#snippet reachesAnyTagWarning(scoped: boolean)} + Nothing fixed at its start or its end, so it allows anyone{scoped ? ' in its workspaces' : ''} to reach + almost any tag. We highly recommend a prefix or a suffix, ideally both, to limit its reach: for instance + gpu-$args[size] + or gpu-$args[size]-eu. +{/snippet} + +{#snippet dynamicTagInfo(scoped: boolean)} + {#if tagName.includes('$workspace')} +
    Interpolated tag based on workspace id the job was created in
    + {/if} + {#if dynamicTag?.kind == 'flow_expr'} +
    + Interpolated tag based on the flow value at {dynamicTag.path}, resolved when the flow + step starts +
    + {:else if dynamicTag} +
    Interpolated tag based on args input of {dynamicTag.path}
    + {/if} + {#if dynamicTagTied} +
    + Its placeholders read the same value, or one reads inside the other, so it allows only jobs + whose tag is written exactly like this +
    + {:else if dynamicTagReachesAnyTag} +
    + {@render reachesAnyTagWarning(scoped)} +
    + {:else if dynamicTag} +
    + Allows any tag it resolves to{scoped ? ' in these workspaces' : ''}, with the text around the + placeholder as written +
    + {/if} {/snippet}
    {#each customTags as customTag} + {@const name = nameOf(customTag)} {customTag} - {#if reachesAnyTag(customTag)} + {#if reachesAnyTag(name)} - {@render reachesAnyTagWarning()} + {@render reachesAnyTagWarning(name != customTag)}
    {/snippet} @@ -229,8 +267,11 @@
    {#if extractedCustomTag} + {@const dynamic = tagName.includes('$workspace') || dynamicTag != undefined}
    -
    Workspace specific tag
    +
    + Workspace specific {#if dynamic}dynamic tag{:else}tag{/if} +
    Tag: {extractedCustomTag.tag} @@ -243,6 +284,20 @@ All workspaces except {extractedCustomTag.workspaces?.map(formatWorkspace).join(', ')} {/if}
    + {@render dynamicTagInfo(true)} + + {#if !dynamic && tagEditor} + {#if (customTags ?? []).includes(tagName)} +
    + {tagName} is also listed without workspaces, which keeps it open to every + workspace +
    + {:else} +
    + Workspaces outside this scope cannot use it, even through a dynamic tag it fits +
    + {/if} + {/if}
    {:else if newTag.trim()} {#if newTag.includes('(') || newTag.includes(')') || newTag.includes('+') || newTag.includes('^') || newTag.includes('*') || ((newTag.includes('.') || newTag.includes('$args[') || newTag.includes('$flow_expr[')) && !dynamicTag)} @@ -266,31 +321,7 @@ Tag: {newTag.trim()}
    - {#if newTag.includes('$workspace') && !dynamicTag} -
    Interpolated tag based on workspace id the job was created in
    - {/if} - {#if dynamicTag?.kind == 'flow_expr'} -
    - Interpolated tag based on the flow value at {dynamicTag.path}, resolved when - the flow step starts -
    - {:else if dynamicTag} -
    Interpolated tag based on args input of {dynamicTag.path}
    - {/if} - {#if dynamicTagTied} -
    - Its placeholders read the same value, or one reads inside the other, so it allows only - jobs whose tag is written exactly like this -
    - {:else if dynamicTagReachesAnyTag} -
    - {@render reachesAnyTagWarning()} -
    - {:else if dynamicTag} -
    - Allows any tag it resolves to, with the text around the placeholder as written -
    - {/if} + {@render dynamicTagInfo(false)}
    {/if} {/if} @@ -348,7 +379,11 @@ A dynamic tag is checked on the tag it resolves to: it is allowed when that tag is listed here, or fits a listed dynamic tag such as
    gpu-$args[size]
    , which allows any tag starting with -
    gpu-
    . +
    gpu-
    . Scope it like any other tag, e.g. +
    gpu-$args[size](workspace1+workspace2)
    , to allow those + tags only in some workspaces. A tag listed by its own name for some workspaces, e.g. +
    gpu-secret(workspace1)
    , stays limited to them even + when a dynamic tag fits it, unless it is also listed without workspaces. {/if}
    From 7d0f2ce3bf920a68f1ea22d59481d239947f1b51 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 22 Sep 2026 11:32:11 +0200 Subject: [PATCH 070/112] fix: size the AI chat output budget by model, not provider (#11286) * fix: size the AI chat output budget by model, not provider Co-Authored-By: Claude Opus 5 (1M context) * fix: keep self-hosted open-weight models on the fallback output budget Co-Authored-By: Claude Opus 5 (1M context) * chore: point ee-repo-ref at the free tier output clamp change Co-Authored-By: Claude Opus 5 (1M context) * fix: treat codestral as open-weight for the output budget Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to 559a9ff78ea03439553cf2b412e73765762e9013 This commit updates the EE repository reference after PR #821 was merged in windmill-ee-private. Previous ee-repo-ref: 953f7c1cc9a70b740d5d1b129cb1401950854b02 New ee-repo-ref: 559a9ff78ea03439553cf2b412e73765762e9013 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../copilot/chat/AIChatManager.svelte.ts | 4 +- .../lib/components/copilot/chat/anthropic.ts | 3 +- .../copilot/chat/openai-responses.ts | 6 +- .../copilot/chat/outputTokenLimit.ts | 5 + .../components/copilot/lib.toolCalls.test.ts | 36 ++++++- frontend/src/lib/components/copilot/lib.ts | 100 ++++++++++-------- .../src/lib/components/copilot/modelConfig.ts | 65 ++++++++++++ .../components/copilot/reasoningRegistry.ts | 17 ++- .../workspaceSettings/AISettings.svelte | 4 +- 10 files changed, 185 insertions(+), 57 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index c336c4fdd3..b82e25d93b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -5370ae3a95a3dc10171f72da74286a654996eee6 +559a9ff78ea03439553cf2b412e73765762e9013 diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 871dead4bc..27f9bfc43f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -110,7 +110,7 @@ import type { Selection } from 'monaco-editor' import type AIChatInput from './AIChatInput.svelte' import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' import { closeInterruptedToolBatch, runChatLoop, truncateToToolPairedPrefix } from './chatLoop' -import { OutputTokenLimitError } from './outputTokenLimit' +import { FREE_TIER_OUTPUT_TOKEN_LIMIT_MESSAGE, OutputTokenLimitError } from './outputTokenLimit' import { sanitizeToolCallArguments } from './toolCallArguments' import { billedTokens, normalizeContextUsage, type ChatTokenUsage } from './tokenUsage' import { logAiUsage } from '$lib/utils/aiUsageReporter' @@ -378,7 +378,7 @@ function isImageRejection(err: unknown, models: (string | undefined)[] = []): bo function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean): string { // The request went through; only its response was cut short. if (err instanceof OutputTokenLimitError) { - return err.message + return get(copilotInfo).freeTier ? FREE_TIER_OUTPUT_TOKEN_LIMIT_MESSAGE : err.message } const errorMessage = err instanceof Error ? err.message : typeof err === 'string' ? err : undefined diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index a311d16990..122425e264 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -141,7 +141,8 @@ export async function getAnthropicCompletion( const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, - forceModelProvider: options?.forceModelProvider + forceModelProvider: options?.forceModelProvider, + reasoningEffort: options?.reasoningEffort }) const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) let anthropicTools = convertOpenAIToolsToAnthropic(tools) diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index b216a08107..0e460f1921 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -272,7 +272,8 @@ export async function getOpenAIResponsesCompletion( messages, stream: true, tools, - forceModelProvider: options?.forceModelProvider + forceModelProvider: options?.forceModelProvider, + reasoningEffort: options?.reasoningEffort }) const { instructions, input } = convertMessagesToResponsesInput(messages) const responsesConfig = applyReasoningToConfig( @@ -331,7 +332,8 @@ export async function* getOpenAIResponsesCompletionStream( messages, stream: true, tools, - forceModelProvider: options?.forceModelProvider + forceModelProvider: options?.forceModelProvider, + reasoningEffort: options?.reasoningEffort }) const { instructions, input } = convertMessagesToResponsesInput(messages) // No prompt cache key here: a rejected key has to be retried without it, and this is diff --git a/frontend/src/lib/components/copilot/chat/outputTokenLimit.ts b/frontend/src/lib/components/copilot/chat/outputTokenLimit.ts index b1127a1db7..19b74f0c68 100644 --- a/frontend/src/lib/components/copilot/chat/outputTokenLimit.ts +++ b/frontend/src/lib/components/copilot/chat/outputTokenLimit.ts @@ -10,3 +10,8 @@ export class OutputTokenLimitError extends Error { this.name = 'OutputTokenLimitError' } } + +/** Shown instead on Windmill's free tier, whose server clamps the output below any + * workspace setting: pointing at that setting would send the user to a no-op. */ +export const FREE_TIER_OUTPUT_TOKEN_LIMIT_MESSAGE = + "The response was cut off at the free tier's output token limit. Ask it to continue, or add your own API key in the workspace AI settings for a higher limit." diff --git a/frontend/src/lib/components/copilot/lib.toolCalls.test.ts b/frontend/src/lib/components/copilot/lib.toolCalls.test.ts index b011c1f672..6966e018d1 100644 --- a/frontend/src/lib/components/copilot/lib.toolCalls.test.ts +++ b/frontend/src/lib/components/copilot/lib.toolCalls.test.ts @@ -287,10 +287,42 @@ describe('parseOpenAICompletion tool call arguments', () => { }) describe('output token limit', () => { - it("gives DeepSeek DeepSeek's own thinking-mode budget rather than the 8192 fallback", async () => { + it('follows the model through every host that serves it', async () => { const { getModelMaxTokens } = await import('./lib') expect(getModelMaxTokens('deepseek', 'deepseek-flash')).toBe(131072) - expect(getModelMaxTokens('deepseek', 'deepseek-v4-pro')).toBe(131072) + expect(getModelMaxTokens('azure_foundry', 'DeepSeek-V4-Pro')).toBe(131072) + expect(getModelMaxTokens('openrouter', 'deepseek/deepseek-v4-pro')).toBe(131072) + // gpt-oss reasons whether or not the chat sends an effort. + expect(getModelMaxTokens('groq', 'openai/gpt-oss-120b')).toBe(16000) + expect(getModelMaxTokens('openrouter', 'openai/o3')).toBe(100000) + // A legacy `/thinking` selection is its base model, not the `thinking` row. + expect(getModelMaxTokens('anthropic', 'claude-sonnet-4-5/thinking')).toBe(64000) + }) + + it('gives a model outside the table room to think only when the request asks it to', async () => { + const { getModelMaxTokens } = await import('./lib') + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3', 'high')).toBe(32768) + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3', 'none')).toBe(8192) + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3')).toBe(8192) + // The registry does not know Custom AI models, so an effort says nothing about them. + expect(getModelMaxTokens('customai', 'local-model', 'high')).toBe(8192) + // A self-hosted open-weight model runs at its operator's context length, not the + // vendor's, while a Custom AI proxy in front of a hosted model keeps its row. + expect(getModelMaxTokens('customai', 'deepseek-v4-pro', 'high')).toBe(8192) + expect(getModelMaxTokens('customai', 'gpt-5')).toBe(128000) + }) + + it('sizes the budget from the effort the same request carries', async () => { + const { getCompletion } = await import('./lib') + const create = vi.fn() + + await getCompletion([], new AbortController(), undefined, { + forceModelProvider: { provider: 'mistral', model: 'mistral-medium-latest' }, + openaiClient: { chat: { completions: { create } } } as any, + reasoningEffort: 'high' + }) + + expect(create.mock.calls[0][0]).toMatchObject({ max_tokens: 32768, reasoning_effort: 'high' }) }) it('fails a response cut off at the output token limit instead of ending the turn', async () => { diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 5094764807..b55aa06c92 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -15,11 +15,17 @@ import { get, type Writable } from 'svelte/store' import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' import { + getKnownModelMaxOutputTokens, requiresMaxCompletionTokens, usesAnthropicMessagesApi, usesOpenRouterPromptCaching } from './modelConfig' -import { applyReasoningToConfig } from './reasoningRegistry' +import { + applyReasoningToConfig, + requestsReasoning, + stripLegacyThinkingSuffix, + type ReasoningEffort +} from './reasoningRegistry' import { formatResourceTypes } from './utils' import { appendPendingToolImages, @@ -310,45 +316,31 @@ export async function fetchAvailableModels( return data?.data.map((m) => m.id) ?? [] } -export function getModelMaxTokens(provider: AIProvider, model: string) { - if (provider === 'deepseek') { - // DeepSeek thinks by default and counts the thinking toward max_tokens, so - // the 8192 fallback cuts long turns off mid-thought. 131072 is DeepSeek's - // own default at the highest effort (65536 at the default one). - return 131072 - } else if (model.includes('gpt-5')) { - return 128000 - } else if ( - (provider === 'azure_openai' || provider === 'openai' || provider === 'azure_foundry') && - model.startsWith('o') - ) { - return 100000 - } else if ( - // Raising this further would also raise the worst case of the - // non-streaming completion path, which the Anthropic SDK refuses once - // the request could run past ~10 minutes. - model.includes('claude-sonnet') || - model.includes('claude-haiku') || - model.includes('claude-fable') || - model.includes('claude-mythos') || - // Opus only from 4.5 on. Opus 4.1 and older cap at 32K and fall through - // to the row below. Dots are normalized because OpenRouter writes - // `anthropic/claude-opus-4.5` where Anthropic writes `claude-opus-4-5`. - /claude-opus-(4-(5|6|7|8)|5)(?!\d)/.test(model.replace(/\./g, '-')) || - model.includes('gemini-2.5') || - model.includes('gemini-3') - ) { - return 64000 - } else if (model.includes('gpt-4.1')) { - return 32768 - } else if (model.includes('claude-opus')) { - return 32000 - } else if (model.includes('gpt-4o') || model.includes('codestral')) { - return 16384 - } else if (model.includes('gpt-4-turbo') || model.includes('gpt-3.5')) { - return 4096 - } - return 8192 +// Thinking counts toward max_tokens, so a model outside the table that is asked to +// reason gets more room than the plain fallback. A host rejects a budget above the +// model's cap, so the reasoning fallback must stay within the cap of every model it +// reaches (true of every reasoning model OpenRouter serves outside the table). Custom +// AI never gets it: the registry does not know its models, and the per-model override +// is its way to a larger budget. +const FALLBACK_MAX_TOKENS = 8192 +const REASONING_FALLBACK_MAX_TOKENS = 32768 + +/** + * The output budget a request sends when the workspace sets no override. `reasoningEffort` + * is the effort the request carries, as `resolveRequestReasoning` resolves it. + */ +export function getModelMaxTokens( + provider: AIProvider, + model: string, + reasoningEffort?: ReasoningEffort +): number { + const bareModel = stripLegacyThinkingSuffix(model) + return ( + getKnownModelMaxOutputTokens(provider, bareModel) ?? + (requestsReasoning(provider, bareModel, reasoningEffort) + ? REASONING_FALLBACK_MAX_TOKENS + : FALLBACK_MAX_TOKENS) + ) } // Resolves the completion token cap for a model: the workspace's per-model @@ -356,8 +348,16 @@ export function getModelMaxTokens(provider: AIProvider, model: string) { // Anthropic request paths so both honor the same limit. `cap` bounds the result // (used by short metadata completions, see METADATA_MAX_TOKENS) — a hard ceiling // that wins over both the workspace override and the default. -function resolveMaxTokens(modelProvider: AIProviderModel, cap?: number): number { - const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model) +function resolveMaxTokens( + modelProvider: AIProviderModel, + cap?: number, + reasoningEffort?: ReasoningEffort +): number { + const defaultMaxTokens = getModelMaxTokens( + modelProvider.provider, + modelProvider.model, + reasoningEffort + ) const modelKey = `${modelProvider.provider}:${modelProvider.model}` let customMaxTokensStore: Record | undefined try { @@ -379,9 +379,10 @@ export const METADATA_MAX_TOKENS = 4096 function getModelSpecificConfig( modelProvider: AIProviderModel, tools?: OpenAI.Chat.Completions.ChatCompletionTool[], - maxTokensCap?: number + maxTokensCap?: number, + reasoningEffort?: ReasoningEffort ) { - const maxTokens = resolveMaxTokens(modelProvider, maxTokensCap) + const maxTokens = resolveMaxTokens(modelProvider, maxTokensCap, reasoningEffort) if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' || @@ -903,7 +904,8 @@ export function getProviderAndCompletionConfig({ tools, forceModelProvider, maxTokensCap, - promptCaching + promptCaching, + reasoningEffort }: { messages: ChatCompletionMessageParam[] stream: K @@ -913,6 +915,9 @@ export function getProviderAndCompletionConfig({ // Opt-in: a cache write costs more than an uncached read, so it only pays off where // the same prefix is sent again. True for the chat loop, false for one-shot calls. promptCaching?: boolean + // The effort the caller will add with `applyReasoningToConfig`. Only sizes the + // output budget here: a request that reasons needs room for the thinking. + reasoningEffort?: ReasoningEffort }): { provider: AIProvider config: K extends true @@ -929,7 +934,7 @@ export function getProviderAndCompletionConfig({ provider: modelProvider.provider, config: { ...providerConfig, - ...getModelSpecificConfig(modelProvider, tools, maxTokensCap), + ...getModelSpecificConfig(modelProvider, tools, maxTokensCap, reasoningEffort), messages: processedMessages, stream } as any @@ -1135,7 +1140,8 @@ export async function getCompletion( stream: true, tools, forceModelProvider: options?.forceModelProvider, - promptCaching: options?.promptCaching + promptCaching: options?.promptCaching, + reasoningEffort: options?.reasoningEffort }) // Use Responses API for OpenAI and Azure OpenAI diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index be49907ed4..77d0cf942a 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -117,6 +117,58 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ ['codestral', 32_000] ] +// Output token budgets, matched like the context windows above so one row covers +// every host of a model: native API, Azure AI Foundry, OpenRouter, Together, Bedrock. +// A host rejects any request above its own limit, so each value is one every host of +// the model accepts, not the vendor's maximum. Models not listed fall back in +// `getModelMaxTokens`. +const MODEL_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [ + // Anthropic — the Anthropic SDK refuses a non-streaming request whose max_tokens + // implies more than ~10 minutes, so these stay below the models' own maximum. + // Opus caps at 32K before 4.5. + ['claude-opus-4.5', 64_000], + ['claude-opus-4.6', 64_000], + ['claude-opus-4.7', 64_000], + ['claude-opus-4.8', 64_000], + ['claude-opus-5', 64_000], + ['claude-opus', 32_000], + ['claude-sonnet', 64_000], + ['claude-haiku', 64_000], + ['claude-fable', 64_000], + ['claude-mythos', 64_000], + // OpenAI + ['gpt-5', 128_000], + ['gpt-4.1', 32_768], + ['gpt-4o', 16_384], + ['gpt-4-turbo', 4_096], + ['gpt-3.5', 4_096], + ['o1', 100_000], + ['o3', 100_000], + ['o4-mini', 100_000], + // Google + ['gemini-3', 64_000], + ['gemini-2.5', 64_000] +] + +// Open-weight models, which a self-hosted server (Custom AI) caps at whatever context +// length its operator set, often below these: vLLM rejects a request whose prompt plus +// max_tokens exceeds it. So these rows only apply to the providers that host them. +const OPEN_WEIGHT_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [ + // gpt-oss reasons on every request. Bedrock documents 16K, where Groq takes 65536 + ['gpt-oss', 16_000], + // DeepSeek — thinks by default and counts the thinking toward max_tokens. 131072 + // is DeepSeek's own default at the highest effort. R1 is capped by its OpenRouter + // host. + ['deepseek-v4', 131_072], + ['deepseek-flash', 131_072], + ['deepseek-r1', 16_000], + // Mistral + ['codestral', 16_384], + // Models named for thinking reason on every request, whether or not the chat sends + // an effort. Every one OpenRouter lists takes at least this much. + ['thinking', 32_768] +] + // Version separators differ by route to the same model: Anthropic writes // `claude-opus-4-8`, OpenRouter writes `anthropic/claude-opus-4.8`. Collapsing // dots to dashes on both sides keeps one table entry covering every route — @@ -197,6 +249,19 @@ export function getKnownModelContextWindow(model: string): number | undefined { return matchModel(MODEL_CONTEXT_WINDOW_MATCHERS, model) } +const MODEL_MAX_OUTPUT_TOKEN_MATCHERS = buildModelMatchers(MODEL_MAX_OUTPUT_TOKENS) +const OPEN_WEIGHT_MAX_OUTPUT_TOKEN_MATCHERS = buildModelMatchers(OPEN_WEIGHT_MAX_OUTPUT_TOKENS) + +export function getKnownModelMaxOutputTokens( + provider: AIProvider, + model: string +): number | undefined { + return ( + matchModel(MODEL_MAX_OUTPUT_TOKEN_MATCHERS, model) ?? + (provider === 'customai' ? undefined : matchModel(OPEN_WEIGHT_MAX_OUTPUT_TOKEN_MATCHERS, model)) + ) +} + /** Trim/compaction logic needs a number; assume a conservative window when unknown. */ export const ASSUMED_CONTEXT_WINDOW = 128000 diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 242f6cb329..9915e9c42f 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -374,7 +374,8 @@ const ANTHROPIC_ALWAYS_THINKING = /fable|mythos/ /** * Disable token to forward when the user explicitly turns reasoning off on a * model that reasons *by default* — omitting the field would silently keep - * the default-on behavior. Undefined means omission is the correct off. + * the default-on behavior. Undefined means omission is the correct off. Every + * token is `'none'`: `requestsReasoning` reads that value as off. */ export function explicitOffToken(provider: AIProvider, model: string): ReasoningEffort | undefined { switch (reasoningProviderFamily(provider, model)) { @@ -434,6 +435,20 @@ export function resolveRequestReasoning( return undefined } +/** + * Whether a request carrying `effort`, as `resolveRequestReasoning` resolves it, + * has the model reason. An effort sent to a model the registry does not mark as + * reasoning-capable answers no: an explicit choice is always sent, but that does + * not make the model think. + */ +export function requestsReasoning( + provider: AIProvider, + model: string, + effort: ReasoningEffort | undefined +): boolean { + return !!effort && effort !== 'none' && supportsReasoning(provider, model) +} + export type ReasoningApiKind = 'anthropic' | 'responses' | 'completions' | 'deepseek' | 'mistral' /** diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 3957f47b74..05159e3057 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -21,6 +21,7 @@ getKnownModelContextWindow, getModelContextWindowFromTable } from '../copilot/modelConfig' + import { resolveRequestReasoning } from '../copilot/reasoningRegistry' import { supportsAutocomplete } from '../copilot/utils' import TestAiKey from '../copilot/TestAIKey.svelte' import Label from '../Label.svelte' @@ -650,7 +651,8 @@ min={1} max={2_000_000} getDefault={(provider, model) => ({ - tokens: getModelMaxTokens(provider, model), + // What a chat sends at the default effort; the budget depends on it. + tokens: getModelMaxTokens(provider, model, resolveRequestReasoning({ provider, model })), assumed: false })} /> From 02006f364a4217e967435d9e71d8617c03b65413 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 22 Sep 2026 13:03:30 +0200 Subject: [PATCH 071/112] fix: keep a cancel that lands after a worker last read its queue row (#11246) * fix: keep a cancel that lands after a worker last read its queue row Co-Authored-By: Claude Opus 5 * fix: take the cancellation from the queue row the completion deletes Co-Authored-By: Claude Opus 5 * test: wait for the completion to block on the row instead of a timer Co-Authored-By: Claude Opus 5 * refactor: settle a missed cancel from the deleted queue row alone Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...dde0be9e0d192e0aee224bff78d8bd2ccbd8b.json | 16 ++ ...99c2e1e9ca0ed33077ade8d7560e7cc21fa06.json | 22 --- ...933eeff7396430607c02cbd0cc682d5c34d61.json | 23 +++ ...f86505bc499320e08d1aad15a9a82c3823c0b.json | 28 +++ backend/tests/perpetual_cancel_race.rs | 183 ++++++++++++++++++ backend/windmill-queue/src/jobs.rs | 76 ++++++-- 6 files changed, 310 insertions(+), 38 deletions(-) create mode 100644 backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json delete mode 100644 backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json create mode 100644 backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json create mode 100644 backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json create mode 100644 backend/tests/perpetual_cancel_race.rs diff --git a/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json b/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json new file mode 100644 index 0000000000..0bc09d9430 --- /dev/null +++ b/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, canceled_reason = $3 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b" +} diff --git a/backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json b/backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json deleted file mode 100644 index 8a4b957c2a..0000000000 --- a/backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06" -} diff --git a/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json b/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json new file mode 100644 index 0000000000..9930e0494f --- /dev/null +++ b/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "canceled!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61" +} diff --git a/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json b/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json new file mode 100644 index 0000000000..b11e41fe89 --- /dev/null +++ b/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "canceled_reason", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b" +} diff --git a/backend/tests/perpetual_cancel_race.rs b/backend/tests/perpetual_cancel_race.rs new file mode 100644 index 0000000000..df239eb7ab --- /dev/null +++ b/backend/tests/perpetual_cancel_race.rs @@ -0,0 +1,183 @@ +//! A cancel reaches the queue row first and the worker only later: one landing after the worker's +//! last read of that row completes the run carrying no `canceled_by` of its own, and a perpetual +//! script must still stop rather than restart as if nothing had been cancelled. + +use serde_json::json; +use sqlx::{types::Json, Pool, Postgres}; +use uuid::Uuid; +use windmill_queue::{add_completed_job, get_mini_completed_job}; + +const W_ID: &str = "test-workspace"; +const PATH: &str = "u/test-user/loop"; + +async fn start_perpetual_run(db: &Pool) -> anyhow::Result { + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, \ + language, lock, restart_unless_cancelled) \ + VALUES ($1, 1, $2, '', '', 'echo', 'test-user', 'bash', '', true)", + ) + .bind(W_ID) + .bind(PATH) + .execute(db) + .await?; + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \ + permissioned_as_email, kind, runnable_id, runnable_path, script_lang, tag, args, \ + visible_to_owner) \ + VALUES ($1, $2, 'test-user', now(), 'u/test-user', 'test@windmill.dev', 'script', 1, \ + $3, 'bash', 'bash', '{\"n\": 1}', true)", + ) + .bind(id) + .bind(W_ID) + .bind(PATH) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag) \ + VALUES ($1, $2, now(), true, now(), 'bash')", + ) + .bind(id) + .bind(W_ID) + .execute(db) + .await?; + Ok(id) +} + +/// Waits for another connection to be stuck on the queue row of `id`, which is where the +/// completion's delete lands while the cancel is uncommitted. +async fn wait_until_blocked_on(db: &Pool, id: Uuid) -> anyhow::Result<()> { + for _ in 0..100 { + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_locks l JOIN v2_job_queue q \ + ON q.ctid = ('(' || l.page || ',' || l.tuple || ')')::tid \ + WHERE NOT l.granted AND l.locktype = 'tuple' AND q.id = $1) \ + OR EXISTS (SELECT 1 FROM pg_stat_activity \ + WHERE wait_event_type = 'Lock' AND query LIKE '%v2_job_queue%')", + ) + .bind(id) + .fetch_one(db) + .await?; + if blocked { + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + anyhow::bail!("the completion never reached the delete, so the race was not exercised") +} + +/// The same cancel, one step earlier: still being written when the completion reads the queue row, +/// which reads it without a lock. Only the delete that follows waits for the writer. +#[sqlx::test(fixtures("base"))] +async fn a_cancel_still_being_written_is_kept(db: Pool) -> anyhow::Result<()> { + let running = start_perpetual_run(&db).await?; + let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap(); + + let mut cancel = db.begin().await?; + sqlx::query( + "UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1", + ) + .bind(running) + .execute(&mut *cancel) + .await?; + + let completing = tokio::spawn({ + let db = db.clone(); + async move { + add_completed_job( + &db, + &job, + true, + false, + Json(&json!("done")), + None, + 0, + None, + false, + None, + false, + ) + .await + } + }); + // Committing on a timer would let a slow completion read the cancel through the insert + // instead, leaving the half this test is for unexercised and green. Waiting for the delete to + // block on the row is what puts it there. + wait_until_blocked_on(&db, running).await?; + cancel.commit().await?; + completing.await??; + + let (status, canceled_by): (String, Option) = + sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1") + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(status, "canceled", "the run is recorded as canceled"); + assert_eq!(canceled_by.as_deref(), Some("test-user")); + + let queued: Vec = sqlx::query_scalar( + "SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2", + ) + .bind(W_ID) + .bind(PATH) + .fetch_all(&db) + .await?; + assert!( + queued.is_empty(), + "a canceled perpetual run queues no next one: {queued:?}" + ); + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn a_cancel_landing_after_the_worker_read_its_row_is_kept( + db: Pool, +) -> anyhow::Result<()> { + let running = start_perpetual_run(&db).await?; + sqlx::query( + "UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1", + ) + .bind(running) + .execute(&db) + .await?; + + let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap(); + add_completed_job( + &db, + &job, + true, + false, + Json(&json!("done")), + None, + 0, + // What the worker carries when the cancel landed after it last read the row. + None, + false, + None, + false, + ) + .await?; + + let (status, canceled_by): (String, Option) = + sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1") + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(status, "canceled", "the run is recorded as canceled"); + assert_eq!(canceled_by.as_deref(), Some("test-user")); + + let queued: Vec = sqlx::query_scalar( + "SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2", + ) + .bind(W_ID) + .bind(PATH) + .fetch_all(&db) + .await?; + assert!( + queued.is_empty(), + "a canceled perpetual run queues no next one: {queued:?}" + ); + Ok(()) +} diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 9eae01c476..50eb9c8b1f 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1285,7 +1285,25 @@ async fn commit_completed_job( } let mut _skip_downstream_error_handlers = false; - tx = delete_job(tx, &job_id).warn_after_seconds(10).await?; + let (ntx, canceled_at_delete) = delete_job(tx, &job_id).warn_after_seconds(10).await?; + tx = ntx; + // `canceled_by` is only what the worker last read from the queue row. Deleting the row waits + // for a cancel still being written, so the row it removed is the final word on whether this + // job was canceled. + if canceled_by.is_none() { + if let Some(canceled) = canceled_at_delete { + sqlx::query!( + "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, \ + canceled_reason = $3 WHERE id = $1", + job_id, + canceled.username, + canceled.reason, + ) + .execute(&mut *tx) + .warn_after_seconds(10) + .await?; + } + } // tracing::error!("3 {:?}", start.elapsed()); if completed_job.is_flow_step() { @@ -1611,6 +1629,22 @@ async fn restart_job_if_perpetual_inner( }; if restart { + // Not `canceled_by`: a worker reads the queue row on a widening interval, up to every 5s + // once a job has run for a minute, so a cancel landing after its last read reaches a + // completion carrying none. `commit_completed_job` records it from the queue row it + // deletes, so the completed row is what says whether this loop was stopped. + let canceled = sqlx::query_scalar!( + "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed \ + WHERE id = $1 AND workspace_id = $2", + queued_job.id, + &queued_job.workspace_id + ) + .fetch_optional(db) + .await? + .unwrap_or(false); + if canceled { + return Ok(()); + } let tx = PushIsolationLevel::IsolatedRoot(db.clone()); // perpetual jobs can run one job per 10s max. If the job was faster than 10s, schedule the next one with the appropriate delay @@ -5153,34 +5187,44 @@ async fn extract_result_from_job_result( } } +/// Also reports the cancellation the deleted row carried, if any. Unlike a plain read of the queue +/// row, this waits for a cancel that is still being written, so it is the last word on one. pub async fn delete_job<'c>( mut tx: Transaction<'c, Postgres>, job_id: &Uuid, -) -> windmill_common::error::Result> { +) -> windmill_common::error::Result<(Transaction<'c, Postgres>, Option)> { #[cfg(feature = "prometheus")] if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { QUEUE_DELETE_COUNT.inc(); } otel_incr_queue_delete_count(); - let job_removed = - sqlx::query_scalar!("DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1", job_id,) - .fetch_optional(&mut *tx) - .await; + let job_removed = sqlx::query!( + "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason", + job_id, + ) + .fetch_optional(&mut *tx) + .await; - if let Err(job_removed) = job_removed { - tracing::error!( - "Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling" - ); - } else { - let job_removed = job_removed.unwrap().flatten().unwrap_or(0); - if job_removed != 1 { - tracing::error!("Job {job_id} could not be deleted, returned not 1: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"); + let canceled = match &job_removed { + Err(job_removed) => { + tracing::error!( + "Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling" + ); + None } - } + Ok(None) => { + tracing::error!("Job {job_id} could not be deleted, no row was removed. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"); + None + } + Ok(Some(row)) => row.canceled_by.as_ref().map(|username| CanceledBy { + username: Some(username.clone()), + reason: row.canceled_reason.clone(), + }), + }; tracing::debug!("Job {job_id} deleted"); - Ok(tx) + Ok((tx, canceled)) } pub async fn job_is_complete(db: &DB, id: Uuid, w_id: &str) -> error::Result { From 732214361f36eb07d65854118bd053c96ce681fb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 22 Sep 2026 13:17:58 +0200 Subject: [PATCH 072/112] fix: stop the raw app editor stealing focus in ai sessions (#11287) * fix: preserve raw app editor focus during ai file updates * fix: sync ai file edits through the existing files effect * docs: explain raw app file synchronization dependency * fix: avoid echoing raw app files back into the editor * fix: synchronize raw app file operations through one effect * fix: stop session draft sync from re-applying the editor's own edits Co-Authored-By: Claude Opus 5 (1M context) * fix: open raw app files without taking focus and follow idle ai edits Co-Authored-By: Claude Opus 5 (1M context) * fix: keep selecting raw app documents that live outside the app files Co-Authored-By: Claude Opus 5 (1M context) * fix: tie a pending raw app editor focus request to the picked file Co-Authored-By: Claude Opus 5 (1M context) * chore: pin ui_builder to 60b6904 Co-Authored-By: Claude Opus 5 (1M context) * fix: always send explicit raw app file replacements to the editor Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Guilhem Lemouel Co-authored-by: Claude Opus 5 (1M context) --- frontend/scripts/ui_builder_artifact.json | 4 +- .../components/raw_apps/RawAppEditor.svelte | 106 ++++++++++++------ .../sessions/useUserDraftSync.svelte.ts | 9 +- 3 files changed, 79 insertions(+), 40 deletions(-) diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 87ce066c87..68c1c558b1 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "1522e43", - "sha256": "0c44db3a52e02e6530860d0e9e9031fcdad7a09a98897b6c97e6f522c1d4754c" + "version": "60b6904", + "sha256": "85b68e8418c97d96f3a419b3e345ef8a1987976943b8439a7df841f9217a9b34" } diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 9a5eb001ec..78d3ba8102 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -566,7 +566,10 @@ // The only writer of `activeTabId`, `selectedRunnable` and `selectedDocument`. // Everything switches through here, so the three can't disagree: one left // stale marks two sidebar rows selected, or leaves a tab over an empty pane. - function select(next: EditorSelection, opts?: { force?: boolean; notifyIframe?: boolean }): void { + function select( + next: EditorSelection, + opts?: { force?: boolean; notifyIframe?: boolean; focus?: boolean } + ): void { if (next.kind === 'preview') { // In split mode Preview is always shown on the right, so selecting it is // a no-op (would collapse the left pane). `force` lets closeTab fall back @@ -575,21 +578,23 @@ activeTabId = PREVIEW_TAB_ID selectedRunnable = undefined selectedDocument = undefined + iframeFocusPending = undefined return } if (next.kind === 'file') { activeTabId = ensureFileTab(next.path) selectedRunnable = undefined selectedDocument = next.path - if (opts?.notifyIframe !== false) openInIframe(next.path) + if (opts?.notifyIframe !== false) openInIframe(next.path, opts?.focus) return } activeTabId = ensureRunnableTab(next.key) selectedDocument = undefined selectedRunnable = next.key + iframeFocusPending = undefined } - function activateTab(id: string, opts?: { force?: boolean }) { + function activateTab(id: string, opts?: { force?: boolean; focus?: boolean }) { if (!tabs.some((t) => t.id === id)) return select(selectionOfTab(id), opts) } @@ -602,13 +607,20 @@ closeTab(runnableTabId(key)) } - // Ask the UI Builder iframe to open a document. `populateFiles` replays - // `iframeDocument` on iframe load, so record it even when the iframe isn't - // ready yet (the postMessage is then skipped). - function openInIframe(path: string) { + // Ask the UI Builder iframe to open a document, taking the keyboard only when + // the user picked it. For a file whose current content the iframe doesn't hold + // yet, `populateFiles` opens `iframeDocument` in the same message as that + // content: opening first errors on a new file and races the write on a changed + // one (the iframe applies the edit twice). It also opens it on iframe load. + // Documents outside `files` (wmill.ts, ui/, node_modules/) never go through it. + function openInIframe(path: string, focus = false) { iframeDocument = path - if (iframeLoaded) { - iframe?.contentWindow?.postMessage({ type: 'selectFile', path }, '*') + const content = files?.[path] + if (iframeLoaded && (content === undefined || iframeFiles?.[path] === content)) { + iframeFocusPending = undefined + iframe?.contentWindow?.postMessage({ type: 'selectFile', path, focus }, '*') + } else { + iframeFocusPending = focus ? path : undefined } } @@ -730,6 +742,7 @@ summary = update.summary } if (update.files !== undefined) { + iframeFiles = undefined files = update.files } if (update.runnables !== undefined) { @@ -810,6 +823,17 @@ } let iframeLoaded = $state(false) // @hmr:keep + // The files the iframe holds: last posted to it, or last reported by it. Explicit + // replacements (history, YAML) clear it so they are always sent: the iframe can + // hold edits it hasn't reported yet, which a skip would leave in place. + let iframeFiles: Record | undefined + // The document the user picked that `openInIframe` left for `populateFiles` to + // open. It takes the keyboard only if it is still the document being opened. + let iframeFocusPending: string | undefined + // Last keystroke or click in the editor, or edit reported by the iframe. AI + // edits only move the editor to their file once the user has paused this long. + let lastUserInputAt = 0 + const USER_IDLE_MS = 3000 // Briefly drops the `setActiveDocument` echo VS Code fires while we're // pushing the initial file set — the iframe auto-opens a default editor // during boot which we don't want to treat as a user-driven activation. @@ -857,6 +881,10 @@ function populateFiles() { if (files) { + // `files` is reassigned with unchanged content (the session draft sync, history + // restores, the iframe's own reports). Re-sending it makes the iframe reopen its + // document and rewrite the files under the user's cursor. + if (deepEqual(files, iframeFiles)) return suppressSetActiveDocument = true if (suppressTimer !== undefined) clearTimeout(suppressTimer) suppressTimer = setTimeout(() => { @@ -875,10 +903,14 @@ } } function setFilesInIframe(newFiles: Record) { + const target = iframe?.contentWindow + if (!target) return + iframeFiles = { ...newFiles } + iframeFocusPending = undefined const files = Object.fromEntries( Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/')) ) - iframe?.contentWindow?.postMessage( + target.postMessage( { type: 'setFiles', files: files @@ -889,14 +921,20 @@ function setFilesAndSelectInIframe(newFiles: Record, pathToSelect: string) { iframeDocument = pathToSelect + const target = iframe?.contentWindow + if (!target) return + iframeFiles = { ...newFiles } + const focus = iframeFocusPending === pathToSelect + iframeFocusPending = undefined const files = Object.fromEntries( Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/')) ) - iframe?.contentWindow?.postMessage( + target.postMessage( { type: 'setFilesAndSelect', files: files, - pathToSelect: pathToSelect + pathToSelect: pathToSelect, + focus }, '*' ) @@ -1012,14 +1050,15 @@ return frontendFiles }, setFrontendFile: (path, content): LintResult => { - console.log('setting frontend file', path, content) + console.log('setting frontend file', path, `${content.length} chars`) if (!files) { files = {} } files[path] = content - // Combined setFilesAndSelect avoids a race, so let it do the telling. - select({ kind: 'file', path }, { notifyIframe: false }) - setFilesAndSelectInIframe(files, path) + // Follow the AI to the file it edits, but never pull a user who is editing + // off their file. The files effect sends the content. + if (Date.now() - lastUserInputAt < USER_IDLE_MS) ensureFileTab(path) + else select({ kind: 'file', path }) return lint() }, deleteFrontendFile: (path) => { @@ -1027,7 +1066,6 @@ files = {} } delete files[path] - setFilesInIframe(files) }, listBackendRunnables: () => { return Object.entries(runnables).map(([key, runnable]) => ({ @@ -1399,9 +1437,11 @@ if (e.data.type === 'setFiles') { // Normalize Windows-style path separators to Linux-style const normalizedFiles = normalizeFilePaths(e.data.files) + iframeFiles = { ...normalizedFiles } // Only mark pending changes if files actually changed (ignore echo from setFilesInIframe) if (!deepEqual(files, normalizedFiles)) { files = normalizedFiles + lastUserInputAt = Date.now() historyManager.markPendingChanges() } } else if (e.data.type === 'getBundle') { @@ -2015,6 +2055,7 @@ } $effect(() => { iframe?.addEventListener('load', () => { + iframeFiles = undefined iframeLoaded = true }) }) @@ -2080,6 +2121,7 @@ } }) $effect(() => { + // populateFiles must track file entries so in-place AI edits trigger synchronization. iframe && iframeLoaded && files && populateFiles() }) $effect(() => { @@ -2108,7 +2150,7 @@ // future `FileExplorer` caller feeds folder paths back in. function handleSelectPath(path: string) { if (!path || path.endsWith('/')) return - select({ kind: 'file', path }) + select({ kind: 'file', path }, { focus: true }) } // Track previous values for change detection @@ -2202,19 +2244,12 @@ data: RawAppData }) { try { + iframeFiles = undefined files = structuredClone($state.snapshot(entry.files)) runnables = structuredClone($state.snapshot(entry.runnables)) summary = entry.summary replaceData(structuredClone($state.snapshot(entry.data))) - // If the open document survives into the new files, use the combined message - if (iframeDocument && isOpenableDocument(iframeDocument)) { - // Use combined setFilesAndSelect message to avoid race condition - setFilesAndSelectInIframe(entry.files, iframeDocument) - } else { - // Otherwise just set files normally - setFilesInIframe(entry.files) - } populateRunnables() } catch (error) { console.error('Failed to apply entry:', error) @@ -2351,7 +2386,12 @@ gateJobIds={false} extraSourceWindow={() => externalPreviewWindow} /> -
    +
    (lastUserInputAt = Date.now())} + onpointerdowncapture={() => (lastUserInputAt = Date.now())} + class="max-h-full overflow-hidden h-full min-h-0 flex flex-col" +> files, - (newFiles) => { - files = newFiles - setFilesInIframe(newFiles ?? {}) - } - } + bind:files onSelectPath={handleSelectPath} onSelectRunnable={(key) => select({ kind: 'runnable', key })} onDeleteRunnable={deleteRunnable} @@ -2495,7 +2529,7 @@ activateTab(id)} + onSelect={(id) => activateTab(id, { focus: true })} onClose={(id) => closeTab(id)} onReorder={(next) => reorderTabs(next)} > @@ -2580,7 +2614,7 @@ activateTab(id)} + onSelect={(id) => activateTab(id, { focus: true })} onClose={(id) => closeTab(id)} onReorder={(next) => reorderTabs(next)} > diff --git a/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts index 7e89d3b945..64f7f7e6c2 100644 --- a/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts +++ b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts @@ -94,7 +94,10 @@ export function useUserDraftSync(opts: UserDraftSyncOptions): void // re-apply only advances the sig, never reverts an edit or fires a save. if (sig === lastInboundSig) return lastInboundSig = sig - codec.applyDraftToStore(incoming) + // Copies both ways: a store sharing proxies with the draft makes every local + // edit re-fire this effect as if a draft came in, reassigning the store on + // each keystroke. + codec.applyDraftToStore($state.snapshot(incoming) as Draft) }) }) @@ -125,7 +128,9 @@ export function useUserDraftSync(opts: UserDraftSyncOptions): void untrack(() => { const current = UserDraft.get(codec.itemKind, path, { workspace }) if (current && codec.sig(current) === sig) return - const toSave = codec.storeToDraft(current) ?? draft + const toSave = $state.snapshot(codec.storeToDraft(current) ?? draft) as Draft + // Our own write comes back through the handle; the inbound side must skip it. + lastInboundSig = codec.sig(toSave) UserDraft.save(codec.itemKind, path, toSave, { workspace }) }) } From 841f7e1436ca1217ffde16f5d70c9b49ef60be13 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 22 Sep 2026 14:13:11 +0200 Subject: [PATCH 073/112] fix(cli): sync workspace dependency files on Windows and fix CLI tests (#11288) * fix(cli): sync workspace dependency files on Windows Co-Authored-By: Claude Opus 5 (1M context) * test(cli): pin workspace dependency path parsing on both separators Co-Authored-By: Claude Opus 5 (1M context) * fix(cli): classify workspace dependency files on either separator Co-Authored-By: Claude Opus 5 (1M context) * test(cli): check out the fork deploy clone without autocrlf Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- cli/src/commands/sync/sync.ts | 4 ++-- cli/src/types.ts | 2 +- cli/src/utils/script_common.ts | 4 +++- cli/src/utils/utils.ts | 3 ++- cli/test/gitsync_deploy_branch_config.test.ts | 5 +++- cli/test/sync_pull_push.test.ts | 12 ++++++---- cli/test/workspace_deps_path_unit.test.ts | 23 +++++++++++++++++++ 7 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 cli/test/workspace_deps_path_unit.test.ts diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 32cff4a3cb..6185decc5a 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1153,7 +1153,7 @@ export function ZipFSElement( ? "script" : p.endsWith(".resource.json") ? "resource" - : p.startsWith("dependencies/") + : isWorkspaceDependencies(p) ? "dependencies" : "other"; @@ -2752,7 +2752,7 @@ export async function compareDynFSElement( if (skipMetadata) { continue; } - if (k.startsWith("dependencies/")) { + if (isWorkspaceDependencies(k)) { if (!workspaceDependenciesPathToLanguageAndFilename(k)) { log.warn(`Skipping unrecognized workspace dependencies file: ${k}`); continue; diff --git a/cli/src/types.ts b/cli/src/types.ts index 5713d06390..866b2be21e 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -391,7 +391,7 @@ export function getTypeStrFromPath( if (isSharedLockPath(p)) { return "shared_lock"; } - if (p.startsWith("dependencies" + SEP)) { + if (isWorkspaceDependencies(p)) { return "workspace_dependencies"; } if (isFileResource(p) || isFilesetResource(p)) { diff --git a/cli/src/utils/script_common.ts b/cli/src/utils/script_common.ts index d804318491..611268c27c 100644 --- a/cli/src/utils/script_common.ts +++ b/cli/src/utils/script_common.ts @@ -45,7 +45,9 @@ export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [ ] as const; export function workspaceDependenciesPathToLanguageAndFilename(path: string): { name: string | undefined, language: ScriptLanguage } | undefined { - const relativePath = path.replace("dependencies/", ""); + // Sync paths carry the platform separator, while a set's name is always + // forward-slashed. + const relativePath = path.replaceAll("\\", "/").replace("dependencies/", ""); for (const { filename, language } of workspaceDependenciesLanguages) { if (relativePath.endsWith(filename)) { return { diff --git a/cli/src/utils/utils.ts b/cli/src/utils/utils.ts index 0801c03911..526dffbde4 100644 --- a/cli/src/utils/utils.ts +++ b/cli/src/utils/utils.ts @@ -223,8 +223,9 @@ export function isRawAppFile(path: string): boolean { return isRawAppPath(path); } +/** Sync paths carry the platform separator: `dependencies\x` on Windows. */ export function isWorkspaceDependencies(path: string): boolean { - return path.startsWith("dependencies/"); + return path.startsWith("dependencies/") || path.startsWith("dependencies\\"); } export function printSync(input: string | Uint8Array) { diff --git a/cli/test/gitsync_deploy_branch_config.test.ts b/cli/test/gitsync_deploy_branch_config.test.ts index 39c435d872..eeab7a0120 100644 --- a/cli/test/gitsync_deploy_branch_config.test.ts +++ b/cli/test/gitsync_deploy_branch_config.test.ts @@ -63,7 +63,10 @@ test("git-sync fork deploy follows the fork branch's wmill.yaml", async () => { git(seed, "commit", "-m", "dedupe"); git(seed, "push", "origin", forkBranch); - git(work, "clone", `file://${bare}`, "."); + // Git for Windows defaults to autocrlf=true, which checks the pulled LF + // files out as CRLF; the deploy's pull would rewrite every one of them + // and the clean-tree assertion below would read that as a change. + git(work, "clone", "-c", "core.autocrlf=false", `file://${bare}`, "."); const deployed = await backend.runCLICommand( [ "sync", diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 24c4a282a3..fa52510e6c 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -2185,18 +2185,20 @@ describe("settings sync", () => { const files = await listFilesRecursive(tempDir); expect(files).toContain("settings.yaml"); - // Read and modify a safe setting (webhook URL) + // Read and modify a safe setting (webhook URL). The server resolves the + // webhook host when it is saved and refuses one that does not resolve to a + // public address, so this must be a real public hostname. + const webhook = "https://example.com/hook"; const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); let modifiedSettings: string; if (settingsContent.includes("webhook:")) { modifiedSettings = settingsContent.replace( /webhook:.*/, - 'webhook: "https://test-webhook.example.com/hook"' + `webhook: "${webhook}"` ); } else { - modifiedSettings = - settingsContent + '\nwebhook: "https://test-webhook.example.com/hook"\n'; + modifiedSettings = settingsContent + `\nwebhook: "${webhook}"\n`; } await writeFile(`${tempDir}/settings.yaml`, modifiedSettings, "utf-8"); @@ -2213,7 +2215,7 @@ describe("settings sync", () => { ); expect(apiResp.status).toEqual(200); const settingsData = await apiResp.json(); - expect(settingsData.webhook).toEqual("https://test-webhook.example.com/hook"); + expect(settingsData.webhook).toEqual(webhook); }); }); }); diff --git a/cli/test/workspace_deps_path_unit.test.ts b/cli/test/workspace_deps_path_unit.test.ts new file mode 100644 index 0000000000..45d725c6e5 --- /dev/null +++ b/cli/test/workspace_deps_path_unit.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { isWorkspaceDependencies } from "../src/utils/utils.ts"; +import { workspaceDependenciesPathToLanguageAndFilename } from "../src/utils/script_common.ts"; + +// Sync paths carry the platform separator. A Windows path parsed to a different +// set name pushes the file to a set the workspace does not have. +test("workspace dependency paths parse the same with either separator", () => { + for (const sep of ["/", "\\"]) { + expect(isWorkspaceDependencies(`dependencies${sep}requirements.in`)).toBe(true); + expect( + workspaceDependenciesPathToLanguageAndFilename(`dependencies${sep}requirements.in`), + ).toEqual({ name: undefined, language: "python3" }); + expect( + workspaceDependenciesPathToLanguageAndFilename(`dependencies${sep}team.package.json`), + ).toEqual({ name: "team", language: "bun" }); + expect( + workspaceDependenciesPathToLanguageAndFilename( + `dependencies${sep}team${sep}python.requirements.in`, + ), + ).toEqual({ name: "team/python", language: "python3" }); + } + expect(isWorkspaceDependencies("f\\dependencies\\requirements.in")).toBe(false); +}); From 25c8bfaab412d440e51be92af61066af025d334b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 22 Sep 2026 14:13:32 +0200 Subject: [PATCH 074/112] chore(main): release 1.817.0 (#11268) * chore(main): release 1.817.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 24 +++ backend/Cargo.lock | 166 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- chat-sdk/package-lock.json | 4 +- chat-sdk/package.json | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 22 files changed, 154 insertions(+), 130 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 438887d2eb..6dc5574962 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.816.0" + ".": "1.817.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e039fb45..595426d619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [1.817.0](https://github.com/windmill-labs/windmill/compare/v1.816.0...v1.817.0) (2026-09-22) + + +### Features + +* add workspace mentions to AI composer ([#11264](https://github.com/windmill-labs/windmill/issues/11264)) ([e7ba595](https://github.com/windmill-labs/windmill/commit/e7ba5954b69514d72c140f9b1e697fb2236df23e)) +* allow workspace-scoped dynamic custom tags ([#11283](https://github.com/windmill-labs/windmill/issues/11283)) ([efa7a0a](https://github.com/windmill-labs/windmill/commit/efa7a0a70af43f8827542059dd450326a9b88d62)) +* check dynamic worker tags on the tag they resolve to ([#11271](https://github.com/windmill-labs/windmill/issues/11271)) ([69fafb9](https://github.com/windmill-labs/windmill/commit/69fafb9262f90184e6c2dc9a84f1c3feeb9d9638)) +* deploy from the UI to a workspace on another instance ([#11245](https://github.com/windmill-labs/windmill/issues/11245)) ([4d12ea4](https://github.com/windmill-labs/windmill/commit/4d12ea461454f89367bd91daa7e8c39a89035507)) + + +### Bug Fixes + +* add missing credential fields to settings redaction lists ([#11272](https://github.com/windmill-labs/windmill/issues/11272)) ([2a40f25](https://github.com/windmill-labs/windmill/commit/2a40f25bda81d41b9f578338c85656744f59ebfc)) +* apply SSRF validation to workspace webhook URLs ([#11285](https://github.com/windmill-labs/windmill/issues/11285)) ([586699c](https://github.com/windmill-labs/windmill/commit/586699c4938b5203920a43e69a706310078b93f5)) +* apply token path scopes to the native trigger list ([#11281](https://github.com/windmill-labs/windmill/issues/11281)) ([faf7b22](https://github.com/windmill-labs/windmill/commit/faf7b22be0827738c90c1f3ed6cd3e214d6c63d3)) +* check workflow-as-code task tags against CUSTOM_TAGS ([#11273](https://github.com/windmill-labs/windmill/issues/11273)) ([2699d50](https://github.com/windmill-labs/windmill/commit/2699d5065ea3dfe985636733fb6022ca23187291)) +* clear the impersonation token from session storage on logout ([#11274](https://github.com/windmill-labs/windmill/issues/11274)) ([9936673](https://github.com/windmill-labs/windmill/commit/9936673ee62fb6dd929a654a6138aeca687c0fb7)) +* enforce token path scopes on GET /raw_apps/list ([#11284](https://github.com/windmill-labs/windmill/issues/11284)) ([9ad2c91](https://github.com/windmill-labs/windmill/commit/9ad2c91ddb661ab60ada9ec4039ef8d025602969)) +* keep a cancel that lands after a worker last read its queue row ([#11246](https://github.com/windmill-labs/windmill/issues/11246)) ([02006f3](https://github.com/windmill-labs/windmill/commit/02006f364a4217e967435d9e71d8617c03b65413)) +* size the AI chat output budget by model, not provider ([#11286](https://github.com/windmill-labs/windmill/issues/11286)) ([7d0f2ce](https://github.com/windmill-labs/windmill/commit/7d0f2ce3bf920a68f1ea22d59481d239947f1b51)) +* stop cutting DeepSeek chat turns off mid-thought ([#11282](https://github.com/windmill-labs/windmill/issues/11282)) ([5821700](https://github.com/windmill-labs/windmill/commit/58217006ece56bbaf0447af67cac487e72b928c7)) +* stop the raw app editor stealing focus in ai sessions ([#11287](https://github.com/windmill-labs/windmill/issues/11287)) ([7322143](https://github.com/windmill-labs/windmill/commit/732214361f36eb07d65854118bd053c96ce681fb)) + ## [1.816.0](https://github.com/windmill-labs/windmill/compare/v1.815.0...v1.816.0) (2026-09-21) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 82c625df67..e0bfce842b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -928,9 +928,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.3" +version = "1.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0baa720ebadea158c5bda642ac444a2af0cdf7bb66b46d1e4533de5d1f449d0" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" dependencies = [ "aws-credential-types", "aws-runtime", @@ -938,8 +938,8 @@ dependencies = [ "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -14792,7 +14792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -14880,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.816.0" +version = "1.817.0" dependencies = [ "async-stream", "async-trait", @@ -14914,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14927,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "argon2", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15090,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15107,7 +15107,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15133,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.816.0" +version = "1.817.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15143,7 +15143,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15160,7 +15160,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15182,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15205,7 +15205,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15221,7 +15221,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15265,7 +15265,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -15314,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15339,7 +15339,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15367,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15389,7 +15389,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15409,7 +15409,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15447,7 +15447,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15476,7 +15476,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.816.0" +version = "1.817.0" dependencies = [ "lazy_static", "serde", @@ -15488,7 +15488,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.816.0" +version = "1.817.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15512,7 +15512,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15526,7 +15526,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.816.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15564,7 +15564,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.816.0" +version = "1.817.0" dependencies = [ "chrono", "lazy_static", @@ -15578,7 +15578,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15597,7 +15597,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.816.0" +version = "1.817.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15704,7 +15704,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.816.0" +version = "1.817.0" dependencies = [ "chrono", "futures", @@ -15724,7 +15724,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.816.0" +version = "1.817.0" dependencies = [ "regex", "serde", @@ -15741,7 +15741,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15768,7 +15768,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "futures", @@ -15785,7 +15785,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.816.0" +version = "1.817.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15801,7 +15801,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -15822,7 +15822,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -15853,7 +15853,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "arc-swap", @@ -15878,7 +15878,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-stream", @@ -15913,7 +15913,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "futures", @@ -15931,7 +15931,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.816.0" +version = "1.817.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15940,7 +15940,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -15952,7 +15952,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -15964,7 +15964,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "gosyn", @@ -15976,7 +15976,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -15988,7 +15988,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -16000,7 +16000,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "nu-parser", @@ -16011,7 +16011,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16022,7 +16022,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16034,7 +16034,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16045,7 +16045,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16067,7 +16067,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -16079,7 +16079,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16093,7 +16093,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16110,7 +16110,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16123,7 +16123,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde", @@ -16135,7 +16135,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16169,7 +16169,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16185,7 +16185,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16199,7 +16199,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16238,7 +16238,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "const_format", @@ -16278,7 +16278,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.816.0" +version = "1.817.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16289,7 +16289,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16324,7 +16324,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16348,7 +16348,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16381,7 +16381,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16408,7 +16408,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16441,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16461,7 +16461,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16495,7 +16495,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16531,7 +16531,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16554,7 +16554,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16578,7 +16578,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -16602,7 +16602,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16637,7 +16637,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16665,7 +16665,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16709,7 +16709,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-once-cell", @@ -16827,7 +16827,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.816.0" +version = "1.817.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a911d57b81..74da8a4244 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.816.0" +version = "1.817.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.816.0" +version = "1.817.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index dcd846c0d8..ccba27bcc5 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.816.0" +version = "1.817.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.816.0" +version = "1.817.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.816.0" +version = "1.817.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.816.0" +version = "1.817.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 1eacbd24d3..0244b7b106 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.816.0" +version = "1.817.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 46c498de4c..0dd9f8620e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.816.0 + version: 1.817.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 7f0649a2ab..1e6cfdd474 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -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.816.0"; +export const VERSION = "v1.817.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index e657c139d7..ff470d4ffe 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.816.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.816.0", + "version": "1.817.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index a157a5175a..5655adc64e 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -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.816.0", + "version": "1.817.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 45eb4e941f..9728b6edd7 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -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.816.0"; +export const VERSION = "1.817.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 072729566c..611cd0c91f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.816.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.816.0", + "version": "1.817.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index f535994371..46db11793f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.816.0", + "version": "1.817.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", diff --git a/lsp/Pipfile b/lsp/Pipfile index 418c7a8f8e..79d2672f4a 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.816.0" +wmill = ">=1.817.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index a5c0d33b62..48f573bab1 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.816.0 + version: 1.817.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 9117e1bf7d..ad6c0e8bc1 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.816.0' + ModuleVersion = '1.817.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index b493b81c41..baab12d832 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.816.0" +version = "1.817.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index c72327a9a7..26441041a1 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.816.0", + "version": "1.817.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index afa9e10871..4badfa307b 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.816.0", + "version": "1.817.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 198c13b19c..2edd9ebec3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.816.0 +1.817.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index cb1c272906..e07061650d 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.816.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.816.0", + "version": "1.817.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index f0dffa300a..01bb378dcd 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.816.0", + "version": "1.817.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 7dec3d97717406d078499ea6509cdb00c89c3480 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:15:05 +0200 Subject: [PATCH 075/112] feat(db-manager): add a schema diagram view (#11030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(datatables): put a data table's connection under Postgres roles A data table backed by the instance database resolved to exactly one Postgres connection, `custom_instance_user`, for everyone who could reach it at all. There was no way to say this job reads, that one writes, this one never sees the salaries table. A data table role is now a real Postgres login on the cluster, defined once for the instance by a superadmin and named exactly as they named it. A script that declares `-- role analytics` connects as `analytics`, and Postgres decides what it may touch — grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`. A data table with no `permissions` block behaves exactly as before. Everything that opens a connection on someone's behalf goes through one chokepoint, `get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when there is none. The role logs in as itself — never `SET ROLE`, which a script could `RESET ROLE` its way out of. A fork's data table entry becomes a pointer at the workspace that governs it rather than a copy of it. The settings clone used to hand a fork a byte-identical entry naming the parent's database, which a fork admin could edit to grant themselves `admin` there; a pointer has nothing local to edit, and its tenants are evaluated as a member of the governing workspace, by email. `permissions` is stripped from the workspace export and ignored on import: tenants name principals of one workspace, and a settings push is not where an access decision should be made. Operations that see the whole database whatever the roles grant stay with the governing workspace's admins: editing the roles, a migration that declares none, and opening a replication stream for a Postgres trigger or capture. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): gate the paths that reach a whole database as admin Auditing what still resolved through the unchecked resolver turned up three that act for a caller and hand back the admin connection: `resolve_pg_source_checked` (behind schema export, the full-schema read, database creation, import and the forked-database drop), the connection test, and the schema snapshot a fork clone takes of its parent. On a data table under roles each let any workspace member — or a fork admin who is nobody in the governing workspace — read or copy the whole database whatever its roles grant. All three now require admin reach on the governing workspace. A dump taken under a restricted role would be a silently truncated copy rather than an error, so refusing is the only right answer for the copy paths. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): confine roles to the instance database, and stop a fork reaching the parent's bookkeeping A data table role is a login on Windmill's own Postgres. Nothing stopped a workspace admin putting a *resource-backed* data table under roles, at which point the executor dialled the host that resource names — one the admin chose — with the role's real cluster password, and `CONNECT` is granted to every registered instance database. Both ends now refuse: the permissions endpoint rejects the save, and the chokepoint refuses to substitute credentials on a non-instance entry rather than trusting the record it read. Two more places reached the governing database without answering to it. The initial-migration generator returned a `pg_dump` of the whole schema to any member. And the migration rename/delete cascade followed a fork's pointer into the parent, so a fork admin renaming or removing their own local entry relabelled or wiped the parent's `_wm_migrations` — after which the parent re-runs every migration from zero. The remote half is now skipped when the entry resolves into another workspace, which is also just correct: a fork renaming what it calls a data table changes nothing about the data table. Also: revoking a tenant now bounces the replication streams of every workspace holding an entry that resolves here, not only the governing one, so a fork's trigger stops rather than living on inside its open connection; the instance role catalog and the governing workspace's tenant lists are no longer returned to someone who cannot edit them; and the tenant rename dedup collapses non-adjacent duplicates, per role rather than once any role changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): fail loudly where a role or a pointer can be left half-recorded Three ways the feature could end up in a state nobody could see or undo. Creating a role writes the cluster first and the catalog second, but the catalog write was an `UPDATE` that matched nothing when the instance Postgres settings row was absent — leaving a live login with a password nobody recorded: invisible to the catalog, un-recreatable because the name is taken, and un-deletable because there is no entry to delete. It now errors, so the operation is retryable once the row is restored. Deleting a workspace only nulls the fork lineage; the data table entries pointing at it are left resolving to nothing. Sweeping them is not an option — turning a pointer back into a copy would hand each fork the database outright — so the delete now names the data tables it stranded, and resolving one says which workspace is missing rather than reporting a data table this workspace never had. `InstanceDatatableRole` derived `Debug` while holding a Postgres password; it is now hand-written so `{:?}` on the catalog cannot put a live credential in a log line. Adds the two branches the reviews found unpinned: a caller who is not a member of the governing workspace at all, and `NoIdentity` — the compatibility path for an agent worker that predates this and sends no job id. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): unbreak two operator messages and two comments that described other code The two strings this branch added for states an operator hits once — the catalog write that matched nothing, and the delete that stranded a pointer — were collapsed from their multi-line form with the indentation left in, so both rendered with a fourteen-space gap mid-sentence. `list_datatables` claimed to report a chain it cannot follow and then dropped it; it does drop it, and the comment now says why that is the right place to stay quiet. The non-superadmin check in `edit_datatable_config` was introduced as also covering references, which it does not and need not: `reference` is overwritten from the stored entry for every caller before the check runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): serialize role catalog mutations, and state each helper's authorization contract The catalog is one JSON document, so create, rename, enable and delete are all read-modify-write. Two concurrent creates read the same snapshot, both succeed in the cluster, and the second write drops the first — leaving a live Postgres login with a password nobody recorded, which is the exact state the delete path exists to prevent. Every mutation now runs in one transaction holding an advisory lock across the read, the cluster DDL and the write, so a lost update cannot happen and a failure rolls the whole thing back. The DDL helpers take that transaction rather than the pool, which is what makes the lock cover them. Their statements moved off `sqlx::raw_sql`: the simple protocol is only needed for genuinely multi-statement SQL, and its future is not `Send`, which an axum handler holding the transaction requires. Each of these is one statement anyway. The new cross-crate surface now says what callers must do. `read_role_catalog` returns plaintext credentials; `create`/`rename`/`set_login`/`drop_instance_role` and `converge_connect_grants` mutate cluster-wide state; `read_datatable_entry` reads a workspace's raw config. All of them are superadmin-gated by their current handlers, but nothing said so at the definition, which is where the next caller looks. Also: the roles table reloads after a failed login toggle instead of leaving it claiming a flip that did not land; the rename affordance is the design-system `Button`, not a raw one; and `resolve_datatable_pg_as_caller` drops a `role` parameter no caller ever filled — browsing resolves as the data table's default until the database manager grows a picker. Why role passwords stay a plain `String` while the instance user's password beside them is a `StringOrSecretRef`, asked three times across reviews: that one is a secret ref because an operator supplies it and may want it from their own backend, while these are minted here and never entered by anyone, so there is nothing for a ref to point at. Encrypting generated secrets at rest is a separate change that would take the replication password with it. Now said at the field. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): give the role catalog its own row, out of reach of the config machinery Putting it inside `custom_instance_pg_databases` was the wrong call, and it cost two ways. The catalog serializes a generated Postgres password per role, and that row is the operator-facing instance config, so the passwords reached `get_instance_config` and its YAML editor — a live cluster credential in a response body, a UI field and any log of either. Worse in the other direction: `to_settings_map` strips the catalog, so a full-row upsert of that key writes the row back without it and the catalog is gone, while the cluster keeps every login it described. `custom_instance_replication_pwd` is the precedent and says exactly why — a generated secret, written only by the server, never operator-authored, hidden so the config machinery cannot read, rewrite or drop it. The catalog is the same thing, so it now has the same shape: `datatable_roles`, in `HIDDEN_SETTINGS`, `PROTECTED_SETTINGS` and the agent-worker denylist. No redaction to keep in step with three code paths, and no way for a neighbouring write to take it out. Two races on the same shared documents. `edit_datatable_config` read the stored data tables outside its transaction and then wrote the whole `datatable` document, so a permissions save committing in between was silently rolled back; it now reads under `FOR UPDATE`. And `set_datatable_permissions` validated role ids against the catalog before opening its transaction, so a deletion in between let it write a deleted role back — including as the default, which every later job then fails on; it now holds the catalog lock and the settings row across validation and write. Completes the authorization contracts the previous commit claimed but did not finish: `read_datatable_entry` (which it named and missed), `resolve_governing_datatable`, whose whole job is to answer for a workspace the caller may not belong to, and `converge_connect_grants_with`, which had not inherited its wrapper's. Also the generic Python SDK reference: `_format_py_params` learned the bare `*` last time, but `extract_py_functions` is a second formatter and still rendered `datatable(name, role)`, so code written from that page passed a keyword-only argument positionally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all three handlers left it green — it pinned Postgres, not the code it was written for. It now drives `create_datatable_role` twice concurrently and asserts the catalog kept both names. Checked the way the last one should have been: removing the lock from the handler makes it fail with "wmtest_a_… is a live cluster login the catalog forgot". The contracts added last commit were stricter than this PR's own callers, which is worse than none — the next reader sees a rule already broken and learns to ignore it. `read_role_catalog` said superadmin-only while two of its four callers are open to any workspace member, and `converge_connect_grants` said superadmin while `set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the rule that actually holds is about the credential never reaching a response, log, audit record or export, not about who may call. They now say that. `read_datatable_entry` gets the same treatment rather than the one the earlier message claimed for it: it is the primitive every resolution goes through, so it is deliberately open, and what must not escape is `permissions` — it names the governing workspace's users, groups and folders. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): close the last ways a role or a pointer can be left pointing at nothing The raw settings readers hand back whatever is in the row, so moving the catalog into its own `global_settings` key protected the config machinery and left `GET /settings/global/datatable_roles` and the settings listing returning every live password. Both now filter that one key. The neighbouring `custom_instance_replication_pwd` has the same shape and is not touched here: it predates this and widening the fix to it is a decision about an operator workflow, not a consequence of this change. Three ways a save could leave something resolving to nothing: A permissioned data table could be moved to a PostgreSQL resource. The block was carried across as a server-owned field, the runtime refuses roles on a resource-backed table, so the save succeeded and every job afterwards failed. Refused instead — turning roles off first is one step, and it keeps discarding an access decision something somebody chose. Renaming a governing data table left every fork pointing at the old name: the data table disappears from their pickers and their jobs stop, with nothing in the renaming workspace to suggest why. The rename now follows into the pointers in the same transaction. Deleting one cannot be followed the same way, so it is reported instead — the response names what it stranded, the way deleting a workspace does, and the fork's own error already says which workspace is gone. Also: `ensure_instance_db_grant_options_unchecked` claimed superadmin while the permissions handler reaches it as a workspace admin (the same class fixed last commit, one instance missed); the role entry kept an `instance_config_schema` derive it no longer needs; `write_role_catalog` was the one writer of that table not stamping `updated_at`; and the concurrency test dropped its roles only on success — a failing run is exactly the one that creates them without recording them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * refactor(datatables): put the role catalog in its own table, not in global_settings Five findings across three rounds were all the same choice. A set of live Postgres credentials was living in `global_settings`, which has generic read, list, write, config-export and CLI round-trip paths that know nothing about what they carry: the passwords reached the instance config and its YAML editor, a full-row upsert of a neighbouring key erased the catalog, `GET /settings/global/{key}` and the settings listing returned them raw, and this round the redaction that fixed the last two turned `wmill instance push` into something that wipes every password — a fix breaking the assumption the previous fix made. `POST /settings/global/datatable_roles` could also empty it outside the lock. The approved plan offered a table or `global_settings`, so this is the other option it already allowed rather than a new design. `datatable_role` is a table: no generic settings path can read it, list it, export it, write it or round-trip it, so none of the five needs a guard. The redaction, the hidden/protected/agent-denylist entries and the JSON document all go with it. One row per role also removes the read-modify-write the concurrency work was about: two concurrent creates are two inserts, and the unique index on `name` is what settles a collision. The advisory lock stays for the one window rows do not cover — `CREATE ROLE` is invisible to another transaction until commit, so without it both creates pass their `pg_roles` check. Also from this round: rename mappings are checked against the configuration they claim to describe, since fork pointers are rewritten from them — a caller could otherwise submit `main -> missing` against an unchanged config and repoint every fork of `main` at a name nothing has, and `A -> B` plus `B -> C` moved what pointed at `A` all the way to `C`. And the warning naming forks a delete stranded reached the response but not the screen: both the data table settings save and the workspace delete now show it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): validate a rename against the save it describes, and re-check under the locks Three from the round, all about deciding on state that could already have moved. A permission save resolved the data table and checked it was instance-backed before taking any lock, then wrote under one. A config save committing in between could move the table onto a PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in which case the write targeted a key that no longer existed and reported success having changed nothing. It now re-resolves and re-checks on the locked state. Rename validation checked that the source existed before and the target existed after, which still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows onto a different data table, silently, because it keeps resolving. The rule is now the actual old-to-new key transition — a source may only survive if another rename took its name, and a target may only pre-exist if another rename freed it. That also stops two sources sharing one target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so a swap cannot be done one save at a time, and refusing it was a regression against main. The pointer cascade now runs in two passes through a temporary name, the way the migration cascade one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once from what it named before the save. The tenant mutators say what they are for: they write an access decision for any workspace named, with an arbitrary mutation, and exist for the transaction that frees or renames a principal. Editing a decision on purpose belongs in the permissions endpoint. Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole document, so a workspace whose pointers name something else is not rewritten to a byte-identical value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from the JSON document, one of which would emit `pwd`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): cascade on the leave route that is used, gate migrations before the admin connection, and drop a role atomically The tenant cascade on leaving went onto `/users/leave`. The UI and the generated client call `/workspaces/leave` — a different handler in a different crate with the same name — which deleted the membership and left `u/` in the tenant lists. Leaving and rejoining therefore restored the access the leave was supposed to end, and a later account taking the username would have inherited it. The regression test drives the route the client actually calls; without the fix it fails with "leaving kept the tenant". The migration endpoints authorized too late. `run_datatable_migrations` opened the data table's admin connection, created `_wm_migrations` and read it before reaching the per-migration role check — so with nothing pending, nothing was checked at all. Rollback returned before its check when nothing was applied, and the status endpoint had none. All three now ask, before any connection is opened, whether the caller can reach the data table as any role at all; which role a given migration runs as is still decided per migration, and by the executor after that. Deleting a role committed the cluster drop and the catalog row, then swept the tenant lists in separate transactions. A sweep failing part-way left workspaces naming a role nothing can connect as, while the retry answered `NotFound` because the catalog entry was already gone. The sweep now runs in the same transaction, so the drop, the row and every tenant list commit together. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse to copy a data table that is under roles pg_dump carries no roles and the import runs with --no-privileges, so a copied data table arrives owned by the admin connection with no GRANT for any role. The settings clone brings `permissions` across, so the fork's tenants pass Windmill's check, connect as the role they were given, and are denied by Postgres on everything: an entry that reads as configured and answers nothing. Refuse the copy — in the import endpoint before any data moves, and in the fork path the CLI takes. Replaying the source's owners and ACLs into the clone is what lifts this, and is a change of its own. Dropping `permissions` from the copy instead would be the unsafe half, since the copy holds the parent's rows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse the clone's database too, not only its data A clone is two endpoints: `create_pg_database` then `import_pg_database`. Only the second refused a data table under roles, so a fork asking to clone one created and registered an empty `wm_fork_…` instance database and then failed — and nothing collects it, since `drop_forked_datatable_databases` only drops entries carrying `forked_from` and no entry names this one. Refuse in both, so the clone stops before a database exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * nit worker error msg * fix pg_dump stuck on version 17 on nix * fix(datatables): refuse a malformed role annotation instead of ignoring it `-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed the annotation parser's exact-match rule, so the query fell through to the data table's default role and ran, silently, under a login the author did not choose. Naming a role exists precisely to not do that. A leading comment whose first word is `role` is now an annotation attempt: the keyword matches case-insensitively, one trailing `;` is tolerated, and anything else is an error naming the line. Only callers that already know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its comments. Also bumps the dev shell's postgres client to 18 — it trailed the server the dev database runs, which takes out every data table export, clone and fork-with-data. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse a malformed role query string instead of ignoring it `?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference parser's exact-match rule, so the connection resolved to the data table's default role and ran under a login the caller never asked for — the URI half of the same trap as a malformed `-- role` annotation. The key now matches case-insensitively, and anything else in the query string is an error naming it; `role` is the only parameter a reference takes. Callers that only need the entry keep a lenient `datatable_ref_name`, since they never act on the role. The DuckDB `ATTACH` parser propagates it rather than attaching under the default. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): carry the role annotation into the row_to_json retry The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment block never reached the second attempt — and with it the `-- role ` line that decides which login the query runs as. The retry connected as the data table's default role instead, so a query the first attempt was denied could succeed on the second, reported as "recovered with the row_to_json fix". Carry the leading comment block over. The retry itself is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore(datatables): don't mount the roles UI until the ACL editor lands Enforcement ships first. The permissions drawer is what turns roles on, and the catalog section is what creates them — both are only useful once there is a way to grant a role the privileges it needs, which arrives with the ACL editor. Left mounted they would offer a feature whose other half does not exist. The two components are complete and reviewed; only their call sites here are commented out, with a note pointing the follow-up PRs at them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): honour `-- role: x`, and fix the DuckDB attach test Two review findings, both real. `attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable` returns `Result>` now and one call site kept a single `unwrap`. Its `?Role=analytics` case also asserted a refusal, contradicting the parser in the same commit, which matches the key case-insensitively. Replaced with the cases that are genuinely malformed, and a positive one for the cased key. `-- role: analytics` fell through to the default role — the silent fallback the strict parser exists to remove, for the spelling most likely to be typed. The keyword now accepts an optional colon, attached or spaced, while a word that merely starts with it (`rolebased`) is still not an attempt. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): clone a fork's pointer instead of failing after the copy Forking a fork with cloning left an orphan database. The preflight resolves the pointer and sees the governing entry, so both endpoints ran and filled the new database; `apply_forked_datatable` then refused the inherited pointer and rolled the fork back, stranding a registered `wm_fork_*` that no entry names and whose name blocks the retry. Refusing earlier would have been the smaller change, but forking a fork and cloning worked before pointers existed, so it would trade an orphan for a regression. Resolve what the pointer names and write the terminal entry the clone needs: the whole `database` object rather than a patch of its `resource_path`, since a pointer has none, and `reference` removed with it. Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through to the default role. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse to roll back the catalog while roles exist The down migration dropped the table and left every role behind: live Postgres logins whose passwords only that table carried, so after a revert Windmill could neither use, disable nor delete them, and re-applying could not recreate them because the names were taken. Cleaning up here is not possible either — dropping a role means reassigning what it owns in every instance database, and a migration runs in one — so it now refuses while the catalog is non-empty and says to delete the roles through instance settings, which does the cluster work. Also enforces the instance-only invariant the resolved-pointer clone relies on rather than only asserting it in a comment. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * refactor(datatables): settle clonability in one place, before anything is created A clone is three stages a workspace apart — `create_pg_database`, then `import_pg_database`, then `apply_forked_datatable` inside the fork transaction. Only the third can roll back, and `CREATE DATABASE` is not transactional, so any refusal that lives there strands a registered `wm_fork_*` that no entry names and whose name blocks the retry. That orphan has now been fixed three times, most recently reintroduced by a guard added one commit ago. Patching each new refusal into the first endpoint is not the fix; having two places that can refuse is. `ensure_datatable_is_clonable` now answers every reason a copy can be refused and returns what it resolved, and the stage that writes the entry only does the work. Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role created concurrently cannot slip between the check and the drop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): let a retried clone reclaim its own leftover database A clone creates its target database one request before it copies into it, and the fork that would name it is written a request after that. Any failure in between — a pg_dump error, a bad restore, a dropped connection, the source's roles changing mid-flow — left a registered `wm_fork_*` that no entry names, and every retry then failed on its name. This predates data table roles. `create_pg_database` now reclaims such a leftover before creating: only a `wm_fork_*` database Windmill registered as a data table database and that no data table or ducklake entry names, in any workspace, archived ones included. The drop never terminates connections, so a clone still copying into it makes the reclaim fail instead of being cut off. It is limited to callers who administer the source — reaching it is not enough, since on a data table without roles every member reaches it — and anyone else gets the refusal an existing database always got. Co-Authored-By: Claude Opus 5 (1M context) * Revert "fix(datatables): let a retried clone reclaim its own leftover database" This reverts commit 7dd3275a10. The reclaim tied the caller to the source they administer, but not to the database it dropped. Between another workspace's import and its final fork request, that workspace's target is full, registered, unnamed and has no open connection, so an admin of any instance data table could name it and have it dropped and recreated empty. The victim's fork would then commit pointing at the empty copy. Safe reclaim needs durable clone ownership and serialization with the request that names the database; until then the leftover stays, as it did before this PR. Co-Authored-By: Claude Opus 5 (1M context) * docs(datatables): record the stale clone database as a known limitation A clone is three requests and `CREATE DATABASE` is not transactional, so a failure after the first leaves a registered `wm_fork_*` behind, as it did before data table roles. Accepted for this PR: it is harmless to data and goes away once the clone is a single server-side operation. The comment also records why the obvious fix is wrong: reclaiming the leftover on retry, without durable clone ownership, can drop another workspace's fully copied database between its import and its final fork request. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): bounce the streams reading a data table when it is deleted Deleting a governing data table, or the workspace that holds it, only collected the fork pointers it stranded, for the warning. A Postgres trigger or capture already streaming through one of those pointers kept the replication connection it opened while the pointer still resolved, so it went on dispatching the governing database's rows after the fork lost access — until its connection happened to restart. The governing workspace's own streams on a deleted entry did the same. Both deletion paths now bounce the affected listeners inside their own transaction, through the helper a permission change already uses, so a listener that reconnects re-resolves the entry and finds it gone. The helper is split so a caller can pass the (workspace, local name) pairs it already holds. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): keep the fork schema baseline, and bounce streams on every removal Three fixes from review. `edit_datatable_config` took `forked_from` wholesale from the stored entry, so the fork schema diff's save of an advanced baseline was silently discarded and an applied change was offered again. Whether an entry carries a clone stamp is still carried from the store, since that is what marks its database droppable, but the baseline inside it is now taken from the request. The stranded-pointer warning and the stream bounce ran over the optional `deleted_datatables` hint, which the settings-sync CLI never sends, so removing a governing data table through `wmill` bounced nothing. Removals are now derived from the stored configuration against the saved one. `delete_workspace` read the pointers to bounce before its transaction, so a fork committing a pointer during the deletion was missed. The read now happens inside the transaction, after the workspace row is deleted: a fork's insert key-share locks that row through its parent foreign key, so it is either seen or fails on the missing parent. Co-Authored-By: Claude Opus 5 (1M context) * refactor(datatables): keep Postgres triggers and data table roles apart A replication stream reads every row of every table whatever the data table's roles grant, and its listener checks access only when it connects. Rather than chase every way access can change and bounce the streams each one affects, a data table now carries one or the other: - a Postgres trigger or capture cannot be created on, or connect to, a data table under roles; - roles cannot be turned on while an enabled trigger or a live capture reads the data table, its own or a fork's through its pointer. The refusal names each one to disable. This removes the stream bounces on roles edits and on data table and workspace deletion, and the trigger gate that admitted admins. The fork schema baseline fix from the same review round is kept. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved Creating or editing a trigger that points at a data table under roles was accepted, and its listener then retried the refused connection every 30 seconds forever. The save is now refused, and a trigger that reaches such a data table anyway (re-enabled, or cloned into a fork) is disabled by its listener with the reason, as a missing replication slot is. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): disable a data table role before deleting it Deleting a role reassigns and drops what it owns in each registered database on its own connection, and each of those passes commits as it goes. A database failing part-way left the role enabled in the catalog and able to log in, but already stripped in the databases reached before it. The role is now disabled in its own commit first, so a failed delete leaves a disabled role to retry. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): serialize roles going on with a stream starting Turning roles on looked for enabled triggers and live captures once, without a lock anything starting a stream also took. A trigger enabled in that window could have its listener connect before roles committed, and a healthy listener never checks again. Both transitions now serialize on one advisory lock: roles going on hold it exclusive while they look, and trigger create, edit and enable, and capture setup and ping hold it shared while they commit. Either the look sees the stream, or the listener connects after roles are committed and refuses. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): wait out live listeners, and resolve stored names containing `?` Turning roles on counted a trigger as gone once disabled, and a capture once its client stopped pinging, but the listener keeps its replication connection until its next heartbeat notices. A trigger or capture whose listener pinged in the last 15 seconds, the window a server holds a listener for, now still counts as streaming. Data table names could contain `?` before they were restricted, and such entries are still stored. Splitting `?role=` off a reference misread them: `a?b` became `a` with an unknown parameter, and the clone checks looked at a different entry than the one copied. An entry stored under the whole reference is now looked up first, in the Postgres executor, DuckDB ATTACH and the clone checks. Agent workers cannot read the workspace and keep the strict parse, which refuses such a name rather than misreading it. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): warn when a settings sync strands fork pointers A settings save reported the fork pointers left resolving to nothing only for the names in `deleted_datatables`, which `wmill sync push` never sends. The save now works out what it removed from the locked entries, and the CLI prints the stranded pointers it returns. Also correct the replication helper's contract: no role or admin check makes a replication connection safe, so a data table under roles is refused outright rather than gated as an admin operation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse a save that drops a data table's roles through an undeclared rename A data table's roles follow its entry only through a declared rename. A settings sync sends the whole map and never declares one, so renaming a data table under roles there read as a delete and a new entry on the same database: the new entry carried no roles, and every caller connected as admin. Such a save is now refused, naming both entries. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): no entry without roles may newly reach a database under roles The previous guard only caught a new name replacing an entry under roles. A whole-map save could also repoint an existing entry without roles at that database, or another workspace could point one there, and every caller of that entry would connect as admin. The rule is now stated on the saved entries: one that carries no roles and newly points at an instance database any entry under roles uses, in this workspace or another, is refused. A declared rename carries its roles and passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move data table role catalog and resolution to the enterprise edition Roles are an Enterprise Edition feature. The catalog, the Postgres logins, CONNECT convergence, tenant evaluation and the role half of connection resolution move to windmill-ee-private. Every public function keeps its path and signature and forwards through datatable_roles_oss, which re-exports the enterprise implementation or, without it, refuses. Without the enterprise edition a data table under roles, or a caller naming a role, is refused a connection rather than resolved as admin, and the reach and admin-access checks refuse one under roles. A data table not under roles resolves as before in every edition, and an instance database keeps the CONNECT grants it was created with. The catalog lock, the stream lock, the tenant cascades and the permissions stripping stay in OSS: they only restrict. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table permissions endpoints to the enterprise edition The permissions read, save and usable-roles handlers move to windmill-ee-private; the routes stay registered and, without the enterprise edition, answer that data table roles are an Enterprise Edition feature. ensure_governs_datatable and ensure_reaches_datatable keep their paths: the first refuses, the second passes a data table not under roles. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table role catalog endpoints to the enterprise edition The superadmin list, create, update and delete handlers move to windmill-ee-private. The routes stay registered and, without the enterprise edition, refuse after authentication. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the roles tests on the enterprise edition, refusals without it Each test that exercises roles runs with private and enterprise. Two tests run without them: every roles route answers the Enterprise refusal, and a data table saved under roles, or a named role, is refused a connection while one not under roles resolves as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): gate the roles UI mount sites on an enterprise license Both mount sites are still commented out; the gate travels with them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the tenant matcher test on the enterprise edition The matcher it covers is enterprise code now, so without the enterprise edition the test hit the stub and failed the default windmill-common run. It runs with private and enterprise, and a counterpart without them asserts that no tenant list covers anyone, the wildcard and a workspace admin included. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607 This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private. Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4 New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607 Automated by sync-ee-ref workflow. * fix(datatables): refuse roles while a same-workspace alias reaches the database Co-Authored-By: Claude Opus 5 (1M context) * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) * feat(datatables): data table roles in the DB manager and raw apps Co-Authored-By: Claude Opus 5 (1M context) * fix: never add a role to the reference of a data table whose name contains '?' Co-Authored-By: Claude Opus 5 (1M context) * fix: read the roles of a data table whose name contains '?' The generated client leaves a '?' in a path param unencoded, so the lookup 404'd and the raw-app picker blocked Start on such a data table. Co-Authored-By: Claude Opus 5 (1M context) * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): refuse a reference naming both a legacy data table and a role When a workspace stores both `sales` and a legacy `sales?role=analytics`, the reference resolved to the legacy entry without a role, so browsing `sales` as `analytics` reached another data table. Co-Authored-By: Claude Opus 5 (1M context) * fix: declare the default role in migrations written for a data table whose name contains '?' Such a data table connects as its default role without naming it, so the migrations the manager wrote for it declared no role and ran as admin. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): let CE migrations connect as an explicitly named admin Co-Authored-By: Claude Opus 5 (1M context) * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): serialize roles going on with aliases saved from other workspaces Co-Authored-By: Claude Opus 5 (1M context) * docs(datatables): note that legacy names with ? cannot be migrated Co-Authored-By: Claude Opus 5 (1M context) * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * feat(datatables): put a data table's connection under Postgres roles A data table backed by the instance database resolved to exactly one Postgres connection, `custom_instance_user`, for everyone who could reach it at all. There was no way to say this job reads, that one writes, this one never sees the salaries table. A data table role is now a real Postgres login on the cluster, defined once for the instance by a superadmin and named exactly as they named it. A script that declares `-- role analytics` connects as `analytics`, and Postgres decides what it may touch — grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`. A data table with no `permissions` block behaves exactly as before. Everything that opens a connection on someone's behalf goes through one chokepoint, `get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when there is none. The role logs in as itself — never `SET ROLE`, which a script could `RESET ROLE` its way out of. A fork's data table entry becomes a pointer at the workspace that governs it rather than a copy of it. The settings clone used to hand a fork a byte-identical entry naming the parent's database, which a fork admin could edit to grant themselves `admin` there; a pointer has nothing local to edit, and its tenants are evaluated as a member of the governing workspace, by email. `permissions` is stripped from the workspace export and ignored on import: tenants name principals of one workspace, and a settings push is not where an access decision should be made. Operations that see the whole database whatever the roles grant stay with the governing workspace's admins: editing the roles, a migration that declares none, and opening a replication stream for a Postgres trigger or capture. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): gate the paths that reach a whole database as admin Auditing what still resolved through the unchecked resolver turned up three that act for a caller and hand back the admin connection: `resolve_pg_source_checked` (behind schema export, the full-schema read, database creation, import and the forked-database drop), the connection test, and the schema snapshot a fork clone takes of its parent. On a data table under roles each let any workspace member — or a fork admin who is nobody in the governing workspace — read or copy the whole database whatever its roles grant. All three now require admin reach on the governing workspace. A dump taken under a restricted role would be a silently truncated copy rather than an error, so refusing is the only right answer for the copy paths. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): confine roles to the instance database, and stop a fork reaching the parent's bookkeeping A data table role is a login on Windmill's own Postgres. Nothing stopped a workspace admin putting a *resource-backed* data table under roles, at which point the executor dialled the host that resource names — one the admin chose — with the role's real cluster password, and `CONNECT` is granted to every registered instance database. Both ends now refuse: the permissions endpoint rejects the save, and the chokepoint refuses to substitute credentials on a non-instance entry rather than trusting the record it read. Two more places reached the governing database without answering to it. The initial-migration generator returned a `pg_dump` of the whole schema to any member. And the migration rename/delete cascade followed a fork's pointer into the parent, so a fork admin renaming or removing their own local entry relabelled or wiped the parent's `_wm_migrations` — after which the parent re-runs every migration from zero. The remote half is now skipped when the entry resolves into another workspace, which is also just correct: a fork renaming what it calls a data table changes nothing about the data table. Also: revoking a tenant now bounces the replication streams of every workspace holding an entry that resolves here, not only the governing one, so a fork's trigger stops rather than living on inside its open connection; the instance role catalog and the governing workspace's tenant lists are no longer returned to someone who cannot edit them; and the tenant rename dedup collapses non-adjacent duplicates, per role rather than once any role changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): fail loudly where a role or a pointer can be left half-recorded Three ways the feature could end up in a state nobody could see or undo. Creating a role writes the cluster first and the catalog second, but the catalog write was an `UPDATE` that matched nothing when the instance Postgres settings row was absent — leaving a live login with a password nobody recorded: invisible to the catalog, un-recreatable because the name is taken, and un-deletable because there is no entry to delete. It now errors, so the operation is retryable once the row is restored. Deleting a workspace only nulls the fork lineage; the data table entries pointing at it are left resolving to nothing. Sweeping them is not an option — turning a pointer back into a copy would hand each fork the database outright — so the delete now names the data tables it stranded, and resolving one says which workspace is missing rather than reporting a data table this workspace never had. `InstanceDatatableRole` derived `Debug` while holding a Postgres password; it is now hand-written so `{:?}` on the catalog cannot put a live credential in a log line. Adds the two branches the reviews found unpinned: a caller who is not a member of the governing workspace at all, and `NoIdentity` — the compatibility path for an agent worker that predates this and sends no job id. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): unbreak two operator messages and two comments that described other code The two strings this branch added for states an operator hits once — the catalog write that matched nothing, and the delete that stranded a pointer — were collapsed from their multi-line form with the indentation left in, so both rendered with a fourteen-space gap mid-sentence. `list_datatables` claimed to report a chain it cannot follow and then dropped it; it does drop it, and the comment now says why that is the right place to stay quiet. The non-superadmin check in `edit_datatable_config` was introduced as also covering references, which it does not and need not: `reference` is overwritten from the stored entry for every caller before the check runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): serialize role catalog mutations, and state each helper's authorization contract The catalog is one JSON document, so create, rename, enable and delete are all read-modify-write. Two concurrent creates read the same snapshot, both succeed in the cluster, and the second write drops the first — leaving a live Postgres login with a password nobody recorded, which is the exact state the delete path exists to prevent. Every mutation now runs in one transaction holding an advisory lock across the read, the cluster DDL and the write, so a lost update cannot happen and a failure rolls the whole thing back. The DDL helpers take that transaction rather than the pool, which is what makes the lock cover them. Their statements moved off `sqlx::raw_sql`: the simple protocol is only needed for genuinely multi-statement SQL, and its future is not `Send`, which an axum handler holding the transaction requires. Each of these is one statement anyway. The new cross-crate surface now says what callers must do. `read_role_catalog` returns plaintext credentials; `create`/`rename`/`set_login`/`drop_instance_role` and `converge_connect_grants` mutate cluster-wide state; `read_datatable_entry` reads a workspace's raw config. All of them are superadmin-gated by their current handlers, but nothing said so at the definition, which is where the next caller looks. Also: the roles table reloads after a failed login toggle instead of leaving it claiming a flip that did not land; the rename affordance is the design-system `Button`, not a raw one; and `resolve_datatable_pg_as_caller` drops a `role` parameter no caller ever filled — browsing resolves as the data table's default until the database manager grows a picker. Why role passwords stay a plain `String` while the instance user's password beside them is a `StringOrSecretRef`, asked three times across reviews: that one is a secret ref because an operator supplies it and may want it from their own backend, while these are minted here and never entered by anyone, so there is nothing for a ref to point at. Encrypting generated secrets at rest is a separate change that would take the replication password with it. Now said at the field. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): give the role catalog its own row, out of reach of the config machinery Putting it inside `custom_instance_pg_databases` was the wrong call, and it cost two ways. The catalog serializes a generated Postgres password per role, and that row is the operator-facing instance config, so the passwords reached `get_instance_config` and its YAML editor — a live cluster credential in a response body, a UI field and any log of either. Worse in the other direction: `to_settings_map` strips the catalog, so a full-row upsert of that key writes the row back without it and the catalog is gone, while the cluster keeps every login it described. `custom_instance_replication_pwd` is the precedent and says exactly why — a generated secret, written only by the server, never operator-authored, hidden so the config machinery cannot read, rewrite or drop it. The catalog is the same thing, so it now has the same shape: `datatable_roles`, in `HIDDEN_SETTINGS`, `PROTECTED_SETTINGS` and the agent-worker denylist. No redaction to keep in step with three code paths, and no way for a neighbouring write to take it out. Two races on the same shared documents. `edit_datatable_config` read the stored data tables outside its transaction and then wrote the whole `datatable` document, so a permissions save committing in between was silently rolled back; it now reads under `FOR UPDATE`. And `set_datatable_permissions` validated role ids against the catalog before opening its transaction, so a deletion in between let it write a deleted role back — including as the default, which every later job then fails on; it now holds the catalog lock and the settings row across validation and write. Completes the authorization contracts the previous commit claimed but did not finish: `read_datatable_entry` (which it named and missed), `resolve_governing_datatable`, whose whole job is to answer for a workspace the caller may not belong to, and `converge_connect_grants_with`, which had not inherited its wrapper's. Also the generic Python SDK reference: `_format_py_params` learned the bare `*` last time, but `extract_py_functions` is a second formatter and still rendered `datatable(name, role)`, so code written from that page passed a keyword-only argument positionally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all three handlers left it green — it pinned Postgres, not the code it was written for. It now drives `create_datatable_role` twice concurrently and asserts the catalog kept both names. Checked the way the last one should have been: removing the lock from the handler makes it fail with "wmtest_a_… is a live cluster login the catalog forgot". The contracts added last commit were stricter than this PR's own callers, which is worse than none — the next reader sees a rule already broken and learns to ignore it. `read_role_catalog` said superadmin-only while two of its four callers are open to any workspace member, and `converge_connect_grants` said superadmin while `set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the rule that actually holds is about the credential never reaching a response, log, audit record or export, not about who may call. They now say that. `read_datatable_entry` gets the same treatment rather than the one the earlier message claimed for it: it is the primitive every resolution goes through, so it is deliberately open, and what must not escape is `permissions` — it names the governing workspace's users, groups and folders. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): close the last ways a role or a pointer can be left pointing at nothing The raw settings readers hand back whatever is in the row, so moving the catalog into its own `global_settings` key protected the config machinery and left `GET /settings/global/datatable_roles` and the settings listing returning every live password. Both now filter that one key. The neighbouring `custom_instance_replication_pwd` has the same shape and is not touched here: it predates this and widening the fix to it is a decision about an operator workflow, not a consequence of this change. Three ways a save could leave something resolving to nothing: A permissioned data table could be moved to a PostgreSQL resource. The block was carried across as a server-owned field, the runtime refuses roles on a resource-backed table, so the save succeeded and every job afterwards failed. Refused instead — turning roles off first is one step, and it keeps discarding an access decision something somebody chose. Renaming a governing data table left every fork pointing at the old name: the data table disappears from their pickers and their jobs stop, with nothing in the renaming workspace to suggest why. The rename now follows into the pointers in the same transaction. Deleting one cannot be followed the same way, so it is reported instead — the response names what it stranded, the way deleting a workspace does, and the fork's own error already says which workspace is gone. Also: `ensure_instance_db_grant_options_unchecked` claimed superadmin while the permissions handler reaches it as a workspace admin (the same class fixed last commit, one instance missed); the role entry kept an `instance_config_schema` derive it no longer needs; `write_role_catalog` was the one writer of that table not stamping `updated_at`; and the concurrency test dropped its roles only on success — a failing run is exactly the one that creates them without recording them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * refactor(datatables): put the role catalog in its own table, not in global_settings Five findings across three rounds were all the same choice. A set of live Postgres credentials was living in `global_settings`, which has generic read, list, write, config-export and CLI round-trip paths that know nothing about what they carry: the passwords reached the instance config and its YAML editor, a full-row upsert of a neighbouring key erased the catalog, `GET /settings/global/{key}` and the settings listing returned them raw, and this round the redaction that fixed the last two turned `wmill instance push` into something that wipes every password — a fix breaking the assumption the previous fix made. `POST /settings/global/datatable_roles` could also empty it outside the lock. The approved plan offered a table or `global_settings`, so this is the other option it already allowed rather than a new design. `datatable_role` is a table: no generic settings path can read it, list it, export it, write it or round-trip it, so none of the five needs a guard. The redaction, the hidden/protected/agent-denylist entries and the JSON document all go with it. One row per role also removes the read-modify-write the concurrency work was about: two concurrent creates are two inserts, and the unique index on `name` is what settles a collision. The advisory lock stays for the one window rows do not cover — `CREATE ROLE` is invisible to another transaction until commit, so without it both creates pass their `pg_roles` check. Also from this round: rename mappings are checked against the configuration they claim to describe, since fork pointers are rewritten from them — a caller could otherwise submit `main -> missing` against an unchanged config and repoint every fork of `main` at a name nothing has, and `A -> B` plus `B -> C` moved what pointed at `A` all the way to `C`. And the warning naming forks a delete stranded reached the response but not the screen: both the data table settings save and the workspace delete now show it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): validate a rename against the save it describes, and re-check under the locks Three from the round, all about deciding on state that could already have moved. A permission save resolved the data table and checked it was instance-backed before taking any lock, then wrote under one. A config save committing in between could move the table onto a PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in which case the write targeted a key that no longer existed and reported success having changed nothing. It now re-resolves and re-checks on the locked state. Rename validation checked that the source existed before and the target existed after, which still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows onto a different data table, silently, because it keeps resolving. The rule is now the actual old-to-new key transition — a source may only survive if another rename took its name, and a target may only pre-exist if another rename freed it. That also stops two sources sharing one target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so a swap cannot be done one save at a time, and refusing it was a regression against main. The pointer cascade now runs in two passes through a temporary name, the way the migration cascade one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once from what it named before the save. The tenant mutators say what they are for: they write an access decision for any workspace named, with an arbitrary mutation, and exist for the transaction that frees or renames a principal. Editing a decision on purpose belongs in the permissions endpoint. Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole document, so a workspace whose pointers name something else is not rewritten to a byte-identical value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from the JSON document, one of which would emit `pwd`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): cascade on the leave route that is used, gate migrations before the admin connection, and drop a role atomically The tenant cascade on leaving went onto `/users/leave`. The UI and the generated client call `/workspaces/leave` — a different handler in a different crate with the same name — which deleted the membership and left `u/` in the tenant lists. Leaving and rejoining therefore restored the access the leave was supposed to end, and a later account taking the username would have inherited it. The regression test drives the route the client actually calls; without the fix it fails with "leaving kept the tenant". The migration endpoints authorized too late. `run_datatable_migrations` opened the data table's admin connection, created `_wm_migrations` and read it before reaching the per-migration role check — so with nothing pending, nothing was checked at all. Rollback returned before its check when nothing was applied, and the status endpoint had none. All three now ask, before any connection is opened, whether the caller can reach the data table as any role at all; which role a given migration runs as is still decided per migration, and by the executor after that. Deleting a role committed the cluster drop and the catalog row, then swept the tenant lists in separate transactions. A sweep failing part-way left workspaces naming a role nothing can connect as, while the retry answered `NotFound` because the catalog entry was already gone. The sweep now runs in the same transaction, so the drop, the row and every tenant list commit together. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse to copy a data table that is under roles pg_dump carries no roles and the import runs with --no-privileges, so a copied data table arrives owned by the admin connection with no GRANT for any role. The settings clone brings `permissions` across, so the fork's tenants pass Windmill's check, connect as the role they were given, and are denied by Postgres on everything: an entry that reads as configured and answers nothing. Refuse the copy — in the import endpoint before any data moves, and in the fork path the CLI takes. Replaying the source's owners and ACLs into the clone is what lifts this, and is a change of its own. Dropping `permissions` from the copy instead would be the unsafe half, since the copy holds the parent's rows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse the clone's database too, not only its data A clone is two endpoints: `create_pg_database` then `import_pg_database`. Only the second refused a data table under roles, so a fork asking to clone one created and registered an empty `wm_fork_…` instance database and then failed — and nothing collects it, since `drop_forked_datatable_databases` only drops entries carrying `forked_from` and no entry names this one. Refuse in both, so the clone stops before a database exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * nit worker error msg * fix pg_dump stuck on version 17 on nix * fix(datatables): refuse a malformed role annotation instead of ignoring it `-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed the annotation parser's exact-match rule, so the query fell through to the data table's default role and ran, silently, under a login the author did not choose. Naming a role exists precisely to not do that. A leading comment whose first word is `role` is now an annotation attempt: the keyword matches case-insensitively, one trailing `;` is tolerated, and anything else is an error naming the line. Only callers that already know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its comments. Also bumps the dev shell's postgres client to 18 — it trailed the server the dev database runs, which takes out every data table export, clone and fork-with-data. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse a malformed role query string instead of ignoring it `?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference parser's exact-match rule, so the connection resolved to the data table's default role and ran under a login the caller never asked for — the URI half of the same trap as a malformed `-- role` annotation. The key now matches case-insensitively, and anything else in the query string is an error naming it; `role` is the only parameter a reference takes. Callers that only need the entry keep a lenient `datatable_ref_name`, since they never act on the role. The DuckDB `ATTACH` parser propagates it rather than attaching under the default. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): carry the role annotation into the row_to_json retry The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment block never reached the second attempt — and with it the `-- role ` line that decides which login the query runs as. The retry connected as the data table's default role instead, so a query the first attempt was denied could succeed on the second, reported as "recovered with the row_to_json fix". Carry the leading comment block over. The retry itself is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore(datatables): don't mount the roles UI until the ACL editor lands Enforcement ships first. The permissions drawer is what turns roles on, and the catalog section is what creates them — both are only useful once there is a way to grant a role the privileges it needs, which arrives with the ACL editor. Left mounted they would offer a feature whose other half does not exist. The two components are complete and reviewed; only their call sites here are commented out, with a note pointing the follow-up PRs at them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): honour `-- role: x`, and fix the DuckDB attach test Two review findings, both real. `attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable` returns `Result>` now and one call site kept a single `unwrap`. Its `?Role=analytics` case also asserted a refusal, contradicting the parser in the same commit, which matches the key case-insensitively. Replaced with the cases that are genuinely malformed, and a positive one for the cased key. `-- role: analytics` fell through to the default role — the silent fallback the strict parser exists to remove, for the spelling most likely to be typed. The keyword now accepts an optional colon, attached or spaced, while a word that merely starts with it (`rolebased`) is still not an attempt. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): clone a fork's pointer instead of failing after the copy Forking a fork with cloning left an orphan database. The preflight resolves the pointer and sees the governing entry, so both endpoints ran and filled the new database; `apply_forked_datatable` then refused the inherited pointer and rolled the fork back, stranding a registered `wm_fork_*` that no entry names and whose name blocks the retry. Refusing earlier would have been the smaller change, but forking a fork and cloning worked before pointers existed, so it would trade an orphan for a regression. Resolve what the pointer names and write the terminal entry the clone needs: the whole `database` object rather than a patch of its `resource_path`, since a pointer has none, and `reference` removed with it. Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through to the default role. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse to roll back the catalog while roles exist The down migration dropped the table and left every role behind: live Postgres logins whose passwords only that table carried, so after a revert Windmill could neither use, disable nor delete them, and re-applying could not recreate them because the names were taken. Cleaning up here is not possible either — dropping a role means reassigning what it owns in every instance database, and a migration runs in one — so it now refuses while the catalog is non-empty and says to delete the roles through instance settings, which does the cluster work. Also enforces the instance-only invariant the resolved-pointer clone relies on rather than only asserting it in a comment. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * refactor(datatables): settle clonability in one place, before anything is created A clone is three stages a workspace apart — `create_pg_database`, then `import_pg_database`, then `apply_forked_datatable` inside the fork transaction. Only the third can roll back, and `CREATE DATABASE` is not transactional, so any refusal that lives there strands a registered `wm_fork_*` that no entry names and whose name blocks the retry. That orphan has now been fixed three times, most recently reintroduced by a guard added one commit ago. Patching each new refusal into the first endpoint is not the fix; having two places that can refuse is. `ensure_datatable_is_clonable` now answers every reason a copy can be refused and returns what it resolved, and the stage that writes the entry only does the work. Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role created concurrently cannot slip between the check and the drop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): let a retried clone reclaim its own leftover database A clone creates its target database one request before it copies into it, and the fork that would name it is written a request after that. Any failure in between — a pg_dump error, a bad restore, a dropped connection, the source's roles changing mid-flow — left a registered `wm_fork_*` that no entry names, and every retry then failed on its name. This predates data table roles. `create_pg_database` now reclaims such a leftover before creating: only a `wm_fork_*` database Windmill registered as a data table database and that no data table or ducklake entry names, in any workspace, archived ones included. The drop never terminates connections, so a clone still copying into it makes the reclaim fail instead of being cut off. It is limited to callers who administer the source — reaching it is not enough, since on a data table without roles every member reaches it — and anyone else gets the refusal an existing database always got. Co-Authored-By: Claude Opus 5 (1M context) * Revert "fix(datatables): let a retried clone reclaim its own leftover database" This reverts commit 7dd3275a10. The reclaim tied the caller to the source they administer, but not to the database it dropped. Between another workspace's import and its final fork request, that workspace's target is full, registered, unnamed and has no open connection, so an admin of any instance data table could name it and have it dropped and recreated empty. The victim's fork would then commit pointing at the empty copy. Safe reclaim needs durable clone ownership and serialization with the request that names the database; until then the leftover stays, as it did before this PR. Co-Authored-By: Claude Opus 5 (1M context) * docs(datatables): record the stale clone database as a known limitation A clone is three requests and `CREATE DATABASE` is not transactional, so a failure after the first leaves a registered `wm_fork_*` behind, as it did before data table roles. Accepted for this PR: it is harmless to data and goes away once the clone is a single server-side operation. The comment also records why the obvious fix is wrong: reclaiming the leftover on retry, without durable clone ownership, can drop another workspace's fully copied database between its import and its final fork request. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): bounce the streams reading a data table when it is deleted Deleting a governing data table, or the workspace that holds it, only collected the fork pointers it stranded, for the warning. A Postgres trigger or capture already streaming through one of those pointers kept the replication connection it opened while the pointer still resolved, so it went on dispatching the governing database's rows after the fork lost access — until its connection happened to restart. The governing workspace's own streams on a deleted entry did the same. Both deletion paths now bounce the affected listeners inside their own transaction, through the helper a permission change already uses, so a listener that reconnects re-resolves the entry and finds it gone. The helper is split so a caller can pass the (workspace, local name) pairs it already holds. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): keep the fork schema baseline, and bounce streams on every removal Three fixes from review. `edit_datatable_config` took `forked_from` wholesale from the stored entry, so the fork schema diff's save of an advanced baseline was silently discarded and an applied change was offered again. Whether an entry carries a clone stamp is still carried from the store, since that is what marks its database droppable, but the baseline inside it is now taken from the request. The stranded-pointer warning and the stream bounce ran over the optional `deleted_datatables` hint, which the settings-sync CLI never sends, so removing a governing data table through `wmill` bounced nothing. Removals are now derived from the stored configuration against the saved one. `delete_workspace` read the pointers to bounce before its transaction, so a fork committing a pointer during the deletion was missed. The read now happens inside the transaction, after the workspace row is deleted: a fork's insert key-share locks that row through its parent foreign key, so it is either seen or fails on the missing parent. Co-Authored-By: Claude Opus 5 (1M context) * refactor(datatables): keep Postgres triggers and data table roles apart A replication stream reads every row of every table whatever the data table's roles grant, and its listener checks access only when it connects. Rather than chase every way access can change and bounce the streams each one affects, a data table now carries one or the other: - a Postgres trigger or capture cannot be created on, or connect to, a data table under roles; - roles cannot be turned on while an enabled trigger or a live capture reads the data table, its own or a fork's through its pointer. The refusal names each one to disable. This removes the stream bounces on roles edits and on data table and workspace deletion, and the trigger gate that admitted admins. The fork schema baseline fix from the same review round is kept. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved Creating or editing a trigger that points at a data table under roles was accepted, and its listener then retried the refused connection every 30 seconds forever. The save is now refused, and a trigger that reaches such a data table anyway (re-enabled, or cloned into a fork) is disabled by its listener with the reason, as a missing replication slot is. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): disable a data table role before deleting it Deleting a role reassigns and drops what it owns in each registered database on its own connection, and each of those passes commits as it goes. A database failing part-way left the role enabled in the catalog and able to log in, but already stripped in the databases reached before it. The role is now disabled in its own commit first, so a failed delete leaves a disabled role to retry. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): serialize roles going on with a stream starting Turning roles on looked for enabled triggers and live captures once, without a lock anything starting a stream also took. A trigger enabled in that window could have its listener connect before roles committed, and a healthy listener never checks again. Both transitions now serialize on one advisory lock: roles going on hold it exclusive while they look, and trigger create, edit and enable, and capture setup and ping hold it shared while they commit. Either the look sees the stream, or the listener connects after roles are committed and refuses. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): wait out live listeners, and resolve stored names containing `?` Turning roles on counted a trigger as gone once disabled, and a capture once its client stopped pinging, but the listener keeps its replication connection until its next heartbeat notices. A trigger or capture whose listener pinged in the last 15 seconds, the window a server holds a listener for, now still counts as streaming. Data table names could contain `?` before they were restricted, and such entries are still stored. Splitting `?role=` off a reference misread them: `a?b` became `a` with an unknown parameter, and the clone checks looked at a different entry than the one copied. An entry stored under the whole reference is now looked up first, in the Postgres executor, DuckDB ATTACH and the clone checks. Agent workers cannot read the workspace and keep the strict parse, which refuses such a name rather than misreading it. Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): warn when a settings sync strands fork pointers A settings save reported the fork pointers left resolving to nothing only for the names in `deleted_datatables`, which `wmill sync push` never sends. The save now works out what it removed from the locked entries, and the CLI prints the stranded pointers it returns. Also correct the replication helper's contract: no role or admin check makes a replication connection safe, so a data table under roles is refused outright rather than gated as an admin operation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse a save that drops a data table's roles through an undeclared rename A data table's roles follow its entry only through a declared rename. A settings sync sends the whole map and never declares one, so renaming a data table under roles there read as a delete and a new entry on the same database: the new entry carried no roles, and every caller connected as admin. Such a save is now refused, naming both entries. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): no entry without roles may newly reach a database under roles The previous guard only caught a new name replacing an entry under roles. A whole-map save could also repoint an existing entry without roles at that database, or another workspace could point one there, and every caller of that entry would connect as admin. The rule is now stated on the saved entries: one that carries no roles and newly points at an instance database any entry under roles uses, in this workspace or another, is refused. A declared rename carries its roles and passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move data table role catalog and resolution to the enterprise edition Roles are an Enterprise Edition feature. The catalog, the Postgres logins, CONNECT convergence, tenant evaluation and the role half of connection resolution move to windmill-ee-private. Every public function keeps its path and signature and forwards through datatable_roles_oss, which re-exports the enterprise implementation or, without it, refuses. Without the enterprise edition a data table under roles, or a caller naming a role, is refused a connection rather than resolved as admin, and the reach and admin-access checks refuse one under roles. A data table not under roles resolves as before in every edition, and an instance database keeps the CONNECT grants it was created with. The catalog lock, the stream lock, the tenant cascades and the permissions stripping stay in OSS: they only restrict. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table permissions endpoints to the enterprise edition The permissions read, save and usable-roles handlers move to windmill-ee-private; the routes stay registered and, without the enterprise edition, answer that data table roles are an Enterprise Edition feature. ensure_governs_datatable and ensure_reaches_datatable keep their paths: the first refuses, the second passes a data table not under roles. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table role catalog endpoints to the enterprise edition The superadmin list, create, update and delete handlers move to windmill-ee-private. The routes stay registered and, without the enterprise edition, refuse after authentication. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the roles tests on the enterprise edition, refusals without it Each test that exercises roles runs with private and enterprise. Two tests run without them: every roles route answers the Enterprise refusal, and a data table saved under roles, or a named role, is refused a connection while one not under roles resolves as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): gate the roles UI mount sites on an enterprise license Both mount sites are still commented out; the gate travels with them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the tenant matcher test on the enterprise edition The matcher it covers is enterprise code now, so without the enterprise edition the test hit the stub and failed the default windmill-common run. It runs with private and enterprise, and a counterpart without them asserts that no tenant list covers anyone, the wildcard and a workspace admin included. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607 This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private. Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4 New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607 Automated by sync-ee-ref workflow. * fix(datatables): refuse roles while a same-workspace alias reaches the database Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): let CE migrations connect as an explicitly named admin Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): serialize roles going on with aliases saved from other workspaces Co-Authored-By: Claude Opus 5 (1M context) * docs(datatables): note that legacy names with ? cannot be migrated Co-Authored-By: Claude Opus 5 (1M context) * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): drop a DuckDB data table secret once its ATTACH has used it Co-Authored-By: Claude Opus 5 (1M context) * perf(datatables): resolve a workspace's data tables per pointer hop, not per entry Co-Authored-By: Claude Opus 5 (1M context) * fix(datatables): hold the parent's settings while a fork points at its data tables Co-Authored-By: Claude Opus 5 (1M context) * feat(db-manager): add a schema diagram view The database manager could only show one table at a time, so nothing in it answered "how do these tables relate". This adds a Diagram mode beside the data view, for PostgreSQL: the tree's checkboxes pick the tables, and the canvas draws them as cards on a pan/zoom surface. There are deliberately no edges. A schema of any size turns an edge-drawn ERD into a hairball, so the relationships surface on hover instead: hovering a table lights every table it is linked to and the columns tying them together, hovering a column lights only the column it is actually paired with. Only the connected database can be drawn, so another data table's rows in the tree get no checkbox in this mode, and a row of the connected one toggles what is drawn rather than selecting it for a data pane that is not shown. Foreign keys come from a new ALL_FOREIGN_KEYS marker that reads the whole database in one query. The existing FOREIGN_KEYS marker is per table, which would be one job per card. It reads pg_constraint rather than information_schema, whose constraint_column_usage loses which column of a composite key pairs with which. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * fix(db-manager): discard a superseded relations read The manager is not remounted on every database change and a queued job cannot be recalled, so a slow read could resolve after the next database's and decorate its tables with the previous one's relations: runed stores whatever the fetcher returns, and this one ignored its abort signal. It now throws on a superseded run, the way the per-table foreign-key resource beside it already does, and the result carries the database it was read from so it is only ever shown alongside that one. The database is identified by a key string passed in rather than by the objects read from it: those are replaced whenever the manager re-reads, which says nothing about the database having changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * fix(db-manager): retry a failed relations read on re-entry A failed read was cached like a successful one, so a transient worker or connection failure left the diagram without relations until the schema was reloaded or the manager remounted. The cache now only answers for a read that succeeded, and leaving the diagram and coming back retries. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * fix(db-manager): scope the diagram's selection to its database The manager is not remounted on every database change, so the tables checked for one database's diagram carried into the next: same-named tables were drawn from a database they were never chosen in, and differing names left an empty canvas that the first draw no longer filled, having already run. Both the selection and that first draw are now scoped to the database they belong to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * refactor(db-manager): hold the diagram's state against its database Three separate pieces of diagram state each had to work out on their own which database they were about, and each got it wrong in a different way: the selection carried across a switch, the first-draw latch counted itself done before the schema it needed had arrived, and the relations read could land under the wrong database. They are now one record carrying the database it describes, read through a single derived where a key that no longer matches reads as a fresh diagram. The first draw waits for the schema rather than marking itself done without one, and any table the user checks counts as that draw, so an emptied canvas is never refilled behind them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * fix(db-manager): draw a schema the database actually has `selected.schemaKey` follows the tree and is only replaced once it is empty, so it can still name a schema of the database browsed before this one. The diagram's first draw took it on trust, found no tables under it and stood down for good, leaving that database on a blank canvas. It is now taken only when this database has that schema, and otherwise falls back to the same default the tree starts from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * feat(db-manager): move the view toggle into the drawer header The Data/Diagram toggle sat above the tree's search box, where it read as a filter over the tree rather than a choice about the pane beside it. It now sits with the manager's other header controls, left of the worker tag. The mode moves with it: the drawer holds it and the manager is told which view to show, clamping it for a database that has no diagram. Holding it outside the key that remounts the manager also means picking another data table or role no longer drops the user back into the data view. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * chore: update ee-repo-ref to 7e338e4dabf91689bfd7fb0333c6534040b17b59 This commit updates the EE repository reference after PR #787 was merged in windmill-ee-private. Previous ee-repo-ref: 0edd40979cf36bfba59323f3f6a0811ae1369cf5 New ee-repo-ref: 7e338e4dabf91689bfd7fb0333c6534040b17b59 Automated by sync-ee-ref workflow. * feat(datatables): clone a data table under roles with its owners and grants (#11120) Claude-Session: https://claude.ai/code/session_01UbrtwiYNfayrmqouBJHwGV Co-authored-by: Claude Opus 5 (1M context) * fix: open the raw app data table drawer when the workspace has none Selecting the first data table of an empty list passed undefined to the name check, which threw instead of opening the drawer on no data table. Co-Authored-By: Claude Opus 5 (1M context) * fix(db-manager): wait for table metadata before reading relations Returning to a cached database with the diagram selected remounts the manager with its schema ready but its metadata not yet loaded. The relations read started anyway, then ran again once the metadata landed, and the first queued job cannot be recalled by an abort, so both full-database queries took a worker and hit PostgreSQL. The read now waits for the metadata, whose arrival already re-runs it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * fix(db-manager): keep the relations read across a switch to the data view The relations came from a runed `resource`, which abandons a run whenever any of its inputs changes. A queued job cannot be recalled, so switching to the data view while relations loaded threw away a read that still ran to completion, and coming back queued a second full-database job. A plain read replaces it: at most one per database and metadata, started when the diagram needs it, kept across mode changes, and replaced only by another database, reloaded metadata, or a retry after failing. An answer arriving after its read was replaced is ignored, so it can never decorate another database's tables. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * fix(db-manager): hand down metadata only for the database it was read from A resource keeps its previous value while it refetches, so after a switch of database the manager was handed the previous database's metadata as the new one's. The diagram started its relations read beside it, then again once the right metadata arrived: two full-database jobs, the first unrecallable. It also drew the previous database's columns meanwhile. The metadata now carries the database it was read for, and nothing is handed down while it belongs to another. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * fix(db-manager): address the schema diagram's review nits - Clear a pinned highlight once its table is unchecked or its column is folded away by collapsing the card; it otherwise left the canvas dimmed around nothing on screen. - Use the design-system button for a card's expand control. - Describe the view-mode counter as what it records — switches between the data and diagram views, not how the manager was opened. - Give the relation index loop its real reason: hovering either table of a pair lights both of its columns. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * fix(db-manager): keep schema diagram cards in place and open tables from the header - Cards were draggable, but the canvas is laid out from scratch on every change, so a moved card snapped back at the next one. Dragging is off. - Double-clicking a card anywhere opened its table. Expanding a card moves its rows under the pointer, so a double-click on the expand control landed its second click on a row and left the diagram. Only the header opens the table now; the rows keep hovering and pinning columns. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP * chore: update ee-repo-ref to 39f8896379295728a509c4d642574bb8173368e8 This commit updates the EE repository reference after PR #784 was merged in windmill-ee-private. Previous ee-repo-ref: 720f334ee1843878749e9520567a7b657fc2deda New ee-repo-ref: 39f8896379295728a509c4d642574bb8173368e8 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/query_builders.rs | 62 ++++++ docs/feature-telemetry.md | 8 +- frontend/src/lib/components/DBManager.svelte | 209 ++++++++++++++++-- .../lib/components/DBManagerContent.svelte | 25 ++- .../src/lib/components/DBManagerDrawer.svelte | 42 +++- .../lib/components/InstanceSettings.svelte | 6 +- frontend/src/lib/components/dbOps.ts | 16 ++ .../src/lib/components/dbRelations.test.ts | 81 +++++++ frontend/src/lib/components/dbRelations.ts | 137 ++++++++++++ .../dbdiagram/DbSchemaDiagram.svelte | 171 ++++++++++++++ .../components/dbdiagram/DbTableNode.svelte | 118 ++++++++++ .../dbdiagram/DiagramAutoFit.svelte | 20 ++ .../dbdiagram/dbDiagramHighlight.svelte.ts | 80 +++++++ .../dbdiagram/dbDiagramModel.test.ts | 45 ++++ .../components/dbdiagram/dbDiagramModel.ts | 131 +++++++++++ 16 files changed, 1126 insertions(+), 27 deletions(-) create mode 100644 frontend/src/lib/components/dbRelations.test.ts create mode 100644 frontend/src/lib/components/dbRelations.ts create mode 100644 frontend/src/lib/components/dbdiagram/DbSchemaDiagram.svelte create mode 100644 frontend/src/lib/components/dbdiagram/DbTableNode.svelte create mode 100644 frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte create mode 100644 frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts create mode 100644 frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts create mode 100644 frontend/src/lib/components/dbdiagram/dbDiagramModel.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index b82e25d93b..24c90028c4 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -559a9ff78ea03439553cf2b412e73765762e9013 +39f8896379295728a509c4d642574bb8173368e8 diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index 9d85278404..546d2d454b 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -333,6 +333,7 @@ pub fn try_expand_internal_db_query( // Metadata queries "LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type), "FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql), + "ALL_FOREIGN_KEYS" => expand_all_foreign_keys(db_type).map(ExpandedQuery::sql), "PRIMARY_KEY_CONSTRAINT" => { expand_primary_key_constraint(json_str, db_type).map(ExpandedQuery::sql) } @@ -2737,6 +2738,47 @@ fn expand_foreign_keys(json_str: &str, db_type: DbType) -> Result Result { + make_all_foreign_keys_query(db_type) +} + +/// Every foreign key of the database, one row per referencing column. +/// +/// Read from `pg_constraint` rather than `information_schema`: the latter's +/// `constraint_column_usage` loses the pairing of a composite key's columns, +/// which the diagram needs to line a source column up with the column it points +/// at. `unnest(conkey, confkey)` keeps them zipped in declaration order. +fn make_all_foreign_keys_query(db_type: DbType) -> Result { + if db_type != DbType::Postgresql { + return Err(format!( + "The schema diagram is only supported for PostgreSQL, not {:?}", + db_type + )); + } + Ok(String::from( + "SELECT + con.conname as fk_constraint_name, + src_ns.nspname as source_schema, + src.relname as source_table, + src_att.attname as source_column, + tgt_ns.nspname as target_schema, + tgt.relname as target_table, + tgt_att.attname as target_column, + k.ord as ordinal +FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class src ON src.oid = con.conrelid + JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace + JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid + JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace + JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(src_attnum, tgt_attnum, ord) ON true + JOIN pg_catalog.pg_attribute src_att ON src_att.attrelid = src.oid AND src_att.attnum = k.src_attnum + JOIN pg_catalog.pg_attribute tgt_att ON tgt_att.attrelid = tgt.oid AND tgt_att.attnum = k.tgt_attnum +WHERE con.contype = 'f' + AND src_ns.nspname NOT IN ('pg_catalog', 'information_schema') +ORDER BY src_ns.nspname, src.relname, con.conname, k.ord;", + )) +} + fn expand_primary_key_constraint(json_str: &str, db_type: DbType) -> Result { let p: PrimaryKeyConstraintPayload = serde_json::from_str(json_str) .map_err(|e| format!("Invalid PRIMARY_KEY_CONSTRAINT payload: {}", e))?; @@ -4721,6 +4763,26 @@ mod tests { assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n")); } + // ----------------------------------------------------------------------- + // ALL_FOREIGN_KEYS + // ----------------------------------------------------------------------- + + #[test] + fn test_expand_all_foreign_keys_postgresql() { + let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#; + let sql = expand_code(marker, &ScriptLang::Postgresql); + assert!(sql.contains("con.contype = 'f'")); + // Columns of a composite key must stay zipped with the ones they point at. + assert!(sql.contains("unnest(con.conkey, con.confkey) WITH ORDINALITY")); + } + + #[test] + fn test_expand_all_foreign_keys_rejects_non_postgres() { + let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#; + let result = try_expand_internal_db_query(marker, &ScriptLang::Mysql); + assert!(result.unwrap().is_err()); + } + // ----------------------------------------------------------------------- // PRIMARY_KEY_CONSTRAINT // ----------------------------------------------------------------------- diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index ae62d46b94..9dacfc1cbd 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,10 +4,10 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 51 registered actions across nineteen features (`ai_session`, `ai_chat`, -`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`, -`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, -`usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the +It currently carries 58 registered actions across twenty features (`ai_session`, `ai_chat`, +`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`, +`flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, +`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the product is uninstrumented, so new user-facing work is the opportunity to change that. ## When to instrument diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 4775409252..16e64dc5a0 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -1,5 +1,5 @@ + +
    + {#if error} +
    + {error} +
    + {/if} + {#if tables.length === 0} +
    + Check tables in the sidebar to draw them here +
    + {:else} + + highlight.clear()} + > +
    + + + + +
    +
    + {/if} + {#if loading} +
    + + Loading relationships +
    + {/if} +
    + + diff --git a/frontend/src/lib/components/dbdiagram/DbTableNode.svelte b/frontend/src/lib/components/dbdiagram/DbTableNode.svelte new file mode 100644 index 0000000000..d17faaa006 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/DbTableNode.svelte @@ -0,0 +1,118 @@ + + + +
    highlight.hover({ table: table.key })} + onmouseleave={() => highlight.hover(undefined)} + onclick={() => highlight.togglePin({ table: table.key })} + role="button" + tabindex="-1" +> + + +
    + + {table.table} + {table.schema} +
    + + {#each columns as column (column.name)} + {@const columnKey = columnKeyIn(table.key, column.name)} + {@const isLit = highlight.columns?.has(columnKey)} + +
    highlight.hover({ table: table.key, column: column.name })} + onmouseleave={() => highlight.hover({ table: table.key })} + onclick={(e) => { + e.stopPropagation() + highlight.togglePin({ table: table.key, column: column.name }) + }} + > + {#if column.isPrimaryKey} + + {:else if data.index.referencingColumns.has(columnKey)} + + {:else} + + {/if} + {column.name} + {column.datatype} +
    + {/each} + + {#if hasExpandToggle(table)} +
    + +
    + {/if} +
    diff --git a/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte b/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte new file mode 100644 index 0000000000..b271886abd --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte @@ -0,0 +1,20 @@ + diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts b/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts new file mode 100644 index 0000000000..54f52b85ca --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts @@ -0,0 +1,80 @@ +import { getContext, setContext } from 'svelte' +import { columnKeyIn, type RelationIndex } from '../dbRelations' + +/** What the pointer is on: a whole table, or one of its columns. */ +export type HighlightTarget = { table: string; column?: string } + +const EMPTY: ReadonlySet = new Set() + +/** + * The diagram draws no edges, so the relationships only exist while something is + * highlighted. This owns that: what is hovered (or pinned by a click), and which + * tables and columns it lights up. + */ +export class DbDiagramHighlight { + #getIndex: () => RelationIndex + + hovered = $state(undefined) + /** Set by clicking, so the relations can be read without holding the pointer + * still. A hover takes precedence while it lasts. */ + pinned = $state(undefined) + + target = $derived(this.hovered ?? this.pinned) + + /** Tables that stay lit. Undefined means nothing is highlighted and every + * table renders at full strength. */ + tables = $derived.by | undefined>(() => { + const target = this.target + if (!target) return undefined + if (target.column) { + const columnTable = this.#getIndex().columnTable + const tables = new Set([target.table]) + for (const column of this.columns ?? EMPTY) { + const table = columnTable.get(column) + if (table) tables.add(table) + } + return tables + } + return new Set([target.table, ...(this.#getIndex().relatedTables.get(target.table) ?? EMPTY)]) + }) + + /** Columns that stay lit inside the highlighted tables. */ + columns = $derived.by | undefined>(() => { + const target = this.target + if (!target) return undefined + const index = this.#getIndex() + if (target.column) { + const key = columnKeyIn(target.table, target.column) + return new Set([key, ...(index.linkedColumns.get(key) ?? EMPTY)]) + } + return index.relatedColumns.get(target.table) ?? EMPTY + }) + + constructor(getIndex: () => RelationIndex) { + this.#getIndex = getIndex + } + + hover(target: HighlightTarget | undefined) { + this.hovered = target + } + + /** Clicking the already-pinned target unpins it. */ + togglePin(target: HighlightTarget) { + const current = this.pinned + this.pinned = + current && current.table === target.table && current.column === target.column + ? undefined + : target + } + + clear() { + this.hovered = undefined + this.pinned = undefined + } +} + +const contextKey = 'DbDiagramHighlight' + +export const setDbDiagramHighlight = (highlight: DbDiagramHighlight) => + setContext(contextKey, highlight) +export const getDbDiagramHighlight = () => getContext(contextKey) diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts b/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts new file mode 100644 index 0000000000..8b6b584860 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { CARD_WIDTH, cardHeight, layoutTables, type DiagramTable } from './dbDiagramModel' + +function table(name: string, columnCount: number): DiagramTable { + return { + key: `shop.${name}`, + schema: 'shop', + table: name, + columns: Array.from({ length: columnCount }, (_, i) => ({ + name: `c${i}`, + datatype: 'text', + isPrimaryKey: i === 0 + })) + } +} + +describe('layoutTables', () => { + // Runs again on every checkbox click, so cards must not land on top of each + // other for any mix of card heights. + it('never overlaps two cards', () => { + const tables = Array.from({ length: 40 }, (_, i) => table(`t${i}`, 1 + (i % 17))) + const expanded = new Set(['shop.t3', 'shop.t20']) + const positions = layoutTables(tables, expanded) + + const boxes = tables.map((t) => ({ + x: positions[t.key].x, + y: positions[t.key].y, + h: cardHeight(t, expanded.has(t.key)) + })) + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i] + const b = boxes[j] + const apart = + a.x + CARD_WIDTH <= b.x || b.x + CARD_WIDTH <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y + expect(apart, `${tables[i].key} overlaps ${tables[j].key}`).toBe(true) + } + } + }) + + it('places the same tables in the same spots every time', () => { + const tables = Array.from({ length: 12 }, (_, i) => table(`t${i}`, 3 + i)) + expect(layoutTables(tables, new Set())).toEqual(layoutTables(tables, new Set())) + }) +}) diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts b/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts new file mode 100644 index 0000000000..ccd32cdb22 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts @@ -0,0 +1,131 @@ +import type { DBSchema } from '$lib/stores' +import type { ColumnDef } from '../apps/components/display/dbtable/utils' +import type { SelectedTable } from '../DBManager.svelte' +import { tableKeyOf } from '../dbRelations' + +export type DiagramColumn = { + name: string + datatype: string + isPrimaryKey: boolean +} + +export type DiagramTable = { + /** `schema.table`, the key relations and highlights are addressed by. */ + key: string + schema: string + table: string + columns: DiagramColumn[] +} + +/** Card geometry. The node component must render exactly the rows the layout + * budgeted for, or cards overlap. */ +export const CARD_WIDTH = 248 +export const HEADER_HEIGHT = 34 +export const ROW_HEIGHT = 22 +const CARD_PADDING = 6 +const GAP_X = 32 +const GAP_Y = 20 +/** Rows shown before a card collapses into a "+N more" row. */ +export const COLLAPSED_ROW_LIMIT = 12 +/** Cards are packed towards this width/height ratio, so the canvas opens on + * something screen-shaped rather than one very long column. */ +const TARGET_ASPECT = 1.7 + +export function visibleColumns(table: DiagramTable, expanded: boolean): DiagramColumn[] { + return expanded ? table.columns : table.columns.slice(0, COLLAPSED_ROW_LIMIT) +} + +export function hiddenColumnCount(table: DiagramTable, expanded: boolean): number { + return table.columns.length - visibleColumns(table, expanded).length +} + +/** Whether the card carries a row to expand or collapse its column list. */ +export function hasExpandToggle(table: DiagramTable): boolean { + return table.columns.length > COLLAPSED_ROW_LIMIT +} + +export function cardHeight(table: DiagramTable, expanded: boolean): number { + const rows = visibleColumns(table, expanded).length + (hasExpandToggle(table) ? 1 : 0) + return HEADER_HEIGHT + rows * ROW_HEIGHT + CARD_PADDING +} + +/** The tables to draw, in the order they are laid out. */ +export function buildDiagramTables( + dbSchema: DBSchema, + colDefs: Record | undefined, + selected: SelectedTable[] +): DiagramTable[] { + if (dbSchema.lang === 'graphql') return [] + const tables: DiagramTable[] = [] + + for (const { schema, table } of selected) { + const key = tableKeyOf({ schema, table }) + const fromColDefs = colDefs?.[key] + let columns: DiagramColumn[] + if (fromColDefs?.length) { + columns = fromColDefs.map((col) => ({ + name: col.field ?? '', + datatype: col.datatype ?? '', + isPrimaryKey: !!col.isprimarykey + })) + } else { + // The cached schema has no primary-key flag; it only stands in until the + // column metadata query lands. + const schemaColumns = dbSchema.schema[schema]?.[table] + if (!schemaColumns) continue + columns = Object.entries(schemaColumns).map(([name, col]) => ({ + name, + datatype: col.type, + isPrimaryKey: false + })) + } + tables.push({ key, schema, table, columns }) + } + + tables.sort((a, b) => a.schema.localeCompare(b.schema) || a.table.localeCompare(b.table)) + return tables +} + +/** Packs the cards into balanced columns, in reading order. Runs again on every + * change to the selection, so it must be deterministic: the same tables always + * land in the same place. */ +export function layoutTables( + tables: DiagramTable[], + expanded: Set +): Record { + const heights = tables.map((t) => cardHeight(t, expanded.has(t.key))) + const stacked = heights.reduce((a, b) => a + b, 0) + GAP_Y * Math.max(0, tables.length - 1) + + let columnCount = 1 + let bestScore = Infinity + for (let c = 1; c <= tables.length; c++) { + const width = c * CARD_WIDTH + (c - 1) * GAP_X + const height = stacked / c + // Compared in log space so twice-too-wide and twice-too-tall score alike. + const score = Math.abs(Math.log(width / height / TARGET_ASPECT)) + if (score < bestScore) { + bestScore = score + columnCount = c + } + } + + const target = stacked / columnCount + const positions: Record = {} + let column = 0 + let columnHeight = 0 + + tables.forEach((table, i) => { + const height = heights[i] + // Overshooting the target by more than stopping short of it costs less in + // the next column. + const overshoot = columnHeight + height - target + if (column < columnCount - 1 && columnHeight > 0 && overshoot > target - columnHeight) { + column++ + columnHeight = 0 + } + positions[table.key] = { x: column * (CARD_WIDTH + GAP_X), y: columnHeight } + columnHeight += height + GAP_Y + }) + + return positions +} From 592ef736104ff63dd36978a69353a2585636da87 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 22 Sep 2026 17:28:52 +0200 Subject: [PATCH 076/112] fix: keep a watched job's code when its completion update drops it (#11290) Co-authored-by: Claude Opus 5 --- frontend/src/lib/components/JobLoader.svelte | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 324bdea6c7..0994905121 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -444,6 +444,8 @@ streamedResult.length >= completedResult.length ? streamedResult : completedResult + njob.raw_code ??= job?.raw_code + njob.raw_flow = job?.raw_flow ?? njob.raw_flow job = njob await onJobCompleted(testId, job, callbacks) } @@ -870,6 +872,11 @@ const njob = previewJobUpdates.job as Job & { result_stream?: string } njob.logs = pickMoreCompleteLogs(job?.logs, njob.logs) njob.result_stream = job?.result_stream ?? '' + // The server sends the completed job without its code to keep the + // update small (no raw_code, "..." for a deployed flow's inline + // steps), so keep the code fetched with the initial job. + njob.raw_code ??= job?.raw_code + njob.raw_flow = job?.raw_flow ?? njob.raw_flow job = njob onJobCompleted(id, job, callbacks) } From ea0b947644d4094304ecea26406fa6aac15dcfac Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 22 Sep 2026 17:29:20 +0200 Subject: [PATCH 077/112] fix: stop the chat repeating run results the card already shows (#11293) * fix: tell the chat not to repeat run results the card already shows * fix: add the run result note on completion, background runs included * fix: skip the run result note on failed runs * fix: keep the run result note off background completions --- .../components/copilot/chat/shared.test.ts | 44 +++++++++++++++++++ .../src/lib/components/copilot/chat/shared.ts | 16 ++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 768b6d1b65..005a7666db 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -1510,6 +1510,50 @@ describe('pollJobCompletion detach', () => { }) }) +describe('executeTestRun result note', () => { + // A failed run's card lands on the error, with the logs a tab away: the model must stay + // free to quote them, so only a success says the user already sees the run. + it('tells the model not to repeat a successful result, and not a failed one', async () => { + vi.useFakeTimers() + try { + const { executeTestRun } = await import('./shared') + const { JobService } = await import('$lib/gen') + const getJobUpdates = vi.mocked(JobService.getJobUpdates) + getJobUpdates.mockReset() + getJobUpdates.mockResolvedValue({ completed: true, running: false } as any) + const run = async (success: boolean) => { + vi.mocked(JobService.getJob).mockReset() + vi.mocked(JobService.getJob).mockResolvedValue({ + type: 'CompletedJob', + success, + result: [1, 2], + logs: 'ran' + } as any) + const promise = executeTestRun({ + jobStarter: async () => 'job1', + workspace: 'w', + toolId: 'tool1', + startMessage: 'Starting...', + contextName: 'script', + // The job hooks are what mark the global/sessions chat. + toolCallbacks: { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + onJobStatus: vi.fn(), + onJobStarted: vi.fn() + } as any + }) + await vi.advanceTimersByTimeAsync(1000) + return promise + } + expect(await run(true)).toContain('Do not repeat') + expect(await run(false)).not.toContain('Do not repeat') + } finally { + vi.useRealTimers() + } + }) +}) + describe('deriveChatJobStatus', () => { // CompletedJob is discriminated by the presence of a `success` key; the branch // order deliberately mirrors JobStatusIcon so the badge and scalar never drift. diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index bd347224f9..f58ed3b6d3 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -1871,6 +1871,15 @@ export async function buildTestRunArgs( return parsedArgs } +// Said with the result rather than in the system prompt: there, models still copied a +// result's table or JSON into the reply, under a card that already renders it. Only for +// a successful run in the generic card completedJobToolStatus fills: a failed run's card +// lands on the error with the logs a tab away, and a formatCompletion card shows its own. +// Never on a background completion: its card can be far up the chat by then, so the +// reply may be the only place the user reads the result. +const RESULT_SHOWN_NOTE = + "The user already sees this run's result in its card. Do not repeat the result or logs in your reply: say what it shows, quoting only the values your conclusion rests on." + // The string handed back to the model when a job is backgrounded. It carries the // job id so the model can pull status/args/result/logs on demand (get_run / list_runs), // and tells it the completion will be reported later (notify-only wake). @@ -2015,7 +2024,12 @@ export async function executeTestRun(config: TestRunConfig): Promise { ...(job.success ? {} : { error: getErrorMessage(job.result) }) }) - const summary = formatResultSummary(job.result, job.logs, job.success) + // detachEnabled marks the global/sessions chat; the in-editor chats read the summary + // alone. The card opening on the result is up to each tool: a run card does, a + // generic one only with `autoCollapseDetails: false` at its registration. + const summary = + (detachEnabled && job.success ? `${RESULT_SHOWN_NOTE}\n` : '') + + formatResultSummary(job.result, job.logs, job.success) // get_run only exists in the global/sessions chat (the same hosts that wire // the job hooks) — don't advertise it to in-editor chats. if (detachEnabled && config.contextName === 'flow' && !job.success) { From 42655ff5ef3e4eae8ff90c623683bb321ae7d06b Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:07:21 +0200 Subject: [PATCH 078/112] fix: memoize the resolved authed so one request resolves identity once (#11299) * fix: memoize the resolved authed so one request resolves identity once Co-Authored-By: Claude Opus 5 (1M context) * test: pin that the memo preserves job token provenance Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-api-auth/src/auth.rs | 81 +++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 6e5aa6b1da..869f40e14c 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -1073,7 +1073,8 @@ fn no_auth_admin_authed() -> ApiAuthed { } /// Resolves OptJobAuthed from request parts. -/// Takes ownership of Parts and returns them back. +/// Takes ownership of Parts and returns them back, memoized into the extensions so a +/// second extraction in the same request is free. #[allow(unreachable_code, unused_mut)] pub async fn resolve_opt_job_authed( mut parts: Parts, @@ -1089,10 +1090,11 @@ pub async fn resolve_opt_job_authed( )); } - let already_authed = parts.extensions.get::().cloned(); - - if let Some(authed) = already_authed { - return Ok((authed, parts)); + // Safe only while what the route checks below read is fixed before the first + // extraction: path and method always are, `workspace_id` only while every + // `GatewayWorkspaceId` writer stays layered outside auth extraction. + if let Some(opt_job_authed) = parts.extensions.get::().cloned() { + return Ok((opt_job_authed, parts)); } let already_tokened = parts.extensions.get::().cloned(); @@ -1185,6 +1187,9 @@ pub async fn resolve_opt_job_authed( if let Some(workspace_id) = workspace_id { Span::current().record("workspace_id", &workspace_id); } + // Separate from the ApiAuthed insert above, which handlers taking + // `Extension` read. + parts.extensions.insert(opt_job_authed.clone()); return Ok((opt_job_authed, parts)); } } @@ -1311,3 +1316,69 @@ pub async fn list_tokens_internal( Ok(Json(tokens)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Dropping the `AUTH_CACHE` entry between the two resolutions is what makes a memo + /// hit observable: only a working memo can answer the second one. + #[tokio::test] + async fn resolve_opt_job_authed_is_memoized() { + // Never connected: an AUTH_CACHE hit resolves without a query, and AuthCache + // only needs the handle to exist. Without the timeout a regression — which + // falls through to a real lookup — spends sqlx's 30s default failing. + let db = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(50)) + .connect_lazy("postgres://memo@127.0.0.1:1/memo") + .expect("lazy pool"); + + let token = "memo_guard_token"; + // Workspace-scoped: a job token on a workspace-less route is refused by + // check_job_token_for_global_route before the memo is ever taken. + let key = ("memo_workspace".to_string(), token.to_string()); + let job_id = uuid::Uuid::from_u128(0x6d656d6f); + AUTH_CACHE.insert( + key.clone(), + ExpiringAuthCache { + authed: ApiAuthed { + email: "memo@windmill.dev".to_string(), + username: "memo".to_string(), + ..Default::default() + }, + expiry: chrono::Utc::now() + chrono::Duration::hours(1), + job_id: Some(job_id), + }, + ); + + #[cfg(feature = "enterprise")] + let cache = AuthCache::new(db, None, None); + #[cfg(not(feature = "enterprise"))] + let cache = AuthCache::new(db, None); + + let mut parts = http::Request::builder() + .uri("/api/w/memo_workspace/jobs/list") + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .body(()) + .unwrap() + .into_parts() + .0; + parts.extensions.insert(Arc::new(cache)); + + let (_, parts) = resolve_opt_job_authed(parts) + .await + .map_err(|(e, _)| e) + .expect("first resolution"); + + AUTH_CACHE.remove(&key); + + let (second, _) = resolve_opt_job_authed(parts) + .await + .map_err(|(e, _)| e) + .expect("second resolution must hit the memo, not re-resolve"); + assert_eq!(second.authed.username, "memo"); + // The wrapper must survive whole: memoizing only the inner ApiAuthed and + // rebuilding around it drops the provenance forbid_elevated_job_token gates on. + assert_eq!(second.job_id, Some(job_id)); + } +} From 89323fb0258bf13413217d6867885765f37b4678 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:07:31 +0200 Subject: [PATCH 079/112] refactor: separate the tools the chat sends from the ones it has (#11295) * refactor: split the chat's tool list into sent-now and available-in-session views Co-Authored-By: Claude Opus 5 (1M context) * refactor: drop the forward-looking ticket reference from the tool-base comment Co-Authored-By: Claude Opus 5 (1M context) * refactor: make the readonly tool seam enforceable and the comments accurate Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 32 +++++++++----- .../copilot/chat/AIChatManager.test.ts | 42 +++++++++++++++++++ .../chat/AssistantSettingsModal.svelte | 6 +-- .../components/copilot/chat/chatViewHost.ts | 5 ++- .../copilot/chat/planModeController.svelte.ts | 6 +++ 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 27f9bfc43f..74a8b4d627 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -717,7 +717,17 @@ export class AIChatManager implements ChatViewHost { role: 'system', content: '' }) - tools = $state[]>([]) + /** The sources the current mode assembled, concatenated. Private, because neither view + * over it is this list: a consumer reading it would advertise a tool set no request ever + * carries. */ + #assembledTools = $state[]>([]) + /** What the request carries: the assembled tools plus the plan-mode transition the current + * posture offers. Read by the request path and by the YOLO disclosure. */ + tools: Tool[] = $derived([...this.#assembledTools, ...this.planMode.tools]) + /** What the assistant can call in this session, posture-independent: both plan-mode + * transitions, whichever one is offered right now. A reference answer, so flipping the + * autonomy picker must not change it. */ + availableTools: Tool[] = $derived([...this.#assembledTools, ...this.planMode.availableTools]) helpers = $state(undefined) scriptEditorOptions = $state(undefined) @@ -1764,7 +1774,7 @@ export class AIChatManager implements ChatViewHost { try { this.apiTools = await loadApiTools() if (this.mode === AIMode.API) { - this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools] + this.#assembledTools = [searchDocsTool, readDocsPageTool, ...this.apiTools] } } catch (err) { console.error('Error loading api tools', err) @@ -2346,7 +2356,7 @@ export class AIChatManager implements ChatViewHost { customPrompt ) this.systemMessage.content = this.systemMessage.content - this.tools = [...prepareScriptTools(currentModel, lang, context)] + this.#assembledTools = [...prepareScriptTools(currentModel, lang, context)] this.helpers = { getScriptOptions: () => { return { @@ -2378,7 +2388,7 @@ export class AIChatManager implements ChatViewHost { this.systemMessage = prepareFlowSystemMessage(customPrompt) this.systemMessage.content = this.systemMessage.content this.appendFlowAiAgentProviders(this.systemMessage) - this.tools = [...flowTools] + this.#assembledTools = [...flowTools] this.helpers = { ...(this.flowAiChatHelpers ?? {}), getWorkspaceMutationTarget: this.getFlowWorkspaceMutationTarget @@ -2386,17 +2396,17 @@ export class AIChatManager implements ChatViewHost { } else if (mode === AIMode.NAVIGATOR) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareNavigatorSystemMessage(customPrompt) - this.tools = [this.changeModeTool, ...navigatorTools] + this.#assembledTools = [this.changeModeTool, ...navigatorTools] this.helpers = {} } else if (mode === AIMode.ASK) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAskSystemMessage(customPrompt) - this.tools = [...askTools] + this.#assembledTools = [...askTools] this.helpers = {} } else if (mode === AIMode.API) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareApiSystemMessage(customPrompt) - this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools] + this.#assembledTools = [searchDocsTool, readDocsPageTool, ...this.apiTools] this.helpers = {} } else if (mode === AIMode.GLOBAL) { this.configureGlobalMode() @@ -2406,7 +2416,7 @@ export class AIChatManager implements ChatViewHost { } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) - this.tools = [...getAppTools()] + this.#assembledTools = [...getAppTools()] this.helpers = this.appAiChatHelpers } } @@ -2445,7 +2455,7 @@ export class AIChatManager implements ChatViewHost { this.rebuildGlobalSystemMessage() } } - this.tools = assembleGlobalTools(opts) + this.#assembledTools = assembleGlobalTools(opts) this.helpers = pipeline ? { ...baseHelpers, pipeline } : baseHelpers this.systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), opts) this.syncArtifactsSession() @@ -2941,7 +2951,7 @@ export class AIChatManager implements ChatViewHost { try { // Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) - // which reassigns this.tools, this.helpers, this.systemMessage mid-loop. + // which reassigns #assembledTools, this.helpers, this.systemMessage mid-loop. const self = this // Pinned for the whole turn, like the `workspace` the loop routes through: // the global chat's operating workspace follows workspaceStore, so a switch @@ -2966,7 +2976,7 @@ export class AIChatManager implements ChatViewHost { return base }, get tools() { - return [...self.tools, ...self.planMode.tools] + return self.tools }, get helpers() { return self.helpers diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index ef15df4e8d..2962925844 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -4559,3 +4559,45 @@ describe('AIChatManager cross-tab run seams', () => { expect(manager.queuedMessage).toBe('') }) }) + +describe('AIChatManager tool views', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' }) + mocks.tryGetCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' }) + }) + + // Every mode assigns the private base separately. A missed site leaves it empty on a fresh + // manager, which is what these catch — silently, since nothing throws on an empty tool set. + it.each([ + AIMode.SCRIPT, + AIMode.FLOW, + AIMode.NAVIGATOR, + AIMode.ASK, + AIMode.API, + AIMode.GLOBAL, + AIMode.APP + ])('assembles tools for %s', async (mode) => { + const manager = new AIChatManager() + await manager.changeMode(mode) + expect(manager.tools.length).toBeGreaterThan(0) + expect(manager.availableTools.length).toBeGreaterThan(0) + }) + + // Which transition each posture offers is planModeController.test.ts's; what this pins is + // that only `tools` follows the picker. `isSessionChat` is what offers plan mode at all. + it('holds availableTools steady across the autonomy picker', async () => { + const manager = new AIChatManager() + manager.isSessionChat = true + await manager.changeMode(AIMode.GLOBAL) + + const names = (tools: { def: { function: { name: string } } }[]) => + tools.map((t) => t.def.function.name) + const before = names(manager.availableTools) + expect(before).toEqual(expect.arrayContaining(['enter_plan_mode', 'exit_plan_mode'])) + + manager.setAutonomyMode(AIAutonomyMode.YOLO) + expect(names(manager.tools)).not.toContain('enter_plan_mode') + expect(names(manager.availableTools)).toEqual(before) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte index c9def4f9e6..1591b3f1b5 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte @@ -94,9 +94,9 @@ callers that already know which section they mean, such as the "+" menu's Manage return aiChatManager.operatingWorkspace ?? active ?? '' }) - // Exactly what the chat loop sends — plan mode's transition tool is registered - // alongside `tools` there, so listing only `tools` would under-report. - let tools = $derived(summarizeTools([...aiChatManager.tools, ...aiChatManager.planMode.tools])) + // The session's capability surface, not this turn's: the heading answers what the + // assistant can call here, so it must not shift when the autonomy picker does. + let tools = $derived(summarizeTools(aiChatManager.availableTools)) // `SidebarNavigation`'s item shape, which is what the instance and workspace settings // navs are built from too. diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts index 5c4dceb7f1..bdacb57615 100644 --- a/frontend/src/lib/components/copilot/chat/chatViewHost.ts +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -147,7 +147,10 @@ export interface ChatViewHost { * True where the bytes are forwarded somewhere — object storage — rather than read * in the browser. */ attachmentsAsBlobs: boolean - tools: Tool[] + /** What this chat's next request carries, the current posture's plan-mode transition + * included. A consumer narrows it by tool metadata; assembling its own list from the + * sources behind it is how the advertised set and the sent set drift apart. */ + readonly tools: readonly Tool[] autonomyMode: AIAutonomyMode setAutonomyMode: (mode: AIAutonomyMode) => void readonly autoAcceptEditsActive: boolean diff --git a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts index ab80dc3e88..d76c1502dd 100644 --- a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts @@ -106,6 +106,12 @@ export class PlanModeController { return this.#host.active ? [this.exitTool] : [this.enterTool] } + /** Both transitions, whichever posture is selected: what plan mode contributes to this + * chat's capabilities, rather than to the turn it is about to send. */ + get availableTools(): Tool[] { + return this.#host.available ? [this.enterTool, this.exitTool] : [] + } + // This safety tag is what keeps plan mode escapable through its handoff tool. exitTool: Tool = { def: createToolDef(exitPlanModeArgs, EXIT_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL_DESCRIPTION), From d8aaa73573c46a8a23f6c6850d38438ad395752c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 22 Sep 2026 19:07:43 +0200 Subject: [PATCH 080/112] feat: chat with a saved agent from the agent editor (#11292) * feat: chat with a saved agent from the agent editor * fix: keep agent chats apart from a same-path flow's conversations * fix: point an agent editor chat's model gap at the agent, not a step * fix: match the memory gate's icon to the chat's empty state --- .../flows/content/AgentEditorHost.svelte | 202 +++++++++++++++--- .../flows/content/AgentEditorModal.svelte | 37 +++- .../flows/conversations/FlowChat.svelte | 13 +- .../conversations/FlowChatInterface.svelte | 11 +- .../flows/conversations/agentChatInputs.ts | 12 +- .../conversations/agentEditorChat.test.ts | 76 +++++++ .../flows/conversations/agentEditorChat.ts | 101 +++++++++ 7 files changed, 406 insertions(+), 46 deletions(-) create mode 100644 frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts create mode 100644 frontend/src/lib/components/flows/conversations/agentEditorChat.ts diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index b6a98160be..4f69d3a1a1 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -1,7 +1,16 @@ {#if draft.refusal} @@ -359,7 +434,7 @@ - +
    - - - - -
    - + +
    + +
    + + +
    + +
    +
    + + + +
    +
    + {#if chatMounted} +
    + {#if chatGap?.memory} +
    + +

    Chat needs managed memory

    +

    + {chatGap.memoryCanTurnOn + ? 'Without it, every message would be answered without the ones before it.' + : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} +

    + {#if chatGap.memoryCanTurnOn && !readOnly} + + {/if} +
    + {:else if chatGap?.noStream} +
    + + + {chatGap.noStream === 'image' + ? 'Image answers do not stream: each one shows once its run ends.' + : 'Streaming is off: each answer shows once its run ends.'} + + {#if chatGap.noStream === 'off' && !readOnly} + + {/if} +
    + {/if} + +
    + +
    - - - - - + {/if} +
    diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index a6e113f39b..ea45761bf9 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -1,5 +1,7 @@ @@ -81,25 +91,29 @@ {#if entry.kind === 'assistant'}
    - {#if entry.sources} -
    - -
    - {/if}
    {:else if entry.kind === 'search'} - + {}} - /> + label="Searched the web" + expanded={Boolean(entry.sources) && !collapsedSearches.has(index)} + toggleable={Boolean(entry.sources)} + onToggle={() => { + if (!collapsedSearches.delete(index)) collapsedSearches.add(index) + }} + > + {#if entry.sources} + + {/if} + {:else} {@const job = jobOf(entry.jobId)} + {@const failed = job?.type === 'CompletedJob' && !job.success} + {@const search = webSearchResultOf(entry.result)} toggle(index, entry)} contentClass="space-y-3" @@ -110,11 +124,15 @@ {#if job?.logs} {/if} - + {#if search && !failed} + + {:else} + + {/if} {#if entry.resourcePath}
    diff --git a/frontend/src/lib/components/agentTrace.test.ts b/frontend/src/lib/components/agentTrace.test.ts index 3297a99d82..7d1dcf3429 100644 --- a/frontend/src/lib/components/agentTrace.test.ts +++ b/frontend/src/lib/components/agentTrace.test.ts @@ -45,7 +45,7 @@ describe('buildAgentTrace', () => { result: '{"eu-central-1":0.184}', jobId: '0199-job' }, - { kind: 'assistant', content: 'eu-central-1 is down.', sources: undefined } + { kind: 'assistant', content: 'eu-central-1 is down.' } ]) }) @@ -76,8 +76,8 @@ describe('buildAgentTrace', () => { // The worker splits a search the same way: a `tool` message tagged web_search // carrying a constant sentence, then the assistant turn that carries the - // citations. The search row therefore has nothing of its own to show. - it('records the search and puts its citations on the turn that follows', () => { + // citations, which move onto the search so it shows what it found. + it('puts the citations of the turn that follows on the search', () => { const entries = buildAgentTrace([ { role: 'tool', @@ -92,12 +92,11 @@ describe('buildAgentTrace', () => { } ]) expect(entries).toEqual([ - { kind: 'search' }, { - kind: 'assistant', - content: 'Postgres 17 changed the default.', + kind: 'search', sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] - } + }, + { kind: 'assistant', content: 'Postgres 17 changed the default.' } ]) }) @@ -118,7 +117,7 @@ describe('buildAgentTrace', () => { }) describe('splitFinalAnswer', () => { - it('moves the answering turn out of the trace, with its citations', () => { + it('moves the answering turn out of the trace, leaving its citations on the search', () => { const entries = buildAgentTrace([ { role: 'tool', content: 'Used websearch tool', agent_action: { type: 'web_search' } }, { @@ -128,10 +127,12 @@ describe('splitFinalAnswer', () => { agent_action: { type: 'message' } } ]) - expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual({ - trace: [{ kind: 'search' }], - sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] - }) + expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual([ + { + kind: 'search', + sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] + } + ]) }) // A run whose last turn returned a tool call and no text leaves its answer @@ -152,12 +153,12 @@ describe('splitFinalAnswer', () => { } } ]) - expect(splitFinalAnswer(entries, 'Let me check.').trace).toEqual([entries[1]]) + expect(splitFinalAnswer(entries, 'Let me check.')).toEqual([entries[1]]) }) it('leaves the trace whole when the output is not a turn of its own', () => { const entries = buildAgentTrace(messages) - expect(splitFinalAnswer(entries, { rows: 3 })).toEqual({ trace: entries }) + expect(splitFinalAnswer(entries, { rows: 3 })).toEqual(entries) }) }) diff --git a/frontend/src/lib/components/agentTrace.ts b/frontend/src/lib/components/agentTrace.ts index ba0cb78d4e..392e8b00c9 100644 --- a/frontend/src/lib/components/agentTrace.ts +++ b/frontend/src/lib/components/agentTrace.ts @@ -8,10 +8,10 @@ import type { AgentMessage } from './aiAgentResult' * are shown as inputs, while the trace is what the agent did with them. */ export type AgentTraceEntry = - | { kind: 'assistant'; content: string; sources?: WebSearchSource[] } - /** A search records only that one happened: the worker writes a constant - * sentence, and the citations ride on the assistant turn that follows. */ - | { kind: 'search' } + | { kind: 'assistant'; content: string } + /** The worker records only that a search happened. Its citations ride on the assistant + * turn that follows, and are moved here so the search shows what it found. */ + | { kind: 'search'; sources?: WebSearchSource[] } | { kind: 'tool' name: string @@ -99,8 +99,14 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] { continue } const content = contentText(message.content) + // The worker writes a provider call's search right before that call's answer, and + // only a call that searched carries annotations. + const previous = entries[entries.length - 1] + if (message.role === 'assistant' && previous?.kind === 'search' && !previous.sources) { + previous.sources = sourcesOf(message) + } if (message.role === 'assistant' && content !== '') { - entries.push({ kind: 'assistant', content, sources: sourcesOf(message) }) + entries.push({ kind: 'assistant', content }) } } return entries @@ -108,27 +114,21 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] { /** * Separates the turn that produced the output from the rest of the trace, so the - * answer is rendered once with the citations that belong to it. Found by content - * and searched from the end: a run whose last turn returned a tool call leaves its - * answer mid-trace, where inspecting only the final entry prints it twice. + * answer is rendered once. Found by content and searched from the end: a run whose + * last turn returned a tool call leaves its answer mid-trace, where inspecting only + * the final entry prints it twice. */ -export function splitFinalAnswer( - entries: AgentTraceEntry[], - output: unknown -): { trace: AgentTraceEntry[]; sources?: WebSearchSource[] } { +export function splitFinalAnswer(entries: AgentTraceEntry[], output: unknown): AgentTraceEntry[] { if (typeof output !== 'string') { // A schema-shaped output is rendered by the result viewer itself and matches // no turn, so the whole trace stands. - return { trace: entries } + return entries } for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] if (entry.kind === 'assistant' && entry.content === output) { - // The citations are an annotation on that turn, so moving the turn without - // them would leave a run that shows a web search ran and no source for - // what it answered. - return { trace: [...entries.slice(0, i), ...entries.slice(i + 1)], sources: entry.sources } + return [...entries.slice(0, i), ...entries.slice(i + 1)] } } - return { trace: entries } + return entries } diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 5405a6ea25..8d945f94d1 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -26,7 +26,7 @@ import { getChatViewHost } from './chatViewHost' const chatHost = getChatViewHost() - import { isActiveUserQuestion, type ToolDisplayMessage } from './shared' + import { isActiveUserQuestion, webSearchResultOf, type ToolDisplayMessage } from './shared' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import { twMerge } from 'tailwind-merge' import { slide } from 'svelte/transition' @@ -140,6 +140,18 @@ message.previewCard && !message.isLoading && !message.error && !message.needsConfirmation ) ) + + // A provider-side search sets `webSearchSources` and words its own header; any other tool + // gets the same card by returning the web search result shape. + const searchResult = $derived( + message.webSearchSources || message.error ? undefined : webSearchResultOf(message.result) + ) + const sources = $derived(message.webSearchSources ?? searchResult?.sources) + const label = $derived( + searchResult?.query !== undefined + ? `${message.content} · "${searchResult.query}"` + : message.content + ) {#if activeUserQuestion} @@ -281,7 +293,7 @@ weight alone: queued calls (waiting their turn behind the executing tool) are faded, the running one sweeps, a settled one is plain. --> (isExpanded = !isExpanded)} toggleable={detailsAvailable || message.isStreamingArguments === true} @@ -343,8 +355,8 @@ {#if visibleActions.length > 0} - {:else if message.webSearchSources?.length && !message.error} - + {:else if sources?.length && !message.error} + {:else} import { Globe } from 'lucide-svelte' import { SvelteSet } from 'svelte/reactivity' - import type { WebSearchSource } from './shared' + import { isRenderableSourceUrl, type WebSearchSource } from './shared' import { isOfflineReplay } from '$lib/components/recording/offlineReplay.svelte' interface Props { sources: WebSearchSource[] + /** Whether a hostname may be sent to the favicon service. False for the sources a + * workspace's own tool returned: those can name internal services, and disclosing + * them to a third party is the tool author's call to make, not this card's. */ + favicons?: boolean } - let { sources }: Props = $props() + let { sources, favicons = true }: Props = $props() - // The URLs come from the provider's response: only render absolute http(s) - // ones — anything else (javascript:, data:, relative) must not become an - // href. Also dedupes; providers can surface the same page several times. + // The URLs come from the provider's response or from a tool's own result: only render + // absolute http(s) ones — anything else (javascript:, data:, relative) must not become + // an href. Also dedupes; providers can surface the same page several times. const uniqueSources = $derived( Array.from( - new Map( - sources - .filter((s) => { - try { - return ['http:', 'https:'].includes(new URL(s.url).protocol) - } catch { - return false - } - }) - .map((s) => [s.url, s]) - ).values() + new Map(sources.filter((s) => isRenderableSourceUrl(s.url)).map((s) => [s.url, s])).values() ) ) @@ -43,7 +37,7 @@ // to issue none — a recording comes from an arbitrary origin, so its cited // hostnames must not leak from a viewer's browser either. Degrades to the same // Globe the blocked/failed case already uses. - const noFavicons = $derived(isOfflineReplay()) + const noFavicons = $derived(!favicons || isOfflineReplay()) // Favicons come from Google's public favicon service, which discloses each // consulted hostname to a third party from the user's browser — an accepted @@ -62,6 +56,9 @@
    {#each uniqueSources as source (source.url)} {@const hostname = hostnameOf(source.url)} + + {@const title = source.title?.trim() || undefined} failedFavicons.add(hostname)} /> {/if} - {source.title ?? hostname} - {#if source.title} + {title ?? hostname} + {#if title} {hostname} {/if} diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 005a7666db..fb61f3a912 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { DisplayMessage, ToolDisplayMessage } from './shared' -import { openItemPreviewAction } from './shared' +import { openItemPreviewAction, webSearchResultOf } from './shared' vi.mock('monaco-editor', () => ({ editor: {} @@ -1869,3 +1869,38 @@ describe('processToolCall confirmation hooks', () => { expect(tool.fn).toHaveBeenCalled() }) }) + +// Any tool gets the web search card by returning this shape. The card renders urls and +// titles only, so a result carrying anything more must keep its JSON pane. +describe('webSearchResultOf', () => { + it('reads the shape, from the value or its JSON text', () => { + const result = { sources: [{ url: 'https://a.dev', title: 'A' }], query: 'a' } + expect(webSearchResultOf(result)).toEqual(result) + expect(webSearchResultOf(JSON.stringify(result))).toEqual(result) + }) + + // A Python tool serializes an absent optional as null, and the card has nothing to tell + // its author why an almost-right result fell back to JSON. + it('reads null on an optional field as absent', () => { + expect( + webSearchResultOf({ sources: [{ url: 'https://a.dev', title: null }], query: null }) + ).toEqual({ + sources: [{ url: 'https://a.dev', title: undefined }], + query: undefined + }) + }) + + it.each([ + ['an extra key', { sources: [{ url: 'https://a.dev' }], summary: 'x' }], + ['an extra source field', { sources: [{ url: 'https://a.dev', snippet: 'x' }] }], + ['a source without url', { sources: [{ title: 'A' }] }], + // The card renders no relative or javascript: link, so a result whose urls it would + // drop keeps its own JSON rather than showing an empty source list. + ['a relative url', { sources: [{ url: '/docs/pg17', title: 'PG 17' }] }], + ['a javascript: url', { sources: [{ url: 'javascript:alert(1)' }] }], + ['no sources', { sources: [] }], + ['a bare list of links', [{ url: 'https://a.dev' }]] + ])('rejects %s', (_, result) => { + expect(webSearchResultOf(result)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index f58ed3b6d3..772284865d 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -610,6 +610,77 @@ export type WebSearchSource = { title?: string } +/** The result shape any tool returns to have it rendered as a web search card. */ +export type WebSearchResult = { + sources: WebSearchSource[] + query?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A link the card can render: the source list drops everything else, so a result whose + * urls are relative or `javascript:` would show as an empty list in place of its own output. */ +export function isRenderableSourceUrl(url: string): boolean { + try { + return ['http:', 'https:'].includes(new URL(url).protocol) + } catch { + return false + } +} + +/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a + * TypeScript one, so both read as absent. */ +function isOptionalString(value: unknown): boolean { + return value === null || typeof value === 'string' +} + +function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } { + return ( + isRecord(value) && + typeof value.url === 'string' && + isRenderableSourceUrl(value.url) && + Object.entries(value).every( + ([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v)) + ) + ) +} + +/** + * A tool result as a web search, or undefined when it is anything else. The keys must be + * exactly `sources` and an optional `query`: the card renders only urls and titles, so a + * result carrying more would lose it. Accepts the result as its JSON text too. + */ +export function webSearchResultOf(result: unknown): WebSearchResult | undefined { + if (typeof result === 'string') { + try { + result = JSON.parse(result) + } catch { + return undefined + } + } + if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) { + return undefined + } + const sources = result.sources + if ( + !sources.every(isWebSearchSource) || + !Object.entries(result).every( + ([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v)) + ) + ) { + return undefined + } + return { + sources: sources.map((source) => ({ + url: source.url, + title: typeof source.title === 'string' ? source.title : undefined + })), + query: typeof result.query === 'string' ? result.query : undefined + } +} + export type ToolDisplayMessage = { role: 'tool' tool_call_id: string diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 6ef18b7212..077090aba2 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -3,7 +3,7 @@ import type { ChatSendRequestOptions, ChatViewHost } from '$lib/components/copilot/chat/chatViewHost' -import type { DisplayMessage } from '$lib/components/copilot/chat/shared' +import type { DisplayMessage, WebSearchResult } from '$lib/components/copilot/chat/shared' import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import { isPlanCardTool } from '$lib/components/copilot/chat/planMode' import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte' @@ -125,6 +125,33 @@ function toolErrorText(result: unknown): string | undefined { return undefined } +/** + * The provider-side search's row as the web search result shape, or undefined for any other + * row. The worker words it like a tool named `websearch`, which an agent tool may also be + * called; what tells them apart is that every agent tool's row stores its arguments, and the + * search stores none, only the citations it produced. + */ +function builtInWebSearch(message: ChatMessage): WebSearchResult | undefined { + const tool = message.tool + if (tool?.name !== 'websearch' || tool.arguments !== undefined || message.pending) { + return undefined + } + const citations = parseToolPayload(tool.result) + if (citations === undefined) return { sources: [] } + if ( + !Array.isArray(citations) || + !citations.every((c) => c && typeof c === 'object' && typeof c.url === 'string') + ) { + return undefined + } + return { + sources: citations.map((c: { url: string; title?: unknown }) => ({ + url: c.url, + title: typeof c.title === 'string' ? c.title : undefined + })) + } +} + /** The name the worker gives the structured-output tool: suffixed when an agent tool already has it. */ const STRUCTURED_OUTPUT_CALL = /^structured_output(_\d+)?$/ @@ -168,6 +195,25 @@ export function toDisplayMessages( ] } case 'tool': { + const search = builtInWebSearch(message) + if (search) { + const found = search.sources.length > 0 + // `autoCollapseDetails: false` opens it on its sources, as the session chat + // shows a search. A tool that returns the same shape stays collapsed like + // every other tool row, however many times the agent called it. + return [ + { + role: 'tool', + tool_call_id: message.id, + content: 'Searched the web', + toolName: 'web_search', + webSearchSources: found ? search.sources : undefined, + showDetails: found, + autoCollapseDetails: false, + jobId: message.jobId + } + ] + } // The model's call and what the tool sent back, as the stream carried them or the // worker stored them on the row. A row stored without them shows its name and job. const toolName = message.tool?.name diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts index 53fd7b6ab9..a0ae4b8092 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts @@ -125,6 +125,42 @@ describe('toDisplayMessages', () => { }) }) + // The worker words the provider-side search like a tool named `websearch`, which an agent + // tool may also be called. The search stores no arguments, and an agent tool always does. + it('renders the provider-side search as a web search card, not an agent tool of that name', () => { + const citations = JSON.stringify([ + { start_index: 0, end_index: 4, url: 'https://postgresql.org/docs', title: 'Release notes' } + ]) + const [search, agentTool, notCitations] = toDisplayMessages([ + message({ + role: 'tool', + content: 'Used websearch tool', + tool: { name: 'websearch', status: 'success', result: citations } + }), + message({ + role: 'tool', + content: 'Used websearch tool', + tool: { name: 'websearch', status: 'success', arguments: '{}', result: citations } + }), + // The safety valve of a name-based rule: a row that is not the provider's search + // keeps its result, whatever the tool is called. + message({ + role: 'tool', + content: 'Used websearch tool', + tool: { name: 'websearch', status: 'success', result: '{"hits":3}' } + }) + ]) + expect(search).toMatchObject({ + content: 'Searched the web', + webSearchSources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }], + showDetails: true, + autoCollapseDetails: false + }) + expect(agentTool).toMatchObject({ content: 'Used websearch tool', toolName: 'websearch' }) + expect(agentTool).not.toHaveProperty('webSearchSources') + expect(notCitations).toMatchObject({ toolName: 'websearch', result: { hits: 3 } }) + }) + it('shows a call the turn finished without as an error, not as still running', () => { const display = toDisplayMessages([ message({ role: 'user', content: 'hi' }), From 1de54eeea4c4820e7291d9648a44ef3c86979c5a Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 23 Sep 2026 15:44:40 +0200 Subject: [PATCH 084/112] feat(ai-chat): show native script edit diffs (#11278) * feat(ai-chat): show native script edit diffs * fix: render streamed edit diffs * fix: render in-editor script edit diffs * style: soften diff context separators * fix: render full script update diffs * fix: support empty script diffs * fix: bound streamed tool diffs * fix: preserve trailing diff context * fix: defer collapsed tool diffs * fix: retain full-code streaming previews * fix: preserve created script diffs * fix: disambiguate collapsed diff keys * fix: render final newline diffs * fix: preserve multiline diff highlighting * fix: highlight diff sides as complete sources * fix: bound completed tool diffs * fix: diff large completed tool edits Co-Authored-By: Claude Opus 5.5 (1M context) * fix: bound tool diff rendering Co-Authored-By: Claude Opus 5.5 (1M context) * fix: bound tool diff highlighting and expand omitted rows Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reveal omitted diff rows in chunks and bound long-line highlighting Co-Authored-By: Claude Opus 5.5 (1M context) * fix: collapse written scripts, skip empty diff bodies and fit line numbers Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep whole-file tool diffs collapsed while running Co-Authored-By: Claude Opus 5.5 (1M context) * fix: classify whole-file tool calls by argument shape Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reopen failed diff cards and keep empty streamed replacements Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../copilot/chat/ToolCodeDiffView.svelte | 170 ++++++++ .../copilot/chat/ToolDiffCard.svelte | 103 +++++ .../copilot/chat/ToolExecutionDisplay.svelte | 5 + .../copilot/chat/global/core.test.ts | 38 +- .../components/copilot/chat/global/core.ts | 35 +- .../copilot/chat/script/core.test.ts | 50 +++ .../components/copilot/chat/script/core.ts | 15 +- .../src/lib/components/copilot/chat/shared.ts | 10 + .../copilot/chat/toolCodeDiff.test.ts | 411 ++++++++++++++++++ .../components/copilot/chat/toolCodeDiff.ts | 381 ++++++++++++++++ .../copilot/chat/toolCodeDiffHighlight.ts | 57 +++ .../copilot/chat/toolCodeDiffLanguage.ts | 43 ++ .../test_dev/diff-comparison/+page.svelte | 139 ++++++ 13 files changed, 1443 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte create mode 100644 frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte create mode 100644 frontend/src/lib/components/copilot/chat/script/core.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolCodeDiff.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts create mode 100644 frontend/src/routes/test_dev/diff-comparison/+page.svelte diff --git a/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte new file mode 100644 index 0000000000..6f74ff7a80 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte @@ -0,0 +1,170 @@ + + + + +
    + {#each visibleRows as row, index ('key' in row ? `${row.kind}:${row.key}` : `line:${row.oldLine}:${row.newLine}:${index}`)} + {#if row.kind === 'collapsed' || row.kind === 'omitted'} + + {:else} +
    + {row.oldLine ?? ''} + + {row.newLine ?? ''}{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''} + + + {#each row.changedRanges ?? [] as range} + + {/each} + {@html highlightedLine(row) || ' '} + +
    + {/if} + {/each} +
    + + diff --git a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte new file mode 100644 index 0000000000..a2ab9d341c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte @@ -0,0 +1,103 @@ + + +{#snippet headerRight()} +
    + {#if counts && (counts.added > 0 || counts.removed > 0)} + + +{counts.added} + −{counts.removed} + + {/if} + {#if showPreviewChip && message.previewCard} + + {/if} +
    +{/snippet} + + (toggled = { id: toggleKey, open: !expanded })} + toggleable={hasBody} + shimmer={isRunning} + class={message.isQueued && !message.error + ? 'opacity-60 hover:opacity-100 transition-opacity' + : ''} + labelClass="truncate" + contentClass="p-0 overflow-hidden space-y-0" + {headerRight} +> + {#if diff && hasDiff && expanded} + + {/if} + {#if message.error} +
    + {message.error} +
    + {/if} +
    diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 8d945f94d1..d5fee5a4d9 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -40,6 +40,8 @@ import ToolPreviewCard from './ToolPreviewCard.svelte' import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte' import RunScriptCard from './RunScriptCard.svelte' + import ToolDiffCard from './ToolDiffCard.svelte' + import { hasToolCodeDiff } from './toolCodeDiff' import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte' import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte' @@ -132,6 +134,7 @@ // A call that inspected a run rather than starting one gets the same card, bound to // the job it named — what happened in a run reads the same either way. const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun)) + const isDiffCard = $derived(Boolean(message.codeDiff) || hasToolCodeDiff(message.toolName)) // The preview chip sits on the header row (to the right of the tool-call text); // shown once the tool settled, never while loading/erroring/awaiting confirmation. @@ -170,6 +173,8 @@
    {:else if isRunCard} +{:else if isDiffCard} + {:else if planState} diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 7a86e0ba76..a1755bcaa9 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2024,6 +2024,24 @@ describe('global AI tools', () => { }) }) + it('records a creation diff for a new script draft', async () => { + const statuses: any[] = [] + + await callGlobalTool( + 'write_script', + { + path: 'f/scripts/new-script', + language: 'python3', + content: 'print("hello")' + }, + { ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) } + ) + + expect(statuses).toContainEqual({ + codeDiff: { before: '', after: 'print("hello")', lang: 'python' } + }) + }) + it('tells a code app from a drag-and-drop app', async () => { vi.mocked(AppService.listApps).mockResolvedValueOnce([ { path: 'f/apps/code', summary: 'Code app', raw_app: true }, @@ -3080,12 +3098,17 @@ describe('global AI tools', () => { kind: 'script' } as any) - await callGlobalTool('write_script', { - path: 'f/scripts/existing', - summary: 'new summary', - language: 'bun', - content: 'new content' - }) + const statuses: any[] = [] + await callGlobalTool( + 'write_script', + { + path: 'f/scripts/existing', + summary: 'new summary', + language: 'bun', + content: 'new content' + }, + { ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) } + ) expect( getBackendDraft('script', 'f/scripts/existing', { workspace: WORKSPACE }) @@ -3097,6 +3120,9 @@ describe('global AI tools', () => { content: 'new content', language: 'bun' }) + expect(statuses).toContainEqual({ + codeDiff: { before: 'old deployed content', after: 'new content', lang: 'typescript' } + }) }) describe('stale-draft deploy guard and rebase', () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 5b0490c724..a4581bd173 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -137,8 +137,10 @@ import { type RunFormDisplay, type Tool, type ToolCallbacks, + type ToolCodeDiff, type ToolDisplayAction } from '../shared' +import { scriptLangToEditorLang } from '$lib/scripts' import { searchDocsTool, readDocsPageTool } from '../docs/core' import { createDbSchemaTool } from '../script/core' import type { ContextElement } from '../context' @@ -5029,10 +5031,12 @@ function finishAppDraftWrite( function finishDraftWrite( result: DraftPersistResult, existed: boolean, - ctx: WriteDraftCtx + ctx: WriteDraftCtx, + codeDiff?: ToolCodeDiff ): string { const failure = draftWriteFailure(result, ctx) if (failure) return failure + if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff }) ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath) maybeAttachPreviewCard(ctx, result.itemKind, result.item.path) const stored = result.item @@ -5078,7 +5082,11 @@ async function writeDraft( path: string, args: A, ctx: WriteDraftCtx, - opts: { triggerKind?: TriggerKind; override?: boolean } = {} + opts: { + triggerKind?: TriggerKind + override?: boolean + codeDiff?: ToolCodeDiff | ((base: T | undefined, draft: T) => ToolCodeDiff | undefined) + } = {} ): Promise { const { workspace } = ctx startDraftWrite(ctx, type, path) @@ -5097,7 +5105,8 @@ async function writeDraft( triggerKind: opts.triggerKind, force: opts.override }) - return finishDraftWrite(result, existed, ctx) + const codeDiff = typeof opts.codeDiff === 'function' ? opts.codeDiff(base, draft) : opts.codeDiff + return finishDraftWrite(result, existed, ctx, codeDiff) } type ScriptDraftArgs = { @@ -5150,8 +5159,21 @@ const SCRIPT_SPEC: WriteSpec = { } } -function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise { - return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { override: args.override }) +function writeScriptDraft( + args: ScriptDraftArgs, + ctx: WriteDraftCtx, + codeDiff?: ToolCodeDiff +): Promise { + return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { + override: args.override, + codeDiff: + codeDiff ?? + ((base, draft) => ({ + before: base?.content ?? '', + after: draft.content, + lang: scriptLangToEditorLang(draft.language) + })) + }) } type FlowDraftArgs = { @@ -5368,7 +5390,8 @@ async function editScript( language: base.language, content: updated }, - ctx + ctx, + { before: base.content, after: updated, lang: scriptLangToEditorLang(base.language) } ) } diff --git a/frontend/src/lib/components/copilot/chat/script/core.test.ts b/frontend/src/lib/components/copilot/chat/script/core.test.ts new file mode 100644 index 0000000000..f235ae3796 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/script/core.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('monaco-editor', () => ({ + editor: {}, + languages: {}, + KeyCode: {}, + Uri: { parse: (value: string) => ({ toString: () => value }) }, + MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 } +})) +vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({ + getTypeScriptWorker: async () => async () => ({}), + typescriptVersion: 'test' +})) +vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) +vi.mock('$lib/components/vscode', () => ({})) + +import { editCodeToolWithDiff } from './core' + +describe('editCodeToolWithDiff', () => { + it('records the complete script diff after applying replacements', async () => { + const statuses: unknown[] = [] + const applyCode = vi.fn(async () => {}) + + await editCodeToolWithDiff.fn({ + args: { diffs: [{ old_string: 'return 1', new_string: 'return 2' }] }, + helpers: { + getScriptOptions: () => ({ + code: 'export function main() {\n\treturn 1\n}', + lang: 'bun', + path: 'f/example', + args: {} + }), + applyCode + }, + toolCallbacks: { + setToolStatus: (_toolId, status) => statuses.push(status) + }, + toolId: 'edit-1' + }) + + expect(applyCode).toHaveBeenCalledTimes(2) + expect(statuses.at(-1)).toMatchObject({ + codeDiff: { + before: 'export function main() {\n\treturn 1\n}', + after: 'export function main() {\n\treturn 2\n}', + lang: 'typescript' + } + }) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 622f182eae..0c8b3f9a0e 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -27,6 +27,7 @@ import type { ReviewChangesOpts } from '../monaco-adapter' import { copilotInfo, getCurrentModel } from '$lib/aiStore' import { get } from 'svelte/store' import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata' +import { scriptLangToEditorLang } from '$lib/scripts' import { getScriptPrompt, getWorkflowAsCodePrompt } from '$system_prompts' // Score threshold for npm packages search filtering @@ -826,7 +827,12 @@ export const editCodeToolWithDiff: Tool = { toolCallbacks.setToolStatus(toolId, { content: `Code changes applied`, - result: 'Success' + result: 'Success', + codeDiff: { + before: oldCode, + after: updatedCode, + lang: scriptLangToEditorLang(scriptOptions.lang) + } }) return `Applied changes to the script editor.` } catch (error) { @@ -878,7 +884,12 @@ export const editCodeTool: Tool = { toolCallbacks.setToolStatus(toolId, { content: 'Code changes applied', - result: 'Success' + result: 'Success', + codeDiff: { + before: oldCode, + after: args.code, + lang: scriptLangToEditorLang(scriptOptions.lang) + } }) return 'Code has been applied to the script editor.' } catch (error) { diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 772284865d..83a51b527c 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -610,6 +610,13 @@ export type WebSearchSource = { title?: string } +export type ToolCodeDiff = { + before: string + after: string + /** Monaco language id. */ + lang: string +} + /** The result shape any tool returns to have it rendered as a web search card. */ export type WebSearchResult = { sources: WebSearchSource[] @@ -725,6 +732,9 @@ export type ToolDisplayMessage = { * always-visible card that opens (or focuses) the item's preview in the * session side panel. Set only for session chats — the side panel is their surface. */ previewCard?: { kind: PreviewCardKind; path: string } + /** Whole text on both sides of an edit this call saved. Kept in full rather than as a + * reference: drafts are overwritten in place, so the sides cannot be fetched back later. */ + codeDiff?: ToolCodeDiff planArtifactId?: string /** The version this card's proposal wrote, so a card scrolled far up still opens the plan * it proposed rather than what the document became. */ diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts new file mode 100644 index 0000000000..5077585a4f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from 'vitest' +import hljs from 'highlight.js/lib/core' +import { + argumentsCarryWholeFile, + diffLineCounts, + hasToolCodeDiff, + toolCodeDiff, + toolDiffLines, + visibleToolDiffRows +} from './toolCodeDiff' +import { highlightedSourceLines } from './toolCodeDiffHighlight' +import { TOOL_CODE_DIFF_LANGUAGES, toolCodeDiffLanguage } from './toolCodeDiffLanguage' +import type { ToolDisplayMessage } from './shared' + +function message(overrides: Partial): ToolDisplayMessage { + return { + role: 'tool', + tool_call_id: 'call-1', + content: 'Updated script "f/example" as a draft', + ...overrides + } +} + +describe('toolCodeDiff', () => { + it('uses the saved full script sides after an edit succeeds', () => { + const codeDiff = { + before: 'const before = true\n', + after: 'const after = true\n', + lang: 'typescript' + } + + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: { old_string: 'before', new_string: 'after' }, + codeDiff + }) + ) + ).toBe(codeDiff) + }) + + it('uses edit arguments until the full script sides are available', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: { old_string: 'before', new_string: 'after' } + }) + ) + ).toEqual({ before: 'before', after: 'after', lang: 'plaintext' }) + }) + + it('renders streamed edit arguments before their JSON object is complete', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: '{"old_string":"before\\nline","new_string":"after' + }) + ) + ).toEqual({ before: 'before\nline', after: 'after', lang: 'plaintext' }) + }) + + it('uses each replacement from an in-editor edit while its arguments stream', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_code', + parameters: { + diffs: [ + { old_string: 'first old', new_string: 'first new' }, + { old_string: 'second old', new_string: 'second new' } + ] + } + }) + ) + ).toEqual({ + before: 'first old\nsecond old', + after: 'first new\nsecond new', + lang: 'plaintext' + }) + }) + + it('uses each replacement while in-editor edit arguments are incomplete', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_code', + parameters: + '{"diffs":[{"old_string":"first old","new_string":"first new"},{"old_string":"second old","new_string":"second new' + }) + ) + ).toEqual({ + before: 'first old\nsecond old', + after: 'first new\nsecond new', + lang: 'plaintext' + }) + }) + + it('shows a streamed full-code editor update without replacements', () => { + expect( + toolCodeDiff(message({ toolName: 'edit_code', parameters: '{"code":"console.log(1)' })) + ).toEqual({ before: '', after: 'console.log(1)', lang: 'plaintext' }) + }) + + it('shows streamed full-script updates before the saved diff is available', () => { + expect( + toolCodeDiff( + message({ toolName: 'write_script', parameters: '{"content":"print(\\\"hello\\\")' }) + ) + ).toEqual({ before: '', after: 'print("hello")', lang: 'plaintext' }) + }) + + it('only marks tools with a defined argument diff as diff-capable', () => { + expect(hasToolCodeDiff('edit_script')).toBe(true) + expect(hasToolCodeDiff('edit_code')).toBe(true) + expect(hasToolCodeDiff('write_script')).toBe(true) + expect(hasToolCodeDiff('__proto__')).toBe(false) + expect(hasToolCodeDiff('toString')).toBe(false) + expect(hasToolCodeDiff(undefined)).toBe(false) + }) + + it('ignores tool names inherited from Object.prototype', () => { + expect(toolCodeDiff(message({ toolName: 'constructor', parameters: {} }))).toBeUndefined() + }) + + it('counts added and removed lines independently', () => { + expect( + diffLineCounts({ + before: 'one\ntwo\nthree\n', + after: 'one\nnew\nthree\nfour\n', + lang: 'plaintext' + }) + ).toEqual({ + added: 2, + removed: 1 + }) + }) + + it('keeps a final newline as a diffable line', () => { + expect(toolDiffLines({ before: 'one', after: 'one\n', lang: 'plaintext' })).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { kind: 'added', content: '', newLine: 2 } + ]) + }) + + it('does not add a final-newline row when creating or deleting a file', () => { + expect(toolDiffLines({ before: '', after: 'one\n', lang: 'plaintext' })).toEqual([ + { kind: 'added', content: 'one', newLine: 1, changedRanges: [{ start: 0, length: 3 }] } + ]) + expect(toolDiffLines({ before: 'one\n', after: '', lang: 'plaintext' })).toEqual([ + { kind: 'removed', content: 'one', oldLine: 1, changedRanges: [{ start: 0, length: 3 }] } + ]) + }) + + it('keeps both gutters correct across removed and added lines', () => { + expect( + toolDiffLines({ + before: 'one\ntwo\nthree\n', + after: 'one\nnew\nthree\nfour\n', + lang: 'plaintext' + }) + ).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { + kind: 'removed', + content: 'two', + oldLine: 2, + newLine: undefined, + changedRanges: [{ start: 0, length: 3 }] + }, + { + kind: 'added', + content: 'new', + oldLine: undefined, + newLine: 2, + changedRanges: [{ start: 0, length: 3 }] + }, + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 }, + { + kind: 'added', + content: 'four', + oldLine: undefined, + newLine: 4, + changedRanges: [{ start: 0, length: 4 }] + } + ]) + }) + + it('keeps unchanged trailing lines out of the change counts', () => { + const diff = { before: 'one\ntwo\nthree', after: 'one\nnew\nthree', lang: 'plaintext' } + + expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 }) + expect(toolDiffLines(diff).filter((line) => line.content === 'three')).toEqual([ + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 } + ]) + }) + + it('does not report changes for identical files', () => { + const diff = { before: 'one\ntwo\nthree', after: 'one\ntwo\nthree', lang: 'plaintext' } + + expect(diffLineCounts(diff)).toEqual({ added: 0, removed: 0 }) + expect(toolDiffLines(diff)).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { kind: 'context', content: 'two', oldLine: 2, newLine: 2 }, + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 } + ]) + }) + + it('renders a newly created file', () => { + expect(toolDiffLines({ before: '', after: 'print("hello")', lang: 'python' })).toEqual([ + { + kind: 'added', + content: 'print("hello")', + oldLine: undefined, + newLine: 1, + changedRanges: [{ start: 0, length: 14 }] + } + ]) + }) + + it('renders a deleted file', () => { + expect(toolDiffLines({ before: 'print("goodbye")', after: '', lang: 'python' })).toEqual([ + { + kind: 'removed', + content: 'print("goodbye")', + oldLine: 1, + newLine: undefined, + changedRanges: [{ start: 0, length: 16 }] + } + ]) + }) + + it('uses Monaco line alignment for reordered lines', () => { + expect( + toolDiffLines({ + before: 'const a = 1\nconst b = 2', + after: 'const b = 2\nconst a = 1', + lang: 'typescript' + }) + ).toEqual([ + { + kind: 'added', + content: 'const b = 2', + newLine: 1, + changedRanges: [{ start: 0, length: 11, extendsToEnd: true }] + }, + { kind: 'context', content: 'const a = 1', oldLine: 1, newLine: 2 }, + { + kind: 'removed', + content: 'const b = 2', + oldLine: 2, + changedRanges: [{ start: 0, length: 11 }] + } + ]) + }) + + it('avoids expensive alignment while arguments stream', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + + expect(toolDiffLines({ before, after, lang: 'plaintext' }, true)).toHaveLength(400) + expect(diffLineCounts({ before, after, lang: 'plaintext' }, true)).toEqual({ + added: 1_000, + removed: 1_000 + }) + }) + + it('diffs a small edit in a large file', () => { + const beforeLines = Array.from({ length: 600 }, (_, index) => `line ${index}`) + const afterLines = beforeLines.map((line, index) => (index === 400 ? 'changed' : line)) + const diff = { before: beforeLines.join('\n'), after: afterLines.join('\n'), lang: 'plaintext' } + const lines = toolDiffLines(diff) + + expect(lines.filter((line) => line.kind !== 'context')).toMatchObject([ + { kind: 'removed', content: 'line 400', oldLine: 401 }, + { kind: 'added', content: 'changed', newLine: 401 } + ]) + expect(lines[399]).toEqual({ kind: 'context', content: 'line 399', oldLine: 400, newLine: 400 }) + expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 }) + }) + + it('reports a large rewrite as a whole-file change', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + + expect(diffLineCounts({ before, after, lang: 'plaintext' })).toEqual({ + added: 1_000, + removed: 1_000 + }) + }) + + it('keeps an empty streamed replacement paired with its own edit', () => { + const parameters = + '{"diffs":[{"old_string":"a","new_string":""},{"old_string":"b","new_string":"c"}]}' + + expect(toolCodeDiff(message({ toolName: 'edit_code', parameters }))).toEqual({ + before: 'a\nb', + after: '\nc', + lang: 'plaintext' + }) + }) + + it('recognizes calls whose arguments are a whole file', () => { + const call = ( + toolName: string, + parameters: unknown, + codeDiff?: ToolDisplayMessage['codeDiff'] + ) => argumentsCarryWholeFile(message({ toolName, parameters, codeDiff })) + + expect(call('write_script', { path: 'f/a', content: '' })).toBe(true) + expect(call('edit_code', '{"code":"x')).toBe(true) + expect( + call('edit_code', { code: 'x' }, { before: 'old', after: 'x', lang: 'typescript' }) + ).toBe(true) + expect(call('edit_code', { diffs: [{ old_string: 'a', new_string: 'b' }] })).toBe(false) + expect(call('edit_script', { old_string: 'a', new_string: 'b' })).toBe(false) + }) + + it('shows each row of a small edit once', () => { + const lines = toolDiffLines({ before: 'a\nb\nc\n', after: 'a\nB\nc\n', lang: 'plaintext' }) + + expect(visibleToolDiffRows(lines, new Map())).toEqual(lines) + expect(lines.map((line) => line.kind)).toEqual(['context', 'removed', 'added', 'context']) + }) + + it('bounds the rows of a large rewrite', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + const lines = toolDiffLines({ before, after, lang: 'plaintext' }) + const rows = visibleToolDiffRows(lines, new Map()) + + expect(rows).toHaveLength(802) + expect(rows[400]).toEqual({ kind: 'omitted', key: 'removed:0', count: 600 }) + expect(rows[401]).toMatchObject({ kind: 'added', newLine: 1 }) + expect(rows[801]).toEqual({ kind: 'omitted', key: 'added:1000', count: 600 }) + + // One click on a run reveals 400 more rows; the overall cap still holds until `end` is clicked. + const expanded = visibleToolDiffRows(lines, new Map([['removed:0', 1]])) + expect(expanded).toHaveLength(1_001) + expect(expanded[800]).toEqual({ kind: 'omitted', key: 'removed:0', count: 200 }) + expect(expanded[1_000]).toEqual({ kind: 'omitted', key: 'end', count: 801 }) + expect( + visibleToolDiffRows( + lines, + new Map([ + ['removed:0', 1], + ['end', 1] + ]) + ) + ).toHaveLength(1_202) + }) + + it('highlights each line within the scope of the whole source', () => { + const typescript = toolCodeDiffLanguage('typescript') + hljs.registerLanguage(typescript.name, typescript.register) + const code = 'const s = `a\nb`\nconst n = 1\n' + + const lines = highlightedSourceLines(code, typescript.name) + expect(lines).toHaveLength(3) + expect(lines[0]).toBe( + 'const s = `a' + ) + expect(lines[1]).toMatch(/^b`<\/span>/) + expect(highlightedSourceLines(code, typescript.name, 2)).toEqual(lines.slice(0, 2)) + expect(highlightedSourceLines('a\nb', typescript.name)).toEqual(['a', 'b']) + expect(highlightedSourceLines('a\nb\n', typescript.name)).toEqual(['a', 'b']) + expect(highlightedSourceLines('', typescript.name)).toEqual([]) + + const large = Array.from({ length: 2_001 }, () => 'const a = 1 < 2').join('\n') + '\n' + const plain = highlightedSourceLines(large, typescript.name) + expect(plain).toHaveLength(2_001) + expect(plain[0]).toBe('const a = 1 < 2') + + const minified = highlightedSourceLines('x'.repeat(100_001), typescript.name) + expect(minified).toEqual(['x'.repeat(10_000) + '…']) + }) + + it('has a highlighter for every supported editor language', () => { + expect(TOOL_CODE_DIFF_LANGUAGES).toEqual( + expect.arrayContaining([ + 'typescript', + 'javascript', + 'python', + 'json', + 'yaml', + 'sql', + 'shell', + 'powershell', + 'php', + 'rust', + 'graphql', + 'csharp', + 'nu', + 'java', + 'r', + 'go', + 'ruby', + 'plaintext' + ]) + ) + for (const language of TOOL_CODE_DIFF_LANGUAGES) { + const highlighter = toolCodeDiffLanguage(language) + hljs.registerLanguage(highlighter.name, highlighter.register) + expect(() => + hljs.highlight('const changed = true', { language: highlighter.name }) + ).not.toThrow() + } + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts new file mode 100644 index 0000000000..2e5d8bf13c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts @@ -0,0 +1,381 @@ +// This is Monaco's advanced diff engine; it supplies the same multi-line inner ranges. +import { DefaultLinesDiffComputer } from '@codingame/monaco-vscode-api/vscode/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer' +import type { ToolCodeDiff, ToolDisplayMessage } from './shared' + +// Builds a diff from a call's own arguments, for the moments the saved `codeDiff` does not +// exist: while the arguments stream, when the call failed, and for transcripts saved before +// calls recorded one. Argument diffs have no language or surrounding lines. +const ARGS_DIFF_BY_TOOL: Record ToolCodeDiff | undefined> = { + edit_script: (params) => argumentDiff(streamingEditArguments(params)), + write_script: (params) => fullContentArgumentDiff(params, 'content'), + edit_code: (params) => { + if ( + params && + typeof params === 'object' && + Array.isArray((params as { diffs?: unknown }).diffs) + ) { + const diffs = (params as { diffs: unknown[] }).diffs + return argumentDiffs(diffs.map(streamingEditArguments)) + } + if (typeof params === 'string') { + return ( + argumentDiffs(streamingEditArgumentsArray(params)) ?? + fullContentArgumentDiff(params, 'code') + ) + } + return argumentDiff(streamingEditArguments(params)) ?? fullContentArgumentDiff(params, 'code') + } +} + +function fullContentArgumentDiff(params: unknown, key: string): ToolCodeDiff | undefined { + const content = + params && typeof params === 'object' + ? (params as Record)[key] + : typeof params === 'string' + ? partialJsonString(params, key) + : undefined + return typeof content === 'string' ? { before: '', after: content, lang: 'plaintext' } : undefined +} + +function argumentDiff( + edit: { old_string?: string; new_string?: string } | undefined +): ToolCodeDiff | undefined { + return typeof edit?.old_string === 'string' || typeof edit?.new_string === 'string' + ? { + before: typeof edit.old_string === 'string' ? edit.old_string : '', + after: typeof edit.new_string === 'string' ? edit.new_string : '', + lang: 'plaintext' + } + : undefined +} + +function argumentDiffs( + edits: ({ old_string?: string; new_string?: string } | undefined)[] +): ToolCodeDiff | undefined { + const diffs = edits.map(argumentDiff).filter((diff): diff is ToolCodeDiff => diff !== undefined) + if (diffs.length === 0) return undefined + return { + before: diffs.map((diff) => diff.before).join('\n'), + after: diffs.map((diff) => diff.after).join('\n'), + lang: 'plaintext' + } +} + +function streamingEditArguments( + params: unknown +): { old_string?: string; new_string?: string } | undefined { + if (params && typeof params === 'object') + return params as { old_string?: string; new_string?: string } + if (typeof params !== 'string') return undefined + + return { + old_string: partialJsonString(params, 'old_string'), + new_string: partialJsonString(params, 'new_string') + } +} + +function streamingEditArgumentsArray( + partialJson: string +): { old_string?: string; new_string?: string }[] { + const oldStrings = partialJsonStrings(partialJson, 'old_string') + const newStrings = partialJsonStrings(partialJson, 'new_string') + return Array.from({ length: Math.max(oldStrings.length, newStrings.length) }, (_, index) => ({ + old_string: oldStrings[index], + new_string: newStrings[index] + })) +} + +function partialJsonString(partialJson: string, key: string): string | undefined { + return partialJsonStrings(partialJson, key)[0] +} + +function partialJsonStrings(partialJson: string, key: string): string[] { + const matches = partialJson.matchAll(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'g')) + const result: string[] = [] + // An empty string is a real value (a deletion): skipping it would pair the next edit's + // `new_string` with this edit's `old_string`. + for (const match of matches) { + try { + result.push(JSON.parse(`"${match[1]}"`)) + } catch { + continue + } + } + return result +} + +export function hasToolCodeDiff(toolName: string | undefined): boolean { + return toolName !== undefined && Object.hasOwn(ARGS_DIFF_BY_TOOL, toolName) +} + +export function toolCodeDiff(message: ToolDisplayMessage): ToolCodeDiff | undefined { + if (message.codeDiff) return message.codeDiff + if (!message.toolName) return undefined + return Object.hasOwn(ARGS_DIFF_BY_TOOL, message.toolName) + ? ARGS_DIFF_BY_TOOL[message.toolName](message.parameters) + : undefined +} + +// Decided by tool and argument shape, never by the saved `codeDiff`: an overwrite's saved diff +// has a before side, and clearing a script leaves both argument sides empty, yet both calls sent +// a whole file. +export function argumentsCarryWholeFile(message: ToolDisplayMessage): boolean { + if (message.toolName === 'write_script') return true + if (message.toolName !== 'edit_code') return false + const params = message.parameters + if (typeof params === 'string') return /"code"\s*:/.test(params) + return Boolean(params && typeof params === 'object' && 'code' in params) +} + +export function diffLineCounts( + diff: ToolCodeDiff, + streaming = false +): { added: number; removed: number } { + if (streaming) { + return { added: lines(diff.after).length, removed: lines(diff.before).length } + } + + return toolDiffLineCounts(toolDiffLines(diff)) +} + +export function toolDiffLineCounts(lines: ToolDiffLine[]): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of lines) { + if (line.kind === 'added') added++ + else if (line.kind === 'removed') removed++ + } + return { added, removed } +} + +export type ToolDiffLine = { + kind: 'context' | 'added' | 'removed' + content: string + oldLine?: number + newLine?: number + changedRanges?: CharacterRange[] +} + +export type CharacterRange = { start: number; length: number; extendsToEnd?: boolean } + +const STREAMING_PREVIEW_LINES = 200 + +function lines(value: string): string[] { + const result = value.split('\n') + if (result.at(-1) === '') result.pop() + return result +} + +function hasFinalNewline(value: string): boolean { + return value.endsWith('\n') +} + +export function toolDiffLines(diff: ToolCodeDiff, streaming = false): ToolDiffLine[] { + const before = lines(diff.before) + const after = lines(diff.after) + if (streaming) return streamingDiffLines(before, after) + + const result: ToolDiffLine[] = [] + const monacoBefore = monacoLines(diff.before) + const monacoAfter = monacoLines(diff.after) + // Runs on the UI thread. Cost follows the amount changed, not file size: only a large + // rewrite reaches the cap, and Monaco then reports the whole file as one change. + const changes = new DefaultLinesDiffComputer().computeDiff(monacoBefore, monacoAfter, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 200, + computeMoves: false, + extendToSubwords: false + }).changes + const removedRanges = new Map() + const addedRanges = new Map() + for (const change of changes) { + for (const innerChange of change.innerChanges ?? []) { + addCharacterRanges(removedRanges, monacoBefore, innerChange.originalRange) + addCharacterRanges(addedRanges, monacoAfter, innerChange.modifiedRange) + } + } + + let oldIndex = 0 + let newIndex = 0 + for (const change of changes) { + const oldStart = change.original.startLineNumber - 1 + const newStart = change.modified.startLineNumber - 1 + appendContextLines( + result, + before, + after, + oldIndex, + newIndex, + Math.min(oldStart - oldIndex, newStart - newIndex) + ) + appendChangedLines( + result, + 'removed', + before, + oldStart, + change.original.endLineNumberExclusive - 1, + removedRanges + ) + appendChangedLines( + result, + 'added', + after, + newStart, + change.modified.endLineNumberExclusive - 1, + addedRanges + ) + oldIndex = Math.min(change.original.endLineNumberExclusive - 1, before.length) + newIndex = Math.min(change.modified.endLineNumberExclusive - 1, after.length) + } + const trailingContext = Math.max(0, Math.min(before.length - oldIndex, after.length - newIndex)) + appendContextLines(result, before, after, oldIndex, newIndex, trailingContext) + oldIndex += trailingContext + newIndex += trailingContext + appendChangedLines(result, 'removed', before, oldIndex, before.length, removedRanges) + appendChangedLines(result, 'added', after, newIndex, after.length, addedRanges) + if ( + diff.before !== '' && + diff.after !== '' && + hasFinalNewline(diff.before) !== hasFinalNewline(diff.after) + ) { + result.push( + hasFinalNewline(diff.before) + ? { kind: 'removed', content: '', oldLine: before.length + 1 } + : { kind: 'added', content: '', newLine: after.length + 1 } + ) + } + + return result +} + +export type VisibleToolDiffRow = + | ToolDiffLine + | { kind: 'collapsed'; key: string; count: number } + | { kind: 'omitted'; key: string; count: number } + +const CONTEXT_LINES = 3 +// Every visible row is a DOM row with no virtualization, and only context collapses: a whole-file +// rewrite would otherwise render every line of both sides. Two capped runs fit under the overall +// cap, so a rewrite still shows its added side. Each expansion of an omitted row reveals one more +// cap's worth, so no single click renders an unbounded number of rows. +const MAX_CHANGED_RUN_ROWS = 400 +const MAX_VISIBLE_ROWS = 1_000 + +// `expansions` counts the clicks on each collapsed or omitted row, by its key. +export function visibleToolDiffRows( + lines: ToolDiffLine[], + expansions: ReadonlyMap +): VisibleToolDiffRow[] { + const result: VisibleToolDiffRow[] = [] + for (let index = 0; index < lines.length; ) { + const start = index + const kind = lines[start].kind + while (index < lines.length && lines[index].kind === kind) index++ + const count = index - start + + if (kind !== 'context') { + const key = `${kind}:${start}` + const shown = MAX_CHANGED_RUN_ROWS * (1 + (expansions.get(key) ?? 0)) + result.push(...lines.slice(start, Math.min(index, start + shown))) + if (count > shown) result.push({ kind: 'omitted', key, count: count - shown }) + continue + } + + const key = `${lines[start].oldLine}:${lines[start].newLine}:${count}` + if (count <= CONTEXT_LINES * 2 + 1 || expansions.has(key)) { + result.push(...lines.slice(start, index)) + continue + } + result.push(...lines.slice(start, start + CONTEXT_LINES)) + result.push({ kind: 'collapsed', key, count: count - CONTEXT_LINES * 2 }) + result.push(...lines.slice(index - CONTEXT_LINES, index)) + } + + const maxRows = MAX_VISIBLE_ROWS * (1 + (expansions.get('end') ?? 0)) + if (result.length <= maxRows) return result + const hidden = result + .slice(maxRows) + .reduce((total, row) => total + ('count' in row ? row.count : 1), 0) + return [...result.slice(0, maxRows), { kind: 'omitted', key: 'end', count: hidden }] +} + +function streamingDiffLines(before: string[], after: string[]): ToolDiffLine[] { + return [ + ...before + .slice(0, STREAMING_PREVIEW_LINES) + .map((content, index) => ({ kind: 'removed' as const, content, oldLine: index + 1 })), + ...after + .slice(0, STREAMING_PREVIEW_LINES) + .map((content, index) => ({ kind: 'added' as const, content, newLine: index + 1 })) + ] +} + +function monacoLines(value: string): string[] { + const result = lines(value) + return result.length ? result : [''] +} + +function appendContextLines( + result: ToolDiffLine[], + before: string[], + after: string[], + oldStart: number, + newStart: number, + count: number +): void { + for (let index = 0; index < count; index++) { + result.push({ + kind: 'context', + content: before[oldStart + index], + oldLine: oldStart + index + 1, + newLine: newStart + index + 1 + }) + } +} + +function appendChangedLines( + result: ToolDiffLine[], + kind: 'added' | 'removed', + source: string[], + start: number, + end: number, + ranges: Map +): void { + for (let index = start; index < Math.min(end, source.length); index++) { + const changedRanges = ranges.get(index + 1) + result.push({ + kind, + content: source[index], + ...(kind === 'added' ? { newLine: index + 1 } : { oldLine: index + 1 }), + ...(changedRanges?.length ? { changedRanges } : {}) + }) + } +} + +function addCharacterRanges( + ranges: Map, + lines: string[], + range: { + startLineNumber: number + startColumn: number + endLineNumber: number + endColumn: number + } +): void { + for ( + let lineNumber = range.startLineNumber; + lineNumber <= Math.min(range.endLineNumber, lines.length); + lineNumber++ + ) { + const content = lines[lineNumber - 1] + const start = lineNumber === range.startLineNumber ? range.startColumn - 1 : 0 + const extendsToEnd = lineNumber < range.endLineNumber + const end = extendsToEnd ? content.length : range.endColumn - 1 + if (end > start) { + ranges.set(lineNumber, [ + ...(ranges.get(lineNumber) ?? []), + { start, length: end - start, ...(extendsToEnd ? { extendsToEnd } : {}) } + ]) + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts new file mode 100644 index 0000000000..b63d002e9e --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts @@ -0,0 +1,57 @@ +import hljs from 'highlight.js/lib/core' +import { escapeHtml } from '$lib/utils' + +// highlight.js runs on the UI thread at roughly 15 ms per 1,000 lines (50 KB), and must see the +// whole source for multi-line strings and comments to scope correctly. Larger sides, including +// minified one-line sources, render as plain text with each line cut at MAX_PLAIN_LINE_CHARS. +const MAX_HIGHLIGHTED_LINES = 2_000 +const MAX_HIGHLIGHTED_CHARS = 100_000 +const MAX_PLAIN_LINE_CHARS = 10_000 + +// Highlights the whole source before splitting it, so a line inside a multi-line string or +// comment keeps that scope. Only the first `lineLimit` lines are highlighted: a streamed source +// grows on every delta while the preview shows a bounded prefix. +export function highlightedSourceLines( + code: string, + languageName: string, + lineLimit = Infinity +): string[] { + if (code === '' || lineLimit <= 0) return [] + const sourceLines = code.split('\n') + const truncated = sourceLines.length > lineLimit + const shownLines = truncated ? sourceLines.slice(0, lineLimit) : sourceLines + const source = truncated ? shownLines.join('\n') : code + if (shownLines.length > MAX_HIGHLIGHTED_LINES || source.length > MAX_HIGHLIGHTED_CHARS) { + const plain = shownLines.map((line) => + escapeHtml( + line.length > MAX_PLAIN_LINE_CHARS ? line.slice(0, MAX_PLAIN_LINE_CHARS) + '…' : line + ) + ) + if (!truncated && code.endsWith('\n')) plain.pop() + return plain + } + const highlighted = hljs.highlight(source, { language: languageName }).value + return splitHighlightedLines(highlighted, !truncated && code.endsWith('\n')) +} + +// Returns one entry per source line, closing the spans still open at each line end and +// reopening them on the next line so every entry is balanced HTML. +export function splitHighlightedLines(highlighted: string, hasFinalNewline: boolean): string[] { + const result: string[] = [] + const openTags: string[] = [] + let line = '' + + for (const token of highlighted.split(/(<[^>]+>|\n)/)) { + if (token === '\n') { + result.push(line + ''.repeat(openTags.length)) + line = openTags.join('') + } else { + line += token + if (token.startsWith('') openTags.pop() + } + } + + if (!hasFinalNewline) result.push(line) + return result +} diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts new file mode 100644 index 0000000000..ddf2464136 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts @@ -0,0 +1,43 @@ +import { csharp, go, java, json, plaintext, type LanguageType } from 'svelte-highlight/languages' +import graphql from 'svelte-highlight/languages/graphql' +import javascript from 'svelte-highlight/languages/javascript' +import php from 'svelte-highlight/languages/php' +import python from 'svelte-highlight/languages/python' +import powershell from 'svelte-highlight/languages/powershell' +import r from 'svelte-highlight/languages/r' +import ruby from 'svelte-highlight/languages/ruby' +import rust from 'svelte-highlight/languages/rust' +import shell from 'svelte-highlight/languages/shell' +import sql from 'svelte-highlight/languages/sql' +import typescript from 'svelte-highlight/languages/typescript' +import yaml from 'svelte-highlight/languages/yaml' + +// Keyed by the editor language `scriptLangToEditorLang` returns, not the script language. +// for related places search: ADD_NEW_LANG +const LANGUAGE_BY_MONACO: Record> = { + typescript, + javascript, + python, + json, + yaml, + sql, + shell, + powershell, + php, + rust, + graphql, + csharp, + nu: python, + java, + r, + go, + ruby, + text: plaintext, + plaintext +} + +export const TOOL_CODE_DIFF_LANGUAGES = Object.keys(LANGUAGE_BY_MONACO) + +export function toolCodeDiffLanguage(language: string): LanguageType { + return LANGUAGE_BY_MONACO[language] ?? plaintext +} diff --git a/frontend/src/routes/test_dev/diff-comparison/+page.svelte b/frontend/src/routes/test_dev/diff-comparison/+page.svelte new file mode 100644 index 0000000000..c473dbcb64 --- /dev/null +++ b/frontend/src/routes/test_dev/diff-comparison/+page.svelte @@ -0,0 +1,139 @@ + + + + Diff comparison + + +
    +
    + {#each cases as item} + + {/each} +
    + +
    +
    +

    Monaco

    +
    + {#key activeCase.id} + + {/key} +
    +
    + +
    +

    Native

    +
    + {#key activeCase.id} + + {/key} +
    +
    +
    +
    From 7593597617110fbd276f3f32ca2be7064547a080 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 23 Sep 2026 15:50:32 +0200 Subject: [PATCH 085/112] fix: validate app and trigger paths, refuse traversal in workspace export (#11311) * fix: enforce proper_id paths on apps and triggers, refuse traversal in export Co-Authored-By: Claude Opus 5.5 (1M context) * fix: skip proper_id on tables already holding non-conforming paths Co-Authored-By: Claude Opus 5.5 (1M context) * test: pin archive entry path traversal guard Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse windows-normalized traversal in export, check raw app path early Co-Authored-By: Claude Opus 5.5 (1M context) * fix: only treat a colon in the first archive segment as a drive prefix Co-Authored-By: Claude Opus 5.5 (1M context) * test: accept a colon past the first archive segment Co-Authored-By: Claude Opus 5.5 (1M context) * revert: drop proper_id migration, keep path validation in the API Co-Authored-By: Claude Opus 5.5 (1M context) * fix: validate paths in bulk http trigger creation Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-api/src/apps.rs | 7 ++- backend/windmill-api/src/workspaces_export.rs | 60 ++++++++++++++++++- backend/windmill-common/src/utils.rs | 3 + backend/windmill-trigger-http/src/handler.rs | 2 + backend/windmill-trigger/src/handler.rs | 6 +- 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index edd2040143..54886e2568 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -69,7 +69,7 @@ use windmill_common::{ user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ - http_get_from_hub, not_found_if_none, paginate, paginate_optional, + check_proper_path, http_get_from_hub, not_found_if_none, paginate, paginate_optional, query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath, }, variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, @@ -2506,6 +2506,7 @@ async fn create_app_internal<'a>( // inside process_app_multipart!, so checking after this call would leave a // denied app committed in the DB. check_scopes(&authed, || format!("apps:write:{}", &app.path))?; + check_proper_path(&app.path)?; validate_frontend_sdk_scopes(&app.policy)?; if raw_app { validate_raw_app_path_keys(&app.value.0)?; @@ -3252,6 +3253,7 @@ async fn create_app_raw_source( // Before the compile, which costs a job on a worker: it must not run for a // path already taken, nor for one the caller can't write. `create_app_internal` // rejects both, but only after the sources have been built. + check_proper_path(&path)?; if app_exists(&db, &w_id, &path).await? { return Err(Error::BadRequest(format!("App {path} already exists"))); } @@ -3462,6 +3464,9 @@ async fn update_app_internal<'a>( // the token's write scope, not just the source path. if let Some(npath) = ns.path.as_deref() { check_scopes(&authed, || format!("apps:write:{}", npath))?; + if npath != path { + check_proper_path(npath)?; + } } if raw_app { diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index e29113ecd2..ea79aaa572 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -281,8 +281,29 @@ enum ArchiveImpl { Tar(tokio_tar::Builder), } +/// Entry paths come from item paths stored in the workspace; an absolute or +/// drive-prefixed path, or a parent segment, would make extraction write outside +/// the target directory. Win32 strips trailing dots and spaces from a segment, +/// so `.. ` resolves to `..` there: any segment made only of those is refused. +/// A colon matters only in the first segment (a drive prefix); later segments, +/// such as a data table name, may carry one. +fn check_archive_entry_path(path: &str) -> Result<()> { + let is_dot_segment = |seg: &str| !seg.is_empty() && seg.chars().all(|c| c == '.' || c == ' '); + let first = path.split(['/', '\\']).next().unwrap_or_default(); + if path.starts_with(['/', '\\']) + || first.contains(':') + || path.split(['/', '\\']).any(is_dot_segment) + { + return Err(Error::internal_err(format!( + "refusing to write archive entry with path traversal: {path}" + ))); + } + Ok(()) +} + impl ArchiveImpl { async fn write_to_archive(&mut self, content: &str, path: &str) -> Result<()> { + check_archive_entry_path(path)?; match self { ArchiveImpl::Tar(t) => { let bytes = content.as_bytes(); @@ -1647,7 +1668,9 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color.clone(), operator_settings: row.operator_settings.clone(), - datatable: windmill_common::workspaces::strip_datatable_permissions(row.datatable.clone()), + datatable: windmill_common::workspaces::strip_datatable_permissions( + row.datatable.clone(), + ), slack_team_id: row.slack_team_id.clone(), slack_name: row.slack_name.clone(), slack_command_script: row.slack_command_script.clone(), @@ -1792,6 +1815,41 @@ pub(crate) async fn tarball_workspace( Ok((headers, body)) } +#[cfg(test)] +mod archive_entry_path_tests { + use super::check_archive_entry_path; + + #[test] + fn rejects_traversal_and_absolute_paths() { + for ok in [ + "u/admin/app.app.json", + "f/x/a..b.script.json", + "settings.yaml", + "migrations/datatable/foo:bar/20260617120000_name.up.sql", + ] { + assert!( + check_archive_entry_path(ok).is_ok(), + "{ok} should be accepted" + ); + } + for bad in [ + "f/x/../../evil.app.json", + "../evil", + "/etc/evil", + "f\\..\\evil", + "f/x/.. /.. /evil.app.json", + "f/x/.../evil", + "C:/evil", + "\\evil", + ] { + assert!( + check_archive_entry_path(bad).is_err(), + "{bad} should be rejected" + ); + } + } +} + #[cfg(test)] mod fork_export_tests { use super::*; diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 29fc9e99f3..b247c66532 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -245,6 +245,9 @@ lazy_static::lazy_static! { /// Reject a path the `proper_id` constraint would reject anyway, so the caller /// gets a plain 400 instead of the raw Postgres constraint-violation string, /// which names the table and constraint and echoes the input back. +/// +/// `app` and the trigger tables have no such constraint: for them this check is +/// the only authority, so loosening it lets malformed paths be stored. pub fn check_proper_path(path: &str) -> Result<()> { // The column is varchar(255); without this an over-long but well-formed path // still reaches Postgres and leaks the same kind of message back. diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 01ddfd4a81..539c8e43d2 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -13,6 +13,7 @@ use windmill_common::global_settings::{validate_allowed_origins, HTTP_ROUTE_WORK use windmill_common::{ db::UserDB, error::{Error, Result}, + utils::check_proper_path, worker::CLOUD_HOSTED, DB, }; @@ -269,6 +270,7 @@ pub async fn create_many_http_triggers( check_scopes(&authed, || { format!("http_triggers:write:{}", &new_http_trigger.base.path) })?; + check_proper_path(&new_http_trigger.base.path)?; // This route inserts directly, bypassing the shared create handler. // `error_wrapper` would turn the rejection into a 500. diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 66d6770354..ba40ed2c48 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -22,7 +22,7 @@ use windmill_common::{ fetch_draft_only_list_rows, overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, }, - utils::{paginate, Pagination, StripPath}, + utils::{check_proper_path, paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, }; @@ -547,6 +547,7 @@ async fn create_trigger( &new_trigger.base.path ) })?; + check_proper_path(&new_trigger.base.path)?; if *CLOUD_HOSTED && !T::IS_ALLOWED_ON_CLOUD { return Err(Error::BadRequest(format!( @@ -817,6 +818,9 @@ async fn update_trigger( &edit_trigger.base.path ) })?; + if edit_trigger.base.path != path { + check_proper_path(&edit_trigger.base.path)?; + } edit_trigger.error_handling.validate()?; From ceb17223dcb5f66532017036916e91a5323279fb Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 23 Sep 2026 16:36:23 +0200 Subject: [PATCH 086/112] fix: bill from a single service account telemetry count (#11301) * [ee] fix: bill from a single service account telemetry count Co-Authored-By: Claude Opus 5 * [ee] fix: count service accounts held in forks Co-Authored-By: Claude Opus 5 * chore: update ee-repo-ref to ed5a367b4def5280c3a5e1090e7abfa099a01042 This commit updates the EE repository reference after PR #824 was merged in windmill-ee-private. Previous ee-repo-ref: bc1fe61364c2a731ec26186eb04596254886a74c New ee-repo-ref: ed5a367b4def5280c3a5e1090e7abfa099a01042 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 Co-authored-by: windmill-internal-app[bot] --- ...5ab0199084b35664cbb0a586917bfa0da075.json} | 4 ++-- ...6aed1162fe85cb31c0414bc7e9b93a0684b43.json | 20 ------------------- backend/ee-repo-ref.txt | 2 +- 3 files changed, 3 insertions(+), 23 deletions(-) rename backend/.sqlx/{query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json => query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json} (58%) delete mode 100644 backend/.sqlx/query-e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43.json diff --git a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json b/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json similarity index 58% rename from backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json rename to backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json index 5661c59faf..90566827e7 100644 --- a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json +++ b/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true", + "query": "SELECT COUNT(DISTINCT email) FROM usr WHERE is_service_account AND NOT disabled", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf" + "hash": "3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075" } diff --git a/backend/.sqlx/query-e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43.json b/backend/.sqlx/query-e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43.json deleted file mode 100644 index accf6b0a49..0000000000 --- a/backend/.sqlx/query-e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(DISTINCT email) FROM usr\n WHERE is_service_account AND NOT disabled AND workspace_id NOT LIKE 'wm-fork%'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 80c21f89c1..211848b65a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0914cece0eb508150898022a0d78da9caa0a1c6d +ed5a367b4def5280c3a5e1090e7abfa099a01042 From 5b03c1732e905673480d0960b95577d47ec919b3 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:36:47 +0200 Subject: [PATCH 087/112] fix: build ag grid link cells as DOM nodes with a vetted href (#11316) Co-authored-by: Claude Opus 5.5 (1M context) --- .../display/table/utils.dom.test.ts | 29 +++++++++++++++++++ .../apps/components/display/table/utils.ts | 23 +++++++++++---- .../copilot/chat/LinkRenderer.svelte | 2 +- .../copilot/chat => utils}/safeHref.test.ts | 0 .../copilot/chat => utils}/safeHref.ts | 6 ++-- 5 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 frontend/src/lib/components/apps/components/display/table/utils.dom.test.ts rename frontend/src/lib/{components/copilot/chat => utils}/safeHref.test.ts (100%) rename frontend/src/lib/{components/copilot/chat => utils}/safeHref.ts (57%) diff --git a/frontend/src/lib/components/apps/components/display/table/utils.dom.test.ts b/frontend/src/lib/components/apps/components/display/table/utils.dom.test.ts new file mode 100644 index 0000000000..0e039139b0 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/table/utils.dom.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import type { ICellRendererParams } from 'ag-grid-community' +import { defaultCellRenderer } from './utils' + +function render(value: unknown) { + const renderer = defaultCellRenderer('link')! + return renderer({ value } as ICellRendererParams) as HTMLElement +} + +// AG Grid writes a renderer's string output to innerHTML, and link cells hold run results. +describe('link cell renderer', () => { + it('renders a link without parsing its label or href as markup', () => { + const el = render({ href: 'https://x.test/">', label: 'hi' }) + expect(el.tagName).toBe('A') + expect(el.textContent).toBe('hi') + expect(el.getAttribute('href')).toBe('https://x.test/">') + expect(el.children).toHaveLength(0) + }) + + it.each([ + { href: 'javascript:alert(1)', label: 'click' }, + 'javascript:alert(1)', + { href: 'data:text/html,', label: 'click' } + ])('renders a non-navigating href %j as plain text', (value) => { + const el = render(value) + expect(el.tagName).toBe('SPAN') + expect(el.hasAttribute('href')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/apps/components/display/table/utils.ts b/frontend/src/lib/components/apps/components/display/table/utils.ts index 015cdc54db..8b85a5894c 100644 --- a/frontend/src/lib/components/apps/components/display/table/utils.ts +++ b/frontend/src/lib/components/apps/components/display/table/utils.ts @@ -8,6 +8,7 @@ import type { TableAction } from '$lib/components/apps/editor/component' import { mount, unmount } from 'svelte' import { Button } from '$lib/components/common' import { Trash2 } from 'lucide-svelte' +import { safeHref } from '$lib/utils/safeHref' export type WindmillColumnDef = ColDef & { _isActionsColumn?: boolean @@ -110,15 +111,25 @@ export function isLinkObject(value: any): value is LinkObject { export function defaultCellRenderer(cellRendererType: string) { if (cellRendererType === 'link') { + // AG Grid assigns a string returned by a cell renderer to innerHTML, and cell values are + // run results, so the link is built as a DOM node that never parses them as markup. return (params: ICellRendererParams) => { - if (isLinkObject(params.value)) { - const value = params.value - return `${value.label}` - } else if (params.value) { - return `${params.value}` - } else { + if (!params.value) { return params.value } + const { href, label } = isLinkObject(params.value) + ? params.value + : { href: params.value, label: params.value } + const allowedHref = safeHref(String(href), window.location.href) + const el = document.createElement(allowedHref ? 'a' : 'span') + el.textContent = String(label) + if (allowedHref) { + el.setAttribute('href', allowedHref) + el.setAttribute('target', '_blank') + el.setAttribute('rel', 'noopener noreferrer') + el.className = 'underline' + } + return el } } else { return undefined diff --git a/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte b/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte index adc3fe6184..bce53625bb 100644 --- a/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte +++ b/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte @@ -13,7 +13,7 @@ type WindmillItemKind, type WorkspaceItemTargetKind } from './workspaceItems.svelte' - import { safeHref } from './safeHref' + import { safeHref } from '$lib/utils/safeHref' type Props = { href?: string diff --git a/frontend/src/lib/components/copilot/chat/safeHref.test.ts b/frontend/src/lib/utils/safeHref.test.ts similarity index 100% rename from frontend/src/lib/components/copilot/chat/safeHref.test.ts rename to frontend/src/lib/utils/safeHref.test.ts diff --git a/frontend/src/lib/components/copilot/chat/safeHref.ts b/frontend/src/lib/utils/safeHref.ts similarity index 57% rename from frontend/src/lib/components/copilot/chat/safeHref.ts rename to frontend/src/lib/utils/safeHref.ts index 3acdabb464..1d583fa2f7 100644 --- a/frontend/src/lib/components/copilot/chat/safeHref.ts +++ b/frontend/src/lib/utils/safeHref.ts @@ -1,10 +1,8 @@ const SAFE_PROTOCOLS = ['http:', 'https:', 'mailto:'] /** - * The href a rendered markdown link may carry, or undefined for one that must be dropped. - * - * Markdown reaches the chat renderers from a model, and from another member for a shared - * artifact, and `svelte-exmarkdown` passes `javascript:` and `data:` hrefs through untouched. + * The href a link built from untrusted content may carry, or undefined for one that must be + * dropped: `javascript:`, `data:` and any other non-navigating scheme. * Relative links resolve against `base` (the page), so they stay. */ export function safeHref(href: string | undefined, base: string): string | undefined { From 62d0088de5463d44d00a8c9204e03d20649acd07 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 23 Sep 2026 17:37:41 +0200 Subject: [PATCH 088/112] feat: show restart from failed step in run page top bar (#11317) * feat: show restart from failed step in run page top bar Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep failed-step restart button when selection is not restartable Co-Authored-By: Claude Opus 5.5 (1M context) * feat: add restart link to the failed run status line Co-Authored-By: Claude Opus 5.5 (1M context) * fix: use the input error color for the progress bar error state Co-Authored-By: Claude Opus 5.5 (1M context) * feat: mention restarting on a fixed flow version in the error line Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show a chevron on the restart button when it opens a picker Co-Authored-By: Claude Opus 5.5 (1M context) * fix: shrink run page top bar buttons so the bar fits on one line Co-Authored-By: Claude Opus 5.5 (1M context) * fix: target the step that failed the run, not a tolerated failure Co-Authored-By: Claude Opus 5.5 (1M context) * fix: address review nits on the restart link Co-Authored-By: Claude Opus 5.5 (1M context) * fix: skip canceled runs and match the restart link label to its text Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../lib/components/FlowRestartButton.svelte | 33 ++++- .../common/actionRow/ActionRow.svelte | 2 +- .../components/flows/FlowProgressBar.svelte | 6 +- .../components/progressBar/ProgressBar.svelte | 11 +- .../(root)/(logged)/run/[...run]/+page.svelte | 126 ++++++++++++------ 5 files changed, 133 insertions(+), 45 deletions(-) diff --git a/frontend/src/lib/components/FlowRestartButton.svelte b/frontend/src/lib/components/FlowRestartButton.svelte index 11f5cfd28a..2c7f323649 100644 --- a/frontend/src/lib/components/FlowRestartButton.svelte +++ b/frontend/src/lib/components/FlowRestartButton.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts b/frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts index f5ef707a7c..221c60b84f 100644 --- a/frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts +++ b/frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts @@ -1,5 +1,6 @@ import { z } from 'zod' -import { createToolDef, type Tool, type ToolCallbacks } from '../shared' +import { createToolDef, type ToolCallbacks } from '../shared' +import { NONE, type SessionTool } from '../sessionCapabilities' import { artifactOverflowBytes, MAX_ARTIFACT_BYTES, normalizeChangeNote } from './artifactLimits' import { currentVersion, @@ -98,8 +99,9 @@ function targetsPlan(args: unknown, h: ArtifactToolHelpers): boolean { ) } -export const artifactTools: Tool<{}>[] = [ +export const artifactTools: SessionTool<{}>[] = [ { + requires: NONE, def: createToolDef( createArtifactSchema, 'create_artifact', @@ -156,6 +158,7 @@ export const artifactTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( updateArtifactSchema, 'update_artifact', @@ -208,6 +211,7 @@ export const artifactTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( listArtifactsSchema, 'list_artifacts', @@ -242,6 +246,7 @@ export const artifactTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( readArtifactSchema, 'read_artifact', @@ -303,6 +308,7 @@ export const artifactTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( listArtifactVersionsSchema, 'list_artifact_versions', diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 144617779f..282e7e1aa6 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -569,6 +569,32 @@ const assistantTools = (...ids: string[]): ChatCompletionMessageParam => ({ function: { name: 'do_thing', arguments: '{}' } })) }) +describe('runChatLoop per-chat tool schemas', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.resolveRequestReasoning.mockReturnValue(undefined) + mocks.getOpenAIResponsesCompletion.mockResolvedValue({}) + mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ shouldContinue: false, tokenUsage }) + }) + + // The def `schemaFor` returns is the chat's own: it must be what the provider is sent and + // what the tool is dispatched with, while the shared tool object keeps its own. + it('sends and dispatches the def schemaFor returns, leaving the shared tool alone', async () => { + const base = { type: 'function', function: { name: 'dyn', parameters: {} } } as any + const perChat = { + type: 'function', + function: { name: 'dyn', parameters: { marked: 1 } } + } as any + const shared = { def: base, fn: async () => '', schemaFor: async () => perChat } + + await runChatLoop({ ...createConfig({ workspace: `ws-${randomUUID()}` }), tools: [shared] }) + + expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][2]).toEqual([perChat]) + expect(mocks.parseOpenAIResponsesCompletion.mock.calls[0][4][0].def).toBe(perChat) + expect(shared.def).toBe(base) + }) +}) + const tool = (id: string): ChatCompletionMessageParam => ({ role: 'tool', tool_call_id: id, diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index 5091d76af5..47159d205d 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -71,13 +71,8 @@ export interface ChatLoopConfig { * lets the caller recover partial output if the loop throws or is aborted. */ addedMessages?: ChatCompletionMessageParam[] - /** Called before each iteration (e.g. to refresh tool schemas, or to record - * which model the iteration is about to use). */ - onBeforeIteration?: ( - tools: Tool[], - helpers: any, - modelProvider: ReasoningProviderModel - ) => Promise + /** Called before each iteration (e.g. to record which model it is about to use). */ + onBeforeIteration?: (modelProvider: ReasoningProviderModel) => Promise /** Fired for each completed provider response, before the loop continues. The * loop can fail or be aborted at any iteration, so spend has to be handed over * as it happens — a callback only at the end would discard everything the @@ -399,7 +394,6 @@ export async function runChatLoop(config: ChatLoopConfig): Promise (t.schemaFor ? { ...t, def: await t.schemaFor(helpers) } : t)) + ) const pendingUserMessage = getPendingUserMessage?.() diff --git a/frontend/src/lib/components/copilot/chat/datatableTools.ts b/frontend/src/lib/components/copilot/chat/datatableTools.ts index bb7976d396..0656bf43a9 100644 --- a/frontend/src/lib/components/copilot/chat/datatableTools.ts +++ b/frontend/src/lib/components/copilot/chat/datatableTools.ts @@ -6,10 +6,10 @@ import { datatableReference } from '$lib/components/dbTypes' import { createToolDef, executeTestRun, - type Tool, type ToolDisplayMessage, type ChatJobResultFormat } from './shared' +import { NONE, RUN_PREVIEW, type SessionTool } from './sessionCapabilities' /** * Workspace-scoped datatable tools, with no app whitelist and no creation policy. @@ -246,9 +246,10 @@ export function formatChatJobCompletion( * The unrestricted workspace datatable tools, for registration in global mode. * Helper-free: each tool reads `workspace` directly from the tool call params. */ -export function getDatatableTools(): Tool<{}>[] { +export function getDatatableTools(): SessionTool<{}>[] { return [ { + requires: NONE, def: getListDatatablesToolDef(), planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { @@ -298,6 +299,7 @@ export function getDatatableTools(): Tool<{}>[] { } }, { + requires: NONE, def: getGetDatatableTableSchemaToolDef(), planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { @@ -337,6 +339,8 @@ export function getDatatableTools(): Tool<{}>[] { } }, { + // `runScript` below goes through /jobs/run/preview, which jobs.rs refuses operators. + requires: RUN_PREVIEW, def: getExecDatatableSqlToolDef(), requiresConfirmation: true, confirmationMessage: 'Execute SQL on datatable', diff --git a/frontend/src/lib/components/copilot/chat/docs/core.ts b/frontend/src/lib/components/copilot/chat/docs/core.ts index 9ae9d0c988..ee0c04983f 100644 --- a/frontend/src/lib/components/copilot/chat/docs/core.ts +++ b/frontend/src/lib/components/copilot/chat/docs/core.ts @@ -1,4 +1,4 @@ -import type { Tool } from '../shared' +import { NONE, type SessionTool } from '../sessionCapabilities' import type { ChatCompletionTool } from 'openai/resources/index.mjs' import { DocumentationService } from '$lib/gen' @@ -33,7 +33,8 @@ const READ_DOCS_PAGE_TOOL: ChatCompletionTool = { } } -export const readDocsPageTool: Tool<{}> = { +export const readDocsPageTool: SessionTool<{}> = { + requires: NONE, def: READ_DOCS_PAGE_TOOL, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { @@ -85,7 +86,8 @@ const SEARCH_DOCS_TOOL: ChatCompletionTool = { } } -export const searchDocsTool: Tool<{}> = { +export const searchDocsTool: SessionTool<{}> = { + requires: NONE, def: SEARCH_DOCS_TOOL, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { diff --git a/frontend/src/lib/components/copilot/chat/ducklakeTools.ts b/frontend/src/lib/components/copilot/chat/ducklakeTools.ts index dfadc58db9..3e19789e96 100644 --- a/frontend/src/lib/components/copilot/chat/ducklakeTools.ts +++ b/frontend/src/lib/components/copilot/chat/ducklakeTools.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { DataMetricService, WorkspaceService } from '$lib/gen' -import { createToolDef, type Tool } from './shared' +import { createToolDef } from './shared' +import { NONE, type SessionTool } from './sessionCapabilities' /** * Workspace-scoped DuckLake tools, the pipeline counterpart to `list_datatables` @@ -69,9 +70,10 @@ const NO_DATA_METRICS_NOTE = const DEFAULT_DATA_METRICS_LIMIT = 200 /** The workspace DuckLake tools, for registration in global mode. */ -export function getDucklakeTools(): Tool<{}>[] { +export function getDucklakeTools(): SessionTool<{}>[] { return [ { + requires: NONE, def: listDataMetricsToolDef, planModeSafe: true, showDetails: true, @@ -106,6 +108,7 @@ export function getDucklakeTools(): Tool<{}>[] { } }, { + requires: NONE, def: listDucklakesToolDef, planModeSafe: true, fn: async ({ workspace, toolId, toolCallbacks }) => { diff --git a/frontend/src/lib/components/copilot/chat/files/fileTools.ts b/frontend/src/lib/components/copilot/chat/files/fileTools.ts index 246bf814d6..b0d5554173 100644 --- a/frontend/src/lib/components/copilot/chat/files/fileTools.ts +++ b/frontend/src/lib/components/copilot/chat/files/fileTools.ts @@ -8,7 +8,8 @@ */ import { z } from 'zod' import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' -import { createToolDef, type Tool } from '../shared' +import { createToolDef } from '../shared' +import { NONE, type SessionTool } from '../sessionCapabilities' import { readFile, searchFilesInWorker, @@ -95,7 +96,8 @@ const searchFilesToolDef = createToolDef( 'Search the user-attached files with a regular expression and return matching lines with their line numbers. Use this to locate content before reading a specific window with read_file.' ) -export const searchFilesTool: Tool<{}> = { +export const searchFilesTool: SessionTool<{}> = { + requires: NONE, def: searchFilesToolDef, planModeSafe: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { @@ -164,7 +166,8 @@ const readFileToolDef = createToolDef( 'Read a bounded window of lines from a user-attached file. Returns each line prefixed with its 1-based number (`→`) plus a pagination note. Files are not in context, so use this to inspect their contents.' ) -export const readFileTool: Tool<{}> = { +export const readFileTool: SessionTool<{}> = { + requires: NONE, def: readFileToolDef, planModeSafe: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { @@ -193,7 +196,7 @@ export const readFileTool: Tool<{}> = { } } -export const fileTools: Tool<{}>[] = [searchFilesTool, readFileTool] +export const fileTools: SessionTool<{}>[] = [searchFilesTool, readFileTool] function rosterLine(f: AttachedFile): string { // Message rows are addressed by their stable id (names may collide); session diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index 51fea34d58..eef5d396a4 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -302,7 +302,7 @@ const patchFlowJsonToolDef = createToolDef( 'patch_flow_json', 'Make a quick exact text edit in the current compact flow JSON. Prefer this for small localized changes; use set_flow_json for larger structural rewrites.' ) -// Will be overridden by setSchema +// Replaced per chat by schemaFor const testRunFlowSchema = z.object({ args: z .object({}) @@ -435,11 +435,10 @@ export const flowTools: Tool[] = [ contextName: 'flow' }) }, - setSchema: async function (helpers: FlowAIChatHelpers) { - await buildSchemaForTool(this.def, async () => { - const flowInputsSchema = await helpers.getFlowInputsSchema() - return flowInputsSchema - }) + schemaFor: async (helpers: FlowAIChatHelpers) => { + const def = structuredClone(testRunFlowToolDef) + await buildSchemaForTool(def, () => helpers.getFlowInputsSchema()) + return def }, requiresConfirmation: true, confirmationMessage: 'Run a test of the current flow', diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index a1755bcaa9..3c4a0c80ed 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -7655,9 +7655,9 @@ describe('open_page workspace gating', () => { // A workspace the user belongs to but whose `whoami` never answers. const FLAKY = 'flaky_ws' const openPage = () => getGlobalTool('open_page') - const pageSchema = () => (openPage().def.function.parameters as any)?.properties?.page ?? {} + let def = openPage().def + const pageSchema = () => (def.function.parameters as any)?.properties?.page ?? {} const advertisedPages = () => (pageSchema().enum ?? []) as string[] - const pristineDef = openPage().def beforeEach(() => { // Admin of the workspace being browsed, plain member of the one a session operates on. @@ -7680,16 +7680,26 @@ describe('open_page workspace gating', () => { superadmin.set(undefined) whoamiByWorkspace.clear() clearWorkspaceRoleCache() - openPage().def = pristineDef + }) + + // Every chat shares this tool object, so a schema written back to it would reach the + // other chats' requests. + it("never writes a chat's schema back to the shared tool", async () => { + const shared = openPage().def + const snapshot = structuredClone(shared) + def = await openPage().schemaFor!({ operatingWorkspace: SESSION }) + expect(def).not.toBe(shared) + expect(openPage().def).toBe(shared) + expect(shared).toEqual(snapshot) }) // Reading the ambient `userStore` instead offers a session the pages of the workspace // the user happens to be browsing. it('gates on the operating workspace, not the one userStore describes', async () => { - await openPage().setSchema?.({ operatingWorkspace: NAV }) + def = await openPage().schemaFor!({ operatingWorkspace: NAV }) expect(advertisedPages()).toContain('workspace_settings') - await openPage().setSchema?.({ operatingWorkspace: SESSION }) + def = await openPage().schemaFor!({ operatingWorkspace: SESSION }) expect(advertisedPages()).toContain('runs') expect(advertisedPages()).not.toContain('workspace_settings') await expect( @@ -7704,7 +7714,7 @@ describe('open_page workspace gating', () => { // Neither layer may read as a denial: the role was never established, and the model // sees the schema before it can ever reach the handler's message. it('advertises nothing and blames no denial when the role lookup fails', async () => { - await openPage().setSchema?.({ operatingWorkspace: FLAKY }) + def = await openPage().schemaFor!({ operatingWorkspace: FLAKY }) expect(advertisedPages()).toEqual([]) expect(pageSchema().description).toContain("couldn't be checked") const refusal = await callGlobalTool('open_page', { page: 'runs' }, toolCallbacks, { @@ -7717,7 +7727,7 @@ describe('open_page workspace gating', () => { // A workspace absent from `userWorkspaces` is settled, not unknown: inviting a retry // would be false, and asking `whoami` at all only earns a 401 on every iteration. it('reports a plain denial for a workspace the user is not a member of', async () => { - await openPage().setSchema?.({ operatingWorkspace: 'unreachable_ws' }) + def = await openPage().schemaFor!({ operatingWorkspace: 'unreachable_ws' }) expect(advertisedPages()).toEqual([]) expect(pageSchema().description).not.toContain("couldn't be checked") const refusal = await callGlobalTool('open_page', { page: 'runs' }, toolCallbacks, { @@ -7734,7 +7744,7 @@ describe('open_page workspace gating', () => { it('asks whoami while the workspace list is still unresolved', async () => { usersWorkspaceStore.set(undefined) - await openPage().setSchema?.({ operatingWorkspace: SESSION }) + def = await openPage().schemaFor!({ operatingWorkspace: SESSION }) expect(UserService.whoami).toHaveBeenCalledWith(expect.objectContaining({ workspace: SESSION })) expect(advertisedPages()).not.toEqual([]) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index a4581bd173..9a5d1f43f1 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -135,7 +135,6 @@ import { type CreatedResourceTriggerKind, type PreviewCardKind, type RunFormDisplay, - type Tool, type ToolCallbacks, type ToolCodeDiff, type ToolDisplayAction @@ -197,6 +196,15 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' import type { UserDraftItemKind } from '$lib/gen' import { bundleRawAppDraft } from './rawAppBundlerBridge' +import { + DEPLOY, + NONE, + RUN_PREVIEW, + WRITE_DRAFT, + type SessionAccess, + type SessionTool, + type SessionToolPolicy +} from '../sessionCapabilities' import { buildRunsUrl, buildSchedulesUrl, @@ -770,7 +778,7 @@ const deployWorkspaceItemSchema = z.object({ .boolean() .optional() .describe( - 'Deploy even if the draft was started from an older deployed version, overwriting the version deployed since. Defaults to false; prefer calling rebase_draft first to keep the newer changes.' + 'Deploy even if the draft was started from an older deployed version, overwriting the version deployed since. Defaults to false.' ) }) @@ -930,7 +938,7 @@ const runScriptSchema = z.object({ const runScriptToolDef = createToolDef( runScriptSchema, 'run_script', - 'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real"); otherwise use test_run_script.', + 'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', { strict: false } ) @@ -969,7 +977,7 @@ const runFlowSchema = z.object({ const runFlowToolDef = createToolDef( runFlowSchema, 'run_flow', - 'Run a DEPLOYED flow for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real"); otherwise use test_run_flow.', + 'Run a DEPLOYED flow for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', { strict: false } ) @@ -1139,7 +1147,7 @@ const openPreviewSchema = z.object({ kind: z .enum(['script', 'flow', 'raw_app', 'pipeline']) .describe( - 'Item kind to preview. Use "raw_app" for code-based apps (created via init_app). Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' + 'Item kind to preview. Use "raw_app" for code-based apps. Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' ), path: z .string() @@ -1272,7 +1280,11 @@ type FolderPromptContext = { folders?: string[]; foldersRead?: string[]; isAdmin // non-exhaustive hint alongside permission-agnostic guidance (the complete set // needs a folder-listing tool — follow-up). // Capped so a folder-heavy workspace can't dominate the prompt. -function buildFolderGuidance(username: string, ctx?: FolderPromptContext): string { +function buildFolderGuidance( + username: string, + ctx: FolderPromptContext | undefined, + canCreateFolder: boolean +): string { if (!ctx) return '' const MAX = 40 const writable = ctx.folders ?? [] @@ -1288,7 +1300,7 @@ function buildFolderGuidance(username: string, ctx?: FolderPromptContext): strin writable.length > 0 ? ` Folders here include ${fmt(writable)} (you can also write to others not listed).` : '' - return `- As a workspace admin you can write to any existing folder.${known} If the user names a folder, use it; if they explicitly ask for a new folder, create it with \`create_folder\`; otherwise ask them which folder to use rather than guessing or creating one unprompted.` + return `- As a workspace admin you can write to any existing folder.${known} If the user names a folder, use it;${canCreateFolder ? ' if they explicitly ask for a new folder, create it with `create_folder`;' : ''} otherwise ask them which folder to use rather than guessing${canCreateFolder ? ' or creating one unprompted' : ''}.` } // Everything below states the writable set as fact, including the empty case, so an // unresolved role has to say nothing at all: "you have no shared folders" is a claim, @@ -1299,11 +1311,15 @@ function buildFolderGuidance(username: string, ctx?: FolderPromptContext): strin const lines: string[] = [] if (writable.length > 0) { lines.push( - `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path. Use \`create_folder\` only when the user explicitly asks for a new folder.` + `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}` ) } else { lines.push( - `- You have no shared folders you can write to in this workspace, so use \`u/${username}/\`. If the user explicitly asks for a shared folder, create one with \`create_folder\` (you become an owner); otherwise ask before placing shared work rather than inventing an \`f//...\` path.` + `- You have no shared folders you can write to in this workspace, so use \`u/${username}/\`. ${ + canCreateFolder + ? 'If the user explicitly asks for a shared folder, create one with `create_folder` (you become an owner); otherwise ask' + : 'If the user explicitly asks for a shared folder, say plainly that you cannot create one here; otherwise ask' + } before placing shared work rather than inventing an \`f//...\` path.` ) } if (readOnly.length > 0) { @@ -1319,9 +1335,19 @@ const buildGlobalSystemPrompt = ( previewTools: boolean, folderCtx?: FolderPromptContext, skills: AiSkillListItem[] = [], - mcpServers: McpServer[] = [] + mcpServers: McpServer[] = [], + access?: SessionAccess ) => { - const folderGuidance = buildFolderGuidance(username, folderCtx) + // Each `can*` mirrors the capability the matching tools declare in `requires`, so a + // rule cannot outlive the tool it describes. An unresolved profile keeps every block. + const canWriteDraft = !access || access.has('write_draft') + const canRunPreview = !access || access.has('run_preview') + // The deploy tools take their kind as an argument, so `deploy` gates only the folder text. + const canCreateFolder = !access || access.has('deploy') + // Each gated block carries its own leading newline, so dropping one leaves no blank + // line behind and a full-access prompt is byte-for-byte the ungated text. + const when = (cond: boolean, block: string) => (cond ? block : '') + const folderGuidance = buildFolderGuidance(username, folderCtx, canCreateFolder) const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : '' // `previewTools` doubles as "this is a session chat" — sessions are the only // chats that get the preview tool set. The alpha heads-up only makes sense @@ -1353,48 +1379,89 @@ const buildGlobalSystemPrompt = ( The current user's workspace username is "${username}".${instanceLine} -Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps. +${ + canWriteDraft + ? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.' + : "Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit scripts, flows, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused scripts, flows and apps outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor." +}${when( + canWriteDraft, + ` Path conventions: - A workspace path starts with one of two namespaces; its trailing may itself contain "/", so a path has three or more segments: - \`u/${username}/\` — your personal scope. Default for ad-hoc, exploratory, or scratch work. - \`f//\` — a shared folder scope; the must already exist (a bare \`f/\` with no folder segment is INVALID and will fail). - If the user supplies a fully qualified \`f//...\` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a \`u/${username}/...\` path unless a tool rejects it. -- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/\`. Never invent an \`f//...\` path for a folder that does not exist; create one with \`create_folder\` only when the user explicitly asks for a new folder.${folderGuidanceBlock} +- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/\`. Never invent an \`f//...\` path for a folder that does not exist${when( + canCreateFolder, + '; create one with `create_folder` only when the user explicitly asks for a new folder' + )}.${folderGuidanceBlock}` + )}${when( + canCreateFolder && !canWriteDraft, + '\n- You can create a shared folder with `create_folder` when the user explicitly asks for one. You cannot create anything inside it here, so do not offer to.' + )} -Rules: -- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items. +Rules:${when( + canWriteDraft, + ` +- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.` + )} - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. -- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule} +- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when( + canWriteDraft, + ` - Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. -- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path. -- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs. +- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.` + )}${when( + !canWriteDraft, + ` +- Three changes are still open to you where the server allows them: discard_local_draft drops a draft this user left behind, deploy_workspace_item deploys one when the user asks, and delete_workspace_item removes an item already deployed in the workspace. You cannot create or edit one.` + )} +- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.${when( + canWriteDraft, + ` - You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value. - Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition. - When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it. - Hub scripts are prebuilt integrations for third-party services, hosted outside the workspace under \`hub///\` paths. Use search_hub_scripts to find one before hand-writing an integration, then read_workspace_item with type "script" and the returned hub path to get its code, language, and input schema. -- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database). -- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. -${pipelineBullet} +${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. +${pipelineBullet}` + )}${when( + canRunPreview && canWriteDraft, + ` - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. -- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested. +- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.` + )} - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run. - get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars). - Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Carry over every filter the user described — Runs takes the page's whole filter set (time window, path, user, folder, label, tag, worker, trigger kind, args/result, ...), so don't drop a criterion just because it wasn't in the request's main clause. Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself. -- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click. +- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.${when( + canWriteDraft, + ` - When the user is happy with the changes and wants to review or deploy them, use open_page with page "compare" — it opens the Compare & Deploy review page.${ - previewTools - ? ' By default it preselects the items this chat modified; pass items (":" entries) to control the selection' - : ' Pass items (":" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace' - }, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed. -- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's. + previewTools + ? ' By default it preselects the items this chat modified; pass items (":" entries) to control the selection' + : ' Pass items (":" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace' + }, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.` + )}${when( + canRunPreview, + ` +- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.` + )}${when( + !canRunPreview, + ` +- run_script and run_flow are how you run anything here: they run the DEPLOYED item under this user's own permissions, and show them an argument form prefilled with what you sent. Read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every argument you can infer rather than asking for it in chat.` + )} - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive). - When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task. - Keep context targeted.${ previewTools - ? ` + ? `${when( + canWriteDraft, + ` - After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited. -- Building a data pipeline: call open_preview(kind="pipeline", path="") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty or not-yet-created folder is fine (create_folder first if needed, then open it). Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor. +- Building a data pipeline: call open_preview(kind="pipeline", path="") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.` + )} - When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app"). - To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open. - get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend. call), call list_app_runs to get that run's job_id from the live preview, then get_run with it. Use this when a backend call errors or returns something unexpected. @@ -1418,29 +1485,41 @@ Documentation: Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". -- Use read_flow_module_code and set_flow_module_code for inline script bodies. -- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites. +- Use read_flow_module_code${when(canWriteDraft, ' and set_flow_module_code')} for inline script bodies.${when( + canWriteDraft, + ` +- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.` + )} Raw apps: - The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app. -- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents. +- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when( + canWriteDraft, + ` - A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings. - Use write_app_file, patch_app_file, and delete_app_file for frontend files. - Use write_app_runnable and delete_app_runnable for backend runnables. - Use init_app only after confirming framework, path, and summary with the user. -- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving. +- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.` + )} Data Tables: - Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL. -- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists or call exec_datatable_sql. -- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries. -- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.${ +- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists${when(canRunPreview, ' or call exec_datatable_sql')}. +- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.${when( + canRunPreview, + ` +- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.` + )}${ isCloudHosted() ? '' : ` - A raw app may use a datatable through a role (\`data.roles\` in its raw_app.yaml). When working on such an app, pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can.` - } -- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.${ + }${when( + canWriteDraft, + ` +- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.` + )}${ skills.length > 0 ? ` @@ -2337,7 +2416,7 @@ function getDatatableInstructions(language?: ScriptLang): string { const lang = language ?? 'bun' return `# Datatable SQL SDK reference -Datatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the \`list_datatables\`, \`get_datatable_table_schema\`, and \`exec_datatable_sql\` tools. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime. +Datatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime. - A runnable accesses a datatable via \`wmill.datatable()\` (the default "main") or \`wmill.datatable('')\`, referencing tables as \`schema.table\`. - Use parameterized queries (the tagged template in TypeScript, \`$1\`/\`$2\` placeholders in Python) — never interpolate untrusted values into SQL strings. @@ -2400,12 +2479,23 @@ export type SessionPromptContext = { /** Session-state guidance appended to the global system prompt so the model * knows where its work lands (staged fork vs the live workspace). */ -export function getSessionContextPromptSection(ctx: SessionPromptContext): string { +export function getSessionContextPromptSection( + ctx: SessionPromptContext, + access?: SessionAccess +): string { + // Concatenated onto an already capability-gated prompt, so it has to honour the same + // profile rather than assume the gating happened upstream. + const canDeploy = !access || access.has('deploy') + const canWriteDraft = !access || access.has('write_draft') + const canRunPreview = !access || access.has('run_preview') + const targets = ['reads', canWriteDraft && 'drafts', canRunPreview && 'test runs', 'deploys'] + .filter(Boolean) + .join(', ') const lines = [ '', '', 'Session state:', - '- This chat is a Windmill AI session with its own operating workspace: every tool call (reads, drafts, test runs, deploys) targets that workspace.' + `- This chat is a Windmill AI session with its own operating workspace: every tool call (${targets}) targets that workspace.` ] if (ctx.pendingForkOf) { lines.push( @@ -2432,6 +2522,13 @@ export function getSessionContextPromptSection(ctx: SessionPromptContext): strin '- No operating workspace is set yet; the user picks one (or a new staged fork) before the first message is sent.' ) } + // The kind enum already withholds what the rules refuse; this says why, and where the user + // promotes the rest instead. The rules gate deletes too, so it is owed to every such profile. + if (!canDeploy) { + lines.push( + "- This workspace refuses direct deployment for this user, except for schedules and triggers — those are the only kinds deploy_workspace_item and delete_workspace_item can still act on. Scripts, flows, apps, resources and variables must be promoted from the session's deploy panel (fork or pull request); do not offer to deploy or delete them directly." + ) + } return lines.join('\n') } @@ -2478,7 +2575,8 @@ const readSkillSchema = z.object({ .describe('The exact skill resource path as listed in the Skills section of the system prompt.') }) -export const readSkillTool: Tool<{}> = { +export const readSkillTool: SessionTool<{}> = { + requires: NONE, def: createToolDef( readSkillSchema, 'read_skill', @@ -2730,7 +2828,7 @@ const RUNS_TIMEFRAME_LABELS = runsTimeframes.map((tf) => tf.label) as [string, . // input_schema requires; a top-level oneOf would be rejected. Each per-page URL builder // drops any key that isn't one of its page's real query params, so a field that doesn't // apply to the chosen page is harmless. This full schema is used to PARSE tool args; the -// advertised schema (what the model sees) is narrowed per-user in `setSchema`. +// advertised schema (what the model sees) is narrowed per-user in `schemaFor`. const openPageFullSchema = z.object({ page: z.enum(OPEN_PAGE_NAMES).describe('Which page to open'), path: z @@ -3177,8 +3275,9 @@ function summarizeOpenPage(url: string, page: OpenPageName): string { return parts.length ? parts.join(', ') : `all ${OPEN_PAGE_LABELS[page].toLowerCase()}` } -export const openPageTool: Tool<{}> = { - // The initial def assumes an untracked chat and no resolved role; setSchema below +export const openPageTool: SessionTool<{}> = { + requires: NONE, + // The initial def assumes an untracked chat and no resolved role; schemaFor below // rebuilds it with the caller's real surface before each iteration. def: createToolDef( buildOpenPageDefSchema( @@ -3197,9 +3296,9 @@ export const openPageTool: Tool<{}> = { autoCollapseDetails: false, // Re-narrow the advertised `page` enum to this user's permissions each iteration, so // the model never sees (or suggests) a page the user can't reach. - setSchema: async function (helpers) { + schemaFor: async (helpers) => { const access = await allowedOpenPages(operatingWorkspaceFromHelpers(helpers)) - this.def = createToolDef( + return createToolDef( buildOpenPageDefSchema( access.pages, allowedTriggerKinds(), @@ -3282,10 +3381,11 @@ export const openPageTool: Tool<{}> = { } } -export const globalTools: Tool<{}>[] = [ +export const globalTools: SessionTool<{}>[] = [ readSkillTool, openPageTool, { + requires: NONE, def: createToolDef( getInstructionsSchema, 'get_instructions', @@ -3295,6 +3395,15 @@ export const globalTools: Tool<{}>[] = [ fn: async (ctx) => { const { args, toolId, toolCallbacks } = ctx const parsed = getInstructionsSchema.parse(args) + // Every subject is authoring guidance written around the draft tools by name, and a + // tool result is the one place the toolset filter cannot reach. + const access = (ctx.helpers as GlobalToolHelpers | undefined)?.access + if (access && !access.has('write_draft')) { + const message = + 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.' + toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' }) + return message + } const label = parsed.subject === 'script' && parsed.language ? `${parsed.subject} (${parsed.language})` @@ -3308,6 +3417,7 @@ export const globalTools: Tool<{}>[] = [ searchDocsTool, readDocsPageTool, { + requires: NONE, def: createToolDef( askUserQuestionSchema, 'askUserQuestion', @@ -3376,6 +3486,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( updateUserInstructionsSchema, 'update_user_instructions', @@ -3449,6 +3560,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( listWorkspaceItemsSchema, 'list_workspace_items', @@ -3513,6 +3625,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( readWorkspaceItemSchema, 'read_workspace_item', @@ -3562,6 +3675,8 @@ export const globalTools: Tool<{}>[] = [ } }, { + // folders.rs `create_folder` runs `check_deploy_rules` and has no operator check. + requires: DEPLOY, def: createToolDef( createFolderSchema, 'create_folder', @@ -3604,6 +3719,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef(writeScriptSchema, 'write_script', 'Create or overwrite a draft script.'), showDetails: true, streamArguments: true, @@ -3614,6 +3730,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a draft flow.'), showDetails: true, streamArguments: true, @@ -3655,6 +3772,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeScheduleToolSchema, 'write_schedule', @@ -3680,6 +3798,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeTriggerSchema, 'write_trigger', @@ -3707,10 +3826,11 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( getTriggerSchemaSchema, 'get_trigger_schema', - 'Get the configuration schema for one trigger kind. Call before write_trigger.' + 'Get the configuration schema for one trigger kind — its config fields differ per kind.' ), planModeSafe: true, fn: async (ctx) => { @@ -3719,15 +3839,17 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( z.object({}), 'get_schedule_schema', - "Get the shape of write_schedule's `advanced` object: retry, pausing, tags, and error-handler tuning." + "Get the shape of a schedule's `advanced` object: retry, pausing, tags, and error-handler tuning." ), planModeSafe: true, fn: async () => JSON.stringify(advancedScheduleShape(), null, 2) }, { + requires: WRITE_DRAFT, def: createToolDef( editScriptSchema, 'edit_script', @@ -3742,6 +3864,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( patchFlowJsonSchema, 'patch_flow_json', @@ -3756,6 +3879,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: RUN_PREVIEW, def: testRunScriptToolDef, fn: async (ctx) => { const parsed = testRunScriptSchema.parse(ctx.args) @@ -3773,6 +3897,9 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + // Ungated, unlike the test runs: this executes the DEPLOYED item under the user's own + // permissions, the one run an operator's token allows. `run_flow` likewise. + requires: NONE, def: runScriptToolDef, fn: async (ctx) => { const parsed = runScriptSchema.parse(ctx.args) @@ -3787,6 +3914,7 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + requires: RUN_PREVIEW, def: testRunFlowToolDef, fn: async (ctx) => { const parsed = testRunFlowSchema.parse(ctx.args) @@ -3801,6 +3929,7 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + requires: NONE, def: runFlowToolDef, fn: async (ctx) => { const parsed = runFlowSchema.parse(ctx.args) @@ -3814,6 +3943,7 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + requires: RUN_PREVIEW, def: testRunStepToolDef, fn: async (ctx) => { const parsed = testRunStepSchema.parse(ctx.args) @@ -3828,6 +3958,7 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + requires: NONE, def: createToolDef( listRunsSchema, 'list_runs', @@ -3857,6 +3988,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( z.object({}), 'list_workers', @@ -3900,6 +4032,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( getRunSchema, 'get_run', @@ -3929,6 +4062,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( cancelJobSchema, 'cancel_job', @@ -3956,6 +4090,21 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Ungated as a whole: the draft may predate a role change, and what the server accepts + // turns on the kind. Each kind's handler, reached through `deployDraft`'s switch: + requires: NONE, + kindRequires: { + // scripts.rs `create_script_internal`, flows.rs `create_flow`/`update_flow` and + // apps.rs `create_app_raw`/`update_app_raw` refuse operators; all run the rules. + script: ['deploy', 'manage_code'], + flow: ['deploy', 'manage_code'], + app: ['deploy', 'manage_code'], + resource: DEPLOY, + variable: DEPLOY, + // The schedule and trigger handlers check neither. + schedule: NONE, + trigger: NONE + } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( deployWorkspaceItemSchema, 'deploy_workspace_item', @@ -3972,6 +4121,8 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Rebasing writes a fresh draft, so drafts.rs `require_can_write_path` applies. + requires: WRITE_DRAFT, def: createToolDef( rebaseDraftSchema, 'rebase_draft', @@ -3986,6 +4137,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( diffSchema, 'diff', @@ -4009,6 +4161,20 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Ungated as a whole, like deploying. Each kind's handler, reached through + // `deleteWorkspaceItem`'s switch: + requires: NONE, + kindRequires: { + // scripts.rs `delete_script_by_path` calls `require_admin`; a non-admin archives. + script: ['admin'], + // flows.rs `delete_flow_by_path` and apps.rs `delete_app` refuse operators. + flow: ['deploy', 'manage_code'], + app: ['deploy', 'manage_code'], + resource: DEPLOY, + variable: DEPLOY, + schedule: NONE, + trigger: NONE + } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( deleteWorkspaceItemSchema, 'delete_workspace_item', @@ -4025,6 +4191,9 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Ungated: discarding your OWN draft skips drafts.rs `require_can_write_path`, so a + // user who has LOST write access can still clean up. + requires: NONE, def: createToolDef( discardLocalDraftSchema, 'discard_local_draft', @@ -4040,6 +4209,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeResourceSchema, 'write_resource', @@ -4055,6 +4225,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeVariableSchema, 'write_variable', @@ -4070,6 +4241,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( searchResourceTypesSchema, 'search_resource_types', @@ -4105,6 +4277,7 @@ export const globalTools: Tool<{}>[] = [ updateEditorCache: false }), { + requires: NONE, def: createToolDef( readFlowModuleCodeSchema, 'read_flow_module_code', @@ -4117,6 +4290,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( setFlowModuleCodeSchema, 'set_flow_module_code', @@ -4131,6 +4305,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( initAppSchema, 'init_app', @@ -4145,6 +4320,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( readAppFileSchema, 'read_app_file', @@ -4157,6 +4333,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( searchAppSchema, 'search_app', @@ -4169,6 +4346,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeAppFileSchema, 'write_app_file', @@ -4183,6 +4361,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( deleteAppFileSchema, 'delete_app_file', @@ -4194,6 +4373,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( patchAppFileSchema, 'patch_app_file', @@ -4208,6 +4388,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeAppRunnableSchema, 'write_app_runnable', @@ -4223,6 +4404,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( deleteAppRunnableSchema, 'delete_app_runnable', @@ -4234,6 +4416,9 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Reaches apps.rs `execute_component` rather than jobs.rs, but that handler refuses + // operators too once `force_viewer_static_fields` marks the call a preview. + requires: RUN_PREVIEW, def: testRunAppRunnableToolDef, fn: async (ctx) => { const parsed = testRunAppRunnableSchema.parse(ctx.args) @@ -4248,6 +4433,7 @@ export const globalTools: Tool<{}>[] = [ }, ...artifactTools, { + requires: NONE, def: createToolDef( openPreviewSchema, 'open_preview', @@ -4259,6 +4445,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( getPreviewStatusSchema, 'get_preview_status', @@ -4268,6 +4455,7 @@ export const globalTools: Tool<{}>[] = [ fn: async (ctx) => getSessionPreviewStatus(sessionIdFromCtx(ctx)) }, { + requires: NONE, def: createToolDef( closePageSchema, 'close_page', @@ -4279,6 +4467,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( getRuntimeLogsSchema, 'get_app_runtime_logs', @@ -4299,6 +4488,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( listAppRunsSchema, 'list_app_runs', @@ -4318,6 +4508,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( searchDomSchema, 'search_dom', @@ -4346,6 +4537,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( readDomSchema, 'read_dom', @@ -4374,6 +4566,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( takeScreenshotSchema, 'take_screenshot', @@ -4454,7 +4647,7 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([ * chat, or `globalTools` minus the preview tools for the regular global * side-panel chat. */ -export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] { +export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): SessionTool<{}>[] { const tools = sessionPreview ? globalTools : globalTools.filter((t) => !SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name)) @@ -4493,6 +4686,9 @@ type WriteDraftCtx = { export type SessionToolHelpers = { sessionId?: string } export type GlobalToolHelpers = SessionToolHelpers & { + /** The session's capability profile. Undefined outside a session, or before the first + * send resolves it. */ + access?: SessionAccess /** Runs the flow editor mounted on `storagePath`, if one is. `memoryId` names the * chat-mode conversation the turn belongs to. */ testActiveFlow?: ( @@ -8442,6 +8638,9 @@ export function prepareGlobalSystemMessage( user?: GlobalPromptIdentity skills?: AiSkillListItem[] mcpServers?: McpServer[] + /** Capabilities of the chat's operating workspace; undefined keeps every block. Must + * match the profile the toolset was filtered with, or the prompt names withheld tools. */ + access?: SessionAccess } ): ChatCompletionSystemMessageParam { const user = opts?.user ?? get(userStore) @@ -8458,7 +8657,8 @@ export function prepareGlobalSystemMessage( opts?.previewTools ?? false, folderCtx, opts?.skills ?? [], - opts?.mcpServers ?? [] + opts?.mcpServers ?? [], + opts?.access ) if (instructions?.workspace?.trim()) { content = `${content}\n\nWORKSPACE INSTRUCTIONS (configured by a workspace admin, shared by everyone in this workspace — you cannot modify these):\n${instructions.workspace.trim()}` diff --git a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts index 21ba3643de..58e45c6d08 100644 --- a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts +++ b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts @@ -4,7 +4,7 @@ * on another. Plan mode's decoration is not here: it is applied per request, later. */ import type { ChatCompletionSystemMessageParam } from 'openai/resources/index.mjs' -import type { Tool } from '../shared' +import type { SessionTool } from '../sessionCapabilities' import { getSessionContextPromptSection, globalToolsFor, @@ -29,15 +29,15 @@ export function assembleGlobalSystemMessage( ): ChatCompletionSystemMessageParam { const systemMessage = prepareGlobalSystemMessage(instructions, opts) if (opts.sessionContext) { - systemMessage.content += getSessionContextPromptSection(opts.sessionContext) + systemMessage.content += getSessionContextPromptSection(opts.sessionContext, opts.access) } if (opts.pipelineContext) { - systemMessage.content += getPipelinePromptSection(opts.pipelineContext) + systemMessage.content += getPipelinePromptSection(opts.pipelineContext, opts.access) } return systemMessage } -export function assembleGlobalTools(opts: GlobalAssemblyOpts): Tool[] { +export function assembleGlobalTools(opts: GlobalAssemblyOpts): SessionTool[] { return [ ...globalToolsFor({ sessionPreview: opts.previewTools ?? false }), ...(opts.pipelineContext ? pipelineTools : []), diff --git a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts index 90afa0b04b..620e5a86ea 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { ResourceService, type GetMcpToolsResponse } from '$lib/gen' -import { createToolDef, type Tool } from '../shared' +import { createToolDef } from '../shared' +import { NONE, type SessionTool } from '../sessionCapabilities' import { enabledMcpPaths } from '$lib/components/mcp/enabledServers' import { isOwnOrSharedMcpPath, MCP_LIST_PER_PAGE, mcpViewer } from '$lib/components/mcp/ownServers' @@ -349,9 +350,10 @@ const callMcpToolSchema = z.object({ * mode and behind the user's confirmation — building both from one body keeps * them from drifting. */ -function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> { +function createCallTool(servers: McpServer[], mode: 'read' | 'write'): SessionTool<{}> { const isRead = mode === 'read' return { + requires: NONE, def: createToolDef( callMcpToolSchema, isRead ? 'call_mcp_read_tool' : 'call_mcp_write_tool', @@ -419,12 +421,13 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> * are not registered at all, so a workspace without an MCP connection pays no * per-iteration schema cost for them. */ -export function createMcpTools(servers: McpServer[]): Tool<{}>[] { +export function createMcpTools(servers: McpServer[]): SessionTool<{}>[] { if (servers.length === 0) return [] const serverList = servers.map((s) => s.path).join(', ') return [ { + requires: NONE, def: createToolDef( searchMcpToolsSchema, 'search_mcp_tools', diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts new file mode 100644 index 0000000000..82ad8162d3 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { whoami, deployRules } = vi.hoisted(() => ({ + whoami: vi.fn(), + deployRules: vi.fn() +})) + +vi.mock('$lib/gen', () => ({ UserService: { whoami } })) +vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules })) + +import { resolveSessionAccess } from './sessionAccess' +import { clearWorkspaceRoleCache } from '$lib/user' + +type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean } + +function user(overrides: WhoamiOverrides) { + return { + email: 'u@windmill.dev', + username: 'u', + is_admin: false, + is_super_admin: false, + operator: false, + created_at: '', + disabled: false, + groups: [], + folders: [], + folders_read: [], + folders_owners: [], + ...overrides + } +} + +async function capabilitiesFor(overrides: WhoamiOverrides, workspace = 'ws') { + whoami.mockResolvedValueOnce(user(overrides)) + return await resolveSessionAccess(workspace) +} + +describe('resolveSessionAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + deployRules.mockResolvedValue({ ok: true }) + // The role memo is app-wide, so without this each case answers from the previous one. + clearWorkspaceRoleCache() + }) + + it('gives a developer every capability but admin', async () => { + const caps = await capabilitiesFor({}) + expect([...caps].sort()).toEqual(['deploy', 'manage_code', 'run_preview', 'write_draft']) + }) + + // The folder, resource and variable handlers run the rules but have no operator check, + // so withholding `deploy` from an operator would be stricter than the server. + it('leaves an operator the deploy-rule capability, and nothing their token refuses', async () => { + const caps = await capabilitiesFor({ operator: true }) + expect([...caps]).toEqual(['deploy']) + }) + + it('takes deploy from the rules alone, so a rule blocks an operator too', async () => { + deployRules.mockResolvedValue({ ok: false, refusedBy: 'DisableDirectDeployment' }) + const caps = await capabilitiesFor({ operator: true }) + expect([...caps]).toEqual([]) + }) + + // Both spellings of `authed.is_admin`, which the draft path honours and the handlers + // refusing `authed.is_operator` do not — the session path never clears that flag. + it.each([{ is_admin: true }, { is_super_admin: true }])( + 'lets an admin who is also an operator draft and deploy, but not preview or manage code (%o)', + async (role) => { + const caps = await capabilitiesFor({ ...role, operator: true }) + expect([...caps].sort()).toEqual(['admin', 'deploy', 'write_draft']) + } + ) + + it('keeps drafting when a rule refuses deploy', async () => { + deployRules.mockResolvedValue({ + ok: false, + reason: 'restricted to deployers', + refusedBy: 'RestrictDeployToDeployers' + }) + const caps = await capabilitiesFor({}) + expect(caps.has('deploy')).toBe(false) + expect(caps.has('write_draft')).toBe(true) + }) + + it('grants everything when the role cannot be resolved', async () => { + whoami.mockRejectedValueOnce(new Error('network')) + const access = await resolveSessionAccess('ws') + expect(access.has('write_draft')).toBe(true) + expect(access.has('deploy')).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts new file mode 100644 index 0000000000..1ac13136a4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -0,0 +1,68 @@ +import { getWorkspaceRole } from '$lib/user' +import { checkDeployRules } from '$lib/utils_workspace_deploy' +import type { SessionAccess, SessionCapability } from '../sessionCapabilities' + +const ALL_CAPABILITIES: SessionCapability[] = [ + 'write_draft', + 'run_preview', + 'deploy', + 'manage_code', + 'admin' +] + +/** Fail open, here and at every resolution failure below: blanking a toolset on a + * transient error tells a developer mid-session that they cannot author anything, + * which is worse and far less legible than the 403 they get by trying. Matches + * `checkDeployPermission`, which fails open for the same reason. */ +export function fullSessionAccess(): SessionAccess { + return new Set(ALL_CAPABILITIES) +} + +/** Pure, so the profiles this can produce are enumerable rather than listed by hand. + * `isAdmin` is one field because the server's `authed.is_admin` is + * `usr.is_admin || super_admin` (auth.rs), which `whoami` reports as two. */ +export function capabilitiesForRole(role: { + isAdmin: boolean + operator: boolean + deployRulesPass: boolean +}): SessionAccess { + const capabilities = new Set() + // Per-capability precedence, NOT a role ladder: drafts.rs `require_can_write_path` + // returns Ok on `authed.is_admin` BEFORE its operator branch, while jobs.rs + // `run_preview_*` and the script/flow/app handlers refuse `authed.is_operator` with no + // admin escape — and on the session path that flag is never cleared for an admin. + if (role.isAdmin || !role.operator) { + capabilities.add('write_draft') + } + if (!role.operator) { + capabilities.add('run_preview') + capabilities.add('manage_code') + } + // No operator term: the rules are their own gate, and the handlers that also refuse + // operators say so through `manage_code`. Admins bypass the rules inside the check. + if (role.deployRulesPass) { + capabilities.add('deploy') + } + if (role.isAdmin) { + capabilities.add('admin') + } + return capabilities +} + +export async function resolveSessionAccess(workspace: string): Promise { + // Shares the 5-minute memo with the identity the same pre-flight resolves beside this + // one, so a send costs one `whoami` rather than two, at the cost of a role changed + // elsewhere landing within that window rather than on the very next message. + // `lookup_failed` covers a rejected request AND a body too malformed to map. + const lookup = await getWorkspaceRole(workspace) + if (lookup.kind !== 'resolved') { + return fullSessionAccess() + } + const me = lookup.user + + return capabilitiesForRole({ + isAdmin: !!me.is_admin || !!me.is_super_admin, + operator: !!me.operator, + deployRulesPass: (await checkDeployRules(workspace, me)).ok + }) +} diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts new file mode 100644 index 0000000000..42136112c4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' + +// The toolset pulls in the script/flow editor tools, hence monaco. Same stand-ins as +// global/core.test.ts; nothing here executes a tool, so bare shapes are enough. +vi.mock('monaco-editor', () => ({ + editor: {}, + languages: {}, + KeyCode: {}, + Uri: { parse: (value: string) => ({ toString: () => value }) }, + MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 } +})) +vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({ + getTypeScriptWorker: async () => async () => ({}), + typescriptVersion: 'test' +})) +vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) +vi.mock('$lib/components/vscode', () => ({})) + +// `globalToolsFor` withholds take_screenshot off Blink, and node reports a `Node.js/` +// user agent — so without this the description sweep below silently skips a tool that +// ships in every real Chromium session. Claim the maximal toolset instead. +vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 Chrome/120.0.0.0' }) + +import { globalTools, prepareGlobalSystemMessage, type SessionPromptContext } from './core' +import { appendPlanModeInstructions } from '../planMode' +import { PlanModeController } from '../planModeController.svelte' +import { pipelineTools } from '../pipeline/core' +import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssembly' +import { filterSessionTools, sessionToolAllowed } from './sessionToolset' +import { capabilitiesForRole, fullSessionAccess } from './sessionAccess' +import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCapabilities' + +const ASSEMBLY_OPTS = { + previewTools: true, + pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] }, + mcpServers: [{ path: 'f/test/server' } as any] +} + +/** Every tool that can reach a session toolset: what assembly builds, so a source added + * there needs no edit here, unioned with the static sources so the set cannot shrink if + * assembly stops reaching one, plus the plan tools the manager appends. */ +function sessionReachableTools(): SessionTool[] { + const plan = new PlanModeController({ available: true } as any).availableTools + const byName = new Map>() + for (const t of [ + ...assembleGlobalTools(ASSEMBLY_OPTS), + ...globalTools, + ...pipelineTools, + ...plan + ]) { + byName.set(t.def.function.name, t) + } + return [...byName.values()] +} + +function withheldNames(access: SessionAccess): string[] { + return sessionReachableTools() + .filter((t) => !sessionToolAllowed(t, access)) + .map((t) => t.def.function.name) +} + +/** The tools a session actually ships, through the same assembly production uses. */ +function shippedSessionTools(access: SessionAccess) { + return filterSessionTools(assembleGlobalTools(ASSEMBLY_OPTS), access) +} + +function accessWith(capabilities: SessionCapability[]): SessionAccess { + return new Set(capabilities) +} + +/** The profiles a real session can hold, keyed for the test name, over every combination + * of the role facts `capabilitiesForRole` reads. */ +const REACHABLE_PROFILES = [ + ...new Map( + [false, true].flatMap((isAdmin) => + [false, true].flatMap((operator) => + // `checkDeployRules` bypasses on admin, so an admin has only the one outcome. + (isAdmin ? [true] : [false, true]).map((deployRulesPass) => { + const access = capabilitiesForRole({ isAdmin, operator, deployRulesPass }) + return [[...access].sort().join(',') || 'none', access] as const + }) + ) + ) + ).entries() +] + +/** One per branch of getSessionContextPromptSection — each words the deploy target + * differently, so a gate fixed in one branch can still leak in another. */ +const SESSION_CONTEXTS: SessionPromptContext[] = [ + { pendingForkOf: 'parent' }, + { workspaceId: 'dev', parentWorkspaceId: 'parent', isDevWorkspace: true }, + { workspaceId: 'fork', parentWorkspaceId: 'parent' }, + { workspaceId: 'fork', forkParentUnknown: true }, + { workspaceId: 'live' }, + {} +] + +describe('session tool policies', () => { + // Full access must be a no-op, or every existing session (and the ai_evals + // baseline measured against it) changes behaviour. + it('withholds nothing from a session with every capability', () => { + const tools = sessionReachableTools() + const filtered = filterSessionTools(tools, fullSessionAccess()) + expect(filtered).toHaveLength(tools.length) + filtered.forEach((tool, i) => expect(tool).toBe(tools[i])) + }) + + it('withholds a tool that arrives without a policy', () => { + expect(sessionToolAllowed({ def: globalTools[0].def }, fullSessionAccess())).toBe(false) + }) + + it('passes the toolset through untouched when access is unresolved', () => { + const tools = globalTools.map((t) => ({ def: t.def })) + expect(filterSessionTools(tools, undefined)).toBe(tools) + }) + + // The kind is an argument, so its enum carries the permission, per the handler each kind + // reaches. Asserted on the shipped schema, since that is all the model sees. + it('offers the kind-taking tools only the kinds their handlers accept', () => { + const shipped = (name: string, access: SessionAccess) => + shippedSessionTools(access).find((t) => t.def.function.name === name)! + const kindsOf = (name: string, access: SessionAccess) => + (shipped(name, access).def.function.parameters as any).properties.type.enum + + // A developer a protection rule refuses keeps the two kinds no rule reaches. + const refused = accessWith(['write_draft', 'run_preview', 'manage_code']) + expect(kindsOf('deploy_workspace_item', refused)).toEqual(['schedule', 'trigger']) + expect(kindsOf('delete_workspace_item', refused)).toEqual(['schedule', 'trigger']) + + // An operator where no rule applies: only the code handlers refuse them. + const operator = accessWith(['deploy']) + expect(kindsOf('deploy_workspace_item', operator)).toEqual([ + 'schedule', + 'trigger', + 'resource', + 'variable' + ]) + + // Deleting a script is admin-only, where deploying one is not. + const developer = accessWith(['write_draft', 'run_preview', 'manage_code', 'deploy']) + expect(kindsOf('deploy_workspace_item', developer)).toContain('script') + expect(kindsOf('delete_workspace_item', developer)).not.toContain('script') + + // Full access narrows nothing and ships the shared object itself, so the tool defs — + // part of every iteration's cached prefix — are byte for byte what they were. + const original = globalTools.find((t) => t.def.function.name === 'deploy_workspace_item')! + expect(shipped('deploy_workspace_item', fullSessionAccess())).toBe(original) + // And narrowing never writes through to it: the def is shared by every session, so a + // mutation here would strip one user's kinds from everyone else's schema. + expect((original.def.function.parameters as any).properties.type.enum).toContain('script') + }) + + // Naming a tool the model was not given produces invented calls. Asserted over the + // ASSEMBLED message: the session-state, pipeline and plan-mode sections are each + // appended by a different caller, and each can name a withheld tool. + it.each(REACHABLE_PROFILES)( + 'never names a withheld tool in the assembled prompt (%s)', + (_label, access) => { + const withheld = withheldNames(access) + // The tool DEFINITIONS ship alongside the prompt, so a withheld name in a + // description is the same broken promise as one in the prompt. + const defs = JSON.stringify(shippedSessionTools(access).map((t) => t.def)) + expect(withheld.filter((n) => defs.includes(n))).toEqual([]) + for (const previewTools of [false, true]) { + for (const ctx of SESSION_CONTEXTS) { + const msg = assembleGlobalSystemMessage(undefined, { + previewTools, + user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] }, + access, + sessionContext: ctx, + pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] } + }) + // Both decoration variants: the escalation one adds its own tool mentions. + for (const blocks of [0, 9]) { + const full = appendPlanModeInstructions(msg, blocks).content as string + expect(withheld.filter((n) => full.includes(n))).toEqual([]) + } + } + } + } + ) + + // A tool result is the one place the sweep above cannot see, and `get_instructions` + // ships to every profile with guidance written around the draft tools by name. + it('does not hand authoring guidance naming withheld tools to a session that cannot draft', async () => { + const tool = globalTools.find((t) => t.def.function.name === 'get_instructions')! + const call = (access: SessionAccess) => + tool.fn({ + args: { subject: 'script', language: 'bun' }, + workspace: 'ws', + helpers: { access }, + toolId: 't1', + toolCallbacks: { setToolStatus: () => {} } + } as any) as Promise + + const readOnly = accessWith([]) + const withheld = withheldNames(readOnly) + const restricted = await call(readOnly) + expect(withheld.filter((n) => restricted.includes(n))).toEqual([]) + // The gate is the whole test, so pin that it is not simply refusing everyone. + expect(await call(fullSessionAccess())).toContain('write_script') + }) + + // A full-access profile must gate nothing at all: the text has to match the ungated + // build byte for byte, or every session's cached prefix and the ai_evals baseline + // move underneath us. + it('builds an unchanged prompt when every capability is present', () => { + const user = { username: 'alex', folders: ['shared'], folders_read: ['shared'] } + for (const previewTools of [false, true]) { + const ungated = prepareGlobalSystemMessage(undefined, { previewTools, user }).content + const full = prepareGlobalSystemMessage(undefined, { + previewTools, + user, + access: fullSessionAccess() + }).content + expect(full).toBe(ungated) + } + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts new file mode 100644 index 0000000000..4789967a6d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts @@ -0,0 +1,46 @@ +import type { ChatCompletionFunctionTool } from 'openai/resources/index.mjs' +import type { SessionAccess, SessionToolPolicy } from '../sessionCapabilities' + +type FilterableTool = { + def: ChatCompletionFunctionTool + requires?: SessionToolPolicy + kindRequires?: Readonly>> +} + +const satisfies = (policy: SessionToolPolicy, access: SessionAccess) => + policy.every((c) => access.has(c)) + +/** Fails closed: every array that feeds a session is typed to declare `requires`, so a tool + * without one arrived some other way, and leaking it is the worse of the two answers. */ +export function sessionToolAllowed(tool: FilterableTool, access: SessionAccess): boolean { + return tool.requires ? satisfies(tool.requires, access) : false +} + +/** Filter an assembled toolset, and cut each kind-taking tool's `type` enum to the kinds + * `access` can land. `access` undefined means "not resolved yet, or not a session" — the + * toolset passes through untouched. */ +export function filterSessionTools( + tools: T[], + access: SessionAccess | undefined +): T[] { + if (!access) return tools + return tools.filter((t) => sessionToolAllowed(t, access)).map((t) => narrowKinds(t, access)) +} + +/** Returns the tool itself when every kind is allowed, so full access ships the same bytes: + * tool defs are part of every iteration's cached prefix. Builds new objects otherwise — the + * defs are module singletons shared by every session. */ +function narrowKinds(tool: T, access: SessionAccess): T { + const kindRequires = tool.kindRequires + if (!kindRequires) return tool + const parameters = tool.def.function.parameters as { + properties: Record + } + const kinds = parameters.properties.type.enum ?? [] + // A kind with no entry is withheld, as a tool with no `requires` is. + const allowed = kinds.filter((k) => kindRequires[k] && satisfies(kindRequires[k], access)) + if (allowed.length === kinds.length) return tool + const def = structuredClone(tool.def) + ;(def.function.parameters as typeof parameters).properties.type.enum = allowed + return { ...tool, def } +} diff --git a/frontend/src/lib/components/copilot/chat/pipeline/core.ts b/frontend/src/lib/components/copilot/chat/pipeline/core.ts index f696b1c869..a1a8f1ad6b 100644 --- a/frontend/src/lib/components/copilot/chat/pipeline/core.ts +++ b/frontend/src/lib/components/copilot/chat/pipeline/core.ts @@ -1,7 +1,14 @@ import { z } from 'zod' import { $ScriptLang } from '$lib/gen/schemas.gen' import type { ScriptLang } from '$lib/gen' -import { createToolDef, executeTestRun, findAndReplace, type Tool } from '../shared' +import { createToolDef, executeTestRun, findAndReplace } from '../shared' +import { + NONE, + RUN_PREVIEW, + WRITE_DRAFT, + type SessionAccess, + type SessionTool +} from '../sessionCapabilities' import type { PipelineOutputKind } from '$lib/components/assets/AssetGraph/pipelineTemplates' // ============================================================================ @@ -113,7 +120,7 @@ const readPipelineNodeSchema = z.object({ const readPipelineNodeToolDef = createToolDef( readPipelineNodeSchema, 'read_pipeline_node', - 'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before edit_pipeline_node so edits target the exact current text.' + 'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before editing a node so edits target the exact current text.' ) // ---------------------------------------------------------------------------- @@ -205,8 +212,9 @@ function inferredLineageNote(reads: string[], writes: string[]): string { return ` Inferred lineage: ${parts.join('; ')}.` } -export const pipelineTools: Tool[] = [ +export const pipelineTools: SessionTool[] = [ { + requires: NONE, def: getPipelineGraphToolDef, planModeSafe: true, fn: async ({ helpers, toolId, toolCallbacks }) => { @@ -221,6 +229,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: NONE, def: readPipelineNodeToolDef, planModeSafe: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { @@ -236,6 +245,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: WRITE_DRAFT, def: buildPipelineNodeToolDef, streamArguments: true, showDetails: true, @@ -258,6 +268,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: WRITE_DRAFT, def: editPipelineNodeToolDef, streamArguments: true, showDetails: true, @@ -286,6 +297,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: WRITE_DRAFT, def: removePipelineNodeToolDef, fn: async ({ args, helpers, toolId, toolCallbacks }) => { const pipeline = requirePipeline(helpers) @@ -300,6 +312,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: RUN_PREVIEW, def: testPipelineNodeToolDef, requiresConfirmation: true, confirmationMessage: 'Run pipeline node', @@ -331,8 +344,14 @@ export const pipelineTools: Tool[] = [ * /pipeline editor is open. Describes the annotation model and the direct-draft * workflow so the model uses the pipeline tools rather than the generic * write_script draft tools. + * + * `access` is the session's resolved capabilities (undefined outside a session, or + * before they resolve). The node-authoring guidance is dropped without `write_draft`, + * since the tools it names are withheld from that session; the annotation model stays, + * as it is what lets the model read and explain an existing pipeline. */ -export function getPipelinePromptSection(ctx: PipelineContext): string { +export function getPipelinePromptSection(ctx: PipelineContext, access?: SessionAccess): string { + const canWriteDraft = !access || access.has('write_draft') return ` Data Pipeline editor (ACTIVE): @@ -342,8 +361,12 @@ Data Pipeline editor (ACTIVE): - \`materialize\` (the managed output): a managed \`// materialize ducklake:///\` means the runtime writes the node's output table FOR you — write the body as a single SELECT and the runtime wraps it in the create/replace, so do NOT also write your own CREATE TABLE / INSERT. The \`dbt://\` target below is the opposite: the node writes its own DDL and none of the write strategies apply to it. IMPORTANT: a MANAGED \`// materialize\` is **DuckDB-only** and its target MUST be a DuckLake table (\`ducklake:///
    \`) — deploy rejects a \`ducklake://\` target on any other language. For a \`python3\`/\`bun\`/\`postgresql\` node writing the lake, do NOT use \`// materialize\`; write the output via the SDK instead (e.g. \`wmill.writeS3File(...)\`, a \`CREATE TABLE\` in postgresql, or \`wmill.databaseUrlFromResource\`/ducklake helpers) and let the output be inferred. Reach for \`duckdb\` when a node should materialize a DuckLake table. The one target any language BUT DBT'S OWN may declare (a dbt project's writes come from its manifest, so \`// materialize\` on a dbt script is rejected at deploy) is a WAREHOUSE RELATION: \`// materialize manual dbt:////\`, with \`\` a warehouse the workspace configures under Settings → dbt. \`manual\` is its only mode — nothing generates warehouse DDL, so the node issues its own write and the annotation records the outcome. Use it on an ingestion node a dbt project reads as a \`source\`: the declared relation and the dbt model become ONE graph node, and a downstream \`// on dbt:////\` fires when that node completes. Write strategy: with no option it REPLACES the whole table each run (full refresh; the only mode whose output columns may change); \`// materialize append\` INSERT-appends rows (incremental); \`// materialize key=\` merges/upserts on \`\`. \`// materialize manual \` opts OUT of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. \`materialize\` is paired with partitioning for incremental pipelines: a \`// partitioned \` node runs once per partition (append/merge into a fixed-schema table), and the \`{partition}\` token — usable in any asset URI AND in the body SQL — is substituted with the current partition's IDENTITY string at run time. To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition() = {partition}\`. \`wm_partition(ts)\` buckets a timestamp with the exact identity format the runtime used (daily/hourly/weekly/monthly), so it always matches and you never hand-write a \`strftime\` format. Do NOT write \`= TIMESTAMP {partition}\`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at runtime. For \`dynamic\` partitioning the identity is your caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE = {partition}\`. \`materialize\` is an output DECLARATION on the node — it is not a command; there is no "materialize run". - \`measure\` / \`dimension\` (declared metrics): on a node that materializes a DuckLake table, \`// measure = [where ]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension = \` names a way to slice it (e.g. \`// dimension region = region\`, \`// dimension month = date_trunc('month', ordered_at)\`). They execute nothing: they are catalogued at deploy so the editor and other agents can reuse the definition instead of re-deriving it and silently disagreeing. Keep the predicate in the \`where\` clause rather than folding it into the aggregate: it is rendered as \` FILTER (WHERE )\`, which is what lets two measures with different predicates sit under one GROUP BY. DuckLake-only, and only meaningful next to \`// materialize\`. Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures, an obvious \`count(*)\` earns nothing. To USE a metric another node declares, read that node with read_pipeline_node and reuse its exact expression rather than guessing it. - Use get_pipeline_graph to see the current nodes/assets/triggers, and read_pipeline_node before editing one. -- Every node of this pipeline lives at \`f/${ctx.folder}/\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`\`). +- Every node of this pipeline lives at \`f/${ctx.folder}/\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`\`).${ + canWriteDraft + ? ` - Build new nodes with build_pipeline_node and edit existing ones with edit_pipeline_node. These apply directly as unsaved drafts on the canvas (like the flow/script editor applies AI edits) — they DO NOT deploy. There is no separate Accept/Reject step. Prefer these over the generic write_script/edit_script draft tools while a pipeline is open. - Reuse existing asset paths from the graph when wiring a downstream node to an upstream one (read the upstream's write asset, then \`// on\` that same URI). - Only deploy when the user explicitly asks; the user deploys drafts from the canvas.` + : '' + }` } diff --git a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts index d76c1502dd..01e5040040 100644 --- a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts @@ -1,6 +1,7 @@ import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter' -import { createToolDef, type Tool, type ToolCallbacks } from './shared' +import { createToolDef, type ToolCallbacks } from './shared' +import { NONE, type SessionTool } from './sessionCapabilities' import { appendPlanModeInstructions, derivePlanTitle, @@ -100,7 +101,7 @@ export class PlanModeController { /** Only the transition the current posture allows; auto-accepting exposes neither, since * entering is the user's choice. */ - get tools(): Tool[] { + get tools(): SessionTool[] { if (!this.#host.available) return [] if (this.#host.autoAccepting) return [] return this.#host.active ? [this.exitTool] : [this.enterTool] @@ -108,12 +109,16 @@ export class PlanModeController { /** Both transitions, whichever posture is selected: what plan mode contributes to this * chat's capabilities, rather than to the turn it is about to send. */ - get availableTools(): Tool[] { + get availableTools(): SessionTool[] { return this.#host.available ? [this.enterTool, this.exitTool] : [] } // This safety tag is what keeps plan mode escapable through its handoff tool. - exitTool: Tool = { + exitTool: SessionTool = { + // Ungated even for a user who can change nothing: the posture is the USER's choice, + // and its instructions order this call to hand the plan over — withholding it + // strands the model in a round nothing else can end. + requires: NONE, def: createToolDef(exitPlanModeArgs, EXIT_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL_DESCRIPTION), planModeSafe: true, requiresConfirmation: true, @@ -162,7 +167,8 @@ export class PlanModeController { } } - enterTool: Tool = { + enterTool: SessionTool = { + requires: NONE, def: createToolDef(enterPlanModeArgs, ENTER_PLAN_MODE_TOOL, ENTER_PLAN_MODE_TOOL_DESCRIPTION), planModeSafe: true, requiresConfirmation: true, diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 0c8b3f9a0e..92cb889b24 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -9,6 +9,7 @@ import type { } from 'openai/resources/index.mjs' import { type DBSchema, dbSchemas } from '$lib/stores' import type { ContextElement } from '../context' +import { NONE, RUN_PREVIEW, type SessionTool } from '../sessionCapabilities' import { createSearchHubScriptsTool, type Tool, @@ -462,9 +463,12 @@ export const resourceTypeTool: Tool = { // Generic DB schema tool factory shared by the script, flow and global modes export function createDbSchemaTool( opts: { description?: string; updateEditorCache?: boolean } = {} -): Tool { +): SessionTool { const { description, updateEditorCache = true } = opts return { + // `getDbSchemas` below introspects by running a query job through /jobs/run/preview, + // which jobs.rs refuses operators — so this reads like a lookup but gates like a run. + requires: RUN_PREVIEW, def: description ? { ...DB_SCHEMA_FUNCTION_DEF, @@ -610,7 +614,8 @@ const SEARCH_NPM_PACKAGES_TOOL: ChatCompletionFunctionTool = { } // Helpers-agnostic so both script mode and global mode can offer it. -export const searchNpmPackagesTool: Tool<{}> = { +export const searchNpmPackagesTool: SessionTool<{}> = { + requires: NONE, def: SEARCH_NPM_PACKAGES_TOOL, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts new file mode 100644 index 0000000000..fc77b3e1c4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -0,0 +1,52 @@ +import type { Tool } from './shared' + +// A leaf on purpose: every module defining a session-reachable tool imports these +// constants, so importing anything with side effects here drags it into all of them. + +/** + * What a user may do in ONE workspace, as an AI session's toolset needs to know it. + * Best-effort, not a boundary — the token is the enforcement point, so this narrows + * what the model is offered and guarantees nothing. + */ +export type SessionCapability = + /** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and + * triggers included, though an operator may create those directly. */ + | 'write_draft' + /** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ + | 'run_preview' + /** Passes `check_deploy_rules`. */ + | 'deploy' + /** Passes the operator refusal the script, flow and app handlers make before creating or + * deleting one. The same condition as `run_preview` today, but another handler family's + * check, so each follows its own gate if the two ever diverge. */ + | 'manage_code' + /** Passes `require_admin`. */ + | 'admin' + +export type SessionAccess = ReadonlySet + +/** + * The capabilities without which a tool's call cannot succeed — usually because the + * backend refuses it, occasionally because only that capability produces the tool's + * input. Never a relevance judgement: a tool the server would accept ships, even when + * it is of little use to the session, because withholding it is a stricter answer than + * the one the user would get by trying. + */ +export type SessionToolPolicy = readonly SessionCapability[] + +export const NONE: SessionToolPolicy = [] +export const WRITE_DRAFT: SessionToolPolicy = ['write_draft'] +export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] +export const DEPLOY: SessionToolPolicy = ['deploy'] + +/** + * A tool that can reach an AI session's toolset. `requires` is mandatory, and declared + * beside the `fn` whose call is what proves it right. + */ +export type SessionTool = Tool & { + requires: SessionToolPolicy + /** For a tool taking the item kind as its `type` argument, where one verdict for the whole + * tool is either too strict or too loose: what each kind's handler needs. A session is + * offered only the kinds it can land, and the rest never appear in the schema it sees. */ + kindRequires?: Readonly>> +} diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 83a51b527c..5e8bdb8a4b 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -7,6 +7,7 @@ import type { UserDraftItemKind } from '$lib/gen' // The gate's two refusals, from a module that holds prose and one size limit: under the // shallow-import rule below, the rest of plan mode is not reachable from here. import { PLAN_MODE_MESSAGES } from './planModeMessages' +import { NONE } from './sessionCapabilities' // Import-free leaf, so it satisfies the shallow-import rule below. import { openItemPreviewAction, @@ -1249,7 +1250,10 @@ export interface Tool { workspace: string helpers: T }) => MaybePromise - setSchema?: (helpers: any) => Promise + /** This chat's definition of the tool, for a schema that depends on the chat (its workspace, + * its open flow). Returns a new def: tools are module singletons shared by every chat, so + * one written back leaks into the others. */ + schemaFor?: (helpers: any) => Promise /** Safe to run while plan mode is active. Absence fails closed. */ planModeSafe?: boolean /** The arguments a plan-mode-safe tool still refuses while the posture holds — for a tool @@ -1549,6 +1553,7 @@ export function isHubPath(path: string): boolean { } export const createSearchHubScriptsTool = (withContent: boolean = false) => ({ + requires: NONE, def: searchHubScriptsToolDef, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts index 46dc39b737..d56c1f6541 100644 --- a/frontend/src/lib/utils_workspace_deploy.ts +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -803,33 +803,25 @@ export function deployPermissionForKinds( } /** - * Whether the current user may deploy into `workspace`. Mirrors `check_deploy_rules` in + * The workspace's deploy-protection rules alone. Mirrors `check_deploy_rules` in * windmill-common so the UI can disable the action with a reason instead of letting the * click 403: `DisableDirectDeployment` is evaluated before `RestrictDeployToDeployers`, so * the same message wins here as on the server when both block; admins and superadmins bypass * both rules, while `wm_deployers` members bypass only the latter. * - * The operator refusal is not part of that mirror. The server refuses operators in the item - * handlers instead, and for fewer kinds, so refusing them for everything here is deliberately - * stricter than the server rather than a faithful copy of it. + * Separate from `checkDeployPermission`, which adds a blanket operator refusal: the server + * refuses operators per kind, in the item handlers, not in these rules. * * Fails open on any error — the server still enforces on the actual deploy. - * Shared by the session dock and the compare page so both gate identically. */ -export async function checkDeployPermission( +export async function checkDeployRules( workspace: string, - /** Pre-fetched `whoami` for `workspace`, to save a round trip when the caller already has one. */ - whoami?: User + /** Pre-fetched identity for `workspace`, to save a round trip. Narrowed to the fields + * read so a caller holding a `UserExt` can pass it without a cast. */ + whoami?: Pick ): Promise { try { const me = whoami ?? (await UserService.whoami({ workspace })) - if (me.operator) { - return { - ok: false, - reason: "You're an operator in this workspace — operators can't deploy", - refusedBy: 'operator' - } - } const userInfo = { is_admin: !!me.is_admin, is_super_admin: !!me.is_super_admin, @@ -877,6 +869,36 @@ export async function checkDeployPermission( } } +/** + * Whether the current user may deploy into `workspace` at all: the protection rules above, + * plus a refusal of every operator. + * + * That refusal is not part of the `check_deploy_rules` mirror. The server refuses operators + * in the item handlers instead, and for fewer kinds, so refusing them for everything here is + * deliberately stricter than the server rather than a faithful copy of it. A caller mirroring + * one handler wants `checkDeployRules`. + * + * Shared by the session dock and the compare page so both gate identically. + */ +export async function checkDeployPermission( + workspace: string, + whoami?: Pick +): Promise { + try { + const me = whoami ?? (await UserService.whoami({ workspace })) + if (me.operator) { + return { + ok: false, + reason: "You're an operator in this workspace — operators can't deploy", + refusedBy: 'operator' + } + } + return await checkDeployRules(workspace, me) + } catch { + return { ok: true } + } +} + /** * Whether `me` may write at `path` in `workspace` — the per-item half of the deploy gate, which * `checkDeployPermission`'s workspace-level rules don't cover. Advisory only: it exists so the UI From 504d01a5631e33b3a2604ef401ab8b73e4a55395 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:54:25 +0200 Subject: [PATCH 091/112] feat: rework the git sync setup into a guided flow (#11308) * feat: rework git sync settings into a flat repo list and setup modal Co-Authored-By: Claude Opus 5 (1M context) * feat: add the connect step for gitlab and github in git sync setup Co-Authored-By: Claude Opus 5 (1M context) * feat: open the github app install page from the git sync setup next step Co-Authored-By: Claude Opus 5 (1M context) * feat: let git sync setup set the repository folder Co-Authored-By: Claude Opus 5 (1M context) * feat: add the configure sync step to git sync setup Co-Authored-By: Claude Opus 5 (1M context) * style: even out the git sync filter settings columns Co-Authored-By: Claude Opus 5 (1M context) * style: pin the filter input to the bottom of the filter list Co-Authored-By: Claude Opus 5 (1M context) * style: frame the git sync path filters in a box with an empty hint Co-Authored-By: Claude Opus 5 (1M context) * refactor: use a multiselect for the git sync path filters Co-Authored-By: Claude Opus 5 (1M context) * feat: grey out the skipped connect step in git sync setup Co-Authored-By: Claude Opus 5 (1M context) * style: move the check job line to the bottom of the setup modal Co-Authored-By: Claude Opus 5 (1M context) * fix: hide the check job line once the repository check resolved Co-Authored-By: Claude Opus 5 (1M context) * style: center the repository check loader in the setup modal Co-Authored-By: Claude Opus 5 (1M context) * fix: stop the setup modal shifting when the check job id arrives Co-Authored-By: Claude Opus 5 (1M context) * style: move the existing resource hint to the setup modal footer Co-Authored-By: Claude Opus 5 (1M context) * feat: always show the existing resource option when one exists Co-Authored-By: Claude Opus 5 (1M context) * style: drop the card and collapse from the git sync filter settings Co-Authored-By: Claude Opus 5 (1M context) * style: use a success alert for the pull from git notice Co-Authored-By: Claude Opus 5 (1M context) * feat: make initializing the repository a step of the git sync setup Co-Authored-By: Claude Opus 5 (1M context) * style: hide the push job lines once they succeed Co-Authored-By: Claude Opus 5 (1M context) * style: reword the push step intro Co-Authored-By: Claude Opus 5 (1M context) * style: copy button on the cli snippet and a taller changes list Co-Authored-By: Claude Opus 5 (1M context) * style: match the push step loading to the check step Co-Authored-By: Claude Opus 5 (1M context) * fix: stop the push step shifting when a job id arrives Co-Authored-By: Claude Opus 5 (1M context) * style: let the changes list fill the push step Co-Authored-By: Claude Opus 5 (1M context) * feat: end the git sync setup on a done step instead of a modal Co-Authored-By: Claude Opus 5 (1M context) * feat: delete a git sync repository from a row menu with a confirmation Co-Authored-By: Claude Opus 5 (1M context) * style: lift the git sync done message off the notices Co-Authored-By: Claude Opus 5 (1M context) * feat: restore github app installation transfer between instances Co-Authored-By: Claude Opus 5 (1M context) * fix: address regressions from the git sync setup rework Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the overlay z-index floor in step with the ai chat Co-Authored-By: Claude Opus 5 (1M context) * fix: hold the push state in the setup dialog, not in the step it destroys Co-Authored-By: Claude Opus 5 (1M context) * fix: block every dismissal while setup work is in flight and keep sync reachable Co-Authored-By: Claude Opus 5 (1M context) * fix: regenerate the resource path when the repository changes Co-Authored-By: Claude Opus 5 (1M context) * fix: configure the resource the connect step actually created Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a modal open when a drawer above it takes the click Co-Authored-By: Claude Opus 5 (1M context) * fix: snapshot the topmost surface for keyboard clicks too Co-Authored-By: Claude Opus 5 (1M context) * style: size the add repository buttons and explain git promotion Co-Authored-By: Claude Opus 5 (1M context) * revert: drop the promotion-first action from the empty state Co-Authored-By: Claude Opus 5 (1M context) * style: shrink the test connection result line Co-Authored-By: Claude Opus 5 (1M context) * feat: offer the fork sync and fork pull request toggles during setup Co-Authored-By: Claude Opus 5 (1M context) * style: group promotion repositories under their own label Co-Authored-By: Claude Opus 5 (1M context) * feat: offer the deploy branch pull request toggle during setup Co-Authored-By: Claude Opus 5 (1M context) * fix: wait for the repository credential before saving the setup Co-Authored-By: Claude Opus 5 (1M context) * fix: ignore a superseded credential load and drop the bindable default Co-Authored-By: Claude Opus 5 (1M context) * fix: key setup readiness to the repository it was loaded for Co-Authored-By: Claude Opus 5 (1M context) * fix: tie setup readiness to the attempt that asked for it Co-Authored-By: Claude Opus 5 (1M context) * feat: link to where the access token is created Co-Authored-By: Claude Opus 5 (1M context) * style: say plainly how each provider connects Co-Authored-By: Claude Opus 5 (1M context) * feat: sync resources, variables, schedules and triggers by default Co-Authored-By: Claude Opus 5 (1M context) * style: use the accent colour for the token help link Co-Authored-By: Claude Opus 5 (1M context) * style: move the CI/CD deploy hint next to the pull toggle Co-Authored-By: Claude Opus 5 (1M context) * fix: keep setup gated when the connection could not be read Co-Authored-By: Claude Opus 5 (1M context) * fix: only explain pull-from-git where the workspace can turn it on Co-Authored-By: Claude Opus 5 (1M context) * fix: offer a save retry once the repository is initialized Co-Authored-By: Claude Opus 5 (1M context) * fix: say when pulling fell back to polling on the last setup step Co-Authored-By: Claude Opus 5 (1M context) * fix: clear the initialized flag when a new draft starts Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../src/lib/components/GitDiffPreview.svelte | 7 +- .../common/drawer/Disposable.svelte | 16 +- .../lib/components/common/modal/Modal2.svelte | 172 +++-- .../components/common/stepper/Stepper.svelte | 25 +- .../components/git_sync/GitPushPreview.svelte | 263 ++++++++ .../git_sync/GitSyncContext.svelte.ts | 92 ++- .../git_sync/GitSyncModalManager.svelte | 2 + .../git_sync/GitSyncRepositoryCard.svelte | 96 +-- .../components/git_sync/GitSyncSection.svelte | 426 ++++++++----- .../git_sync/GitSyncSetupModal.svelte | 600 ++++++++++++++++++ .../git_sync/GitSyncSuccessContent.svelte | 72 +++ .../git_sync/GitSyncSuccessModal.svelte | 79 +-- .../git_sync/PushWorkspaceModal.svelte | 374 ++--------- .../git_sync/setup/ConfigureSyncStep.svelte | 314 +++++++++ .../setup/GithubAppConnectForm.svelte | 232 +++++++ .../setup/GitlabProjectConnectForm.svelte | 114 ++++ .../git_sync/setup/TokenUrlConnectForm.svelte | 95 +++ .../git_sync/setup/connectionDefaults.ts | 45 ++ .../git_sync/setup/githubAppSetup.svelte.ts | 50 ++ .../git_sync/setup/repoConnection.test.ts | 29 + .../git_sync/setup/repoConnection.ts | 117 ++++ .../lib/components/select/MultiSelect.svelte | 11 +- .../workspaceSettings/FilterList.svelte | 61 +- .../GitSyncFilterSettings.svelte | 46 +- 24 files changed, 2541 insertions(+), 797 deletions(-) create mode 100644 frontend/src/lib/components/git_sync/GitPushPreview.svelte create mode 100644 frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte create mode 100644 frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte create mode 100644 frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte create mode 100644 frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte create mode 100644 frontend/src/lib/components/git_sync/setup/GitlabProjectConnectForm.svelte create mode 100644 frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte create mode 100644 frontend/src/lib/components/git_sync/setup/connectionDefaults.ts create mode 100644 frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts create mode 100644 frontend/src/lib/components/git_sync/setup/repoConnection.test.ts create mode 100644 frontend/src/lib/components/git_sync/setup/repoConnection.ts diff --git a/frontend/src/lib/components/GitDiffPreview.svelte b/frontend/src/lib/components/GitDiffPreview.svelte index 948ae4d4e8..1d18806109 100644 --- a/frontend/src/lib/components/GitDiffPreview.svelte +++ b/frontend/src/lib/components/GitDiffPreview.svelte @@ -1,8 +1,11 @@ -
    +
    Preview of changes:
    {#if !added.length && !deleted.length && !edited.length} diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 642d1366f6..886c14042b 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -71,6 +71,16 @@ } } + // The entry follows the prop for as long as the overlay is open: a dialog opened before + // the AI chat still has to rise above it when the chat is opened underneath it. + $effect(() => { + const wanted = open && minZIndex > 0 ? minZIndex : undefined + untrack(() => { + if (wanted !== undefined) minZIndexEntries[id] = wanted + else delete minZIndexEntries[id] + }) + }) + // A disposable can be unmounted while still open, by an ancestor that tears its whole // subtree down. Its id would then sit on the stack forever, and since the topmost entry // arbitrates Escape, every overlay opened afterwards would stop answering it. @@ -84,10 +94,10 @@ offset = initialOffset if (stack.val.includes(id)) { stack.val = stack.val.filter((drawer) => drawer !== id) - if (minZIndex > 0) { - delete minZIndexEntries[id] - } } + // Unconditionally: `minZIndex` may have fallen to 0 since the entry was written (the + // chat closed), and a leftover entry keeps every later overlay elevated for good. + delete minZIndexEntries[id] } export function isOpen() { diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index b9e57740b9..6d7c8fdd0f 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -10,6 +10,9 @@ import { fade } from 'svelte/transition' import { zIndexes } from '$lib/zIndexes' import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte' + import Disposable from '../drawer/Disposable.svelte' + import { overlayHostActive, setTopmostSurface } from '../overlayHost.svelte' + import { untrack } from 'svelte' interface Props { title: string @@ -30,6 +33,9 @@ * window, so a stacked pair would both close on one press; set it * false on the underlying modal while its child is up. */ closeOnEscape?: boolean + /** Blocks every way out — the header ✕, Escape and an outside click — for a dialog + * holding work in flight that a dismissal would orphan. */ + preventDismiss?: boolean /** Wider side padding and a lighter title, for a dialog whose body is a form rather * than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */ formStyling?: boolean @@ -51,10 +57,11 @@ contentClasses = '', closeOnOutsideClick = true, closeOnEscape = true, + preventDismiss = false, formStyling = false, headerLeft, headerRight, - children + children: body }: Props = $props() const widthMap = { @@ -84,8 +91,30 @@ isOpen = true } + // Registered in the disposable stack so a drawer opened from inside the dialog (a + // resource picker's editor) stacks above it, and so Escape and outside clicks aimed + // at that drawer do not close the dialog underneath. + let disposable: Disposable | undefined = $state(undefined) + const hostActive = overlayHostActive() + setTopmostSurface(() => disposable?.isTopmost() ?? true) + $effect(() => { + isOpen + untrack(() => (isOpen ? disposable?.openDrawer() : disposable?.closeDrawer())) + }) + + /** Whether this dialog was the overlay on top when the interaction started. `clickOutside` + * awaits its async `exclude` before reporting, and an overlay above closes itself in that + * gap — by the time the report lands, the dialog looks topmost and would close with it. */ + let topmostAtPress = true + function snapshotTopmost() { + topmostAtPress = disposable?.isTopmost() ?? true + } + function handleKeyDown(event: KeyboardEvent) { - if (!isOpen || !closeOnEscape) return + if (!isOpen || !closeOnEscape || preventDismiss) return + // Hidden hosts stay mounted and still receive window keys — see overlayHost. + if (!hostActive()) return + if (!(disposable?.isTopmost() ?? true)) return if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() @@ -97,79 +126,94 @@ return fade(node, { duration: 200 }) } - // Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so - // the dialog isn't hidden behind it; otherwise keep the default modal - // stacking just above disposables (zIndexes.disposables). - const overlayZIndex = $derived( - chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10 - ) + // Elevate above the AI chat panel while chat is open so the dialog isn't hidden behind it. + const minZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : 0) - + { + // A keyboard-activated control emits a click with no pointer event before it (`detail` + // 0), so the pointerdown snapshot would be whatever a previous interaction left. + if (e.detail === 0) snapshotTopmost() + }} +/> -{#if isOpen} - -
    -
    + + {#snippet children({ zIndex })} + {#if isOpen} +
    closeOnOutsideClick && close() - }} + class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto bg-black bg-opacity-60 w-full h-full'} + style="z-index: {zIndex}" + transition:fadeFast|local > - -
    - -

    {title}

    -
    +
    +
    + closeOnOutsideClick && !preventDismiss && topmostAtPress && close(), + // A dropdown opened from inside the dialog portals its menu out of it, so a + // click on one of its items lands outside this node and would close the + // dialog under the menu. + exclude: async () => + Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[] + }} + > + +
    -
    - {@render headerLeft?.()} -
    -
    - - {@render headerRight?.()} -
    - +

    {title}

    +
    + +
    + {@render headerLeft?.()} +
    +
    + + {@render headerRight?.()} +
    + +
    +
    + + + +
    {})} + > + {@render body?.()} +
    - - - -
    {})} - > - {@render children?.()} -
    -
    +
    -
    -
    - -{/if} + + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/common/stepper/Stepper.svelte b/frontend/src/lib/components/common/stepper/Stepper.svelte index de6303cedc..5b13d9ddfc 100644 --- a/frontend/src/lib/components/common/stepper/Stepper.svelte +++ b/frontend/src/lib/components/common/stepper/Stepper.svelte @@ -12,6 +12,9 @@ allowStepNavigation?: boolean /** Compact variant, for steering a dialog rather than a full page. */ small?: boolean + /** Steps this run does not go through, greyed out and not clickable — a step the + * chosen path skips rather than one not reached yet. */ + disabledIndices?: number[] } let { @@ -21,7 +24,8 @@ statusByStep = [], hasValidations = false, allowStepNavigation = false, - small = false + small = false, + disabledIndices = [] }: Props = $props() const dispatch = createEventDispatcher() @@ -75,15 +79,20 @@ {#each tabs ?? [] as step, index} + {@const disabled = disabledIndices.includes(index)}
  • { - dispatch('click', { index }) + if (!disabled) dispatch('click', { index }) }} > {#if statusByStep[index] === 'pending'} @@ -94,7 +103,9 @@ small ? 'h-4 w-4 rounded-full flex items-center justify-center text-2xs' : 'h-6 w-6 rounded-full flex items-center justify-center text-xs', - getStepColor(index, selectedIndex, statusByStep, maxReachedIndex) + disabled + ? 'bg-gray-200' + : getStepColor(index, selectedIndex, statusByStep, maxReachedIndex) )} class:font-bold={selectedIndex === index} > @@ -105,7 +116,11 @@ {step} diff --git a/frontend/src/lib/components/git_sync/GitPushPreview.svelte b/frontend/src/lib/components/git_sync/GitPushPreview.svelte new file mode 100644 index 0000000000..bed6d741d1 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitPushPreview.svelte @@ -0,0 +1,263 @@ + + + +
    + {#if isPreviewLoading} +
    + + Listing the changes to push... +
    + {:else} +

    + Here is what will get pushed to the repository, according to the filters you set. +

    + {/if} + + {#if previewError} + +
    + {previewError} + +
    +
    + {/if} + + {#if previewResult && !previewError} +
    +

    Changes to push

    + + {#if previewResult.changes?.length > 0} + + {:else} +
    + The repository already matches this workspace: there is nothing to push. +
    + {/if} +
    + {/if} + + {#if applyError} + + {applyError} + + {/if} + +
    + {#each [{ id: previewJobId, status: previewJobStatus, label: 'Changes job', running: isPreviewLoading }, { id: applyJobId, status: applyJobStatus, label: 'Push job', running: isApplying }] as job (job.label)} + + {#if job.running || (job.id && job.status !== 'success')} +
    + {#if job.status === 'running'} + + {:else if job.status === 'failure'} + + {/if} + {#if job.id} + {job.label}: + + {job.id} + + {/if} +
    + {/if} + {/each} + + +
    +
    + +
    + + {#if showCliInstructions} +
    + +
    {cliInstructions}
    +
    + {/if} +
    +
    +
    diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 075c6b1f24..97b79b7c1b 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -1,5 +1,5 @@ import { getContext, setContext } from 'svelte' -import { enterpriseLicense, userStore } from '$lib/stores' +import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { get } from 'svelte/store' import { sendUserToast } from '$lib/toast' import { apiErrorMessage } from '$lib/utils' @@ -46,7 +46,15 @@ export type GitSyncSettings = { export type ModalState = { push: { idx: number; repo: GitSyncRepository; open: boolean } | null pull: { idx: number; repo: GitSyncRepository; open: boolean; settingsOnly?: boolean } | null - success: { open: boolean; savedWithoutInit?: boolean; autoPullOn?: boolean } | null + success: { + open: boolean + savedWithoutInit?: boolean + autoPullOn?: boolean + /** Why pulling fell back to polling, when it did. */ + webhookError?: string + /** Whether this workspace is the one that would turn pulling on for this repository. */ + ownsAutoPull?: boolean + } | null } export type ValidationState = { @@ -202,6 +210,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] @@ -290,8 +303,23 @@ export function createGitSyncContext(workspace: string) { closeModal('pull') } - function showSuccessModal(savedWithoutInit?: boolean, autoPullOn?: boolean) { - activeModals.success = { open: true, savedWithoutInit, autoPullOn } + /** Only a sync repository in a non-fork workspace has a pull toggle of its own: promotion + * repositories have none, and a fork's pulling is its parent's to set. */ + function ownsAutoPull(repo: GitSyncRepository): boolean { + const ws = get(workspaceStore) + const isFork = + (ws?.startsWith('wm-fork-') ?? false) || + !!get(userWorkspaces)?.find((w) => w.id === ws)?.parent_workspace_id + return !repo.use_individual_branch && !isFork + } + + function showSuccessModal( + savedWithoutInit?: boolean, + autoPullOn?: boolean, + ownsAutoPull?: boolean, + webhookError?: string + ) { + activeModals.success = { open: true, savedWithoutInit, autoPullOn, ownsAutoPull, webhookError } } function closeSuccessModal() { @@ -314,8 +342,11 @@ export function createGitSyncContext(workspace: string) { repo.detectionJobId = undefined repo.detectionJobStatus = undefined - // Track the detection timestamp to avoid race conditions from old jobs + // Track the detection timestamp to avoid race conditions from old jobs. Stamped before + // the request, or a failure to even start the job reads as superseded and leaves the + // repository loading for good. const detectionTimestamp = Date.now() + repo._detectionTimestamp = detectionTimestamp try { const jobId = await JobService.runScriptByPath({ @@ -335,7 +366,6 @@ export function createGitSyncContext(workspace: string) { repo.detectionJobId = jobId repo.detectionJobStatus = 'running' - repo._detectionTimestamp = detectionTimestamp // Use JobManager for polling - result will be the actual job response await jobManager.runWithProgress(() => Promise.resolve(jobId), { @@ -506,7 +536,9 @@ export function createGitSyncContext(workspace: string) { } } - async function saveRepository(idx: number, savedWithoutInit = false) { + /** `announce` off for a caller that reports the save itself — the setup dialog ends on a + * step saying the same thing, and a modal over it would say it twice. */ + async function saveRepository(idx: number, savedWithoutInit = false, announce = true) { const repo = repositories[idx] if (!repo || !validateRepository(repo, idx)) { throw new Error('Cannot save invalid repository') @@ -539,7 +571,8 @@ export function createGitSyncContext(workspace: string) { if (repoToSave.auto_pull) { repoToSave.auto_pull = { ...repoToSave.auto_pull, - enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined + enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined, + webhook_error: await readWebhookError(repoToSave) } } @@ -553,7 +586,31 @@ export function createGitSyncContext(workspace: string) { repoToSave.detectionState = undefined repoToSave.extractedSettings = undefined // Show success modal for new connections - showSuccessModal(savedWithoutInit, repoToSave.auto_pull?.enabled === true) + if (announce) { + showSuccessModal( + savedWithoutInit, + repoToSave.auto_pull?.enabled === true, + ownsAutoPull(repoToSave), + repoToSave.auto_pull?.webhook_error + ) + } + } + } + + /** The webhook is registered after the settings commit, best-effort, and a failure is + * reported only through the stored settings — so it stays invisible until they are read + * back. Without this, pulling reads as working when it has silently fallen back to + * polling (an app missing the webhook permission, an instance GitHub cannot reach). */ + async function readWebhookError(repo: GitSyncRepository): Promise { + if (!repo.auto_pull?.enabled) return undefined + try { + const settings = await WorkspaceService.getSettings({ workspace }) + return settings.git_sync?.repositories?.find( + (r) => r.git_repo_resource_path.replace('$res:', '') === repo.git_repo_resource_path + )?.auto_pull?.webhook_error + } catch { + // Only costs the warning; the card shows it on the next load. + return undefined } } @@ -704,6 +761,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] @@ -712,9 +774,10 @@ export function createGitSyncContext(workspace: string) { legacyImported: false, isUnsavedConnection: true, collapsed: false - // Pull-from-Git defaults are applied by the repository card once the - // selected resource resolves: only app-backed repos (instant webhook - // delivery) default to auto-pull on; polling is opt-in for token repos. + // Pull-from-Git defaults are applied once the selected resource resolves, by + // whoever is configuring the connection (see applyNewConnectionDefaults): only + // app-backed repos (instant webhook delivery) default to auto-pull on; polling is + // opt-in for token repos. }) gitSyncTestJobs.push({ jobId: '', @@ -741,6 +804,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] diff --git a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte index 610dafb562..cb8236cafc 100644 --- a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte @@ -111,5 +111,7 @@ bind:open={gitSyncContext.activeModals.success.open} savedWithoutInit={gitSyncContext.activeModals.success.savedWithoutInit} autoPullOn={gitSyncContext.activeModals.success.autoPullOn} + explainAutoPullOff={gitSyncContext.activeModals.success.ownsAutoPull ?? false} + webhookError={gitSyncContext.activeModals.success.webhookError} /> {/if} diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index 291c89c606..8c3af1c54d 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -1,7 +1,6 @@ {#if !gitSyncContext} @@ -132,7 +228,7 @@ {#snippet actions()} {#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined} +
  • + {:else} +
    + {#each syncRows as { repo, idx } (idx)} + {@render repoRow(repo, idx)} + {/each} + {#if promotionRows.length > 0} + +
    Promotion repositories
    + {#each promotionRows as { repo, idx } (idx)} + {@render repoRow(repo, idx)} + {/each} + {/if} +
    - {#if $enterpriseLicense} - - {#if primarySync && !primarySync.repo?.isUnsavedConnection && !devSingleRepo} - {#if secondarySync.length > 0 || secondarySyncExpanded} -
    - - - {#if secondarySyncExpanded} -
    - {#if secondarySync.length === 0} -
    - No secondary sync repositories configured -
    - {:else} - {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)} -
    - -
    - {/each} - {/if} - - {#if !hasUnsavedSecondary} -
    - -
    - {/if} -
    - {/if} -
    - {:else} - - {#if !hasUnsavedSecondary} -
    - + {addSyncLabel} + + {#if !$enterpriseLicense}{/if}
    {/if} - {/if} - {/if} - - - {#if showPromotion && !devSingleRepo} -
    - gitSyncContext.addPromotionRepository()} - isCollapsible={false} - showEmptyState={primaryPromotion?.repo === null} - /> - - - {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} - {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} -
    - - - {#if secondaryPromotionExpanded} -
    - {#if secondaryPromotion.length === 0} -
    - No secondary promotion repositories configured -
    - {:else} - {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} -
    - -
    - {/each} - {/if} - - {#if !hasUnsavedSecondaryPromotion} -
    - -
    - {/if} -
    - {/if} -
    - {:else} - - {#if !hasUnsavedSecondaryPromotion} -
    - -
    - {/if} - {/if} + {#if showAddPromotion} +
    + + + Each deploy in this workspace pushes its changes to a dedicated + wm_deploy/** + branch of the repository instead of committing to its tracked branch directly. Merging + that branch promotes the change: the workspace that syncs the tracked branch deploys + it on merge, so set up Git Sync there. Windmill can open the pull request for each + deploy branch, or you can use the open-pr-on-commit workflow. + + {#if !$enterpriseLicense}{/if} +
    {/if}
    - {:else if !showPromotion} + {/if} + + {#if $enterpriseLicense && !showPromotion}
    + (deleting = undefined)} + > + + Deploys of this workspace will stop being committed to + {deleting?.path}. The repository itself and its resource are + left untouched. + + + + + + (settingsPath = undefined)} + > + settingsDrawer?.closeDrawer()} + > + {#if settingsIdx !== -1} + {#key settingsIdx} + + {/key} + {/if} + + + {/if} {/if} + +{#snippet repoRow(repo: GitSyncRepository, idx: number)} + {@const validation = gitSyncContext?.getValidation(idx)} +
    +
    +
    + + {repo.git_repo_resource_path || 'No resource selected'} + + {#if repo.isUnsavedConnection} + Not saved + {:else if validation?.hasChanges} + Unsaved changes + {/if} + {#if repo.legacyImported} + Legacy configuration + {/if} +
    + {rowLabel(idx)} +
    +
    + + (deleting = { idx, path: repo.git_repo_resource_path }) + } + ]} + /> +
    +
    +{/snippet} diff --git a/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte b/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte new file mode 100644 index 0000000000..c89ed45d76 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte @@ -0,0 +1,600 @@ + + + +
    + { + if (e.detail.index === 0 && step > 1) back() + }} + /> + +
    +
    + {#if step === 1} + {#snippet githubIcon()} + + {/snippet} + {#snippet gitlabIcon()} + + {/snippet} + {#snippet resourceIcon()} + + {/snippet} +
    + {@render providerCard( + 'github_app', + githubIcon, + 'GitHub App (recommended)', + 'Connect with the GitHub UI.', + !$enterpriseLicense + )} + {@render providerCard( + 'github_pat', + githubIcon, + 'GitHub personal access token', + 'Generate a token from GitHub to allow Windmill to access your account.' + )} + {@render providerCard( + 'gitlab', + gitlabIcon, + 'GitLab', + 'Generate a token from GitLab to allow Windmill to access your account.' + )} + {#if hasResource || provider === 'existing'} + {@render providerCard( + 'existing', + resourceIcon, + 'Use an existing git_repository resource', + 'Pick a resource that already holds the repository URL and its credentials.' + )} + {/if} +
    + {#if provider === 'existing'} + + {/if} + {:else if step === 5} + {#if done} + + {/if} + {:else if step === 4} + {#if draft} + (applying = v)} + onSuccess={() => { + initialized = true + void saveDraft() + }} + /> + {/if} + {#if saveError} + + {saveError} + + {/if} + {:else if step === 3} + {#if draftIdx !== -1} + + {#key configureAttempt} + + {/key} + {/if} + {#if saveError} + + {saveError} + + {/if} + {:else if $workspaceStore} +
    + {#if provider === 'github_app' && githubApp} + + {:else if gitlabHeld} + + {:else if provider === 'gitlab'} + + {:else} + + {/if} + + {#if connection} +
    + + +
    + Save as resource + + Windmill keeps the repository and its access as a git_repository resource + at this path. + + {#key connection.url} + + {/key} +
    +
    + {/if} + + {#if connectError} + + {connectError} + + {/if} +
    + {/if} +
    + +
    +
    + {#if step > 1 && step < 5} + + {:else if step === 1 && !hasResource && provider !== 'existing'} + + {/if} +
    + {#if step === 5} + + {:else if step === 4} +
    + {#if initialized} + + {:else} + + + {/if} +
    + {:else if step === 3} + {#if draft?.detectionState === 'no-wmill'} + + {:else} + + {/if} + {:else} + + {/if} +
    +
    +
    +
    + +{#snippet providerCard( + key: Provider, + icon: Snippet, + title: string, + subtitle: string, + eeOnly: boolean = false +)} + {#snippet description()} + + {subtitle} + {#if eeOnly}{/if} + + {/snippet} + selectProvider(key)} + /> +{/snippet} diff --git a/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte b/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte new file mode 100644 index 0000000000..047f55ef41 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte @@ -0,0 +1,72 @@ + + + + +
    + {#if savedWithoutInit} + + Only new changes will be pushed to this repository. What this workspace holds today has not + been pushed to it. + + {/if} + + {#if autoPullOn && webhookError} + + {webhookError} + + {:else if autoPullOn} + + New commits to the tracked branch deploy into this workspace automatically. You can adjust + this anytime on the repository's settings. + + {:else if explainAutoPullOff} + + Turn on "Automatically deploy changes from Git" on the repository to have Windmill pull new + commits into this workspace for you. Prefer to control deployment from your own pipeline + (tests, custom gating, deploy on PR merge)? Set up GitHub Actions or a similar CI/CD workflow + instead. + + {/if} + + +
    + +
    +

    Git sync connection saved successfully!

    +

    + Your repository is now configured to receive changes from Windmill. +

    +
    +
    +
    diff --git a/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte b/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte index 32dc68a0f4..00ffa285b2 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte @@ -1,81 +1,24 @@ -
    - -
    -
    - -
    -
    -

    Git sync connection saved successfully!

    -

    Your repository is now configured to receive changes from Windmill.

    -
    -
    - - - {#if savedWithoutInit} -
    -

    Repository saved without initialization

    -

    - Only new changes will be pushed to this repository. Existing content in Windmill has not - been initialized to the repository. -

    -
    - {/if} - - - {#if autoPullOn} -
    -

    - - Pull from Git is on -

    -

    - New commits to the tracked branch deploy into this workspace automatically. You can - adjust this anytime on the repository card. -

    -
    - {:else} -
    -

    - - Deploy changes from Git back to Windmill -

    -

    - Turn on "Automatically deploy changes from Git" on the repository to have Windmill pull - new commits into this workspace for you. -

    -
    -

    - Prefer to control deployment from your own pipeline (tests, custom gating, deploy on PR - merge)? Set up GitHub Actions or similar CI/CD workflows instead. -

    - -
    -
    - {/if} -
    +
    diff --git a/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte b/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte index 87c6ecb807..dea331bea5 100644 --- a/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte +++ b/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte @@ -1,13 +1,9 @@ -
    - -

    Push your current workspace content to the connected Git repository based on the configured - filters.

    -

    Note: This will not update git sync settings in wmill.yaml. Settings can only be pulled from - the repository as it is the source of truth.

    - - - {#if isNewConnection} -
    -

    Settings that will be pushed to repository

    -
    -
    Include paths: {uiState.include_path?.join(', ') || 'None'}
    -
    Exclude paths: {uiState.exclude_path?.join(', ') || 'None'}
    - {#if uiState.extra_include_path?.length > 0} -
    Extra include paths: {uiState.extra_include_path.join(', ')}
    - {/if} -
    Include types: {uiState.include_type?.join(', ') || 'None'}
    -
    -

    To modify these settings, cancel and configure them in the workspace settings.

    -
    + +
    +
    Include paths: {uiState.include_path?.join(', ') || 'None'}
    +
    Exclude paths: {uiState.exclude_path?.join(', ') || 'None'}
    + {#if uiState.extra_include_path?.length} +
    Extra include paths: {uiState.extra_include_path.join(', ')}
    {/if} - - - {#if !previewResult} -
    - -
    - {/if} - - - {#if previewJobId} -
    - {#if previewJobStatus === 'running'} - - {:else if previewJobStatus === 'success'} - - {:else if previewJobStatus === 'failure'} - - {/if} - Preview job: - - {previewJobId} - -
    - {/if} - - - {#if previewError} - - {previewError} - - {/if} - - - {#if previewResult && !previewError} -
    -

    Changes to Push

    - - {#if previewResult.changes?.length > 0} - - {:else} -
    -
    No changes to push to the repository.
    -
    - {/if} -
    - {/if} - - - {#if previewResult && !previewError} - {@const hasChanges = previewResult.changes?.length > 0} - {#if hasChanges} -
    -
    - - {#if isNewConnection && onSaveWithoutInit} - - {/if} -
    -
    - {/if} - {/if} - - - {#if applyJobId} -
    - {#if applyJobStatus === 'running'} - - {:else if applyJobStatus === 'success'} - - {:else if applyJobStatus === 'failure'} - - {/if} - Push job: - - {applyJobId} - -
    - {/if} - - - {#if applyError} - - {applyError} - - {/if} - - -
    - - - {#if showCliInstructions} -
    -
    -# Setup (only needed if local folder not initialized yet)
    -npm install -g windmill-cli
    -wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
    -wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    -
    -# Pull workspace content to git repository
    -wmill sync pull --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    -
    - {/if} -
    +
    Included types: {uiState.include_type?.join(', ') || 'None'}
    +

    + This does not update the git sync settings in wmill.yaml: they + can only be pulled from the repository, which is their source of truth. To change what is + pushed, cancel and edit the filters in the workspace settings. +

    + + {#if open} + {#key gitRepoResourcePath} + + {/key} + {/if} + + + {#snippet actions()} + + {#if isNewConnection && onSaveWithoutInit} + + {/if} + {/snippet} diff --git a/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte b/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte new file mode 100644 index 0000000000..c1ff4e1f0d --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte @@ -0,0 +1,314 @@ + + +{#if repo} +
    + {#if factsError} + +
    + {factsError} + +
    +
    + {/if} + {#if !repo.detectionState || repo.detectionState === 'idle' || repo.detectionState === 'loading'} +
    + + Checking the repository... +
    + {:else if repo.detectionState === 'error'} + +
    + {repo.detectionError || 'The check failed.'} + Check the URL and the token, or go back and connect the repository again. + +
    +
    + {:else if repo.detectionState === 'no-wmill'} + + The repository has no Windmill configuration yet. Choose what to sync, then initialize it + with the current content of this workspace. + + {:else if repo.detectionState === 'has-wmill'} + + The repository already holds a Windmill configuration. Its sync settings are loaded below. + + {/if} + + {#if repo.detectionState === 'no-wmill' || repo.detectionState === 'has-wmill'} + + {#snippet subtitle()} + + {/snippet} + + + {#if mode === 'promotion'} + + {#if managedCredential} + setPromotionOpenPrs(e.detail)} + /> + {:else} + + To open a pull request for each deploy branch, connect the repository through the GitHub + App or give a GitLab repository a project access token — otherwise set up the + open-pr-on-commit workflow in the repository. + + {/if} + {:else if !isFork} +
    + setAutoPull(e.detail)} + > + {#snippet right()} + {#if !$enterpriseLicense}{/if} + {/snippet} + + {#if repo.auto_pull?.enabled} + + {managedCredential + ? 'New commits are delivered instantly by webhook, with polling as a fallback.' + : 'The branch is checked for new commits about every minute.'} + + {:else} + + Leave it off to control deployment from your own pipeline (tests, custom gating, + deploy on PR merge) with + GitHub Actions or a similar CI/CD workflow. + + {/if} + setSyncForks(e.detail)} + /> + {#if managedCredential} + setForkOpenPrs(e.detail)} + > + {#snippet right()} + {#if !$enterpriseLicense}{/if} + {/snippet} + + {/if} +
    + {/if} + {/if} + + {#if repo.detectionState !== 'no-wmill' && repo.detectionState !== 'has-wmill'} + +
    + {#if repo.detectionJobId} + {#if repo.detectionJobStatus === 'success'} + + {:else if repo.detectionJobStatus === 'failure'} + + {/if} + Check job: + + {repo.detectionJobId} + + {/if} +
    + {/if} +
    +{/if} diff --git a/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte b/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte new file mode 100644 index 0000000000..fdea233345 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte @@ -0,0 +1,232 @@ + + +
    + {#if (!setup.loaded || gh.loadingGithubInstallations) && gh.workspaceGithubInstallations.length === 0} +
    + Loading GitHub App installations... +
    + {:else} + {#if usable.length === 0} + + {#if gh.isCheckingInstallation} + Finish installing the Windmill GitHub App in the tab that opened. This dialog picks up the + installation on its own. + {:else} + Install the Windmill GitHub App on the GitHub account or organization that owns the + repository, then come back here. + {/if} + + {:else} + + {/if} +
    diff --git a/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte b/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte new file mode 100644 index 0000000000..2d8c7f2f48 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte @@ -0,0 +1,95 @@ + + +
    + + +
    diff --git a/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts b/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts new file mode 100644 index 0000000000..4af90decf6 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts @@ -0,0 +1,45 @@ +import type { GitSyncRepository } from '../GitSyncContext.svelte' + +/** + * Defaults for a connection that has not been saved yet, once its resource is known. + * Only fills what the user has not set, so it is safe to call again. + */ +export function applyNewConnectionDefaults( + repo: GitSyncRepository, + o: { + mode: 'sync' | 'promotion' + /** Windmill holds a credential for the repository: a GitHub App or a stored token. */ + managedCredential: boolean + isGithubApp: boolean + isFork: boolean + ee: boolean + } +): void { + if (!repo.isUnsavedConnection) return + // Pulling from Git defaults on only where Windmill can receive webhooks. Polling is + // opt-in for token repositories, and forks never get the parent-only defaults (the + // backend rejects them). + if ( + o.mode === 'sync' && + o.managedCredential && + !o.isFork && + o.ee && + repo.auto_pull === undefined + ) { + repo.auto_pull = { enabled: true, mode: 'auto', sync_forks: true } + } + // A promotion deploy pushes a wm_deploy/** branch that exists to be merged; without a + // pull request it is an orphaned branch. Fork PRs stay opt-in everywhere. + if ( + o.mode === 'promotion' && + o.managedCredential && + o.ee && + repo.promotion_open_prs === undefined + ) { + repo.promotion_open_prs = true + } + // Webhook with a polling fallback is the only delivery for app repositories. + if (o.isGithubApp && repo.auto_pull?.mode === 'polling') { + repo.auto_pull = { ...repo.auto_pull, mode: 'auto' } + } +} diff --git a/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts b/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts new file mode 100644 index 0000000000..645973720f --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts @@ -0,0 +1,50 @@ +import { + createGitHubAppState, + loadGithubInstallations, + startInstallationCheck, + stopInstallationCheck, + type GitHubAppState +} from '$lib/githubApp' + +/** + * GitHub App installations for the setup dialog. Owned by the dialog rather than the + * connect form so the installations are known before the user leaves step 1: the + * install page has to be opened synchronously from that click, or the browser blocks + * the new tab. + */ +export class GithubAppSetup { + gh: GitHubAppState = $state(createGitHubAppState()) + loaded = $state(false) + + constructor(private workspace: string) {} + + get usable() { + return this.gh.workspaceGithubInstallations.filter((i) => !i.error) + } + + async reload(): Promise { + try { + await loadGithubInstallations(this.gh, this.workspace) + } catch { + // loadGithubInstallations toasts its own failure + } + this.loaded = true + const usable = this.usable + if (!usable.some((i) => i.installation_id === this.gh.selectedGHAppInstallationId)) { + this.gh.selectedGHAppInstallationId = usable[0]?.installation_id + this.gh.selectedGHAppRepository = undefined + } + } + + /** Must be called from a click handler, synchronously. */ + openInstall(): void { + const url = this.gh.githubInstallationUrl + if (!url) return + window.open(url, '_blank', 'noopener') + startInstallationCheck(this.gh, this.workspace, () => void this.reload()) + } + + dispose(): void { + stopInstallationCheck(this.gh) + } +} diff --git a/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts b/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts new file mode 100644 index 0000000000..4b9cdbe9cb --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' +import { parseRepoUrl, repoSlug, withToken } from './repoConnection' + +describe('parseRepoUrl', () => { + test('drops credentials, query and fragment from a pasted URL', () => { + const url = parseRepoUrl(' https://someone:secret@github.com/acme/infra.git?utm=1#readme ') + expect(url?.toString()).toBe('https://github.com/acme/infra.git') + }) + + test('refuses what cannot carry a token safely or name a repository', () => { + // http would put the token on the wire, and the rest name no repository at all. + expect(parseRepoUrl('http://github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('ssh://git@github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('https://github.com/acme')).toBeUndefined() + }) +}) + +test('withToken encodes the token so a punctuated one survives the round trip', () => { + const token = 'glpat-a/b@c:d' + const embedded = withToken(parseRepoUrl('https://gitlab.com/acme/infra')!, token, 'gitlab') + expect(embedded).toBe(`https://oauth2:${encodeURIComponent(token)}@gitlab.com/acme/infra`) + expect(new URL(embedded).password).toBe(encodeURIComponent(token)) +}) + +test('repoSlug names the resource after the repository', () => { + expect(repoSlug('https://github.com/acme/My-Repo.git')).toBe('my_repo') + expect(repoSlug('https://gitlab.com/group/sub/project/')).toBe('project') +}) diff --git a/frontend/src/lib/components/git_sync/setup/repoConnection.ts b/frontend/src/lib/components/git_sync/setup/repoConnection.ts new file mode 100644 index 0000000000..81955086fd --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/repoConnection.ts @@ -0,0 +1,117 @@ +import { GitSyncService, ResourceService, VariableService } from '$lib/gen' + +/** What a provider form hands back once the user has picked a repository. */ +export type RepoConnection = { + /** The repository URL, never carrying a secret. */ + url: string + isGithubApp?: boolean + /** A token for Windmill to store and renew server-side (EE GitLab). */ + heldToken?: string + /** `url` with the token embedded, filed as a secret variable because the + * resource value itself is readable by anyone who can read the resource. */ + tokenUrl?: string +} + +/** Username git sends alongside a token: GitHub ignores it for PATs, GitLab + * requires a non-empty one and documents `oauth2`. */ +const TOKEN_USERNAME = { github: 'x-access-token', gitlab: 'oauth2' } as const + +export type TokenProvider = keyof typeof TOKEN_USERNAME + +/** Parses a pasted repository URL, dropping any credentials it carried. */ +export function parseRepoUrl(raw: string): URL | undefined { + let u: URL + try { + u = new URL(raw.trim()) + } catch { + return undefined + } + // https only: the token is embedded in this URL, and plain http would put it on the wire. + if (u.protocol !== 'https:') return undefined + if (u.pathname.replace(/\/+$/, '').split('/').filter(Boolean).length < 2) return undefined + u.username = '' + u.password = '' + u.search = '' + u.hash = '' + return u +} + +export function withToken(url: URL, token: string, provider: TokenProvider): string { + const u = new URL(url.toString()) + u.username = TOKEN_USERNAME[provider] + u.password = token.trim() + return u.toString() +} + +/** A resource name derived from the repository, e.g. `acme/infra.git` → `infra`. */ +export function repoSlug(url: string): string { + const last = + url + .replace(/\.git\/?$/, '') + .replace(/\/+$/, '') + .split('/') + .pop() ?? '' + return last.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() || 'git_repository' +} + +/** + * Writes everything a connection needs and creates the `git_repository` resource at + * `path`. A token-bearing URL goes to a secret variable at the same path, which the + * backend renames along with the resource. + */ +export async function createRepositoryResource( + workspace: string, + path: string, + conn: RepoConnection, + location: { branch?: string; folder?: string } +): Promise { + const branch = location.branch?.trim() + // Relative to the repository root, which is how the sync script joins it. + const folder = location.folder?.trim().replace(/^\/+|\/+$/g, '') + let url = conn.url + let createdVariable = false + if (conn.tokenUrl) { + if (await VariableService.existsVariable({ workspace, path })) { + throw new Error(`A variable already exists at ${path}. Pick another path.`) + } + await VariableService.createVariable({ + workspace, + requestBody: { + path, + value: conn.tokenUrl, + is_secret: true, + description: `Authenticated URL of the git repository ${conn.url}` + } + }) + createdVariable = true + url = `$var:${path}` + } + try { + if (conn.heldToken) { + await GitSyncService.setGitCredential({ + workspace, + requestBody: { repo_url: conn.url, token: conn.heldToken } + }) + } + await ResourceService.createResource({ + workspace, + requestBody: { + path, + resource_type: 'git_repository', + description: `Git repository ${conn.url}`, + value: { + url, + ...(branch ? { branch } : {}), + ...(folder ? { folder } : {}), + is_github_app: !!conn.isGithubApp + } + } + }) + } catch (e) { + // Otherwise a retry at the same path is refused over this run's own secret. + if (createdVariable) { + await VariableService.deleteVariable({ workspace, path }).catch(() => {}) + } + throw e + } +} diff --git a/frontend/src/lib/components/select/MultiSelect.svelte b/frontend/src/lib/components/select/MultiSelect.svelte index 419984b20d..0ebb824865 100644 --- a/frontend/src/lib/components/select/MultiSelect.svelte +++ b/frontend/src/lib/components/select/MultiSelect.svelte @@ -18,6 +18,7 @@ let { items, placeholder = 'Select items', + searchPlaceholder = 'Search...', value = $bindable(), class: className = '', style, @@ -42,6 +43,9 @@ items?: Item[] value: Value[] placeholder?: string + /** Placeholder of the dropdown's search field. Worth setting where the list is there to + * create entries rather than pick from, so the field says what to type. */ + searchPlaceholder?: string class?: string style?: string filterText?: string @@ -96,7 +100,12 @@ function onAddValue(item: ProcessedItem) { if (item.__is_create && onCreateItem) { + const before = currentValue.length onCreateItem(item.value) + // Cleared only once the entry exists: it is then in the list, and leaving it in the + // field would filter the list down to it. A consumer that refused the value (a + // duplicate, a name it rejects) keeps what was typed, to be corrected. + if ((value?.length ?? 0) !== before) filterText = '' } else { value = [...currentValue, item.value] } @@ -192,7 +201,7 @@ bind:value={filterText} inputProps={{ onblur: (e) => (e.preventDefault(), searchInputEl?.focus()), - placeholder: 'Search...' + placeholder: searchPlaceholder }} class="!pr-7 !bg-surface" /> diff --git a/frontend/src/lib/components/workspaceSettings/FilterList.svelte b/frontend/src/lib/components/workspaceSettings/FilterList.svelte index fccc59681d..599015aa49 100644 --- a/frontend/src/lib/components/workspaceSettings/FilterList.svelte +++ b/frontend/src/lib/components/workspaceSettings/FilterList.svelte @@ -1,5 +1,6 @@ @@ -33,34 +26,14 @@ {/if}
    -
    - {#each items as item, idx (item)} - - {item} - - - {/each} - - (newItem = e.currentTarget.value)} - onkeydown={(e) => e.key === 'Enter' && (addItem(), e.preventDefault())} - /> - -
    +
    diff --git a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte index dcbef04a51..87409a8134 100644 --- a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte @@ -1,13 +1,10 @@ -
    - -
    +
    +
    - - Git Sync filter settings +

    {title}

    {#if isLegacyRepo} This repository uses legacy configuration format and inherits settings from @@ -142,22 +136,14 @@ {/if}
    -
    - {#if !collapsed} {#if isEditable} -
    -
    +
    +
    @@ -337,14 +323,9 @@
    -
    -
    - {isInitialSetup ? 'Configure initial sync settings' : 'Review migration settings'} -
    -
    {:else} -
    +
    @@ -454,5 +435,4 @@ git push
    {/if} - {/if}
    From 80903c9a64125763e7514c09618e1def21a5bf97 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 24 Sep 2026 12:58:59 +0200 Subject: [PATCH 092/112] feat: per-folder AI chat instructions via ai_instruction resources (#11325) * feat: give the AI chat per-folder instructions from ai_instruction resources Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hold a change back until the model has read its folder instructions Co-Authored-By: Claude Opus 5.5 (1M context) * fix: find nested trigger paths and scope instruction deliveries by workspace Co-Authored-By: Claude Opus 5.5 (1M context) * fix: cap folder instructions per result and record holds as their own outcome Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show a held call as its own row and cover group folders Co-Authored-By: Claude Opus 5.5 (1M context) * fix: state how folder instructions combine with workspace and user ones Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...3023_ai_instruction_resource_type.down.sql | 6 + ...173023_ai_instruction_resource_type.up.sql | 15 ++ docs/auth-surface.md | 6 + .../copilot/chat/AIChatManager.svelte.ts | 49 +++- .../copilot/chat/ToolExecutionDisplay.svelte | 15 +- .../lib/components/copilot/chat/anthropic.ts | 3 +- .../copilot/chat/folderInstructions.ts | 220 ++++++++++++++++++ .../copilot/chat/global/globalAssembly.ts | 3 + .../copilot/chat/openai-responses.ts | 3 +- .../components/copilot/chat/shared.test.ts | 105 ++++++++- .../src/lib/components/copilot/chat/shared.ts | 58 ++++- frontend/src/lib/components/copilot/lib.ts | 3 +- 12 files changed, 473 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/20260923173023_ai_instruction_resource_type.down.sql create mode 100644 backend/migrations/20260923173023_ai_instruction_resource_type.up.sql create mode 100644 frontend/src/lib/components/copilot/chat/folderInstructions.ts diff --git a/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql b/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql new file mode 100644 index 0000000000..25b81b8c69 --- /dev/null +++ b/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql @@ -0,0 +1,6 @@ +-- `created_by` tells this migration's row from one a user created by hand; a hub +-- sync updates in place and leaves it alone. +DELETE FROM resource_type +WHERE workspace_id = 'admins' + AND name = 'ai_instruction' + AND created_by = 'system'; diff --git a/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql b/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql new file mode 100644 index 0000000000..1b89469a91 --- /dev/null +++ b/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql @@ -0,0 +1,15 @@ +-- Folder instructions for the AI chat: a markdown file resource whose +-- `value.content` applies to every item under the directory it lives in. +-- `format_extension` makes the resource editor render it as a plain .md file. +-- Seeded under 'admins' so every workspace sees it before the next hub sync. +INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at) +VALUES ( + 'admins', + 'ai_instruction', + '{"type": "object", "properties": {"content": {"type": "string"}}}', + 'Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.', + 'system', + 'md', + now() +) +ON CONFLICT (workspace_id, name) DO NOTHING; diff --git a/docs/auth-surface.md b/docs/auth-surface.md index a383b9267d..15f2538cc5 100644 --- a/docs/auth-surface.md +++ b/docs/auth-surface.md @@ -46,6 +46,12 @@ Symbols, not line numbers, are cited: they drift less. (`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 token stored as a secret, never `$WM_TOKEN`. Token scopes cannot narrow superadmin routes. +- **Write access to a folder steers its readers' AI chat.** An `ai_instruction` resource's body is + delivered to the global chat of everyone who can read the resource, with no opt-in, whenever the + chat touches a path under its folder (`copilot/chat/folderInstructions.ts`); the chat then acts + with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play + for every reader until turned off. The resource ACL is the only control, so granting write on a + folder grants that influence. - **A remote deploy token is a credential for another instance**, held per account and workspace (`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`). Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 9803735571..c4a6efeabc 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -128,6 +128,11 @@ import type { WorkspaceMutationTarget } from './workspaceTools' import { resolveSessionAccess } from './global/sessionAccess' import type { SessionAccess } from './sessionCapabilities' import { filterSessionTools } from './global/sessionToolset' +import { + listFolderInstructions, + type FolderInstruction, + type FolderInstructionsDelivery +} from './folderInstructions' import { loadWorkspaceSkills, resolveGlobalPromptIdentity, @@ -1349,6 +1354,13 @@ export class AIChatManager implements ChatViewHost { globalSkills = $state([]) private globalSkillsRefreshId = 0 + // The `ai_instruction` resources readable in the operating workspace, loaded + // alongside skills. The prompt names their folders; the tool loop delivers each + // one's body the first time a call touches its folder, recording which call did. + private globalFolderInstructions: FolderInstruction[] = [] + private globalFolderInstructionsRefreshId = 0 + private folderInstructionsDeliveredBy = new Map() + // External MCP servers the user connected (resources of type `mcp`). Loaded // asynchronously alongside skills; the MCP tools are only registered when // this is non-empty, so a workspace with no connection pays no schema cost @@ -2428,6 +2440,7 @@ export class AIChatManager implements ChatViewHost { this.configureGlobalMode() void this.refreshGlobalIdentity() void this.refreshGlobalSkills() + void this.refreshGlobalFolderInstructions() void this.refreshMcpServers() } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) @@ -2482,6 +2495,7 @@ export class AIChatManager implements ChatViewHost { previewTools: this.isSessionChat, user: this.globalIdentity, skills: this.globalSkills, + folderInstructions: this.globalFolderInstructions, mcpServers: this.mcpServers, access: this.sessionAccess, sessionContext: this.sessionContextResolver?.(), @@ -2507,6 +2521,25 @@ export class AIChatManager implements ChatViewHost { } } + // Same shape as refreshGlobalSkills. + refreshGlobalFolderInstructions = async (workspace = this.operatingWorkspace ?? '') => { + const refreshId = ++this.globalFolderInstructionsRefreshId + let instructions: FolderInstruction[] = [] + try { + instructions = await listFolderInstructions(workspace) + } catch (e) { + console.error('Failed to load folder instructions', e) + } + if (refreshId !== this.globalFolderInstructionsRefreshId) { + return + } + this.globalFolderInstructions = + workspace === (this.operatingWorkspace ?? '') ? instructions : [] + if (this.mode === AIMode.GLOBAL) { + this.configureGlobalMode() + } + } + // Same shape as refreshGlobalSkills. An identity that resolves after the operating // workspace moved describes the workspace left behind, so it is dropped, not installed. refreshGlobalIdentity = async (workspace = this.operatingWorkspace ?? '') => { @@ -3583,14 +3616,16 @@ export class AIChatManager implements ChatViewHost { return false } } - // Session chats commit their workspace in beforeSend; the identity, skills, MCP - // servers and capabilities must all match the committed workspace before the system - // prompt is sent. Settling them here rather than mid-turn also keeps the prompt — the - // cached prefix of every iteration — stable for the whole request. + // Session chats commit their workspace in beforeSend; the identity, skills, folder + // instructions, MCP servers and capabilities must all match the committed workspace + // before the system prompt is sent. Settling them here rather than mid-turn also + // keeps the prompt — the cached prefix of every iteration — stable for the whole + // request. if (this.mode === AIMode.GLOBAL) { await Promise.all([ this.refreshGlobalIdentity(this.operatingWorkspace ?? ''), this.refreshGlobalSkills(this.operatingWorkspace ?? ''), + this.refreshGlobalFolderInstructions(this.operatingWorkspace ?? ''), this.refreshMcpServers(this.operatingWorkspace ?? ''), this.refreshSessionAccess(this.operatingWorkspace ?? '') ]) @@ -4050,7 +4085,11 @@ export class AIChatManager implements ChatViewHost { ? { onJobStarted: (job) => this.registerJob(job), onJobStatus: (jobId, update) => this.updateJob(jobId, update), - onJobDetached: (jobId) => this.markJobDetached(jobId) + onJobDetached: (jobId) => this.markJobDetached(jobId), + folderInstructions: { + list: () => this.globalFolderInstructions, + deliveredBy: this.folderInstructionsDeliveredBy + } } : {}), removeToolStatus: (id) => { diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index d5fee5a4d9..a469b3d35c 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -10,7 +10,8 @@ FileText, PanelRight, Lock, - ExternalLink + ExternalLink, + BookOpen } from 'lucide-svelte' import { base } from '$lib/base' import { @@ -171,6 +172,18 @@ {message.toolName} {/if}
    +{:else if message.heldForFolderInstructions} + +
    + + + {message.content} + + {#if message.toolName} + {message.toolName} held until read + {/if} +
    {:else if isRunCard} {:else if isDiffCard} diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 122425e264..ab69d0b1a3 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -446,7 +446,8 @@ export async function parseAnthropicCompletion( toolCall, helpers, toolCallbacks: callbacks, - workspace: options?.workspace + workspace: options?.workspace, + messages }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/folderInstructions.ts b/frontend/src/lib/components/copilot/chat/folderInstructions.ts new file mode 100644 index 0000000000..c07c85cbbd --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/folderInstructions.ts @@ -0,0 +1,220 @@ +import { ResourceService } from '$lib/gen' +import { truncateForPrompt } from './skills/skillResources' + +/** + * Folder instructions are resources of this type: a markdown file resource + * (`format_extension = 'md'`) whose `value.content` the assistant follows for + * every item under the directory the resource lives in, like an AGENTS.md. + */ +export const FOLDER_INSTRUCTIONS_RESOURCE_TYPE = 'ai_instruction' + +/** Bytes of one instruction body a tool result may carry. */ +export const MAX_FOLDER_INSTRUCTIONS_LENGTH = 32 * 1024 +/** Bytes all the bodies one tool result carries may add up to, so nested folders + * cannot stack several full-size bodies onto a single result. */ +export const MAX_FOLDER_INSTRUCTIONS_TOTAL = 48 * 1024 + +/** Scopes the system prompt names. Past this the list is cut, not the delivery: + * a scope left out still has its instructions delivered on first touch. */ +export const MAX_LISTED_FOLDER_INSTRUCTIONS = 50 + +export type FolderInstruction = { + /** The `ai_instruction` resource. */ + path: string + /** The directory it covers, with a trailing `/`: `f/billing/AGENTS` covers `f/billing/`. */ + scope: string +} + +export function folderInstructionScope(path: string): string { + return path.slice(0, path.lastIndexOf('/') + 1) +} + +const PAGE_SIZE = 100 +/** Guard against a paging bug looping forever, not a product cap. */ +const MAX_PAGES = 20 + +/** Every folder instruction the user can read in the workspace. Read access is the + * resource's own ACL, so a folder's instructions reach exactly who can see them. */ +export async function listFolderInstructions(workspace: string): Promise { + if (!workspace) return [] + const rows: FolderInstruction[] = [] + for (let page = 1; page <= MAX_PAGES; page++) { + const resources = await ResourceService.listResource({ + workspace, + resourceType: FOLDER_INSTRUCTIONS_RESOURCE_TYPE, + page, + perPage: PAGE_SIZE + }) + rows.push(...resources.map((r) => ({ path: r.path, scope: folderInstructionScope(r.path) }))) + if (resources.length < PAGE_SIZE) break + } + return rows +} + +/** Workspace paths a tool call names: every string argument called `path` or + * `*_path` that is a `u/`, `f/` or `g/` path, nested ones included — a trigger's target + * sits in `config.path`, and missing it would let that write through unheld. */ +export function workspacePathsInArgs(args: unknown, depth = 0): string[] { + if (!args || typeof args !== 'object' || depth > 4) return [] + if (Array.isArray(args)) return args.flatMap((v) => workspacePathsInArgs(v, depth + 1)) + return Object.entries(args as Record).flatMap(([key, value]) => + typeof value === 'string' + ? (key === 'path' || key.endsWith('_path')) && /^[ufg]\/[^/]+/.test(value) + ? [value] + : [] + : workspacePathsInArgs(value, depth + 1) + ) +} + +/** The instructions covering any of `paths`, outermost scope first, so a nested + * folder's instructions come last and read as the more specific ones. A path naming + * the folder itself (`f/billing`, as a pipeline's is) is covered too. */ +export function instructionsCovering( + instructions: readonly FolderInstruction[], + paths: readonly string[] +): FolderInstruction[] { + return instructions + .filter((i) => paths.some((p) => `${p}/`.startsWith(i.scope))) + .sort((a, b) => a.scope.length - b.scope.length || a.path.localeCompare(b.path)) +} + +/** What a chat hands the tool loop: the instructions in play, and which tool call + * delivered which of them. + * + * A delivery counts only while its tool message is still in the conversation and + * carries the text: the id is recorded because a script quoting the tag in a result + * must not pass for a delivery, and nothing is marked on the message object because + * the completions path sends that to the provider verbatim. So compaction or a + * restored chat gets the instructions again. */ +export type FolderInstructionsContext = { + list: () => readonly FolderInstruction[] + deliveredBy: Map +} + +/** Keyed by workspace too: the chat keeps its conversation across a switch of + * operating workspace, and the same path there can hold different instructions. */ +export type FolderInstructionsDelivery = { workspace: string; paths: readonly string[] } + +/** Instructions delivered by the tool results in `messages`, split by whether the + * model has read them. Results after the latest assistant message answer the batch + * still being processed: the model has not seen those yet, so a delivery there + * must not let a change later in the same batch through. */ +function deliveredIn( + ctx: FolderInstructionsContext, + messages: readonly unknown[], + workspace: string +): { read: Set; thisBatch: Set } { + const read = new Set() + const thisBatch = new Set() + let lastAssistant = -1 + messages.forEach((m, i) => { + if ((m as { role?: unknown } | undefined)?.role === 'assistant') lastAssistant = i + }) + messages.forEach((m, i) => { + const { tool_call_id: id, content } = (m ?? {}) as { tool_call_id?: unknown; content?: unknown } + if (typeof id !== 'string' || typeof content !== 'string') return + const delivery = ctx.deliveredBy.get(id) + if (delivery?.workspace !== workspace) return + for (const p of delivery.paths) { + // The id alone is not enough: a call stopped mid-run is answered with a + // placeholder under the same id, which carries no instructions. + if (content.includes(openingTag(p))) (i < lastAssistant ? read : thisBatch).add(p) + } + }) + return { read, thisBatch } +} + +/** The instructions covering the paths a tool call names that the model has not + * read yet, as the text to hand it, or undefined when there are none. `paths` are + * the bodies the text carries; one already carried earlier in the same batch is + * pointed to rather than repeated. A body that no longer reads (deleted or moved + * since the listing) is left out rather than failing the call it rides on. */ +export async function pendingFolderInstructions( + ctx: FolderInstructionsContext | undefined, + messages: readonly unknown[], + args: unknown, + workspace: string +): Promise<{ paths: string[]; text: string } | undefined> { + if (!ctx || !workspace) return undefined + const paths = workspacePathsInArgs(args) + if (paths.length === 0) return undefined + const { read, thisBatch } = deliveredIn(ctx, messages, workspace) + const pending = instructionsCovering(ctx.list(), paths).filter((i) => !read.has(i.path)) + const inBatch = pending.filter((i) => thisBatch.has(i.path)) + const blocks = ( + await Promise.all( + pending + .filter((i) => !thisBatch.has(i.path)) + .map(async (instruction) => { + try { + return { + instruction, + body: await readFolderInstructionBody(workspace, instruction.path) + } + } catch (e) { + console.error(`Failed to read folder instructions ${instruction.path}`, e) + return undefined + } + }) + ) + ).filter((b) => b !== undefined) + if (blocks.length === 0 && inBatch.length === 0) return undefined + const pointers = inBatch.map( + (i) => + `The instructions for \`${i.scope}\` (${i.path}) are in an earlier result of this same batch of tool calls.` + ) + return { + paths: blocks.map((b) => b.instruction.path), + text: [formatFolderInstructions(blocks), ...pointers].filter(Boolean).join('\n\n') + } +} + +export async function readFolderInstructionBody(workspace: string, path: string): Promise { + const value = (await ResourceService.getResourceValue({ workspace, path })) as + | { content?: unknown } + | undefined + if (typeof value?.content !== 'string') { + throw new Error(`resource ${path} has no string "content"`) + } + return value.content +} + +function openingTag(path: string): string { + return ` { + const cut = truncateForPrompt(body, Math.min(MAX_FOLDER_INSTRUCTIONS_LENGTH, remaining)) + remaining = Math.max(0, remaining - new TextEncoder().encode(cut).byteLength) + return cut + }) + bodies.reverse() + return blocks + .map( + ({ instruction }, i) => + `${openingTag(instruction.path)} scope="${instruction.scope}">\n${bodies[i]}\n` + ) + .join('\n\n') +} + +/** The system prompt section naming the scopes that carry instructions. */ +export function getFolderInstructionsPromptSection( + instructions: readonly FolderInstruction[] +): string { + if (instructions.length === 0) return '' + const scopes = [...new Set(instructions.map((i) => i.scope))].sort() + const listed = scopes.slice(0, MAX_LISTED_FOLDER_INSTRUCTIONS) + const more = scopes.length > listed.length ? ` (and ${scopes.length - listed.length} more)` : '' + return `\n\nFOLDER INSTRUCTIONS: +These folders carry instructions (ai_instruction resources) for every item under them: ${listed.map((s) => `\`${s}\``).join(', ')}${more}. +- The first time a tool call names a path under one of them, its instructions are delivered in a block of the tool result. A call that would change something is held back until you have them: read them, then make the call again. +- Follow them for all work under that folder, together with any WORKSPACE INSTRUCTIONS and USER INSTRUCTIONS above: they add to those, they do not replace them. +- Only where two of them conflict on the same point, the more specific one wins: a nested folder's instructions over its parent folder's, a folder's over the WORKSPACE INSTRUCTIONS, and both over the USER INSTRUCTIONS, which are personal preferences. The user's explicit requests in this chat come before all of them.` +} diff --git a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts index 58e45c6d08..a291e93ec7 100644 --- a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts +++ b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts @@ -13,6 +13,7 @@ import { } from './core' import { createMcpTools } from './mcpTools' import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '../pipeline/core' +import { getFolderInstructionsPromptSection, type FolderInstruction } from '../folderInstructions' // Derived, not retyped: an option added to prepareGlobalSystemMessage is reachable // here at once. Hand-listing the four would compile fine while leaving the new one @@ -21,6 +22,7 @@ import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '. export type GlobalAssemblyOpts = NonNullable[1]> & { sessionContext?: SessionPromptContext pipelineContext?: PipelineContext + folderInstructions?: readonly FolderInstruction[] } export function assembleGlobalSystemMessage( @@ -28,6 +30,7 @@ export function assembleGlobalSystemMessage( opts: GlobalAssemblyOpts ): ChatCompletionSystemMessageParam { const systemMessage = prepareGlobalSystemMessage(instructions, opts) + systemMessage.content += getFolderInstructionsPromptSection(opts.folderInstructions ?? []) if (opts.sessionContext) { systemMessage.content += getSessionContextPromptSection(opts.sessionContext, opts.access) } diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 0e460f1921..31d3b379ac 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -601,7 +601,8 @@ export async function parseOpenAIResponsesCompletion( toolCall, helpers, toolCallbacks: callbacks, - workspace: options?.workspace + workspace: options?.workspace, + messages }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index fb61f3a912..4574df7355 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -54,7 +54,10 @@ vi.mock('$lib/gen', () => ({ AzureTriggerService: { createAzureTrigger: vi.fn() }, AmqpTriggerService: { createAmqpTrigger: vi.fn() }, EmailTriggerService: { createEmailTrigger: vi.fn() }, - SettingService: { getGlobal: vi.fn() } + SettingService: { getGlobal: vi.fn() }, + ResourceService: { + getResourceValue: vi.fn(async ({ path }: { path: string }) => ({ content: `body of ${path}` })) + } })) vi.mock('$lib/utils', () => ({ @@ -205,6 +208,106 @@ describe('buildContextString', () => { }) describe('processToolCall', () => { + it('delivers folder instructions once, holding back a change until they are read', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const read = vi.fn(async () => 'read ok') + const write = vi.fn(async () => 'write ok') + const tools = [ + { def: createToolDef(z.object({}), 'read_item', 'Read'), planModeSafe: true, fn: read }, + { def: createToolDef(z.object({}), 'write_item', 'Write'), fn: write } + ] + const folderInstructions = { + list: () => [ + { path: 'f/billing/AGENTS', scope: 'f/billing/' }, + { path: 'f/billing/eu/AGENTS', scope: 'f/billing/eu/' }, + { path: 'f/other/AGENTS', scope: 'f/other/' }, + { path: 'g/ops/AGENTS', scope: 'g/ops/' } + ], + deliveredBy: new Map() + } + const messages: any[] = [] + const call = async ( + id: string, + name: string, + path: string | object, + workspace = 'test-workspace' + ) => { + const message = await processToolCall({ + tools, + toolCall: { + id, + type: 'function', + function: { + name, + arguments: JSON.stringify(typeof path === 'string' ? { path } : path) + } + }, + helpers: {}, + workspace, + messages, + toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), folderInstructions } + }) + messages.push(message) + return message.content as string + } + const turn = () => messages.push({ role: 'assistant', content: '' }) + + turn() + const readResult = await call('c1', 'read_item', 'f/billing/eu/invoice') + expect(read).toHaveBeenCalledTimes(1) + expect(readResult.startsWith('read ok')).toBe(true) + // Outermost first, so the nested folder's instructions read as the specific ones. + expect(readResult.indexOf('body of f/billing/AGENTS')).toBeLessThan( + readResult.indexOf('body of f/billing/eu/AGENTS') + ) + // Same batch: the model has not read c1's result yet, so the write still waits, + // pointed at that result rather than handed the bodies twice. + const sameBatch = await call('c2', 'write_item', 'f/billing/eu/invoice') + expect(write).not.toHaveBeenCalled() + expect(sameBatch).not.toContain('body of f/billing/AGENTS') + expect(sameBatch).toContain('earlier result of this same batch') + + turn() + expect(await call('c3', 'write_item', 'f/billing/eu/invoice')).toBe('write ok') + + const held = await call('c4', 'write_item', 'f/other/x') + expect(write).toHaveBeenCalledTimes(1) + expect(held).toContain('body of f/other/AGENTS') + expect(held).not.toContain('body of f/billing/AGENTS') + + turn() + expect(await call('c5', 'write_item', 'f/other/x')).toBe('write ok') + + expect(await call('g1', 'read_item', 'g/ops/runbook')).toContain('body of g/ops/AGENTS') + + // The same path in another workspace is another resource, delivered afresh. + expect(await call('w1', 'read_item', 'f/other/x', 'other-workspace')).toContain( + 'body of f/other/AGENTS' + ) + + // A delivery lost from the conversation (compaction) is made again. + messages.splice(0) + turn() + expect(await call('c6', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS') + + // A trigger names its target inside its config. + messages.splice(0) + turn() + const nested = await call('t1', 'write_item', { kind: 'http', config: { path: 'f/other/t' } }) + expect(nested).toContain('body of f/other/AGENTS') + expect(write).toHaveBeenCalledTimes(2) + + // A call stopped mid-run is answered with a placeholder under its id: no delivery. + messages.splice(0) + folderInstructions.deliveredBy.set('stopped', { + workspace: 'test-workspace', + paths: ['f/billing/AGENTS'] + }) + messages.push({ role: 'tool', tool_call_id: 'stopped', content: 'Interrupted' }) + turn() + expect(await call('c7', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS') + }) + it('returns pre-confirmation validation errors without asking for confirmation', async () => { const { createToolDef, processToolCall } = await import('./shared') const error = 'the script needs to be deployed before doing this action' diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 5e8bdb8a4b..acb3f95d0d 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -60,6 +60,7 @@ import { forLater } from '$lib/forLater' import { scriptLangToEditorLang } from '$lib/scripts' import { getCurrentModel } from '$lib/aiStore' import { type editor as meditor } from 'monaco-editor' +import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions' // Prettify function for code arguments - extracts and formats code from JSON function prettifyCodeArguments(content: string): string { @@ -743,6 +744,8 @@ export type ToolDisplayMessage = { /** Refused by the plan-mode gate. Renders as its own lean row rather than a tool * error, so the transcript says the mode stopped it and not that the call failed. */ blockedByPlanMode?: boolean + /** Held back until the model has read the folder instructions it was handed. */ + heldForFolderInstructions?: boolean /** The user declined: the reject button, a Stop, or a posture switch. Set only there, so * a decision is distinguishable from every other way a call errors. */ declinedByUser?: boolean @@ -975,20 +978,29 @@ function stringifyErrorBody(body: unknown): string { * (tab closed while a tool polls) logs nothing, so the statuses sum to the calls that * finished, not to the calls made. */ -type ToolCallStatus = 'ok' | 'error' | 'declined' | 'rejected' | 'blocked_plan_mode' +type ToolCallStatus = + | 'ok' + | 'error' + | 'declined' + | 'rejected' + | 'blocked_plan_mode' + | 'held_for_instructions' export async function processToolCall({ tools, toolCall, helpers, toolCallbacks, - workspace + workspace, + messages = [] }: { tools: Tool[] toolCall: ChatCompletionMessageFunctionToolCall helpers: T toolCallbacks: ToolCallbacks workspace?: string + /** The conversation so far, for which folder instructions it already carries. */ + messages?: readonly ChatCompletionMessageParam[] }): Promise { const tool = tools.find((t) => t.def.function.name === toolCall.function.name) const workspaceId = workspace ?? get(workspaceStore) ?? '' @@ -1072,6 +1084,43 @@ export async function processToolCall({ } } + // After the gates that refuse the call, so a refused call does not use up the + // delivery. A call that changes something is held back until the model has read + // the instructions: arriving with its result, they would be too late to shape it. + const folderInstructions = tool + ? await pendingFolderInstructions( + toolCallbacks.folderInstructions, + messages, + args, + workspaceId + ) + : undefined + if (folderInstructions?.paths.length) { + toolCallbacks.folderInstructions?.deliveredBy.set(toolCall.id, { + workspace: workspaceId, + paths: folderInstructions.paths + }) + } + if (folderInstructions && tool?.planModeSafe !== true) { + logToolOutcome('held_for_instructions') + toolCallbacks.setToolStatus(toolCall.id, { + content: 'Read folder instructions', + heldForFolderInstructions: true, + parameters: args, + isLoading: false, + isQueued: false, + isStreamingArguments: false, + needsConfirmation: false, + showDetails: tool?.showDetails, + autoCollapseDetails: tool?.autoCollapseDetails + }) + return { + role: 'tool' as const, + tool_call_id: toolCall.id, + content: `Not run: this call touches a folder whose instructions you had not read yet. Follow them, then make the call again (adjusted if they require it).\n\n${folderInstructions.text}` + } + } + const requiresConfirmation = tool?.requiresConfirmation === true // By name: skipping the wait is itself an answer on the user's behalf, and one tool // must not be answered for. @@ -1176,7 +1225,7 @@ export async function processToolCall({ const toAdd = { role: 'tool' as const, tool_call_id: toolCall.id, - content: result + content: folderInstructions ? `${result}\n\n${folderInstructions.text}` : result } return toAdd } catch (err) { @@ -1454,6 +1503,9 @@ export interface ToolCallbacks { attachToolImage?: (toolId: string, image: AttachedImage) => void /** Drain every image buffered this batch (insertion order), clearing the buffer. */ takePendingToolImages?: () => AttachedImage[] + /** Wired only by the global/sessions chat: delivers the folder instructions + * covering the paths a tool call names, once per conversation. */ + folderInstructions?: FolderInstructionsContext } export function createToolDef( diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index b55aa06c92..655a872f96 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -1433,7 +1433,8 @@ export async function parseOpenAICompletion( toolCall, helpers, toolCallbacks: callbacks, - workspace: options?.workspace + workspace: options?.workspace, + messages }) messages.push(messageToAdd) addedMessages.push(messageToAdd) From c232ea43b4bfe1ed295dd2f71cc6cefa47772bed Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 24 Sep 2026 14:53:36 +0200 Subject: [PATCH 093/112] fix: limit compare page guidance to fork deploys (#11300) * fix: limit compare page guidance to fork deploys * fix: make open_page compare fork-only and drop stale api catalog bullet * fix: always open the compare page in fork mode from open_page * fix: drop the ignored mode argument from open_page --- ai_evals/cases/global.yaml | 32 ++++++--- .../copilot/chat/AIChatManager.svelte.ts | 1 - .../copilot/chat/global/core.test.ts | 38 +++------- .../components/copilot/chat/global/core.ts | 72 +++++-------------- 4 files changed, 47 insertions(+), 96 deletions(-) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index b17d37cb29..96aa3ab983 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -1112,9 +1112,9 @@ - opens the Kafka triggers page - does not write, deploy, or delete anything -- id: global-openpage7-compare-review +- id: global-draft-does-not-open-compare prompt: |- - Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself. + Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it). I want to look over the change before it gets deployed. runtime: maxTurns: 8 validate: @@ -1122,6 +1122,24 @@ toolExpect: requiredToolsUsed: - write_script + forbiddenToolsUsed: + - open_page + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - creates the script draft without opening any workspace page + - does not deploy or delete anything + +- id: global-openpage7-compare-fork-deploy + prompt: |- + I'm done with my changes in this fork and want to ship them to the parent workspace myself. Take me there. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: - open_page forbiddenToolsUsed: - deploy_workspace_item @@ -1131,17 +1149,9 @@ field: page stringIncludesAnyOf: - compare - # The eval chat is untracked (no modified-items mask), so the model must scope - # the review by passing the item it changed explicitly — an omitted mask would - # preselect every pending change in the workspace. - - tool: open_page - field: items - stringIncludesAnyOf: - - f/evals/global/compare_review_demo skipJudge: true judgeChecklist: - - creates the script draft, then opens the Compare & Deploy review page instead of deploying itself - - preselects only the created script on the review page + - opens Compare & Deploy only because the user explicitly wants to deploy the fork into its parent - does not deploy or delete anything - id: global-openpage8-runs-label-and-worker diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index c4a6efeabc..6e51f80601 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2471,7 +2471,6 @@ export class AIChatManager implements ChatViewHost { : {}), testActiveFlow: async (storagePath: string, args?: Record, memoryId?: string) => this.flowEditorFor(storagePath)?.testFlow(args, memoryId), - getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined), attachedFiles: this.attachedFiles, getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '', setUserInstructions: (instructions: string) => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 3c4a0c80ed..820c593514 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -7616,36 +7616,14 @@ describe('buildOpenPageUrl runs filters', () => { }) }) -describe('buildOpenPageUrl compare selection', () => { - const itemsOf = (url: string) => new URL(url, 'http://x').searchParams.get('items') - - it('explicit items win over the chat mask', () => { - const url = buildOpenPageUrl( - 'compare', - { page: 'compare', items: ['script:f/a/b'] }, - { workspaceId: 'ws', chatItems: ['flow:f/c/d'] } - ) - expect(itemsOf(url)).toBe('script:f/a/b') - }) - - it('omitted items fall back to the chat-modified mask', () => { - const url = buildOpenPageUrl( - 'compare', - { page: 'compare' }, - { workspaceId: 'ws', chatItems: ['flow:f/c/d', 'script:f/a/b'] } - ) - expect(itemsOf(url)).toBe('flow:f/c/d,script:f/a/b') - }) - - it('an empty or absent mask yields no items param (page select-all default)', () => { - expect( - itemsOf( - buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws', chatItems: [] }) - ) - ).toBeNull() - expect( - itemsOf(buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' })) - ).toBeNull() +describe('buildOpenPageUrl compare', () => { + // Without an explicit mode the page auto-picks, which is the draft view outside a fork. + it('always opens the fork comparison', () => { + const params = new URL( + buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' }), + 'http://x' + ).searchParams + expect(params.get('mode')).toBe('fork') }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 9a5d1f43f1..b646b1283d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -1438,11 +1438,7 @@ ${pipelineBullet}` - Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.${when( canWriteDraft, ` -- When the user is happy with the changes and wants to review or deploy them, use open_page with page "compare" — it opens the Compare & Deploy review page.${ - previewTools - ? ' By default it preselects the items this chat modified; pass items (":" entries) to control the selection' - : ' Pass items (":" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace' - }, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.` +- Do not offer or open the Compare & Deploy page for normal draft review. Only use open_page with page "compare" when the user explicitly asks to deploy a forked workspace's changes to its parent workspace.` )}${when( canRunPreview, ` @@ -2803,17 +2799,6 @@ function allowsAllWorkspacesRuns(workspaceId: string | undefined = get(workspace return (!!get(superadmin) || !!get(devopsRole)) && workspaceId === 'admins' } -// The advertised `items` description must match this chat's surface: only chats that -// track their modified items (AI sessions) can honor "omitted = this chat's edits" — -// on an untracked chat (the global side panel) an omitted mask falls through to the -// page's select-all default, so the model is told to pass the items explicitly there. -const COMPARE_ITEMS_DESCRIPTIONS = { - tracked: - "Compare: preselect exactly these changed items, each as ':' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). Omit to preselect the items modified in this chat (everything when this chat modified nothing).", - untracked: - "Compare: preselect exactly these changed items, each as ':' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). If omitted, the page preselects EVERY pending change in the workspace, not just this chat's — when you changed specific items, pass them so the review is scoped to them." -} as const - // The Runs filters the page accepts several values for, all encoded in one param: the // value is a comma-separated list, and for the negatable ones a leading `!` excludes // instead (the page rejects a list mixing included and excluded values). @@ -2979,13 +2964,13 @@ const openPageFullSchema = z.object({ .enum([...WORKSPACE_SETTINGS_TABS] as [string, ...string[]]) .optional() .describe('Workspace settings: which settings tab to open'), - mode: z - .enum(['draft', 'fork']) + items: z + .array(z.string()) + .min(1) .optional() .describe( - "Compare: which comparison to show — 'draft' (deployed items vs their pending drafts) or 'fork' (this forked workspace vs its parent). Omit to auto-pick: the view containing the preselected items (draft whenever any of them is a pending draft); with nothing preselected, fork on a forked workspace and draft otherwise." + "Compare: preselect exactly these fork changes, each as ':' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). Omit to use the page's default selection; pass items only when the user asked to deploy specific ones." ), - items: z.array(z.string()).min(1).optional().describe(COMPARE_ITEMS_DESCRIPTIONS.tracked), new_tab: z .boolean() .optional() @@ -3029,7 +3014,6 @@ const OPEN_PAGE_FIELD_PAGES: Record = { operation: ['audit_logs'], resource: ['audit_logs'], tab: ['workspace_settings'], - mode: ['compare'], items: ['compare'] } @@ -3040,7 +3024,6 @@ const OPEN_PAGE_FIELD_PAGES: Record = { function buildOpenPageDefSchema( pages: readonly OpenPageName[], triggerKinds: readonly PageTriggerKind[], - chatEditsTracked: boolean, allWorkspacesRuns: boolean, roleUnverified = false ): z.ZodTypeAny { @@ -3070,25 +3053,18 @@ function buildOpenPageDefSchema( .enum([...triggerKinds] as [string, ...string[]]) .optional() .describe('Triggers: which trigger kind page to open') - : field === 'items' - ? z - .array(z.string()) - .min(1) - .optional() - .describe(COMPARE_ITEMS_DESCRIPTIONS[chatEditsTracked ? 'tracked' : 'untracked']) - : full[field] + : full[field] } shape.new_tab = full.new_tab return z.object(shape) } const OPEN_PAGE_DESCRIPTION = - 'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy review page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource\'s credentials, set a variable\'s value — pass open with the item path so its editor opens directly). Use page "compare" when the user wants to review and deploy pending changes (the items field controls which changes are preselected). This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.' + 'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource\'s credentials, set a variable\'s value — pass open with the item path so its editor opens directly). Never offer page "compare" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.' -// Non-arg inputs the URL builder needs: the chat's operating workspace (the compare -// page cannot fall back to its own store default inside a session preview) and the -// live modified-items mask backing the compare page's default preselection. -type OpenPageUrlCtx = { workspaceId: string; chatItems?: readonly string[] } +// Non-arg input the URL builder needs: the chat's operating workspace (the compare +// page cannot fall back to its own store default inside a session preview). +type OpenPageUrlCtx = { workspaceId: string } // The Runs page reads its two absolute bounds as `new Date(param)` and drops whatever // doesn't parse, so normalize to ISO here rather than passing a stamp the page will @@ -3241,14 +3217,11 @@ export function buildOpenPageUrl(page: OpenPageName, a: OpenPageArgs, ctx: OpenP case 'workspace_settings': return buildWorkspaceSettingsUrl({ tab: a.tab }) case 'compare': - // Explicit `items` wins; otherwise preselect this chat's modified items. An - // empty mask (chat modified nothing) passes no items so the page keeps its - // select-all default instead of preselecting nothing. - return buildCompareUrl({ - workspace_id: ctx.workspaceId, - mode: a.mode, - items: a.items ?? (ctx.chatItems?.length ? ctx.chatItems : undefined) - }) + // Always fork: an omitted mode lets the page auto-pick the draft view. No + // chat-modified fallback for `items` either: a session's edits are usually + // undeployed drafts, which the fork comparison leaves out, so masking by them + // would open "deploy to parent" with nothing selected. + return buildCompareUrl({ workspace_id: ctx.workspaceId, mode: 'fork', items: a.items }) } } @@ -3277,13 +3250,12 @@ function summarizeOpenPage(url: string, page: OpenPageName): string { export const openPageTool: SessionTool<{}> = { requires: NONE, - // The initial def assumes an untracked chat and no resolved role; schemaFor below - // rebuilds it with the caller's real surface before each iteration. + // The initial def assumes no resolved role; schemaFor below rebuilds it with the + // caller's real surface before each iteration. def: createToolDef( buildOpenPageDefSchema( restrictedOpenPages(get(workspaceStore)), allowedTriggerKinds(), - false, allowsAllWorkspacesRuns() ), 'open_page', @@ -3302,7 +3274,6 @@ export const openPageTool: SessionTool<{}> = { buildOpenPageDefSchema( access.pages, allowedTriggerKinds(), - (helpers as GlobalToolHelpers | undefined)?.getModifiedItems?.() !== undefined, allowsAllWorkspacesRuns(operatingWorkspaceFromHelpers(helpers)), access.roleUnverified ), @@ -3348,10 +3319,7 @@ export const openPageTool: SessionTool<{}> = { if (!urlWorkspace) { return 'Error: no workspace is selected, so no page can be opened.' } - const url = buildOpenPageUrl(page, parsed, { - workspaceId: urlWorkspace, - chatItems: (ctx.helpers as GlobalToolHelpers | undefined)?.getModifiedItems?.() - }) + const url = buildOpenPageUrl(page, parsed, { workspaceId: urlWorkspace }) const pageLabel = OPEN_PAGE_LABELS[page] const summary = summarizeOpenPage(url, page) @@ -4709,10 +4677,6 @@ export type GlobalToolHelpers = SessionToolHelpers & { // Wired only for session chats (see AIChatManager): the artifact tools are session-gated. artifacts?: SessionArtifactsStore getChatId?: () => string | undefined - // Live snapshot of the items this chat modified (`kind:path` mask keys, see - // modifiedItemsMask.ts); undefined when the chat doesn't track them (the global - // side-panel chat). Backs open_page's compare-page default preselection. - getModifiedItems?: () => string[] | undefined openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void } From 9b46b21f517a67867912092c619675d91a197313 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 24 Sep 2026 14:54:13 +0200 Subject: [PATCH 094/112] feat(cli): include extra paths in codebase digest for monorepo imports (#11327) * feat(cli): hash codebase extra_digest_paths and warn on uncovered bundle inputs Co-Authored-By: Claude Opus 5.5 * fix(cli): cache codebase digest only once complete, skip entry in warning Co-Authored-By: Claude Opus 5.5 * fix(cli): accept string extra_digest_paths, always print uncovered-input warning Co-Authored-By: Claude Opus 5.5 * fix(cli): clarify extra_digest_paths resolution and missing-path error Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cli/src/commands/init/template.ts | 2 + cli/src/commands/script/script.ts | 17 ++++++++- cli/src/core/conf.ts | 1 + cli/src/utils/codebase.ts | 53 +++++++++++++++++++++++++-- cli/test/codebase_digest_unit.test.ts | 48 ++++++++++++++++++++++++ cli/wmill.schema.json | 7 ++++ 6 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 cli/test/codebase_digest_unit.test.ts diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts index 9032cee397..8a433d69d4 100644 --- a/cli/src/commands/init/template.ts +++ b/cli/src/commands/init/template.ts @@ -140,6 +140,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ type: "object", properties: { relative_path: { type: "string", description: "Path to the codebase directory" }, + extra_digest_paths: { type: "array", items: { type: "string" }, description: "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively" }, includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" }, excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" }, format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" }, @@ -159,6 +160,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ example: [ "# codebases:", '# - relative_path: ./shared # path to the codebase', + '# # extra_digest_paths: ["../packages/utils/src"] # imported paths outside relative_path, hashed recursively (skip node_modules/dist)', '# includes: ["**/*.ts"] # files to include in bundle', '# excludes: ["node_modules/**"] # files to exclude', '# format: esm # bundle format: "cjs" or "esm"', diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index fe193e5d85..06db3db1bf 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -61,7 +61,11 @@ import { readConfigFile, readEffectiveSyncBehavior, } from "../../core/conf.ts"; -import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts"; +import { + SyncCodebase, + listSyncCodebases, + uncoveredBundleInputs, +} from "../../utils/codebase.ts"; import { pollJobWithQueueLogging } from "../../utils/job_polling.ts"; import fs from "node:fs"; import { createTarBlob, type TarEntry } from "../../utils/tar.ts"; @@ -446,8 +450,19 @@ export async function handleFile( target: format == "cjs" ? "node20.15.1" : "esnext", banner: codebase.banner, // ...(codebase.banner != null && { banner: codebase.banner }), + metafile: true, }); const endTime = performance.now(); + const uncovered = uncoveredBundleInputs( + codebase, + path, + Object.keys(out.metafile?.inputs ?? {}) + ); + if (uncovered.length > 0) { + log.warnAlways( + `${path} bundles files outside codebase ${codebase.relative_path}; edits to them won't be detected as changes unless listed in extra_digest_paths: ${uncovered.join(", ")}` + ); + } bundleContent = out.outputFiles[0].text; outputFiles = out.outputFiles ?? []; if (outputFiles.length == 0) { diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 0c746d2261..7c6eb13274 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -115,6 +115,7 @@ export interface SyncOptions { export interface Codebase { relative_path: string; + extra_digest_paths?: string[]; includes?: string[]; excludes?: string[]; assets?: { diff --git a/cli/src/utils/codebase.ts b/cli/src/utils/codebase.ts index e665d060b2..2eb63caeec 100644 --- a/cli/src/utils/codebase.ts +++ b/cli/src/utils/codebase.ts @@ -1,6 +1,44 @@ import { Codebase, SyncOptions } from "../core/conf.ts"; import * as log from "../core/log.ts"; -import { digestDir } from "./utils.ts"; +import { digestDir, generateHash, generateHashFromBuffer } from "./utils.ts"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +// A bare string is accepted, like `includes`/`excludes` in findCodebase. +function extraDigestPaths(codebase: Codebase): string[] { + return ([] as string[]).concat(codebase.extra_digest_paths ?? []); +} + +async function digestPath(p: string): Promise { + let s; + try { + s = await stat(p); + } catch { + throw new Error( + `Codebase extra_digest_paths entry not found: ${p} (resolved to ${path.resolve(p)})` + ); + } + return s.isDirectory() + ? await digestDir(p, "") + : await generateHashFromBuffer(await readFile(p)); +} + +/** Bundle inputs that no codebase digest covers, so edits to them would not trigger a re-push. */ +export function uncoveredBundleInputs( + codebase: Codebase, + entry: string, + inputs: string[] +): string[] { + const entryAbs = path.resolve(entry); + const roots = [codebase.relative_path, ...extraDigestPaths(codebase)] + .map((r) => path.resolve(r)); + return inputs.filter((i) => { + // non-file namespaces ("", "(disabled):x", "ns:path") and installed packages + if (/^[<(]|^[a-z-]{2,}:/i.test(i) || i.includes("node_modules")) return false; + const abs = path.resolve(i); + return abs != entryAbs && !roots.some((r) => abs == r || abs.startsWith(r + path.sep)); + }); +} export type SyncCodebase = Codebase & { getDigest: (forceTar?: boolean) => Promise; @@ -23,13 +61,20 @@ export function listSyncCodebases(options: SyncOptions): SyncCodebase[] { forceTar?: boolean ) => { if (_digest == undefined) { - _digest = await digestDir( + // Concurrent callers read `_digest` across these awaits: assign it only once complete. + let digest = await digestDir( codebase.relative_path, JSON.stringify(codebase) ); - if (codebase.format == "esm") { - _digest += ".esm"; + const extra = extraDigestPaths(codebase); + if (extra.length > 0) { + const hashes = await Promise.all(extra.map(digestPath)); + digest = await generateHash(digest + hashes.join("")); } + if (codebase.format == "esm") { + digest += ".esm"; + } + _digest = digest; if (!alreadyPrinted) { alreadyPrinted = true; log.info(`Codebase ${codebase.relative_path}, digest: ${_digest}`); diff --git a/cli/test/codebase_digest_unit.test.ts b/cli/test/codebase_digest_unit.test.ts new file mode 100644 index 0000000000..f12b928de4 --- /dev/null +++ b/cli/test/codebase_digest_unit.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + listSyncCodebases, + uncoveredBundleInputs, +} from "../src/utils/codebase.ts"; + +test("extra_digest_paths edits change the codebase digest", async () => { + const root = await mkdtemp(join(tmpdir(), "wmill-codebase-")); + await mkdir(join(root, "f")); + await mkdir(join(root, "packages/utils"), { recursive: true }); + await writeFile(join(root, "f/main.ts"), "export * from '../packages/utils/a'"); + await writeFile(join(root, "packages/utils/a.ts"), "export const a = 1"); + + const digest = () => + listSyncCodebases({ + codebases: [ + { + relative_path: join(root, "f"), + extra_digest_paths: [join(root, "packages/utils")], + }, + ], + } as any)[0].getDigest(); + + try { + const before = await digest(); + await writeFile(join(root, "packages/utils/a.ts"), "export const a = 2"); + expect(await digest()).not.toBe(before); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("uncoveredBundleInputs reports only files outside the digested roots", () => { + const codebase = { relative_path: "f", extra_digest_paths: ["packages/a"] }; + expect( + uncoveredBundleInputs(codebase, "g/entry.ts", [ + "g/entry.ts", + "f/main.ts", + "packages/a/x.ts", + "packages/ab/y.ts", + "node_modules/pg/index.js", + "", + ]) + ).toEqual(["packages/ab/y.ts"]); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json index b4b10087a8..022156e5c9 100644 --- a/cli/wmill.schema.json +++ b/cli/wmill.schema.json @@ -147,6 +147,13 @@ "type": "string", "description": "Path to the codebase directory" }, + "extra_digest_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively" + }, "includes": { "type": "array", "items": { From 2f2882adff8371e33c0fb72cbf0c61298bbcb2ca Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 24 Sep 2026 14:56:24 +0200 Subject: [PATCH 095/112] fix: label chat job links with the end of the job id (#11324) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/copilot/chat/AssistantMessage.svelte | 4 ++-- .../lib/components/copilot/chat/ToolExecutionDisplay.svelte | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte index 603c2c88a4..b6455980a3 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte @@ -16,7 +16,7 @@ import { Bot, ExternalLink } from 'lucide-svelte' import CopyButton from '$lib/components/common/button/CopyButton.svelte' import { base } from '$lib/base' - import { displayDate } from '$lib/utils' + import { displayDate, truncateRev } from '$lib/utils' interface Props { message: DisplayMessage @@ -191,7 +191,7 @@ class="inline-flex items-center gap-1 hover:text-primary hover:underline" title="Open this run" > - job {jobId?.slice(0, 8)} + job {truncateRev(jobId ?? '', 8)} {/if} diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index a469b3d35c..aa8dc47cec 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -14,6 +14,7 @@ BookOpen } from 'lucide-svelte' import { base } from '$lib/base' + import { truncateRev } from '$lib/utils' import { EXIT_PLAN_MODE_TOOL, isPlanCardTool, @@ -291,7 +292,7 @@ class="shrink-0 inline-flex items-center gap-1 font-main text-2xs text-tertiary hover:text-primary hover:underline" title="Open this run" > - job {message.jobId?.slice(0, 8)} + job {truncateRev(message.jobId ?? '', 8)} {/snippet} From 98d3897b8c926cc6d47a2417b6943a5d5612b3aa Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 24 Sep 2026 15:00:23 +0200 Subject: [PATCH 096/112] fix: hide answer actions until the chat turn ends (#11323) * fix: hide answer actions until the chat turn ends Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the last turn's answer actions during a manual compaction Co-Authored-By: Claude Opus 5.5 (1M context) * docs: note answer actions stay hidden while a turn waits on the user Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/copilot/chat/AIChatDisplay.svelte | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 8bb774cfde..2d88d1deb7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -276,16 +276,22 @@ // run's last answer carries the only link to that run. Keyed on the answer's own job (a step // label repeats when a step runs in a loop), and on answers rather than the next row: a // flow's tool rows have their own job, and a stopped turn can end on a tool row. + // None in a turn still running, paused on the user included (their reply lands as a tool + // result, so a row shown during the pause would vanish again): which answer ends the turn is + // unknown until it does, and one followed by a tool call would leave the row's blank gap above + // it. A manual compaction loads without a turn of its own, so the last turn keeps its row. const showsAnswerActions = $derived.by(() => { const shows: boolean[] = new Array(messages.length).fill(false) const answeredLater = new Set() + let inRunningTurn = chatHost.loading && !chatHost.compacting for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message.role === 'user' || message.role === 'summary') { answeredLater.clear() + inRunningTurn = false } else if (message.role === 'assistant' && message.content) { const run = message.jobId ?? message.stepName - shows[i] = !answeredLater.has(run) + shows[i] = !inRunningTurn && !answeredLater.has(run) answeredLater.add(run) } } From 852520636160408cd16119184ffe9c7be92682c8 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 24 Sep 2026 15:01:21 +0200 Subject: [PATCH 097/112] fix: pass the schedule's custom tag when using run now (#11322) * fix: pass the schedule's tag when using run now on a schedule Co-Authored-By: Claude Opus 5.5 * fix: keep flow schedules on the flow's own tag for run now Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../components/triggers/scheduled/utils.ts | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/frontend/src/lib/components/triggers/scheduled/utils.ts b/frontend/src/lib/components/triggers/scheduled/utils.ts index a0a1b8daad..064116a194 100644 --- a/frontend/src/lib/components/triggers/scheduled/utils.ts +++ b/frontend/src/lib/components/triggers/scheduled/utils.ts @@ -1,33 +1,33 @@ -import { JobService, ScheduleService } from "$lib/gen" -import { sendUserToast } from "$lib/utils" +import { JobService, ScheduleService } from '$lib/gen' +import { sendUserToast } from '$lib/utils' export async function runScheduleNow( - path: string, - schedulePath: string, - isFlow: boolean, - workspace_id: string + path: string, + schedulePath: string, + isFlow: boolean, + workspace_id: string ): Promise { - try { - const runByPath = isFlow ? JobService.runFlowByPath : JobService.runScriptByPath - const args = ( - await ScheduleService.getSchedule({ - workspace: workspace_id, - path: schedulePath - }) - ).args - const run = await runByPath({ - path, - requestBody: args ?? {}, - workspace: workspace_id - }) + try { + const runByPath = isFlow ? JobService.runFlowByPath : JobService.runScriptByPath + const { args, tag } = await ScheduleService.getSchedule({ + workspace: workspace_id, + path: schedulePath + }) + const run = await runByPath({ + path, + requestBody: args ?? {}, + workspace: workspace_id, + // scheduled flows run on the flow's own tag, like the cron path + tag: (!isFlow && tag) || undefined + }) - sendUserToast(`Schedule ${path} will run now`, false, [ - { - label: 'Go to the run page', - callback: () => window.open('/run/' + run + '?workspace=' + workspace_id, '_blank') - } - ]) - } catch (err) { - sendUserToast(`Cannot run schedule now: ${err.body}`, true) - } -} \ No newline at end of file + sendUserToast(`Schedule ${path} will run now`, false, [ + { + label: 'Go to the run page', + callback: () => window.open('/run/' + run + '?workspace=' + workspace_id, '_blank') + } + ]) + } catch (err) { + sendUserToast(`Cannot run schedule now: ${err.body}`, true) + } +} From 9375c93fd8d55bf22e3f290f44650affdf1868c2 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Thu, 24 Sep 2026 09:10:28 -0400 Subject: [PATCH 098/112] fix: trust the system CA store for SMTP TLS (#11328) * fix: trust the system CA store for SMTP TLS Co-Authored-By: Claude Opus 5.5 (1M context) * ci: run the smtp-gated backend tests Co-Authored-By: Claude Opus 5.5 (1M context) * chore: depend on webpki-roots 1 directly Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to 48193da8cb30bc4ae82a50f944caef85d8a20b48 This commit updates the EE repository reference after PR #826 was merged in windmill-ee-private. Previous ee-repo-ref: cd3447143b25d9f3301975feca4b202755f2508f New ee-repo-ref: 48193da8cb30bc4ae82a50f944caef85d8a20b48 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- .github/workflows/backend-test.yml | 2 +- backend/Cargo.lock | 5 +++++ backend/Cargo.toml | 1 + backend/ee-repo-ref.txt | 2 +- backend/windmill-common/Cargo.toml | 7 ++++++- 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index fea84e7781..466259f6f8 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -295,4 +295,4 @@ jobs: # never reaches it. Pin the target dir (matching the cache step above) so # its own tests run off this compile rather than a second bundled build. (cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal) - DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline --all -- --nocapture --test-threads=10 + DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp --all -- --nocapture --test-threads=10 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e0bfce842b..57b15bc5a7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15658,11 +15658,14 @@ dependencies = [ "prometheus", "quick_cache", "rand 0.9.5", + "rcgen", "regex", "reqwest 0.13.5", "reqwest-middleware", "reqwest-retry", "rsa", + "rustls 0.23.35", + "rustls-native-certs 0.8.4", "schemars 0.8.22", "semver 1.0.28", "serde", @@ -15683,6 +15686,7 @@ dependencies = [ "tikv-jemalloc-ctl", "tokio", "tokio-postgres", + "tokio-rustls 0.26.5", "tokio-stream", "tokio-util", "tonic 0.13.1", @@ -15693,6 +15697,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "webpki-roots 1.0.9", "windmill-macros", "windmill-parser", "windmill-parser-py", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 74da8a4244..2e81eb2637 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -757,4 +757,5 @@ hyper-http-proxy = { version = "1", default-features = false, features = ["rustl hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } rustls-native-certs = "0.8" +webpki-roots = "1" rcgen = "0.13" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 211848b65a..9d91911b1d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ed5a367b4def5280c3a5e1090e7abfa099a01042 +48193da8cb30bc4ae82a50f944caef85d8a20b48 diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 06de7cc4aa..e95fadfe66 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -18,7 +18,7 @@ parquet = [] aws_auth = ["dep:aws-sdk-sts", "dep:aws-config"] otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk", "dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic", "dep:opentelemetry"] -smtp = ["dep:mail-send"] +smtp = ["dep:mail-send", "dep:rustls", "dep:tokio-rustls", "dep:rustls-native-certs", "dep:webpki-roots"] scoped_cache = [] cloud = [] dev_override = [] @@ -85,6 +85,10 @@ aws-sdk-rds = { workspace = true, optional = true } indexmap.workspace = true bytes.workspace = true mail-send = { workspace = true, optional = true } +rustls = { workspace = true, optional = true } +tokio-rustls = { workspace = true, optional = true } +rustls-native-certs = { workspace = true, optional = true } +webpki-roots = { workspace = true, optional = true } futures-core.workspace = true async-stream.workspace = true const_format.workspace = true @@ -136,6 +140,7 @@ equivalent = "1.0.2" [dev-dependencies] # `test-util` is not part of tokio's `full`; it is what lets tests pause the clock. tokio = { workspace = true, features = ["test-util"] } +rcgen.workspace = true [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemalloc-ctl = { optional = true, workspace = true } From 1b749399521cc5ebaa07fc5a55773113a6a3b903 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:11:26 +0200 Subject: [PATCH 099/112] feat: support aiagent steps in test_run_step (#11321) * fix: support aiagent steps in test_run_step Co-Authored-By: Claude Opus 5.5 (1M context) * fix: offer the agent's tools as a picker in the step run form Co-Authored-By: Claude Opus 5.5 (1M context) * fix: run a blank tool list as no tools when the step form is bypassed Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/lib/components/ModuleTest.svelte | 35 +-- .../lib/components/copilot/chat/flow/core.ts | 12 + .../copilot/chat/global/core.test.ts | 231 ++++++++++++++++++ .../components/copilot/chat/global/core.ts | 90 ++++++- .../src/lib/components/copilot/chat/shared.ts | 58 ++++- .../components/flows/agentFormFields.test.ts | 21 ++ .../lib/components/flows/agentFormFields.ts | 22 ++ .../lib/components/flows/agentToolUtils.ts | 5 + .../flows/content/AiAgentStepInputs.svelte | 6 +- 9 files changed, 438 insertions(+), 42 deletions(-) diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 491c625d09..e35ac291c9 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -4,7 +4,6 @@ type AiAgent, type FlowModule, type InputTransform, - type JavascriptTransform, type Job } from '$lib/gen' import { getScriptByPath } from '$lib/scripts' @@ -20,7 +19,7 @@ type LinkedAgentDraft } from './flows/linkedAgentDrafts' import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils' - import { AGENT_HISTORY_KEYS } from './flows/agentFormFields' + import { agentTestInputTransforms } from './flows/agentFormFields' import { sendUserToast } from '$lib/toast' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -175,37 +174,17 @@ // `args` spans the whole AI agent schema, so on a linked step it carries every brain key as // undefined; overlaying those would shadow the draft's brain, so an inlined step takes only - // the inputs its form offers. A blank history input is unset, as on the step: an expression - // evaluating to nothing reads as an empty memory id, and the step's transform is stale. - const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length) - const formKeys = ( - draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args) - ).filter( - (key) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key]) - ) - const stepTransforms = Object.fromEntries( - Object.entries((agentVal.input_transforms ?? {}) as Record).filter( - ([key]) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key]) - ) - ) - + // the inputs its form offers. // The test form only covers the schema it was given, and for a standalone agent that may be // the flow-local one (the agent editor shows the brain in its own form, not here). Take the // brain from the module as authored and let the form's own keys win over it, so an edit made // in the form after the test panel mounted is what runs. A linked agent needs none of this: // the server reads its brain from the resource. - const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = { - ...(agentVal.agent ? {} : stepTransforms), - ...Object.fromEntries( - formKeys.map((key) => [ - key, - { - expr: `flow_input.${key}`, - type: 'javascript' - } - ]) - ) - } + const inputTransforms = agentTestInputTransforms( + agentVal.agent ? {} : ((agentVal.input_transforms ?? {}) as Record), + args, + draft ? AGENT_FLOW_LOCAL_KEYS : Object.keys(args) + ) await jobLoader?.runFlowPreview( args, diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index eef5d396a4..96b2858957 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -464,6 +464,18 @@ export const flowTools: Tool[] = [ const stepId = args.stepId const stepArgs = args.args || {} + // An agent step runs on the step's own configuration with this run's changes on top, which + // the global chat's run form assembles; this tool sends only what the model named. + if (findModuleInFlow(flow.value, stepId)?.value.type === 'aiagent') { + toolCallbacks.setToolStatus(toolId, { + content: `Step "${stepId}" is an AI agent step`, + error: 'AI agent steps cannot be tested from this chat' + }) + throw new Error( + 'AI agent steps cannot be tested from this chat. Test it from the step\'s "Test this step" tab.' + ) + } + return executeFlowStepTestRun({ flowValue: flow.value, stepId, diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 820c593514..e0df5c4a98 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -6086,6 +6086,237 @@ describe('global AI tools', () => { }) }) + // An agent step has no job of its own, so it runs as a flow preview of itself alone, opened on + // the same rows and the same values as the flow editor's own step test. A setting the step + // leaves alone is not offered at all, so a mounted widget cannot invent a value for it: an + // `enabled_tools` the form synthesized empty would advertise none of the agent's tools. + it("test_run_step opens an aiagent step on the step's own configuration", async () => { + const providerValue = { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' } + let opened: RunFormDisplay | undefined + await callGlobalTool('write_flow', { + path: 'f/flows/agent-step', + summary: 'Flow with an agent step', + modules: JSON.stringify([ + { + id: 'ask', + value: { + type: 'aiagent', + tools: [ + { + id: 'b', + summary: 'get_code', + value: { + tool_type: 'flowmodule', + type: 'rawscript', + language: 'bun', + content: 'export async function main() { return 1 }', + input_transforms: {} + } + } + ], + input_transforms: { + provider: { type: 'static', value: providerValue }, + user_message: { type: 'static', value: 'authored question' } + } + } + } + ]) + }) + + await withCompletedTestJob(() => + callGlobalTool( + 'test_run_step', + { + path: 'f/flows/agent-step', + stepId: 'ask', + args: { user_message: 'What is the capital of France?' } + }, + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => { + opened = form + return form.args + } + } + ) + ) + + // The step configures these two, so the form offers them, filled in with what the step + // holds — and the model's own message wins over the authored one. + expect(opened?.args).toMatchObject({ + provider: providerValue, + user_message: 'What is the capital of France?' + }) + // Those two plus the rows the editor always shows, and nothing else the step is silent + // about: `enabled_tools` above all, whose synthesized empty value would advertise no tools. + expect(Object.keys(opened?.schema?.properties ?? {})).toEqual([ + 'provider', + 'user_message', + 'system_prompt' + ]) + + expect(JobService.runFlowPreview).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: { + value: { + modules: [ + { + id: 'ask', + value: expect.objectContaining({ + type: 'aiagent', + input_transforms: { + provider: { type: 'javascript', expr: 'flow_input.provider' }, + user_message: { type: 'javascript', expr: 'flow_input.user_message' }, + // Every row of the form runs from it, the blank ones too. + system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' } + } + }) + } + ] + }, + args: { provider: providerValue, user_message: 'What is the capital of France?' }, + // Filed under the flow, as the editor's step test does: a managed memory keyed on a + // named memory id is found under it. + path: 'f/flows/agent-step' + } + }) + }) + + // What the form submits is what runs, as in the editor's step test: a field cleared over the + // step's expression runs cleared rather than falling back to the expression, whether the + // mounted form sends it as the empty string or the bypass posture sends the form unchanged. + it.each([ + { + answer: 'the mounted form', + // A field the form could not fill in comes back blank, never absent: text as the empty + // string, a list as `[]`. + callbacks: { + requestRunArgs: async (_toolId: string, form: any) => ({ + ...form.args, + user_message: '', + enabled_tools: form.args.enabled_tools ?? [] + }) + } + }, + { + answer: 'the bypass posture', + callbacks: { + shouldAutoAcceptToolConfirmations: () => true, + requestRunArgs: async (_toolId: string, form: any) => form.args + } + } + ])( + 'test_run_step runs an aiagent field left blank by $answer as blank', + async ({ callbacks }) => { + await callGlobalTool('write_flow', { + path: 'f/flows/agent-expr', + summary: 'Agent fed by an earlier step', + modules: JSON.stringify([ + { + id: 'first', + value: { + type: 'rawscript', + language: 'bun', + content: 'export async function main() { return { question: "hi" } }', + input_transforms: {} + } + }, + { + id: 'ask', + value: { + type: 'aiagent', + tools: [], + input_transforms: { + provider: { + type: 'static', + value: { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' } + }, + user_message: { type: 'javascript', expr: 'results.first.question' }, + enabled_tools: { type: 'javascript', expr: 'results.first.tools' } + } + } + } + ]) + }) + + await withCompletedTestJob(() => + callGlobalTool( + 'test_run_step', + { path: 'f/flows/agent-expr', stepId: 'ask', args: {} }, + { ...toolCallbacks, ...callbacks } + ) + ) + + const body = vi.mocked(JobService.runFlowPreview).mock.calls.at(-1)?.[0].requestBody as any + expect(body.value.modules[0].value.input_transforms.user_message).toEqual({ + type: 'javascript', + expr: 'flow_input.user_message' + }) + expect(body.args.enabled_tools).toEqual([]) + } + ) + + // The memory picker resets a value whose kind it does not offer to its first option, so a + // step saved with a legacy kind would run with its history switched off. And a tool name typed + // by hand that matches none silently narrows the run, so the roster is offered as in the editor. + it("test_run_step offers an aiagent step the editor's choices", async () => { + let opened: RunFormDisplay | undefined + await callGlobalTool('write_flow', { + path: 'f/flows/agent-legacy-memory', + summary: 'Agent with legacy memory', + modules: JSON.stringify([ + { + id: 'ask', + value: { + type: 'aiagent', + tools: [ + { + id: 'b', + summary: 'get_code', + value: { + tool_type: 'flowmodule', + type: 'rawscript', + language: 'bun', + content: 'export async function main() { return 1 }', + input_transforms: {} + } + } + ], + input_transforms: { + provider: { + type: 'static', + value: { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' } + }, + user_message: { type: 'static', value: 'hi' }, + memory: { type: 'static', value: { kind: 'auto', context_length: 5 } }, + enabled_tools: { type: 'static', value: ['get_code'] } + } + } + } + ]) + }) + + await withCompletedTestJob(() => + callGlobalTool( + 'test_run_step', + { path: 'f/flows/agent-legacy-memory', stepId: 'ask', args: {} }, + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => { + opened = form + return form.args + } + } + ) + ) + + const kinds = opened?.schema.properties.memory.oneOf.map( + (variant: any) => variant.properties.kind.enum[0] + ) + expect(kinds).toContain('auto') + expect(opened?.schema.properties.enabled_tools.items.enum).toEqual(['get_code']) + }) + // A step is fed by its input transforms, so its arguments are its own and the flow's // schema describes a different set entirely. Opening the form on the flow's would offer // fields this job ignores and drop the ones it takes. diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index b646b1283d..5664f2349d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -43,6 +43,13 @@ import type { Script, ScriptLang } from '$lib/gen/types.gen' +import { loadSchemaFromModule, memoryPropertyFor } from '$lib/components/flows/flowInfers' +import { flowLocalAgentSchema } from '$lib/components/flows/agentResourceUtils' +import { AGENT_FIELDS, initialVisibleAgentFields } from '$lib/components/flows/agentFormFields' +import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts' +import { resolveLinkedAgentTools } from '$lib/components/flows/flowState' +import { enabledToolNames, type AgentTool } from '$lib/components/flows/agentToolUtils' +import { evalValue } from '$lib/components/flows/utils.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { FRAMEWORK_TEMPLATES, @@ -992,7 +999,7 @@ const testRunStepSchema = z.object({ const testRunStepToolDef = createToolDef( testRunStepSchema, 'test_run_step', - "Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", + "Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. An AI agent step takes the inputs a run supplies rather than code arguments, `user_message` above all; its form opens on the step's own configuration, so send only what this run should change. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", { strict: false } ) @@ -5791,6 +5798,68 @@ async function loadSubflowForFlowStep( } } +/** + * The run form for an agent step: the rows the flow editor's own step test shows, opened on the + * values it opens them with, built from that form's helpers so the two cannot drift. + */ +async function agentStepRunForm( + module: FlowModule, + workspace: string, + proposed: Record | null | undefined +): Promise<{ schema: Record; args: Record }> { + const transforms = (module.value as { input_transforms?: Record }).input_transforms + // A linked step's brain belongs to the resource rather than to this flow, so only the inputs + // this flow supplies are its to edit. + const full = (await loadSchemaFromModule(module, workspace)).schema + const schema = (module.value as { agent?: string }).agent ? flowLocalAgentSchema(full) : full + + const visible = initialVisibleAgentFields(transforms, schema.properties) + // What the model asked to set, shown even where the step configures nothing: this form has no + // control for opening a row, so a field left out of it is one the chat cannot reach at all. + for (const key of Object.keys(proposed ?? {})) visible.add(key) + const position = new Map(AGENT_FIELDS.map((field, index) => [field.key, index])) + const keys = Object.keys(schema.properties ?? {}) + // A key the field registry does not know is kept, so a new one is never silently dropped. + .filter((key) => !position.has(key) || visible.has(key)) + .sort((a, b) => (position.get(a) ?? Infinity) - (position.get(b) ?? Infinity)) + + const evaluated: Record = {} + for (const key of keys) { + const value = evalValue(key, module, undefined, false) + if (value !== undefined) evaluated[key] = value + } + const args = { ...evaluated, ...(proposed ?? {}) } + // A mounted form fills a blank list with `[]`, and the bypass posture submits the form unmounted. + // For `enabled_tools` the two mean opposite things: `[]` runs no tools, an absent key runs all. + if (keys.includes('enabled_tools')) args.enabled_tools ??= [] + + const properties = Object.fromEntries(keys.map((key) => [key, schema.properties[key]])) + if (properties.memory) properties.memory = memoryPropertyFor(properties.memory, args.memory) + // Picked from the agent's tools, as in the editor: a name typed by hand that matches none + // silently narrows the run to fewer tools. + const list = properties.enabled_tools + if (list) { + const { agent, tools } = module.value as { agent?: string; tools?: AgentTool[] } + const roster = agent ? await resolveLinkedAgentTools(agent, workspace, true) : (tools ?? []) + properties.enabled_tools = { + ...list, + items: { ...(list.items ?? { type: 'string' }), enum: enabledToolNames(roster) } + } + } + + return { + schema: { + ...schema, + properties, + order: keys, + required: ((schema.required as string[] | undefined) ?? []).filter((key) => + keys.includes(key) + ) + }, + args + } +} + // Leaf of a workspace path (last segment), for human-readable confirmation // prompts. Falls back to the full path, then a generic noun. function pathLeaf(path: unknown, fallback: string): string { @@ -6248,7 +6317,9 @@ async function testRunFlowStepByPath( toolCallbacks, toolId, loadScript: loadScriptForFlowStep, - loadSubflow: loadSubflowForFlowStep + loadSubflow: loadSubflowForFlowStep, + flowPath: args.path, + withAgentDrafts: (value) => withAgentDrafts(value, workspace) }) // The module resolution landed on, not the id that was asked for: the job's entrypoint @@ -6257,15 +6328,20 @@ async function testRunFlowStepByPath( const isPreprocessor = resolved.module.id === SPECIAL_MODULE_IDS.PREPROCESSOR // The step's own inputs, never the flow's: a step is fed by its input transforms, so the // flow's schema names arguments this job would ignore and omits the ones it takes. + const agentForm = + resolved.module.value.type === 'aiagent' + ? await agentStepRunForm(resolved.module, workspace, args.args) + : undefined const schema = - resolved.code != undefined && resolved.lang + agentForm?.schema ?? + (resolved.code != undefined && resolved.lang ? await schemaForTestRun({ content: resolved.code, language: resolved.lang, schema: resolved.schema, entrypoint: isPreprocessor ? 'preprocessor' : undefined }) - : (resolved.schema ?? {}) + : (resolved.schema ?? {})) const stepSummary = resolved.module.summary return runThroughForm( @@ -6283,7 +6359,7 @@ async function testRunFlowStepByPath( // itself be a draft. schemaNoun: 'step', toolName: 'test_run_step', - proposed: args.args, + proposed: agentForm?.args ?? args.args, startMessage: resolved.startMessage, contextName: resolved.runnableKind, noun: 'step', @@ -6292,7 +6368,9 @@ async function testRunFlowStepByPath( autoAcceptable: true, background: args.background, detachAfterMs: waitSecondsToDetachMs(args.wait_seconds), - startJob: resolved.startJob + startJob: agentForm + ? (submitted) => resolved.startJob(submitted, agentForm.schema.order) + : resolved.startJob }, ctx ) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index acb3f95d0d..1e486c7d41 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -39,6 +39,7 @@ import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } fro import { workspaceStore } from '$lib/stores' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree' +import { agentTestInputTransforms } from '$lib/components/flows/agentFormFields' import type { FunctionParameters } from 'openai/resources/shared.mjs' import { z } from 'zod' import { @@ -2212,6 +2213,13 @@ type FlowStepRunConfig = { toolId: string loadScript?: FlowStepScriptLoader loadSubflow?: FlowStepSubflowLoader + /** The path of the flow the step belongs to. An agent step's preview carries it, as the flow + * editor's own step test does: the run is filed under it, and a managed memory keyed on a + * memory id someone named reads the flow's conversation rather than a stray one. */ + flowPath?: string + /** The flow editor's substitution of a linked agent's unsaved draft, passed in because it + * reaches the draft store, which this module's import list must not. */ + withAgentDrafts?: (value: FlowValue) => Promise } export type FlowStepTestRunConfig = FlowStepRunConfig & { @@ -2236,8 +2244,9 @@ export type ResolvedFlowStepRun = { startMessage: string /** Takes the arguments as submitted. The preprocessor's entrypoint override is added * here rather than by the caller: it is declared by no schema, so anything that - * conforms arguments to one would drop it. */ - startJob: (args: Record) => Promise + * conforms arguments to one would drop it. An agent step also takes the run form's rows: + * each runs from its submitted value, including one the submission leaves out. */ + startJob: (args: Record, formKeys?: readonly string[]) => Promise } function normalizeFlowStepArgs(args: Record | null | undefined): Record { @@ -2281,7 +2290,9 @@ export async function resolveFlowStepRun({ toolCallbacks, toolId, loadScript = loadDeployedScriptForFlowStep, - loadSubflow + loadSubflow, + flowPath, + withAgentDrafts }: FlowStepRunConfig): Promise { const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined @@ -2368,12 +2379,51 @@ export async function resolveFlowStepRun({ } } + if (moduleValue.type === 'aiagent') { + return { + module: targetModule, + // The job started is a flow preview: an agent step has none of its own, and the tool + // calls it makes are that flow's steps. + runnableKind: 'flow', + startMessage: `Starting test run of agent step "${stepId}"...`, + startJob: async (args, formKeys) => { + // The step alone, stripped of the settings around it: a mock would answer for it + // and a suspend would hold the run for an approval nobody is watching for. + const value: FlowValue = { + modules: [ + { + id: targetModule.id, + value: { + ...moduleValue, + input_transforms: agentTestInputTransforms( + moduleValue.input_transforms, + args, + formKeys + ) + } + } + ] + } + return JobService.runFlowPreview({ + workspace, + requestBody: { + // After the inputs are wired, as the flow editor's step test does: a linked agent's + // draft brings its own brain and tools, and the step's run inputs stay on top. + value: withAgentDrafts ? await withAgentDrafts(value) : value, + args, + path: flowPath + } + }) + } + } + } + toolCallbacks.setToolStatus(toolId, { content: `Step type "${moduleValue.type}" not supported for testing`, error: `Cannot test step of type "${moduleValue.type}"` }) throw new Error( - `Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow` + `Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow, aiagent` ) } diff --git a/frontend/src/lib/components/flows/agentFormFields.test.ts b/frontend/src/lib/components/flows/agentFormFields.test.ts index 311b48ade1..48b4c94d92 100644 --- a/frontend/src/lib/components/flows/agentFormFields.test.ts +++ b/frontend/src/lib/components/flows/agentFormFields.test.ts @@ -6,6 +6,7 @@ import { agentMemoryMode, historyInputApplies, agentFieldIsSet, + agentTestInputTransforms, initialVisibleAgentFields } from './agentFormFields' @@ -147,3 +148,23 @@ describe('memoryPropertyFor', () => { ).toBeUndefined() }) }) + +describe('agentTestInputTransforms', () => { + it('unsets a blank history input and feeds every other form key from flow_input', () => { + const authored = { + memory_id: { type: 'static' as const, value: 'thread-1' }, + previous_messages: { type: 'static' as const, value: [{ role: 'user', content: 'hi' }] }, + system_prompt: { type: 'javascript' as const, expr: 'results.a.prompt' } + } + expect( + agentTestInputTransforms(authored, { memory_id: '', previous_messages: [{ role: 'user' }] }, [ + 'memory_id', + 'previous_messages', + 'system_prompt' + ]) + ).toEqual({ + previous_messages: { type: 'javascript', expr: 'flow_input.previous_messages' }, + system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' } + }) + }) +}) diff --git a/frontend/src/lib/components/flows/agentFormFields.ts b/frontend/src/lib/components/flows/agentFormFields.ts index 3d62497ba3..7f09afdf7e 100644 --- a/frontend/src/lib/components/flows/agentFormFields.ts +++ b/frontend/src/lib/components/flows/agentFormFields.ts @@ -30,6 +30,28 @@ export const AGENT_TOOLS_ROW = 'tools' export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number] +/** The input transforms a one-off test of an agent step runs with: each form key is fed from + * `flow_input`, over the step's own transforms. A blank history input is unset, as on the step: + * an expression evaluating to nothing reads as an empty memory id, and the step's transform is + * stale. */ +export function agentTestInputTransforms( + stepTransforms: Record | undefined, + args: Record, + formKeys: readonly string[] = Object.keys(args) +): Record { + const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length) + const kept = (key: string) => + !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key]) + return { + ...Object.fromEntries(Object.entries(stepTransforms ?? {}).filter(([key]) => kept(key))), + ...Object.fromEntries( + formKeys + .filter(kept) + .map((key) => [key, { type: 'javascript' as const, expr: `flow_input.${key}` }]) + ) + } +} + /** What turning managed memory on writes. */ export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 } diff --git a/frontend/src/lib/components/flows/agentToolUtils.ts b/frontend/src/lib/components/flows/agentToolUtils.ts index e9838f60e3..87c15bc66a 100644 --- a/frontend/src/lib/components/flows/agentToolUtils.ts +++ b/frontend/src/lib/components/flows/agentToolUtils.ts @@ -126,6 +126,11 @@ export function toolEnabledName(tool: AgentTool): string | undefined { return toolDisplayName(tool) } +/** The names `enabled_tools` can pick from: every tool in the roster that has one. */ +export function enabledToolNames(tools: AgentTool[]): string[] { + return tools.map((tool) => toolEnabledName(tool)).filter((name): name is string => !!name) +} + /** * Create an AI Agent tool (nested agent) */ diff --git a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte index 33a15922f5..bb0f260a21 100644 --- a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte +++ b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte @@ -48,7 +48,7 @@ import { AlertTriangle, Plus, X } from 'lucide-svelte' import type { PickableProperties } from '../previousResults' import type { FlowCopilotContext } from '$lib/components/copilot/flow' - import { toolEnabledName, type AgentTool } from '../agentToolUtils' + import { enabledToolNames, type AgentTool } from '../agentToolUtils' import { AGENT_FIELDS, AGENT_FIELD_BY_KEY, @@ -216,9 +216,7 @@ // By what each tool is named, not the summary alone: an MCP entry is added without one and is // named by its resource path, so keying on `summary` would leave a whole server with no name // to pick. `narrow_roster` matches that path for the same reason. - const names = tools - .map((tool) => toolEnabledName(tool)) - .filter((name): name is string => !!name) + const names = enabledToolNames(tools) const properties = schemaProperties untrack(() => { const list = properties['enabled_tools'] From d02ff9ac24d6d720c478e8424e73eebae9ec98fe Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 24 Sep 2026 15:12:09 +0200 Subject: [PATCH 100/112] fix: centre the toggle knob inside its track (#11314) * fix: centre the toggle knob inside its track The knob was positioned against the toggle's wrapper rather than the track, so its 2px inset resolved to a 1px gap inside the track's 1px border. On a 1x screen a toggle that lands on a fractional x blurs its edges across a whole pixel, which swallows that 1px gap and makes the knob look like it overflows the track. Position the knob inside the track's border with a 2px gap on every side, size the toggle in whole px so the 18px root font of large screens cannot make the track fractional, and give each size an explicit checked translate now that the knob is no longer exactly one translate-x-full wide. Co-Authored-By: Claude Opus 5 (1M context) * refactor: hoist the toggle knob's shared inset out of the size branches Co-Authored-By: Claude Opus 5 (1M context) * chore(frontend): add a Toggles tab to the kitchen sink Covers the four sizes, the three colors, the label and EE-badge variants, and a grid of quarter-pixel offsets: the knob's gap against the track border only misreads once the track lands between device pixels. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/components/Toggle.svelte | 16 ++-- frontend/src/routes/kitchen_sink/+page.svelte | 83 +++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/Toggle.svelte b/frontend/src/lib/components/Toggle.svelte index 16e1086a94..200b449ee7 100644 --- a/frontend/src/lib/components/Toggle.svelte +++ b/frontend/src/lib/components/Toggle.svelte @@ -93,6 +93,8 @@ +
    +
    diff --git a/frontend/src/routes/kitchen_sink/+page.svelte b/frontend/src/routes/kitchen_sink/+page.svelte index b3ae4ef196..e21f9da550 100644 --- a/frontend/src/routes/kitchen_sink/+page.svelte +++ b/frontend/src/routes/kitchen_sink/+page.svelte @@ -4,6 +4,7 @@ import TabContent from '$lib/components/common/tabs/TabContent.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte' + import Toggle from '$lib/components/Toggle.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' import AssistantMessage from '$lib/components/copilot/chat/AssistantMessage.svelte' import type { DisplayMessage } from '$lib/components/copilot/chat/shared' @@ -133,12 +134,23 @@ That's the full round-trip.` role: 'assistant', content: chatSampleContent } + + const toggleSizes = ['2xs', 'xs', 'sm', 'md'] as const + const toggleColors = ['blue', 'red', 'nord'] as const + // The knob's gap against the track border is thin enough that it only misreads once the + // track lands between device pixels, which needs a 1x screen. These offsets put one row of + // each size on every quarter-pixel so the worst case is on the page rather than left to + // whichever label widths a real view happens to have. + const subpixelOffsets = [0, 0.25, 0.5, 0.75] + let toggleChecked = $state(true) + let toggleDisabled = $state(false) + @@ -195,6 +207,77 @@ That's the full round-trip.`
    + +
    + + +
    + +
    +
    Sizes
    +
    + {#each toggleSizes as size} + + {/each} +
    +
    + +
    +
    Colors
    +
    + {#each toggleColors as color} + + {/each} +
    +
    + +
    +
    Labels
    +
    + + + + +
    +
    + +
    +
    Quarter-pixel offsets
    +
    + Zoom a 1x screen in on these: the knob must keep a visible ring of track on every side in + both states. It reads as overflowing the track once that ring drops below a device pixel. +
    +
    + {#each toggleSizes as size} +
    + {#each subpixelOffsets as offset} +
    + +
    + {/each} +
    + {/each} +
    +
    +
    The three markdownProse presets, same source. Each is sized for its own From ebb3048ca0b6cb9f3c9130ac0871581f3c2ed307 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:14:59 +0200 Subject: [PATCH 101/112] feat: mount session list pages in process instead of iframes (#11289) * feat: mount session list pages in process instead of iframes * fix: seed filter defaults from the query, not the cached search params * fix: type trigger list rows from the generated trigger types * fix: judge hosted lists by the operating workspace's rules and keep role-gated filters * fix: keep the user folders filter key for every role, hidden where it does not apply * fix: drop the user folders filter for users it is not offered to * fix: wait for a known user before dropping the user folders filter * style: tint trigger rows for every kind and align the schedules footer wording * fix: stamp edited_at when a schedule is updated --------- Co-authored-by: Ruben Fiszel --- ...8825cdd7784d0d611cd0069b15d11039ac56.json} | 4 +- backend/windmill-api-schedule/src/lib.rs | 1 + .../ContextualVariableEditor.svelte | 6 +- .../src/lib/components/DeployWorkspace.svelte | 47 +- .../src/lib/components/FilterSearchbar.svelte | 4 +- .../lib/components/NoDirectDeployAlert.svelte | 52 +- .../components/ResourceEditorDrawer.svelte | 2 - frontend/src/lib/components/ShareModal.svelte | 39 +- .../src/lib/components/SupabaseConnect.svelte | 9 +- .../src/lib/components/VariableEditor.svelte | 2 - .../lib/components/home/ListFilters.svelte | 62 +- frontend/src/lib/components/hostedPage.ts | 31 + .../src/lib/components/jobs/JobPreview.svelte | 6 +- .../components/resources/ResourcesList.svelte | 1623 +++++++++++++++++ .../components/resources/resourcesFilter.ts | 19 +- .../components/schedules/SchedulesList.svelte | 718 ++++++++ .../components/schedules/schedulesFilter.ts | 19 +- .../components/sessions/PageListView.svelte | 131 ++ .../components/sessions/PreviewTabHost.svelte | 26 + .../components/sessions/pageDrawerSession.ts | 29 - .../lib/components/sessions/previewPaths.ts | 5 + .../components/sessions/previewRouter.test.ts | 11 + .../lib/components/sessions/previewRouter.ts | 21 +- .../sessions/sessionRuntime.svelte.ts | 3 +- .../components/triggers/TriggerList.svelte | 983 ++++++++++ .../amqp/AmqpTriggerEditorInner.svelte | 2 - .../azure/AzureTriggerEditorInner.svelte | 2 - .../email/EmailTriggerEditorInner.svelte | 2 - .../triggers/gcp/GcpTriggerEditorInner.svelte | 2 - .../triggers/http/RouteEditorInner.svelte | 2 - .../triggers/http/RoutesGenerator.svelte | 12 +- .../kafka/KafkaTriggerEditorInner.svelte | 2 - .../mqtt/MqttTriggerEditorInner.svelte | 2 - .../nats/NatsTriggerEditorInner.svelte | 2 - .../PostgresTriggerEditorInner.svelte | 2 - .../schedules/ScheduleEditorInner.svelte | 2 - .../triggers/sqs/SqsTriggerEditorInner.svelte | 2 - .../components/triggers/triggerListConfig.ts | 506 +++++ .../WebsocketTriggerEditorInner.svelte | 2 - .../components/variables/VariablesList.svelte | 759 ++++++++ .../components/variables/variablesFilter.ts | 19 +- frontend/src/lib/svelte5UtilsKit.svelte.ts | 21 +- .../src/routes/(root)/(logged)/+layout.svelte | 21 +- .../(logged)/amqp_triggers/+page.svelte | 569 +----- .../(logged)/azure_triggers/+page.svelte | 667 +------ .../(logged)/email_triggers/+page.svelte | 552 +----- .../(root)/(logged)/gcp_triggers/+page.svelte | 653 +------ .../(logged)/kafka_triggers/+page.svelte | 594 +----- .../(logged)/mqtt_triggers/+page.svelte | 569 +----- .../(logged)/nats_triggers/+page.svelte | 591 +----- .../(logged)/postgres_triggers/+page.svelte | 673 +------ .../(root)/(logged)/resources/+page.svelte | 1587 +--------------- .../(root)/(logged)/routes/+page.svelte | 591 +----- .../(root)/(logged)/schedules/+page.svelte | 682 +------ .../(root)/(logged)/sessions/+page.svelte | 13 +- .../(root)/(logged)/sqs_triggers/+page.svelte | 566 +----- .../(root)/(logged)/variables/+page.svelte | 721 +------- .../(logged)/websocket_triggers/+page.svelte | 578 +----- 58 files changed, 5057 insertions(+), 9764 deletions(-) rename backend/.sqlx/{query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json => query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json} (82%) create mode 100644 frontend/src/lib/components/hostedPage.ts create mode 100644 frontend/src/lib/components/resources/ResourcesList.svelte create mode 100644 frontend/src/lib/components/schedules/SchedulesList.svelte create mode 100644 frontend/src/lib/components/sessions/PageListView.svelte create mode 100644 frontend/src/lib/components/triggers/TriggerList.svelte create mode 100644 frontend/src/lib/components/triggers/triggerListConfig.ts create mode 100644 frontend/src/lib/components/variables/VariablesList.svelte diff --git a/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json b/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json similarity index 82% rename from backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json rename to backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json index 578edc6044..5c0e45b0d4 100644 --- a/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json +++ b/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", "describe": { "columns": [ { @@ -236,5 +236,5 @@ true ] }, - "hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e" + "hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56" } diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index dfd4a6e3ba..78cd6eb378 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -619,6 +619,7 @@ async fn edit_schedule( dynamic_skip = $23, email = $24, edited_by = $25, + edited_at = now(), permissioned_as = $26, labels = COALESCE($27, labels) WHERE path = $19 AND workspace_id = $20 diff --git a/frontend/src/lib/components/ContextualVariableEditor.svelte b/frontend/src/lib/components/ContextualVariableEditor.svelte index 43277787ba..9adde76e74 100644 --- a/frontend/src/lib/components/ContextualVariableEditor.svelte +++ b/frontend/src/lib/components/ContextualVariableEditor.svelte @@ -1,7 +1,6 @@ -{#if !$userStore?.operator && activeDeployRulesets.length > 0} +{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
    { - if (handOffPageDrawer(RESOURCES_PATH, p)) return // A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag // would still be standing when the next drawer session ends and would swallow that one's // anchor clear. Every session starts having to clear its own. diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index 471ed74099..da6cc9a674 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -10,7 +10,7 @@ } from '$lib/gen' import type { Folder } from '$lib/gen/types.gen' import { createEventDispatcher } from 'svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { Alert, Button, Drawer } from './common' import DrawerContent from './common/drawer/DrawerContent.svelte' import { sendUserToast } from '$lib/toast' @@ -22,11 +22,22 @@ import Toggle from './Toggle.svelte' import { Trash } from 'lucide-svelte' import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud' + import { + useOperatingWorkspace, + useOperatingUser + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() const dispatch = createEventDispatcher() let restricted = $derived( - isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin) + isDemoWorkspaceRestricted( + $operatingWorkspace, + operatingUser.current?.is_admin, + operatingUser.current?.is_super_admin + ) ) type Kind = @@ -81,7 +92,7 @@ defaultPermsLabel = `Folder f/${folderName} permissions` try { const folder: Folder = await FolderService.getFolder({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, name: folderName }) if (path !== currentPath) return @@ -134,7 +145,7 @@ const currentPath = path try { const resource = await ResourceService.getResource({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path: currentPath }) if (path !== currentPath) return @@ -164,27 +175,27 @@ } async function loadOwner() { - own = isOwner(path, $userStore!, $workspaceStore!) + own = isOwner(path, operatingUser.current!, $operatingWorkspace!) } async function loadAcls() { acls = Object.entries( - await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind }) + await GranularAclService.getGranularAcls({ workspace: $operatingWorkspace!, path, kind }) ) } async function loadGroups(): Promise { - groups = await GroupService.listGroupNames({ workspace: $workspaceStore! }) + groups = await GroupService.listGroupNames({ workspace: $operatingWorkspace! }) } async function loadUsernames(): Promise { - usernames = await UserService.listUsernames({ workspace: $workspaceStore! }) + usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! }) } async function deleteAcl(owner: string) { try { await GranularAclService.removeGranularAcls({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path, kind, requestBody: { owner } @@ -193,7 +204,7 @@ for (const varPath of linkedVarPaths) { try { await GranularAclService.removeGranularAcls({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path: varPath, kind: 'variable', requestBody: { owner } @@ -212,7 +223,7 @@ async function addAcl(owner: string, write: boolean) { await GranularAclService.addGranularAcls({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path, kind, requestBody: { owner, write } @@ -221,7 +232,7 @@ for (const varPath of linkedVarPaths) { try { await GranularAclService.addGranularAcls({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path: varPath, kind: 'variable', requestBody: { owner, write } @@ -251,9 +262,7 @@
    {/if}
    - Extra members ({acls?.length ?? 0}) + Extra members ({acls?.length ?? 0}) {#if linkedVarPaths.length > 0}
    void = (url: URL) => { - if (syncQuery) { - window.history.pushState(history.state, '', `?${url?.searchParams.toString()}`) - } - } - - const eventListener = (e: PopStateEvent) => { - if (syncQuery) { - loadFilterFromUrl() - } - } - - onMount(() => { - window.addEventListener('popstate', eventListener) - }) - - onDestroy(() => { - window.removeEventListener('popstate', (e) => eventListener(e)) - }) - - loadFilterFromUrl() + const hosted = useHostedPage() function loadFilterFromUrl() { - let queryValue = new URL(window.location.href).searchParams.get(queryName) ?? undefined - selectedFilter = queryValue + const search = hosted ? hosted.search : window.location.search + selectedFilter = new URLSearchParams(search).get(queryName) ?? undefined } - export async function setQuery(url: URL, key: string, value: string | undefined): Promise { - if (value != undefined) { - url.searchParams.set(key, value) - } else { - url.searchParams.delete(key) - } - queryChange(url) + function writeQuery(value: string | undefined) { + if (!syncQuery) return + const params = new URLSearchParams(hosted ? hosted.search : window.location.search) + if (value != undefined) params.set(queryName, value) + else params.delete(queryName) + if (hosted) hosted.setSearch(params.size ? `?${params}` : '') + else window.history.pushState(history.state, '', `?${params}`) } + $effect(() => { + if (!syncQuery) return + if (hosted) { + hosted.search + untrack(loadFilterFromUrl) + return + } + window.addEventListener('popstate', loadFilterFromUrl) + return () => window.removeEventListener('popstate', loadFilterFromUrl) + }) + + untrack(loadFilterFromUrl) + let filtersAndSelected = $derived( selectedFilter ? filters.includes(selectedFilter) @@ -109,11 +103,7 @@ class="inline-flex items-center gap-1 align-middle" onclick={() => { selectedFilter = selectedFilter == filter ? undefined : filter - if (selectedFilter) { - setQuery(new URL(window.location.href), queryName, selectedFilter) - } else { - setQuery(new URL(window.location.href), queryName, undefined) - } + writeQuery(selectedFilter) }} {color} clickable diff --git a/frontend/src/lib/components/hostedPage.ts b/frontend/src/lib/components/hostedPage.ts new file mode 100644 index 0000000000..424c10c450 --- /dev/null +++ b/frontend/src/lib/components/hostedPage.ts @@ -0,0 +1,31 @@ +import { getContext, setContext } from 'svelte' + +// A list page mounted somewhere other than its own route — an AI session's preview tab. The +// document URL there belongs to the host, so the page's view (the filters in its query) has to +// live wherever the host keeps it, and its rows and links open where the host decides instead of +// in a drawer or a top-level navigation. Components under such a host read this instead of +// `window.location`; outside one there is none, and they keep to the document. + +export type HostedPage = { + /** The page's query, `''` or `?…`. Reactive: the host may re-point it. */ + readonly search: string + /** Replace the page's query. */ + setSearch(search: string): void + /** Open one of the page's rows, by path. */ + openItem(path: string): void + /** Follow an in-app link (base included) the page would otherwise navigate to. */ + openLink(href: string): void +} + +const KEY = Symbol('hostedPage') + +/** Reads context: call during component initialisation. */ +export function setHostedPage(host: HostedPage): void { + setContext(KEY, host) +} + +/** The host of this page, or undefined on the page's own route. Reads context: call during + * component initialisation. */ +export function useHostedPage(): HostedPage | undefined { + return getContext(KEY) +} diff --git a/frontend/src/lib/components/jobs/JobPreview.svelte b/frontend/src/lib/components/jobs/JobPreview.svelte index 972cff6799..7f95ef8f63 100644 --- a/frontend/src/lib/components/jobs/JobPreview.svelte +++ b/frontend/src/lib/components/jobs/JobPreview.svelte @@ -15,8 +15,10 @@ import { Badge } from '../common' import { forLater } from '$lib/forLater' import DurationMs from '../DurationMs.svelte' - import { workspaceStore } from '$lib/stores' import { twMerge } from 'tailwind-merge' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() const POPUP_HEIGHT = 320 as const @@ -115,7 +117,7 @@ Arguments
    diff --git a/frontend/src/lib/components/resources/ResourcesList.svelte b/frontend/src/lib/components/resources/ResourcesList.svelte new file mode 100644 index 0000000000..180474276a --- /dev/null +++ b/frontend/src/lib/components/resources/ResourcesList.svelte @@ -0,0 +1,1623 @@ + + + { + deleteConfirmedCallback = undefined + }} + on:confirmed={() => { + if (deleteConfirmedCallback) { + deleteConfirmedCallback() + } + deleteConfirmedCallback = undefined + }} +> +
    + Are you sure you want to remove {deletePath}? + {#if deleteIsLinked} + + This resource is linked with a variable of the same path. That variable is deleted with it, + unless another resource still references it. + + {/if} + +
    + You can press + SHIFT + while removing a resource to bypass confirmation. +
    +
    +
    +
    + + + + + inferrer?.toggleDrawer?.()}> + + {#snippet actions()} + + {/snippet} + + + + + + {#snippet titleExtra()} + + {/snippet} +
    + {#if resourceTypeViewerObj.description} + + {/if} + {#if resourceTypeViewerObj.isFileset} + + This resource type represents a collection of files. Each file is identified by its + relative path and contains text content. In the CLI, filesets are stored as directories. + + {:else if resourceTypeViewerObj.formatExtension} + + This resource type represents a plain text file with a .{resourceTypeViewerObj.formatExtension} + extension. (e.g. my_file.{resourceTypeViewerObj.formatExtension}) + + {:else} + + {/if} +
    +
    +
    + + + + {#snippet actions()} + + {/snippet} +
    + + +
    + {#if editResourceType.isFileset} + + This resource type represents a collection of files. The schema cannot be edited. + + {:else if editResourceType.formatExtension} + + This resource type represents a plain text file with a .{editResourceType.formatExtension} + extension. (e.g. my_file.{editResourceType.formatExtension}). The schema only + contains a `content` field and thus cannot be edited. + + {:else} +
    Schema
    +
    + +
    + {/if} +
    +
    +
    +
    + + + + {#snippet actions()} + + {/snippet} +
    + + +
    +
    +
    Schema
    +
    + +
    +
    + +
    + +
    +
    +
    +
    +
    + +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.resources} + +{:else} + + + {#if showCreateButtons} +
    + + +
    + {/if} +
    + (showCreateButtons = v)} /> +
    + { + if (e.detail == 'cache') { + loading.resources = true + loadCache() + } else if (e.detail == 'states') { + loading.resources = true + loadState() + } else if (e.detail == 'theme') { + loading.resources = true + loadTheme() + } + }} + > + + + {#snippet extra()} + + Every resource has a Resource Type attached to it which contains its schema and make + it easy in scripts and flows to accept only resources of a specific resource type. + + {/snippet} + + + {#snippet extra()} + + States are actually resources (but excluded from the Workspace tab for clarity). + States are used by scripts to keep data persistent between runs of the same script by + the same trigger (schedule or user) + + {/snippet} + + + {#snippet extra()} + + Cached results are actually resources (but excluded from the Workspace tab for + clarity). Cache are used by flows's step to cache result to avoid recomputing + unnecessarily + + {/snippet} + + + {#snippet extra()} + + Themes are actually resources (but excluded from the Workspace tab for clarity). They + are CSS for the legacy low-code app editor only, and are added from the CSS panel of + an app rather than from this page. + + {/snippet} + + +
    +
    +
    + + {#if showTable} +
    + {#if loading.resources} + + {#each new Array(6) as _} + + {/each} + {:else if filteredItems?.length == 0} + {#if activeFilters} + + {:else} + appConnect?.open?.(), + aiId: 'resources-empty-add-resource', + aiDescription: 'Add resource' + } + : undefined} + /> + {/if} + {:else} + + + + + Path + Resource type + Description + Status + Actions + + +
    + {#if filteredItems} + {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'resource', path) ?? is_draft} + + + + + +
    + { + handledHash = `#/resource/${path}` + openResourceEditor(path, resource_type) + }} + >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} + + {#if labels?.length} +
    + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} +
    + {/if} + +
    +
    + + { + const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) + if (linkedRt) { + resourceTypeViewerObj = { + rt: linkedRt.name, + //@ts-ignore + schema: linkedRt.schema, + description: linkedRt.description ?? '', + formatExtension: linkedRt.format_extension, + isFileset: linkedRt.is_fileset ?? false + } + resourceTypeViewer?.openDrawer?.() + } else { + sendUserToast( + `Resource type ${resource_type} not found in workspace.`, + true + ) + } + }} + > + + + + +
    + + {removeMarkdown(truncate(description ?? '', 200))} + +
    +
    + +
    +
    + {#if is_linked} + + + {#snippet text()} +
    + This resource is linked with a variable of the same path. They + are deleted and renamed together. +
    + {/snippet} +
    + {/if} +
    +
    + {#if is_refreshed} + + + {#snippet text()} +
    + The OAuth token will be kept up-to-date in the background by + Windmill using its refresh token +
    + {/snippet} +
    + {/if} +
    + + {#if is_oauth} +
    + {#if refresh_error} + + + {#snippet text()} +
    + Latest exchange of the refresh token did not succeed. Error: {refresh_error} +
    + {/snippet} +
    + {:else if is_expired} + + + + {#snippet text()} +
    + The access_token is expired, it will get renewed the next time + this variable is fetched or you can request is to be refreshed + in the dropdown on the right. +
    + {/snippet} +
    + {:else} + + + {#snippet text()} +
    + The resource was connected through OAuth and the token is not + expired. +
    + {/snippet} +
    + {/if} +
    + {/if} +
    +
    + +
    + {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !operatingUser.current?.operator} + + {/if} + { + shareModal?.openDrawer?.(path, 'resource') + } + }, + { + displayName: 'Edit', + icon: Pen, + disabled: !canWrite || !showCreateButtons, + action: () => { + openResourceEditor(path, resource_type) + } + }, + // The agent form covers an agent's configuration, not everything a + // resource carries: the workspace-specific toggle in particular is + // only in the generic editor. JSON rather than that editor's form, + // which would render the configuration field by field and write a + // default into every one the agent leaves unset. Both write the same + // draft row, so this is a second view of the same edits. + ...(resource_type === 'ai_agent' + ? [ + { + displayName: 'Edit as JSON', + icon: Braces, + disabled: !canWrite || !showCreateButtons, + action: () => { + // The drawer anchors itself in the hash, which the deep-link + // effect would then read and route back to the agent editor. + // Claim it first, as the row's own link does. + handledHash = `#/resource/${path}` + // A host's item tab opens an agent on JSON already. + if (hosted) hosted.openItem(path) + else resourceEditor?.initEdit?.(path, { json: true }) + } + } + ] + : []), + ...(!ws_specific && isDeployable('resource', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'resource') + } + } + ] + : []), + { + displayName: 'Delete', + disabled: !canWrite || !showCreateButtons, + icon: Trash, + type: 'delete', + action: (event) => { + // TODO + // @ts-ignore + if (event?.shiftKey) { + deleteResource(path, account) + } else { + deleteIsLinked = is_linked ?? false + deletePath = path + deleteConfirmedCallback = () => { + deleteResource(path, account) + } + } + } + }, + ...(account != undefined + ? [ + { + displayName: 'Refresh token', + icon: RotateCw, + action: async () => { + await OauthService.refreshToken({ + workspace: $operatingWorkspace ?? '', + id: account ?? 0, + requestBody: { + path + } + }) + sendUserToast('Token refreshed') + loadResources() + } + } + ] + : []) + ]} + /> +
    +
    + {/each} + {/if} + + + {/if} + + {:else if tab == 'types'} + {#if loading.types} + + {#each new Array(6) as _} + + {/each} + {:else if filteredResourceTypes?.length == 0} +
    + +
    + {:else} +
    + + + + Name + Description + Actions + + +
    + {#if filteredResourceTypes} + {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} + + + { + resourceTypeViewerObj = { + rt: name, + //@ts-ignore + schema: schema, + description: description ?? '', + formatExtension: format_extension, + isFileset: is_fileset ?? false + } + + resourceTypeViewer?.openDrawer?.() + }} + > + + + + + +
    + + {removeMarkdown(truncate(description ?? '', 200))} + +
    +
    + + {#if !canWrite} + +
    + + Shared globally + + This resource type is from the 'admins' workspace shared with all + workspaces + + +
    + {:else if operatingUser.current?.is_admin || operatingUser.current?.is_super_admin} +
    + + +
    + {:else} + + Non Editable + + Since resource types are shared with the whole workspace, only admins + can edit/delete them + + + {/if} +
    +
    + {/each} + {/if} + + + + {/if} + {/if} + + +{/if} + + + + + + + { + loadResources() + }} +/> + + + +{#if !hosted && agentEditorTarget()} + {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }} + t.host === undefined} + onRenamed={(from, to) => { + void loadResources() + // Only while the dialog still shows the agent: closed mid-request, it already cleared the + // anchor, and writing it back would reopen the editor on refresh. + if (agentEditorTarget()?.path !== from) return + // Claimed first, as a row click does, so the deep-link effect does not reopen it. + handledHash = `#/resource/${to}` + setPageDrawerAnchor(RESOURCES_PATH, to) + }} + /> + {/await} +{/if} diff --git a/frontend/src/lib/components/resources/resourcesFilter.ts b/frontend/src/lib/components/resources/resourcesFilter.ts index 0526ff87b3..97d1b61c7e 100644 --- a/frontend/src/lib/components/resources/resourcesFilter.ts +++ b/frontend/src/lib/components/resources/resourcesFilter.ts @@ -76,15 +76,14 @@ export function buildResourcesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only resources in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only resources in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/components/schedules/SchedulesList.svelte b/frontend/src/lib/components/schedules/SchedulesList.svelte new file mode 100644 index 0000000000..80236a300f --- /dev/null +++ b/frontend/src/lib/components/schedules/SchedulesList.svelte @@ -0,0 +1,718 @@ + + + + + +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules} + +{:else} + + + + +
    +
    + + {#snippet children({ item })} + + + + {/snippet} + + +
    + {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !schedules?.length} + {#if activeFilters} + + {:else} + scheduleEditor?.openNew(false), + aiId: 'schedules-empty-add', + aiDescription: 'Add schedule' + }} + /> + {/if} + {:else if items?.length} +
    + {#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + {@const avg_s = jobs + ? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length + : undefined} + +
    +
    + + + { + handledHash = `#${path}` + editSchedule(path, is_flow) + }} + class="min-w-0 grow hover:underline decoration-gray-400" + > +
    + {summary || script_path}{hasDraft ? '*' : ''} +
    +
    + schedule: {path} +
    +
    + {#if labels?.length} + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} + {/if} + + + {#if paused_until && new Date(paused_until) > new Date()} +
    + Paused until {new Date(paused_until).toLocaleString()} +
    + {/if} + + + + + +
    + {#if error} + + + + + + {#snippet text()} +
    + The schedule disabled itself because there was an error scheduling the + next job: {error} +
    + {/snippet} +
    + {/if} +
    + +
    + + {#key toggleResetVersions[path] ?? 0} + { + if (canWrite) { + setScheduleEnabled(path, e.detail) + } else { + sendUserToast('not enough permission', true) + // Permission denied — bump the row's reset + // counter so the Toggle remounts back to the + // prop value. Without this, the local + // `bind:checked` flip from the user's click + // stays stuck on. + bumpToggleReset(path) + } + }} + /> + {/key} +
    +
    + + + { + if (hosted) hosted.openLink(`${base}${href}`) + else goto(href) + } + }, + { + displayName: `Duplicate schedule`, + icon: Copy, + action: () => { + scheduleEditor?.openNew(is_flow, script_path, path) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: !canWrite, + action: async () => { + await ScheduleService.deleteSchedule({ + workspace: $operatingWorkspace ?? '', + path + }) + loadSchedules() + } + }, + { + displayName: canWrite ? 'Edit' : 'View', + icon: canWrite ? Pen : Eye, + action: () => { + editSchedule(path, is_flow) + } + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { + kind: 'schedules' + } + }) + } + } + ] + : []), + { + displayName: 'View runs', + icon: List, + href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true` + }, + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: 'Run now', + icon: Play, + action: () => { + runScheduleNow(script_path, path, is_flow, $operatingWorkspace!) + } + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal?.openDrawer(path, 'schedule') + } + } + ]} + /> +
    +
    +
    + {#if loadingSchedulesWithJobStats} +
    + + Job stats loading... +
    + {:else} +
    + {#if avg_s} +
    Avg: {(avg_s / 1000).toFixed(2)}s
    + {/if} + {#each jobs ?? [] as job} + {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3} + + +
    +
    + +
    +
    +
    + {/each} +
    + {/if} +
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    +
    + {/each} +
    + {:else} + + {/if} +
    + {#if items && items?.length > 15 && nbDisplayed < items.length} +
    + {nbDisplayed} items out of {items.length} + +
    + {/if} +
    +{/if} + + { + loadSchedules() + }} +/> diff --git a/frontend/src/lib/components/schedules/schedulesFilter.ts b/frontend/src/lib/components/schedules/schedulesFilter.ts index 24fb1c90a3..4b887e5aa4 100644 --- a/frontend/src/lib/components/schedules/schedulesFilter.ts +++ b/frontend/src/lib/components/schedules/schedulesFilter.ts @@ -73,15 +73,14 @@ export function buildSchedulesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only schedules in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only schedules in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/components/sessions/PageListView.svelte b/frontend/src/lib/components/sessions/PageListView.svelte new file mode 100644 index 0000000000..7dab54c494 --- /dev/null +++ b/frontend/src/lib/components/sessions/PageListView.svelte @@ -0,0 +1,131 @@ + + +{#snippet loading()} +
    + +
    +{/snippet} + + +
    + {#key `${path}:${workspaceId}:${reloadNonce}`} + {#if listed?.kind === 'variable'} + {#await import('$lib/components/variables/VariablesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'resource'} + {#await import('$lib/components/resources/ResourcesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'schedule'} + {#await import('$lib/components/schedules/SchedulesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'trigger'} + {#await import('$lib/components/triggers/TriggerList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {/if} + {/key} +
    diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index b88907bf02..f1d630caca 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -105,6 +105,7 @@ // A page item's editor reads its draft only when it loads, so a reload remounts it. let pageItemReloadNonce = $state(0) + let pageListReloadNonce = $state(0) export function reload() { // A live editor shares the runtime store the chat mutates, so generic chat @@ -116,6 +117,10 @@ pageItemReloadNonce++ return } + if (slot.kind === 'pagelist') { + pageListReloadNonce++ + return + } try { const win = frame?.contentWindow if (!win) return @@ -334,6 +339,27 @@ {/await} {/if} +{:else if slot.kind === 'pagelist' && mounted && runtime} +
    + {#if overlayHostEl} + {#await import('./PageListView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {/if} +
    {:else if slot.kind === 'artifact' && mounted}
    { - const { pathname, search, hash } = window.location - if (hash) history.replaceState(history.state, '', `${pathname}${search}`) - } - dropAnchor() - setTimeout(dropAnchor, 0) - return true -} - /** * Drop the row a list page deep-links, once its drawer closes. The hash is how the row was * requested; leaving it behind makes the location claim a drawer that is no longer open — diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 13d5abca33..58eca9504d 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -79,6 +79,11 @@ export function pageItemListPath(ref: PageItemRef): string { } } +/** Whether a (base-stripped) path is the list page of a page item kind. */ +export function isPageItemListPath(path: string): boolean { + return pageItemForListPath(path, '') !== undefined +} + /** The page item a list page's row names, or undefined for a page that lists none. */ export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined { const clean = stripBase(pagePath) diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index 3bc76882e5..143ce42975 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -284,6 +284,17 @@ describe('resolvePreviewTab', () => { expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' }) }) + it('mounts the list page of a page item kind in process, whatever its filters', () => { + expect(resolvePreviewTab('/variables?path_start=f%2Fa%2F')).toEqual({ + kind: 'pagelist', + path: '/variables' + }) + expect(resolvePreviewTab('/kafka_triggers')).toEqual({ + kind: 'pagelist', + path: '/kafka_triggers' + }) + }) + it('routes any script item to a live editor', () => { expect(resolvePreviewTab('/scripts/edit/f/foo/bar')).toEqual({ kind: 'editor', diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 13e7626e70..de21660f3f 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -3,6 +3,7 @@ import { AUDIT_LOGS_PATH, FOLDERS_PATH, GROUPS_PATH, + isPageItemListPath, pageItemForListPath, pageItemListPath, pageItemPageHref, @@ -530,13 +531,14 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:') // fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts // its per-(kind,path) cell editor; a `/pipeline/` route mounts the // data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path` -// is the folder); everything else (static pages, regular drag-and-drop apps, any -// other route) stays an iframe. +// is the folder); the list page of a page item kind mounts that list; everything +// else (static pages, regular drag-and-drop apps, any other route) stays an iframe. export type PreviewSlot = | { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string } | { kind: 'artifact'; id: string; version?: number } | { kind: 'runform'; toolCallId: string } | { kind: 'pageitem'; ref: PageItemRef } + | { kind: 'pagelist'; path: string } | { kind: 'iframe' } export function resolvePreviewTab(url: string): PreviewSlot { @@ -546,22 +548,24 @@ export function resolvePreviewTab(url: string): PreviewSlot { if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId } const pageItem = parsePageItemRoute(url) if (pageItem) return { kind: 'pageitem', ref: pageItem } + const route = stripBase(url) + if (isPageItemListPath(route)) return { kind: 'pagelist', path: route } const pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) { return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder } } - const route = parsePreviewItemRoute(url) - if (!route) return { kind: 'iframe' } + const item = parsePreviewItemRoute(url) + if (!item) return { kind: 'iframe' } const editorKind: SessionTargetKind | undefined = - route.kind === 'script' + item.kind === 'script' ? 'script' - : route.kind === 'flow' + : item.kind === 'flow' ? 'flow' - : route.kind === 'app' && route.raw_app + : item.kind === 'app' && item.raw_app ? 'raw_app' : undefined if (!editorKind) return { kind: 'iframe' } - return { kind: 'editor', editorKind, path: route.itemPath } + return { kind: 'editor', editorKind, path: item.itemPath } } /** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when @@ -576,6 +580,7 @@ export function workspacePageHref(location: string): string | undefined { case 'pageitem': return pageItemPageHref(slot.ref) case 'editor': + case 'pagelist': case 'iframe': return location default: { diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index fbb84e1431..6edc8ab1dc 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -406,7 +406,8 @@ function createRuntime(session: Session): SessionRuntime { const tab = owner?.displayedTab if (!tab) return undefined const slotKind = resolvePreviewTab(tab.url).kind - if (slotKind !== 'iframe' && slotKind !== 'pageitem') return undefined + if (slotKind !== 'iframe' && slotKind !== 'pageitem' && slotKind !== 'pagelist') + return undefined return previewLocationContext(whereIs(tab)) } // Pre-flight: materialise the (still-transient) session, then commit diff --git a/frontend/src/lib/components/triggers/TriggerList.svelte b/frontend/src/lib/components/triggers/TriggerList.svelte new file mode 100644 index 0000000000..501eebd90f --- /dev/null +++ b/frontend/src/lib/components/triggers/TriggerList.svelte @@ -0,0 +1,983 @@ + + +{#snippet rowTitle(row: TriggerW & { marked?: any }, hasDraft: boolean | undefined)} + {@const marked = ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind) + ? row.marked + : undefined} +
    + {#if marked} + + {@html marked} + + {:else if triggerKind === 'http'} + {#if row.summary} + {row.summary} + {:else} + {row.http_method!.toUpperCase()} + /{isCloudHosted() || row.workspaced_route || globalHttpWorkspacedRoute + ? row.workspace_id + '/' + row.route_path + : row.route_path} + {/if} + {:else if triggerKind === 'websocket'} + {websocketUrl(row.url!)} + {:else if triggerKind === 'kafka'} + {row.kafka_resource_path} - {row.topics!.join(', ')} + {:else if triggerKind === 'nats'} + {row.nats_resource_path} - {row.subjects!.join(', ')} + {:else if triggerKind === 'email'} + {emailAddress(row)} + {:else if triggerKind === 'gcp'} + {row.path} - {row.topic_id} + {:else if triggerKind === 'azure'} + {row.path} - {row.topic_name + ? row.topic_name + : (row.scope_resource_id?.split('/')?.pop() ?? '')} ({row.azure_mode}, {row.subscription_name}) + {:else} + {row.path} + {/if}{hasDraft ? '*' : ''} +
    + {#if ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)} +
    {row.path}
    + {:else if triggerKind === 'postgres'} +
    + {row.postgres_resource_path} +
    + {/if} +
    + {#if triggerKind === 'http' && row.static_asset_config} + file: {row.static_asset_config.s3} + {:else} + runnable: {row.script_path} + {/if} +
    +{/snippet} + +{#snippet statusDot(row: TriggerW)} + {@const status = config.status} + {#if status} + {@const ping = status.pings + ? row.last_server_ping + ? new Date(row.last_server_ping) + : undefined + : new Date()} + {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} + {@const effectiveMode = row.draft_only ? 'disabled' : row.mode} + {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} +
    + {#if (enabled && (!pinging || row.error)) || (!enabled && row.error) || (enabled && !row.server_id)} + + + + + + {#snippet text()} +
    + {#if enabled} + {#if !row.server_id} + {status.starting} + {:else} + {status.notConnected}{row.error ? ': ' + row.error : ''} + {/if} + {:else} + {status.disabled}: {row.error} + {/if} +
    + {/snippet} +
    + {:else if enabled} + + + + + {#snippet text()} +
    + {status.connected}{status.shuttingDown && !row.server_id ? ' (shutting down...)' : ''} +
    + {/snippet} +
    + {/if} +
    + {/if} +{/snippet} + + { + isDeleting = false + pendingDelete = undefined + }} + on:confirmed={confirmPendingDelete} +> +
    + Are you sure you want to remove this trigger? + {#if pendingDelete && triggerKind === 'postgres'} + + + {:else if pendingDelete && triggerKind === 'gcp'} + + {:else if pendingDelete?.subscription_name && triggerKind === 'azure'} + + {/if} +
    +
    + + +{#await EDITORS[triggerKind]() then Editor} + +{/await} +{#if triggerKind === 'http'} + {#await import('./http/RoutesGenerator.svelte') then Generator} + + {/await} + {#await import('./http/OpenAPISpecGenerator.svelte') then Generator} + + {/await} +{/if} + + (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} +/> + +{#if operatorBlocked} + +{:else} + + +
    + {#if triggerKind === 'http'} + + + {/if} + {#if canCreate} + + {/if} +
    +
    + + {#if config.cloudDisabled && isCloudHosted()} + + {config.title} are disabled in the multi-tenant cloud. + +
    + {/if} +
    +
    + +
    +
    Filter by path of
    + + {#snippet children({ item })} + + + {/snippet} + +
    + + +
    + {#if operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')} + + {:else if isAdmin} + + {/if} +
    +
    + {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !triggers?.length} + editor?.openNew(false), + aiId: config.empty.aiId, + aiDescription: config.empty.aiDescription + } + : undefined} + /> + {:else if items?.length} +
    + {#each items.slice(0, nbDisplayed) as row (row.path)} + {@const { + path, + edited_by, + edited_at, + script_path, + is_flow, + extra_perms, + canWrite, + mode, + retry, + error_handler_path, + error_handler_args, + labels, + draft_only, + is_draft + } = row} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, config.draftKind, path) ?? is_draft} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + {@const effectiveMode = draft_only ? 'disabled' : mode} + {@const live = isLive(row)} + {@const copy = copyUrl(row)} + +
    +
    + + + { + if (hosted) e.preventDefault() + openEdit(path, is_flow) + }} + class="min-w-0 grow hover:underline decoration-gray-400" + > + {@render rowTitle(row, hasDraft)} + + + + + {#if live} + {@render statusDot(row)} + {/if} + +
    + + {#if live} + onToggleMode(path, newMode)} + triggerMode={effectiveMode} + includeModalConfig={{ + triggerPath: path, + triggerKind, + runnableConfig: { + path: script_path, + kind: is_flow ? 'flow' : 'script', + retry, + errorHandlerPath: error_handler_path, + errorHandlerArgs: error_handler_args + } + }} + {canWrite} + hideToggleLabels + hideDropdown + /> + {/if} +
    + +
    + {#if copy} + + {/if} + + openLink(href) + }, + ...(canWrite && !draft_only && mode !== 'suspended' + ? [ + { + displayName: 'Suspend job execution', + icon: Pause, + action: () => { + onToggleMode(path, 'suspended') + } + } + ] + : []), + { + displayName: canWrite ? 'Edit' : 'View', + icon: canWrite ? Pen : Eye, + action: () => openEdit(path, is_flow) + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { kind: config.deployKind } + }) + } + } + ] + : []), + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal?.openDrawer(path, config.shareKind as any) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: !canWrite || (config.adminOnly && !isAdmin), + action: () => deleteTrigger(row) + } + ]} + /> +
    +
    +
    +
    + {#if edited_by}
    edited by {edited_by}
    {/if} +
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    +
    +
    +
    + {/each} +
    + {:else} + + {/if} +
    + {#if items && items?.length > 15 && nbDisplayed < items.length} + {nbDisplayed} items out of {items.length} + + {/if} +
    +{/if} + + { + loadTriggers() + }} +/> diff --git a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte index 7e3c2c5ac1..3afcdf1a43 100644 --- a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { untrack } from 'svelte' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -176,7 +175,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.amqp.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 697bc00ad4..12b6bf0533 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -156,7 +155,6 @@ isFlow: boolean, defaultValues?: Record ) { - if (handOffPageDrawer(TRIGGER_PAGES.azure.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() drawerLoading = true diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 30f848004f..fe472cb8c4 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -143,7 +142,6 @@ defaultConfig?: Partial, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.email.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() drawerLoading = true diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 92902aa44b..03c4a12261 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -167,7 +166,6 @@ defaultValues?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.gcp.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() drawerLoading = true diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 2b28ae0d8d..5fc2999704 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -2,7 +2,6 @@ import { Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -274,7 +273,6 @@ isFlow: boolean, defaultConfig?: Partial ) { - if (handOffPageDrawer(TRIGGER_PAGES.http.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() drawerLoading = true diff --git a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte index 91b2ee7e9e..71b54a39e0 100644 --- a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte +++ b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte @@ -16,16 +16,20 @@ import RouteEditor from './RouteEditor.svelte' import { generateHttpTriggerFromOpenApi, type Source } from './utils' import { isCloudHosted } from '$lib/cloud' - import { usedTriggerKinds, userStore } from '$lib/stores' + import { usedTriggerKinds } from '$lib/stores' import FileInput from '../../common/fileInput/FileInput.svelte' import { emptyStringTrimmed, sendUserToast } from '$lib/utils' import FolderPicker from '../../FolderPicker.svelte' import Required from '$lib/components/Required.svelte' import { Drawer, DrawerContent } from '$lib/components/common' import { get } from 'svelte/store' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() type Props = { closeFn: () => Promise @@ -137,7 +141,9 @@ } } - let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) + let userIsAdmin = $derived( + operatingUser.current?.is_admin || operatingUser.current?.is_super_admin + ) async function generateHttpTrigger() { try { diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 3fb5668f44..ea604bc5a8 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -178,7 +177,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.kafka.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index f3a42f0d49..3627bd2d4d 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { untrack } from 'svelte' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -173,7 +172,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.mqtt.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 16997be11a..045f7155d3 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -161,7 +160,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.nats.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 23ce3fc43c..546b4d2457 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button, TabContent } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -262,7 +261,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.postgres.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index a6a6b5e7a8..f0eed8a9fe 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Badge, Button, ButtonType, Tab, Tabs } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths' @@ -181,7 +180,6 @@ defaultCfg?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(SCHEDULES_PATH, ePath)) return let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 0b3f846c97..5521eca4c3 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -156,7 +155,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.sqs.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/triggerListConfig.ts b/frontend/src/lib/components/triggers/triggerListConfig.ts new file mode 100644 index 0000000000..9e5b24c8f2 --- /dev/null +++ b/frontend/src/lib/components/triggers/triggerListConfig.ts @@ -0,0 +1,506 @@ +import { + AmqpTriggerService, + AzureTriggerService, + EmailTriggerService, + GcpTriggerService, + HttpTriggerService, + KafkaTriggerService, + MqttTriggerService, + NatsTriggerService, + PostgresTriggerService, + SqsTriggerService, + WebsocketTriggerService, + type AmqpTrigger, + type AzureTrigger, + type EmailTrigger, + type GcpTrigger, + type HttpTrigger, + type KafkaTrigger, + type MqttTrigger, + type NatsTrigger, + type PostgresTrigger, + type SqsTrigger, + type TriggerMode, + type UserDraftItemKind, + type WebsocketTrigger +} from '$lib/gen' +import type { TriggerKind as UsedTriggerKind } from '$lib/components/triggers' +import type { TriggerKind } from '$lib/components/sessions/previewPaths' + +type AnyTrigger = + | HttpTrigger + | WebsocketTrigger + | PostgresTrigger + | KafkaTrigger + | NatsTrigger + | MqttTrigger + | AmqpTrigger + | SqsTrigger + | GcpTrigger + | AzureTrigger + | EmailTrigger +type KeysOfUnion = T extends unknown ? keyof T : never +type FieldOf = T extends unknown + ? K extends keyof T + ? T[K] + : never + : never + +/** A row of any trigger list: the fields every kind shares, plus every kind's own as optional, + * so a field read by one kind's branch is still checked against the generated types. */ +export type TriggerRow = Pick< + HttpTrigger, + 'path' | 'script_path' | 'is_flow' | 'mode' | 'edited_by' | 'edited_at' +> & { + [K in Exclude< + KeysOfUnion, + 'path' | 'script_path' | 'is_flow' | 'mode' | 'edited_by' | 'edited_at' + >]?: FieldOf +} + +type Call = (a: A) => Promise + +export type TriggerListConfig = { + /** Page title, and the kind's plural name in the cloud alert. */ + title: string + tooltip: string + documentationLink?: string + /** Follows "New" on the create button. */ + newLabel: string + searchPlaceholder: string + /** The "Filter by path of" choice naming the trigger itself. */ + filterLabel: string + empty: { + title: string + description: string + actionLabel: string + aiId: string + aiDescription: string + } + /** Disabled in the multi-tenant cloud. */ + cloudDisabled: boolean + /** Hidden from operators unless the workspace grants them triggers. */ + operatorGate: boolean + /** Creating and deleting are for admins only. */ + adminOnly: boolean + usedKind: UsedTriggerKind + deployKind: UsedTriggerKind + shareKind: string + draftKind: UserDraftItemKind + /** Kinds holding a live connection: polled, with a status dot. Each message is followed by + * the error or state it reports. */ + status?: { + starting: string + notConnected: string + disabled: string + connected: string + /** Reports a connection whose server is going away. */ + shuttingDown: boolean + /** The server stamps a ping; without one, a connected server counts as alive. */ + pings: boolean + } + /** Mode changes run through the fork-conflict check, under this name. Kinds whose runtime + * address is workspace-prefixed skip it: a fork and its parent never collide. */ + forkConflictLabel?: string + /** Toast a mode change that went through, as "
    + + Path + Value + Description + Status + Actions + + + + {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft} + + + + + +
    + { + handledHash = `#${path}` + editVariable(path) + }} + href="#{path}" + > + {path}{hasDraft ? '*' : ''} + + + {#if labels?.length} + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} + {/if} + +
    + + + +
    + {#if value} + {truncate(value, 20)} + {:else} + ∗∗∗∗ + {/if} +
    + {#if is_secret} + + + {#snippet text()} + This item is secret + {/snippet} + + {/if} +
    +
    + + {truncate(description ?? '', 50)} + + + +
    + {#if is_linked} + + + {#snippet text()} +
    + This variable is linked with a resource of the same path. They are + deleted and renamed together. +
    + {/snippet} +
    + {/if} + {#if account} + + + {#snippet text()} +
    + This OAuth token will be kept up-to-date in the background by + Windmill using its refresh token +
    + {/snippet} +
    + {/if} + + {#if is_refreshed} +
    + {#if refresh_error} + + +
    + + +
    + + {#snippet text()} +
    + Latest exchange of the refresh token did not succeed. Error: {refresh_error} +
    + {/snippet} +
    + {:else if is_expired} + + + {#snippet text()} +
    + The access_token is expired, it will get renewed the next time + this variable is fetched or you can request is to be refreshed + in the dropdown on the right. +
    + {/snippet} +
    + {:else} + + + {#snippet text()} +
    + The variable was connected through OAuth and the token is not + expired. +
    + {/snippet} +
    + {/if} +
    + {/if} +
    +
    + + { + let owner = isOwner(path, operatingUser.current, $operatingWorkspace) + return [ + { + displayName: 'Edit', + icon: Pen, + action: () => editVariable(path), + disabled: !canWrite || !showCreateButtons + }, + { + displayName: 'Delete', + icon: Trash, + type: 'delete', + action: (event) => { + if (event['shiftKey']) { + deleteVariable(path, account) + } else { + deleteIsLinked = is_linked ?? false + deleteConfirmedCallback = () => { + deleteVariable(path, account) + } + } + }, + disabled: !owner || !showCreateButtons + }, + ...(!ws_specific && + isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'variable') + } + } + ] + : []), + { + displayName: 'Permissions', + action: () => { + shareModal?.openDrawer(path, 'variable') + }, + icon: Shield + }, + ...(account != undefined + ? [ + { + displayName: 'Refresh token', + icon: RefreshCw, + action: async () => { + await OauthService.refreshToken({ + workspace: $operatingWorkspace ?? '', + id: account ?? 0, + requestBody: { + path + } + }) + sendUserToast('Token refreshed') + loadVariables() + } + } + ] + : []) + ] + }} + /> + + + {/each} + + + {/if} + + {:else if tab == 'contextual'} +
    + {#if loading.contextual} + + {#each new Array(8) as _} + + {/each} + {:else} + + {#if contextualVariables.filter((x) => x.is_custom).length === 0} + contextualVariableEditor?.initNew(), + aiId: 'variables-empty-add-contextual-variable', + aiDescription: 'Add contextual variable' + } + : undefined} + /> + {:else} + x.is_custom)} + keys={['name', 'value']} + getRowActions={operatingUser.current?.is_admin || + operatingUser.current?.is_super_admin + ? (row) => { + return [ + { + displayName: 'Edit', + action: () => contextualVariableEditor?.editVariable(row.name, row.value) + }, + { + displayName: 'Delete', + type: 'delete', + action: () => { + deleteContextualVariable(row) + } + } + ] + } + : undefined} + /> + {/if} + + !x.is_custom)} + keys={['name', 'value', 'description']} + /> + {/if} +
    + {/if} + + +{/if} + + { + deleteConfirmedCallback = undefined + }} + on:confirmed={() => { + if (deleteConfirmedCallback) { + deleteConfirmedCallback() + } + deleteConfirmedCallback = undefined + }} +> +
    + Are you sure you want to remove this variable? + {#if deleteIsLinked} + + This variable is linked with a resource of the same path. The linked resource will also be + deleted. + + {/if} + +
    + You can press + SHIFT + while removing a variable to bypass confirmation. +
    +
    +
    +
    diff --git a/frontend/src/lib/components/variables/variablesFilter.ts b/frontend/src/lib/components/variables/variablesFilter.ts index 5f7ce1e51a..d609182da0 100644 --- a/frontend/src/lib/components/variables/variablesFilter.ts +++ b/frontend/src/lib/components/variables/variablesFilter.ts @@ -64,15 +64,14 @@ export function buildVariablesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only variables in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only variables in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/svelte5UtilsKit.svelte.ts b/frontend/src/lib/svelte5UtilsKit.svelte.ts index 7b6420ab7e..d77b96df06 100644 --- a/frontend/src/lib/svelte5UtilsKit.svelte.ts +++ b/frontend/src/lib/svelte5UtilsKit.svelte.ts @@ -3,6 +3,8 @@ import { z } from 'zod' import { serializeParam } from '$lib/utils/serializeParam' +import { untrack } from 'svelte' +import { useHostedPage } from '$lib/components/hostedPage' export type SearchParamsResult = S extends z.ZodObject @@ -58,12 +60,14 @@ function deserializeParam(raw: string, fieldSchema: z.ZodType): unknown { export function useSearchParams(schema: S): SearchParamsResult { const shape: Record = (schema as any).shape ?? {} const keys = Object.keys(shape) + const hosted = useHostedPage() + const currentSearch = () => (hosted ? hosted.search : window.location.search) // Reactive snapshot of search params - one $state cell per key const values: Record = $state( Object.fromEntries( keys.map((k) => { - const raw = new URLSearchParams(window.location.search).get(k) + const raw = new URLSearchParams(untrack(currentSearch)).get(k) const parsed = raw != null ? deserializeParam(raw, shape[k]) : null return [k, parsed] }) @@ -71,7 +75,7 @@ export function useSearchParams(schema: S): SearchParamsRes ) function syncFromUrl() { - const sp = new URLSearchParams(window.location.search) + const sp = new URLSearchParams(currentSearch()) for (const k of keys) { const raw = sp.get(k) const parsed = raw != null ? deserializeParam(raw, shape[k]) : null @@ -79,8 +83,13 @@ export function useSearchParams(schema: S): SearchParamsRes } } - // Keep in sync when the user navigates back/forward + // Keep in sync when the user navigates back/forward, or the host re-points the page $effect(() => { + if (hosted) { + hosted.search + untrack(syncFromUrl) + return + } window.addEventListener('popstate', syncFromUrl) return () => window.removeEventListener('popstate', syncFromUrl) }) @@ -94,12 +103,16 @@ export function useSearchParams(schema: S): SearchParamsRes }, set(v: unknown) { ;(values as any)[key] = v - const sp = new URLSearchParams(window.location.search) + const sp = new URLSearchParams(untrack(currentSearch)) if (v == null) { sp.delete(key) } else { sp.set(key, serializeParam(v)) } + if (hosted) { + hosted.setSearch(sp.size ? `?${sp}` : '') + return + } const hash = window.location.hash const newUrl = sp.toString() ? `${window.location.pathname}?${sp}${hash}` diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index bfae9bb5d2..e9d4493e66 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -90,7 +90,11 @@ import SessionModeSwitch from '$lib/components/sessions/SessionModeSwitch.svelte' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' import { copilotInfo } from '$lib/aiStore' - import { parsePreviewItemRoute } from '$lib/components/sessions/previewPaths' + import { + isPageItemListPath, + parsePreviewItemRoute, + stripBase + } from '$lib/components/sessions/previewPaths' import { rememberNavRoute } from '$lib/components/sessions/sessionSwitch.svelte' import { sessionState } from '$lib/components/sessions/sessionState.svelte' import { restoreSessionBackups } from '$lib/components/sessions/sessionMirror.svelte' @@ -431,6 +435,21 @@ } catch {} return } + // A list page mounts in process too, where its rows open as tabs of their own. + const listUrl = navigation.to?.url + if (listUrl && isPageItemListPath(stripBase(listUrl.pathname))) { + navigation.cancel() + const u = new URL(listUrl.href) + u.searchParams.delete('nomenubar') + u.searchParams.delete('workspace') + try { + window.parent.postMessage( + { type: 'wm.session.openList', href: u.pathname + u.search + u.hash }, + window.location.origin + ) + } catch {} + return + } const runTarget = previewRunTarget(navigation.to?.url) if (runTarget) { navigation.cancel() diff --git a/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte index 360defa995..8a21a3a61f 100644 --- a/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte @@ -1,570 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - AMQP triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - amqpTriggerEditor?.openNew(false), - aiId: 'amqp-triggers-empty-add', - aiDescription: 'Add AMQP trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_amqp', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - amqpTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - AMQP trigger is starting... - {:else} - AMQP trigger is not connected{error ? ': ' + error : ''} - {/if} - {:else} - AMQP trigger was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - AMQP trigger is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'amqp', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - amqpTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'amqp' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'amqp_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await AmqpTriggerService.deleteAmqpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte index 11afa9f94b..2193288ccf 100644 --- a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte @@ -1,668 +1,5 @@ - { - deleteAzureTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deleteAzureTriggerCallback) { - isDeleting = true - if (deleteSubscription && subscriptionDeletePayload) { - try { - const msg = await AzureTriggerService.deleteAzureSubscription({ - workspace: $workspaceStore ?? '', - path: subscriptionDeletePayload.azure_resource_path, - requestBody: { - azure_mode: subscriptionDeletePayload.azure_mode, - scope_resource_id: subscriptionDeletePayload.scope_resource_id, - topic_name: subscriptionDeletePayload.topic_name, - subscription_name: subscriptionDeletePayload.subscription_name - } - }) - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - await deleteAzureTriggerCallback() - } - deleteAzureTriggerCallback = undefined - subscriptionToDelete = undefined - subscriptionDeletePayload = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - {#if subscriptionToDelete} - - {/if} -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - Azure Event Grid triggers are disabled in the multi-tenant cloud. - - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - azureTriggerEditor?.openNew(false), - aiId: 'azure-triggers-empty-add', - aiDescription: 'Add Azure Event Grid trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, workspace_id, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_azure', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - {@const is_pull = azure_mode === 'namespace_pull'} - {@const topic_label = topic_name - ? topic_name - : (scope_resource_id?.split('/')?.pop() ?? '')} - -
    -
    - - - azureTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path} - {topic_label} ({azure_mode}, {subscription_name}){hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - - {#if is_pull} -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Azure Event Grid trigger is starting... - {:else} - Could not connect to Azure Event Grid{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - Connected to Azure Event Grid{!server_id - ? ' (shutting down...)' - : ''}
    - {/snippet} -
    - {/if} -
    - {/if} - -
    - - {#if is_pull} - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'azure', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> - {/if} -
    - -
    - {#if !is_pull} - - {/if} - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - azureTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'azure' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'azure_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - isDeleting = false - deleteSubscription = false - subscriptionToDelete = subscription_name - subscriptionDeletePayload = { - azure_mode, - scope_resource_id, - topic_name: topic_name ?? undefined, - subscription_name, - azure_resource_path - } - deleteAzureTriggerCallback = async () => { - const message = await AzureTriggerService.deleteAzureTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(message) - loadTriggers() - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte index 87c9558dbb..8fb28777c8 100644 --- a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte @@ -1,553 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - {#if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} - -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - emailTriggerEditor?.openNew(false), - aiId: 'email-triggers-empty-add', - aiDescription: 'Add email trigger' - } - : undefined} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { workspace_id, workspaced_local_part, path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, marked, local_part, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_email', path) ?? is_draft} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const emailAddress = getEmailAddress( - local_part, - workspaced_local_part ?? false, - workspace_id, - emailDomain ?? '' - )} - -
    -
    - - - emailTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {emailAddress} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'email', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - emailTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'routes' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'email_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: - !canWrite || !($userStore?.is_admin || $userStore?.is_super_admin), - action: async () => { - try { - await EmailTriggerService.deleteEmailTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(`Successfully deleted Email trigger: ${path}`) - loadTriggers() - } catch (error) { - sendUserToast(error.body || error.message, true) - } - } - } - ]} - /> -
    -
    -
    -
    - {#if edited_by}
    edited by {edited_by}
    {/if} -
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    -
    -
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index 24c7553378..3b61eb7801 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -1,654 +1,5 @@ - { - deleteGcpTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deleteGcpTriggerCallback) { - isDeleting = true - if (deleteSubscription && deleteSubscriptionCallback) { - try { - await deleteSubscriptionCallback() - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - await deleteGcpTriggerCallback() - } - deleteGcpTriggerCallback = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - GCP Pub/Sub triggers are disabled in the multi-tenant cloud. - - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - gcpTriggerEditor?.openNew(false), - aiId: 'gcp-triggers-empty-add', - aiDescription: 'Add GCP Pub/Sub trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { gcp_resource_path, project_id, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_gcp', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - gcpTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path} - {topic_id}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - - {#if delivery_type !== 'push'} -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - GCP Pub/Sub trigger is starting... - {:else} - Could not connect to GCP Pub/Sub{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - Connected to GCP Pub/Sub{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - {/if} - -
    - - {#if delivery_type !== 'push'} - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'gcp', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> - {/if} -
    - -
    - {#if delivery_type === 'push'} - - {/if} - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - gcpTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'gcp' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'gcp_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - isDeleting = false - deleteSubscription = false - subscriptionToDelete = subscription_id - currentTopic = topic_id - deleteSubscriptionCallback = async () => { - const requestBody = { subscription_id, project_id } - const message = gcp_resource_path - ? await GcpTriggerService.deleteGcpSubscription({ - workspace: $workspaceStore ?? '', - path: gcp_resource_path, - requestBody - }) - : await GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({ - workspace: $workspaceStore ?? '', - requestBody - }) - sendUserToast(message) - } - deleteGcpTriggerCallback = async () => { - const message = await GcpTriggerService.deleteGcpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(message) - loadTriggers() - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte index 6ee790818a..c4d4f904f6 100644 --- a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte @@ -1,595 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - - - - {#if isCloudHosted()} - - Kafka triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - kafkaTriggerEditor?.openNew(false), - aiId: 'kafka-triggers-empty-add', - aiDescription: 'Add Kafka trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, kafka_resource_path, topics, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_kafka', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - kafkaTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {kafka_resource_path} - {topics.join(', ')} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Consumer is starting... - {:else} - Consumer is not connected{error ? ': ' + error : ''} - {/if} - {:else} - Consumer was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Consumer is connected
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'kafka', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await KafkaTriggerService.deleteKafkaTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - kafkaTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'kafka' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'kafka_trigger') - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    Edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte index 126dfda031..41c30157ff 100644 --- a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte @@ -1,570 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - MQTT triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - mqttTriggerEditor?.openNew(false), - aiId: 'mqtt-triggers-empty-add', - aiDescription: 'Add MQTT trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_mqtt', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - mqttTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - MQTT trigger is starting... - {:else} - MQTT trigger is not connected{error ? ': ' + error : ''} - {/if} - {:else} - MQTT trigger was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - MQTT trigger is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'mqtt', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - mqttTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'mqtt' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'mqtt_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await MqttTriggerService.deleteMqttTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte index 834869891a..ae96a52cfc 100644 --- a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte @@ -1,592 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - - - - {#if isCloudHosted()} - - NATS triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - natsTriggerEditor?.openNew(false), - aiId: 'nats-triggers-empty-add', - aiDescription: 'Add NATS trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, nats_resource_path, subjects, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_nats', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - natsTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {nats_resource_path} - {subjects.join(', ')} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Consumer is starting... - {:else} - Consumer is not connected{error ? ': ' + error : ''} - {/if} - {:else} - Consumer was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Consumer is connected
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'nats', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - natsTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'nats' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'nats_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await NatsTriggerService.deleteNatsTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte index d1c07a7e75..a25078abe1 100644 --- a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte @@ -1,674 +1,5 @@ - { - isDeleting = false - deletePostgresTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deletePostgresTriggerCallback) { - let msg: string - isDeleting = true - if (deleteReplicationSlot && deleteReplicationSlotCallback) { - try { - msg = await deleteReplicationSlotCallback() - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - if (deletePublication && deletePublicationCallback) { - try { - msg = await deletePublicationCallback() - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - msg = await deletePostgresTriggerCallback() - sendUserToast(msg) - } - deletePostgresTriggerCallback = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - - - -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - Postgres triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - postgresTriggerEditor?.openNew(false), - aiId: 'postgres-triggers-empty-add', - aiDescription: 'Add Postgres trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { postgres_resource_path, publication_name, replication_slot_name, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_postgres', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - postgresTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - {postgres_resource_path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Postgres trigger is starting... - {:else} - Could not connect to database{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Connected to database{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'postgres', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - publicationToDelete = publication_name - replicationSlotToDelete = replication_slot_name - isDeleting = false - deletePublication = false - deleteReplicationSlot = false - deletePublicationCallback = async () => { - return await PostgresTriggerService.deletePostgresPublication({ - workspace: $workspaceStore!, - path: postgres_resource_path, - publication: publication_name - }) - } - - deleteReplicationSlotCallback = async () => { - return await PostgresTriggerService.deletePostgresReplicationSlot({ - workspace: $workspaceStore!, - path: postgres_resource_path, - requestBody: { - name: replication_slot_name - } - }) - } - - deletePostgresTriggerCallback = async () => { - const msg = await PostgresTriggerService.deletePostgresTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - return msg - } - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - postgresTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'postgres' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'postgres_trigger') - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index bf62818170..8973bf83b4 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -1,1588 +1,5 @@ - { - deleteConfirmedCallback = undefined - }} - on:confirmed={() => { - if (deleteConfirmedCallback) { - deleteConfirmedCallback() - } - deleteConfirmedCallback = undefined - }} -> -
    - Are you sure you want to remove {deletePath}? - {#if deleteIsLinked} - - This resource is linked with a variable of the same path. That variable is deleted with it, - unless another resource still references it. - - {/if} - -
    - You can press - SHIFT - while removing a resource to bypass confirmation. -
    -
    -
    -
    - - - - - inferrer?.toggleDrawer?.()}> - - {#snippet actions()} - - {/snippet} - - - - - - {#snippet titleExtra()} - - {/snippet} -
    - {#if resourceTypeViewerObj.description} - - {/if} - {#if resourceTypeViewerObj.isFileset} - - This resource type represents a collection of files. Each file is identified by its - relative path and contains text content. In the CLI, filesets are stored as directories. - - {:else if resourceTypeViewerObj.formatExtension} - - This resource type represents a plain text file with a .{resourceTypeViewerObj.formatExtension} - extension. (e.g. my_file.{resourceTypeViewerObj.formatExtension}) - - {:else} - - {/if} -
    -
    -
    - - - - {#snippet actions()} - - {/snippet} -
    - - -
    - {#if editResourceType.isFileset} - - This resource type represents a collection of files. The schema cannot be edited. - - {:else if editResourceType.formatExtension} - - This resource type represents a plain text file with a .{editResourceType.formatExtension} - extension. (e.g. my_file.{editResourceType.formatExtension}). The schema only - contains a `content` field and thus cannot be edited. - - {:else} -
    Schema
    -
    - -
    - {/if} -
    -
    -
    -
    - - - - {#snippet actions()} - - {/snippet} -
    - - -
    -
    -
    Schema
    -
    - -
    -
    - -
    - -
    -
    -
    -
    -
    - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources} - -{:else} - - - {#if showCreateButtons} -
    - - -
    - {/if} -
    - (showCreateButtons = v)} /> -
    - { - if (e.detail == 'cache') { - loading.resources = true - loadCache() - } else if (e.detail == 'states') { - loading.resources = true - loadState() - } else if (e.detail == 'theme') { - loading.resources = true - loadTheme() - } - }} - > - - - {#snippet extra()} - - Every resource has a Resource Type attached to it which contains its schema and make - it easy in scripts and flows to accept only resources of a specific resource type. - - {/snippet} - - - {#snippet extra()} - - States are actually resources (but excluded from the Workspace tab for clarity). - States are used by scripts to keep data persistent between runs of the same script by - the same trigger (schedule or user) - - {/snippet} - - - {#snippet extra()} - - Cached results are actually resources (but excluded from the Workspace tab for - clarity). Cache are used by flows's step to cache result to avoid recomputing - unnecessarily - - {/snippet} - - - {#snippet extra()} - - Themes are actually resources (but excluded from the Workspace tab for clarity). They - are CSS for the legacy low-code app editor only, and are added from the CSS panel of - an app rather than from this page. - - {/snippet} - - -
    -
    -
    - - {#if showTable} -
    - {#if loading.resources} - - {#each new Array(6) as _} - - {/each} - {:else if filteredItems?.length == 0} - {#if activeFilters} - - {:else} - appConnect?.open?.(), - aiId: 'resources-empty-add-resource', - aiDescription: 'Add resource' - } - : undefined} - /> - {/if} - {:else} - - - - - Path - Resource type - Description - Status - Actions - - -
    - {#if filteredItems} - {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'resource', path) ?? is_draft} - - - - - -
    - { - handledHash = `#/resource/${path}` - openResourceEditor(path, resource_type) - }} - >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} - - {#if labels?.length} -
    - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} -
    - {/if} - -
    -
    - - { - const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) - if (linkedRt) { - resourceTypeViewerObj = { - rt: linkedRt.name, - //@ts-ignore - schema: linkedRt.schema, - description: linkedRt.description ?? '', - formatExtension: linkedRt.format_extension, - isFileset: linkedRt.is_fileset ?? false - } - resourceTypeViewer?.openDrawer?.() - } else { - sendUserToast( - `Resource type ${resource_type} not found in workspace.`, - true - ) - } - }} - > - - - - -
    - - {removeMarkdown(truncate(description ?? '', 200))} - -
    -
    - -
    -
    - {#if is_linked} - - - {#snippet text()} -
    - This resource is linked with a variable of the same path. They - are deleted and renamed together. -
    - {/snippet} -
    - {/if} -
    -
    - {#if is_refreshed} - - - {#snippet text()} -
    - The OAuth token will be kept up-to-date in the background by - Windmill using its refresh token -
    - {/snippet} -
    - {/if} -
    - - {#if is_oauth} -
    - {#if refresh_error} - - - {#snippet text()} -
    - Latest exchange of the refresh token did not succeed. Error: {refresh_error} -
    - {/snippet} -
    - {:else if is_expired} - - - - {#snippet text()} -
    - The access_token is expired, it will get renewed the next time - this variable is fetched or you can request is to be refreshed - in the dropdown on the right. -
    - {/snippet} -
    - {:else} - - - {#snippet text()} -
    - The resource was connected through OAuth and the token is not - expired. -
    - {/snippet} -
    - {/if} -
    - {/if} -
    -
    - -
    - {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator} - - {/if} - { - shareModal?.openDrawer?.(path, 'resource') - } - }, - { - displayName: 'Edit', - icon: Pen, - disabled: !canWrite || !showCreateButtons, - action: () => { - openResourceEditor(path, resource_type) - } - }, - // The agent form covers an agent's configuration, not everything a - // resource carries: the workspace-specific toggle in particular is - // only in the generic editor. JSON rather than that editor's form, - // which would render the configuration field by field and write a - // default into every one the agent leaves unset. Both write the same - // draft row, so this is a second view of the same edits. - ...(resource_type === 'ai_agent' - ? [ - { - displayName: 'Edit as JSON', - icon: Braces, - disabled: !canWrite || !showCreateButtons, - action: () => { - // The drawer anchors itself in the hash, which the deep-link - // effect would then read and route back to the agent editor. - // Claim it first, as the row's own link does. - handledHash = `#/resource/${path}` - resourceEditor?.initEdit?.(path, { json: true }) - } - } - ] - : []), - ...(!ws_specific && isDeployable('resource', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'resource') - } - } - ] - : []), - { - displayName: 'Delete', - disabled: !canWrite || !showCreateButtons, - icon: Trash, - type: 'delete', - action: (event) => { - // TODO - // @ts-ignore - if (event?.shiftKey) { - deleteResource(path, account) - } else { - deleteIsLinked = is_linked ?? false - deletePath = path - deleteConfirmedCallback = () => { - deleteResource(path, account) - } - } - } - }, - ...(account != undefined - ? [ - { - displayName: 'Refresh token', - icon: RotateCw, - action: async () => { - await OauthService.refreshToken({ - workspace: $workspaceStore ?? '', - id: account ?? 0, - requestBody: { - path - } - }) - sendUserToast('Token refreshed') - loadResources() - } - } - ] - : []) - ]} - /> -
    -
    - {/each} - {/if} -
    - - {/if} - - {:else if tab == 'types'} - {#if loading.types} - - {#each new Array(6) as _} - - {/each} - {:else if filteredResourceTypes?.length == 0} -
    - -
    - {:else} -
    - - - - Name - Description - Actions - - -
    - {#if filteredResourceTypes} - {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} - - - { - resourceTypeViewerObj = { - rt: name, - //@ts-ignore - schema: schema, - description: description ?? '', - formatExtension: format_extension, - isFileset: is_fileset ?? false - } - - resourceTypeViewer?.openDrawer?.() - }} - > - - - - - -
    - - {removeMarkdown(truncate(description ?? '', 200))} - -
    -
    - - {#if !canWrite} - -
    - - Shared globally - - This resource type is from the 'admins' workspace shared with all - workspaces - - -
    - {:else if $userStore?.is_admin || $userStore?.is_super_admin} -
    - - -
    - {:else} - - Non Editable - - Since resource types are shared with the whole workspace, only admins - can edit/delete them - - - {/if} -
    -
    - {/each} - {/if} -
    - - - {/if} - {/if} - - -{/if} - - - - - - - { - loadResources() - }} -/> - - - -{#if agentEditorTarget()} - {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }} - t.host === undefined} - onRenamed={(from, to) => { - void loadResources() - // Only while the dialog still shows the agent: closed mid-request, it already cleared the - // anchor, and writing it back would reopen the editor on refresh. - if (agentEditorTarget()?.path !== from) return - // Claimed first, as a row click does, so the deep-link effect does not reopen it. - handledHash = `#/resource/${to}` - setPageDrawerAnchor(RESOURCES_PATH, to) - }} - /> - {/await} -{/if} + diff --git a/frontend/src/routes/(root)/(logged)/routes/+page.svelte b/frontend/src/routes/(root)/(logged)/routes/+page.svelte index 53de5457d1..a79a1a0471 100644 --- a/frontend/src/routes/(root)/(logged)/routes/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/routes/+page.svelte @@ -1,592 +1,5 @@ - - - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - -
    - - - -
    -
    -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - routeEditor?.openNew(false), - aiId: 'routes-empty-add', - aiDescription: 'Add route' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { summary, workspace_id, workspaced_route, mode, path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_http', path) ?? is_draft} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - -
    -
    - - - routeEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else if summary} - {summary} - {:else} - {http_method.toUpperCase()} - /{isCloudHosted() || workspaced_route || globalHttpWorkspacedRoute - ? workspace_id + '/' + route_path - : route_path} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - {#if static_asset_config} - file: {static_asset_config.s3} - {:else} - runnable: {script_path} - {/if} -
    -
    - - - -
    - - onToggleMode(path, mode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'http', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - routeEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'routes' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'http_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - try { - await HttpTriggerService.deleteHttpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(`Successfully deleted HTTP route: ${path}`) - loadTriggers() - } catch (error) { - sendUserToast(error.body || error.message, true) - } - } - } - ]} - /> -
    -
    -
    -
    - {#if edited_by}
    edited by {edited_by}
    {/if} -
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    -
    -
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte index e403c981de..88b21bc34c 100644 --- a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte @@ -1,683 +1,5 @@ - - - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.schedules} - -{:else} - - - - -
    -
    - - {#snippet children({ item })} - - - - {/snippet} - - -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !schedules?.length} - {#if activeFilters} - - {:else} - scheduleEditor?.openNew(false), - aiId: 'schedules-empty-add', - aiDescription: 'Add schedule' - }} - /> - {/if} - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_schedule', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const avg_s = jobs - ? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length - : undefined} - -
    -
    - - - { - handledHash = `#${path}` - scheduleEditor?.openEdit(path, is_flow) - }} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {summary || script_path}{hasDraft ? '*' : ''} -
    -
    - schedule: {path} -
    -
    - {#if labels?.length} - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} - {/if} - - - {#if paused_until && new Date(paused_until) > new Date()} -
    - Paused until {new Date(paused_until).toLocaleString()} -
    - {/if} - - - - - -
    - {#if error} - - - - - - {#snippet text()} -
    - The schedule disabled itself because there was an error scheduling the - next job: {error} -
    - {/snippet} -
    - {/if} -
    - -
    - - {#key toggleResetVersions[path] ?? 0} - { - if (canWrite) { - setScheduleEnabled(path, e.detail) - } else { - sendUserToast('not enough permission', true) - // Permission denied — bump the row's reset - // counter so the Toggle remounts back to the - // prop value. Without this, the local - // `bind:checked` flip from the user's click - // stays stuck on. - bumpToggleReset(path) - } - }} - /> - {/key} -
    -
    - - - { - goto(href) - } - }, - { - displayName: `Duplicate schedule`, - icon: Copy, - action: () => { - scheduleEditor?.openNew(is_flow, script_path, path) - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await ScheduleService.deleteSchedule({ - workspace: $workspaceStore ?? '', - path - }) - loadSchedules() - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - scheduleEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'schedules' - } - }) - } - } - ] - : []), - { - displayName: 'View runs', - icon: List, - href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true` - }, - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Run now', - icon: Play, - action: () => { - runScheduleNow(script_path, path, is_flow, $workspaceStore!) - } - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'schedule') - } - } - ]} - /> -
    -
    -
    - {#if loadingSchedulesWithJobStats} -
    - - Job stats loading... -
    - {:else} -
    - {#if avg_s} -
    Avg: {(avg_s / 1000).toFixed(2)}s
    - {/if} - {#each jobs ?? [] as job} - {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3} - - -
    -
    - -
    -
    -
    - {/each} -
    - {/if} -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} -
    - {nbDisplayed} items out of {items.length} - -
    - {/if} -
    -{/if} - - { - loadSchedules() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 99fe4330cb..689afdbb9f 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -71,7 +71,7 @@ type PreviewTarget } from '$lib/components/sessions/previewRouter' import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload' - import { pageItemForListPath } from '$lib/components/sessions/previewPaths' + import { isPageItemListPath, stripBase } from '$lib/components/sessions/previewPaths' import { leafKeyFor, loadKind, @@ -834,12 +834,11 @@ owner?.navigate({ type: 'item', item }) return } - // A row opened on a list page inside a preview tab: its editor opens as a tab of its - // own, leaving the list where it is. - if (d.type === 'wm.session.openPageItem') { - if (typeof d.pagePath !== 'string' || typeof d.path !== 'string') return - const ref = pageItemForListPath(d.pagePath, d.path) - if (ref) owner?.open({ type: 'pageitem', ref }) + // A preview frame navigating to a list page: re-point the active tab to the list + // mounted in process, whose rows open as tabs of their own. + if (d.type === 'wm.session.openList') { + if (typeof d.href !== 'string' || !isPageItemListPath(stripBase(d.href))) return + owner?.navigate({ type: 'page', href: d.href, label: previewLocationLabel(d.href) }) return } // A job clicked inside a preview tab: open the run detail in a NEW tab so the diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index 912d171d86..0f4155d6b3 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -1,567 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - SQS triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - sqsTriggerEditor?.openNew(false), - aiId: 'sqs-triggers-empty-add', - aiDescription: 'Add SQS trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_sqs', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - sqsTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - SQS trigger is starting... - {:else} - Could not connect to SQS{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Connected to SQS{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'sqs', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - sqsTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'sqs' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'sqs_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - try { - await SqsTriggerService.deleteSqsTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } catch (error) { - sendUserToast(error.body, true) - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index 873e812987..839695361e 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -1,722 +1,5 @@ - - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.variables} - -{:else} - - - {#if showCreateButtons} -
    - {#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)} - - {:else} - - {/if} -
    - {/if} -
    - (showCreateButtons = v)} /> - - - - { - loadVariables() - }} - /> - -
    - - - - {#snippet extra()} - - Contextual variables are passed as environment variables when running a script and - depends on the execution context. - - {/snippet} - - - {#if tab == 'workspace'} - - {/if} -
    - - {#if tab == 'workspace'} -
    - {#if !filteredItems} - - {#each new Array(3) as _} - - {/each} - {:else if filteredItems.length == 0} - {#if activeFilters} - - {:else} - variableEditor?.initNew(), - aiId: 'variables-empty-add-variable', - aiDescription: 'Add variable' - } - : undefined} - /> - {/if} - {:else} - - -
    - - Path - Value - Description - Status - Actions - - - - {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'variable', path) ?? is_draft} - - - - - -
    - { - handledHash = `#${path}` - variableEditor?.editVariable(path) - }} - href="#{path}" - > - {path}{hasDraft ? '*' : ''} - - - {#if labels?.length} - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} - {/if} - -
    -
    - - -
    - {#if value} - {truncate(value, 20)} - {:else} - ∗∗∗∗ - {/if} -
    - {#if is_secret} - - - {#snippet text()} - This item is secret - {/snippet} - - {/if} -
    -
    - - {truncate(description ?? '', 50)} - - - -
    - {#if is_linked} - - - {#snippet text()} -
    - This variable is linked with a resource of the same path. They are - deleted and renamed together. -
    - {/snippet} -
    - {/if} - {#if account} - - - {#snippet text()} -
    - This OAuth token will be kept up-to-date in the background by - Windmill using its refresh token -
    - {/snippet} -
    - {/if} - - {#if is_refreshed} -
    - {#if refresh_error} - - -
    - - -
    - - {#snippet text()} -
    - Latest exchange of the refresh token did not succeed. Error: {refresh_error} -
    - {/snippet} -
    - {:else if is_expired} - - - {#snippet text()} -
    - The access_token is expired, it will get renewed the next time - this variable is fetched or you can request is to be refreshed - in the dropdown on the right. -
    - {/snippet} -
    - {:else} - - - {#snippet text()} -
    - The variable was connected through OAuth and the token is not - expired. -
    - {/snippet} -
    - {/if} -
    - {/if} -
    -
    - - { - let owner = isOwner(path, $userStore, $workspaceStore) - return [ - { - displayName: 'Edit', - icon: Pen, - action: () => variableEditor?.editVariable(path), - disabled: !canWrite || !showCreateButtons - }, - { - displayName: 'Delete', - icon: Trash, - type: 'delete', - action: (event) => { - if (event['shiftKey']) { - deleteVariable(path, account) - } else { - deleteIsLinked = is_linked ?? false - deleteConfirmedCallback = () => { - deleteVariable(path, account) - } - } - }, - disabled: !owner || !showCreateButtons - }, - ...(!ws_specific && - isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'variable') - } - } - ] - : []), - { - displayName: 'Permissions', - action: () => { - shareModal?.openDrawer(path, 'variable') - }, - icon: Shield - }, - ...(account != undefined - ? [ - { - displayName: 'Refresh token', - icon: RefreshCw, - action: async () => { - await OauthService.refreshToken({ - workspace: $workspaceStore ?? '', - id: account ?? 0, - requestBody: { - path - } - }) - sendUserToast('Token refreshed') - loadVariables() - } - } - ] - : []) - ] - }} - /> - -
    - {/each} -
    - - {/if} - - {:else if tab == 'contextual'} -
    - {#if loading.contextual} - - {#each new Array(8) as _} - - {/each} - {:else} - - {#if contextualVariables.filter((x) => x.is_custom).length === 0} - contextualVariableEditor?.initNew(), - aiId: 'variables-empty-add-contextual-variable', - aiDescription: 'Add contextual variable' - } - : undefined} - /> - {:else} - x.is_custom)} - keys={['name', 'value']} - getRowActions={$userStore?.is_admin || $userStore?.is_super_admin - ? (row) => { - return [ - { - displayName: 'Edit', - action: () => contextualVariableEditor?.editVariable(row.name, row.value) - }, - { - displayName: 'Delete', - type: 'delete', - action: () => { - deleteContextualVariable(row) - } - } - ] - } - : undefined} - /> - {/if} - - !x.is_custom)} - keys={['name', 'value', 'description']} - /> - {/if} -
    - {/if} - - -{/if} - - { - deleteConfirmedCallback = undefined - }} - on:confirmed={() => { - if (deleteConfirmedCallback) { - deleteConfirmedCallback() - } - deleteConfirmedCallback = undefined - }} -> -
    - Are you sure you want to remove this variable? - {#if deleteIsLinked} - - This variable is linked with a resource of the same path. The linked resource will also be - deleted. - - {/if} - -
    - You can press - SHIFT - while removing a variable to bypass confirmation. -
    -
    -
    -
    + diff --git a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte index 35a167c296..176c2ccbe5 100644 --- a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte @@ -1,579 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - WebSocket triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - websocketTriggerEditor?.openNew(false), - aiId: 'websocket-triggers-empty-add', - aiDescription: 'Add WebSocket trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, url, is_flow, extra_perms, canWrite, marked, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_websocket', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - websocketTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {url.startsWith('$script:') - ? 'URL: ' + url.replace('$script:', 'result of script ') - : url.startsWith('$flow:') - ? 'URL: ' + url.replace('$flow:', 'result of flow ') - : url} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - WebSocket is starting... - {:else} - WebSocket is not connected{error ? ': ' + error : ''} - {/if} - {:else} - WebSocket was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - WebSocket is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'websocket', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - websocketTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'websockets' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'websocket_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await WebsocketTriggerService.deleteWebsocketTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + From 2f1953ea10e8c62137a2b925ef2bd1eccf150736 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 24 Sep 2026 15:36:04 +0200 Subject: [PATCH 102/112] restore the operator alias in the offline seat-count query (#11336) * fix: restore the operator alias in the offline seat-count query Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to bf64ca98472a57cbfd150f8876877d2d4d9c217e This commit updates the EE repository reference after PR #827 was merged in windmill-ee-private. Previous ee-repo-ref: 59ac4f6e051fa9b6656b5d34759c09a04c4d8b85 New ee-repo-ref: bf64ca98472a57cbfd150f8876877d2d4d9c217e Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 9d91911b1d..291341a9ae 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -48193da8cb30bc4ae82a50f944caef85d8a20b48 +bf64ca98472a57cbfd150f8876877d2d4d9c217e From 1fd729ac1aaec1e6a3af0d6db5fc1d6e1db6fa62 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 11:23:30 +0200 Subject: [PATCH 103/112] feat: add an instance-wide accent color setting with sidebar tint (#11335) * feat: add an instance-wide accent color setting with sidebar tint Co-Authored-By: Claude Opus 5.5 (1M context) * fix: paint the cached accent before the license resolves Co-Authored-By: Claude Opus 5.5 (1M context) * feat: serve the banner and accent color from one cached endpoint Co-Authored-By: Claude Opus 5.5 (1M context) * fix: invalidate the instance ui cache and bound it with a ttl Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the instance ui ttl under the client poll period Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: load the banner and accent color once per page load Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: read the banner and accent color without a server cache Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show a cleared accent color as off Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/instance_config.rs | 31 ++++ backend/windmill-api-settings/src/lib.rs | 24 ++- backend/windmill-api/openapi.yaml | 20 +++ .../windmill-common/src/global_settings.rs | 65 +++++++++ .../windmill-common/src/instance_config.rs | 8 + frontend/src/lib/accentColor.test.ts | 25 ++++ frontend/src/lib/accentColor.ts | 138 ++++++++++++++++++ .../src/lib/components/InstanceBanner.svelte | 52 +------ .../src/lib/components/InstanceSetting.svelte | 3 + .../src/lib/components/InstanceUiSync.svelte | 55 +++++++ frontend/src/lib/components/instanceBanner.ts | 6 - .../src/lib/components/instanceSettings.ts | 18 +++ .../AccentColorSetting.svelte | 51 +++++++ .../lib/components/sidebar/sidebarChrome.ts | 6 +- frontend/src/lib/instanceUi.ts | 9 ++ .../src/routes/(root)/(logged)/+layout.svelte | 11 +- frontend/tailwind.config.cjs | 11 +- 17 files changed, 467 insertions(+), 66 deletions(-) create mode 100644 frontend/src/lib/accentColor.test.ts create mode 100644 frontend/src/lib/accentColor.ts create mode 100644 frontend/src/lib/components/InstanceUiSync.svelte create mode 100644 frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte create mode 100644 frontend/src/lib/instanceUi.ts diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 5ab870a364..26a46d272e 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1608,3 +1608,34 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool

    ) { + clear_settings_and_configs(&db).await; + + let mut desired = BTreeMap::new(); + desired.insert( + "accent_color".to_string(), + serde_json::json!("#000;}body{display:none"), + ); + let err = windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect_err("a non-hex accent color must fail the sync"); + assert!(err.to_string().contains("accent_color"), "got: {err}"); + assert!(get_global_setting(&db, "accent_color").await.is_none()); + + desired.insert("accent_color".to_string(), serde_json::json!("#1f9d55")); + windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect("a hex accent color must sync"); +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e8d8a7124c..bc52d85d54 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -58,9 +58,9 @@ use windmill_common::{ error::{self, pg_error_message, JsonResult, Result}, get_database_url, global_settings::{ - AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, - CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, - DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, + ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, + AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, + DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, @@ -115,6 +115,17 @@ async fn get_ruff_config_unauthed(Extension(db): Extension) -> error::Result .unwrap()) } +/// The announcement banner and accent color, which every signed-in session reads on each +/// full page load. +async fn get_instance_ui( + Extension(db): Extension, + _authed: ApiAuthed, +) -> JsonResult { + Ok(Json( + windmill_common::global_settings::get_instance_ui(&db).await?, + )) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() @@ -126,6 +137,7 @@ pub fn global_service() -> Router { "/global/{key}", post(set_global_setting).get(get_global_setting), ) + .route("/instance_ui", get(get_instance_ui)) .route("/list_global", get(list_global_settings)) .route("/github_app_stale_webhooks", get(github_app_stale_webhooks)) .route( @@ -1216,6 +1228,12 @@ async fn run_setting_pre_write_hook( } } } + ACCENT_COLOR_SETTING => match value { + serde_json::Value::Null => {} + serde_json::Value::String(s) if s.trim().is_empty() => {} + v => windmill_common::global_settings::validate_accent_color(v) + .map_err(|e| error::Error::BadRequest(format!("{ACCENT_COLOR_SETTING}: {e}")))?, + }, _ => {} } Ok(()) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0dd9f8620e..016342a6fe 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1762,6 +1762,26 @@ paths: schema: type: string + /settings/instance_ui: + get: + summary: get the instance-wide announcement banner and accent color + description: | + Readable by any authenticated user; every page load reads it. Each value + is the raw stored setting, or null when unset. + operationId: getInstanceUi + tags: + - setting + responses: + "200": + description: the instance_banner and accent_color settings + content: + application/json: + schema: + type: object + properties: + instance_banner: {} + accent_color: {} + /settings_u/ruff_config: get: summary: get instance ruff config (unauthenticated) diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 6b9aae519d..e72a2bb4e2 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -281,6 +281,51 @@ pub fn validate_instance_banner(value: &serde_json::Value) -> Result<(), String> Ok(()) } +/// Instance-wide accent color (`#rrggbb`) that recolors the UI's accent tokens and +/// tints the sidebar, so each environment of a deployment is recognizable at a glance. +/// Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]. +pub const ACCENT_COLOR_SETTING: &str = "accent_color"; + +/// Validate an [`ACCENT_COLOR_SETTING`] value. +/// +/// Only `#rrggbb` is accepted: the value is interpolated into a stylesheet every user +/// loads, so anything looser is CSS injection into every session of the instance. +pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> { + let s = value + .as_str() + .ok_or_else(|| "must be a string".to_string())?; + let hex = s.strip_prefix('#').unwrap_or(""); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("must be a hex color of the form #rrggbb".to_string()); + } + Ok(()) +} + +/// The settings every signed-in browser reads on each full page load. +#[derive(serde::Serialize, Clone, Debug, Default, PartialEq)] +pub struct InstanceUi { + pub instance_banner: Option, + pub accent_color: Option, +} + +pub async fn get_instance_ui(db: &Pool) -> error::Result { + let rows = sqlx::query!( + "SELECT name, value FROM global_settings WHERE name = ANY($1)", + &[INSTANCE_BANNER_SETTING, ACCENT_COLOR_SETTING] as &[&str] + ) + .fetch_all(db) + .await?; + let mut ui = InstanceUi::default(); + for row in rows { + match row.name.as_str() { + INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value), + ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value), + _ => {} + } + } + Ok(ui) +} + /// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value. /// /// The receiver path is appended to it verbatim, so anything that doesn't @@ -939,6 +984,26 @@ mod tests { } } + #[test] + fn accent_color_accepts_only_hex_rgb() { + for ok in ["#1f9d55", "#ABCDEF"] { + assert!( + validate_accent_color(&serde_json::json!(ok)).is_ok(), + "{ok}" + ); + } + for bad in [ + serde_json::json!("1f9d55"), + serde_json::json!("#fff"), + serde_json::json!("red"), + serde_json::json!("#000000;} body{display:none"), + serde_json::json!("#12345g"), + serde_json::json!(123), + ] { + assert!(validate_accent_color(&bad).is_err(), "{bad}"); + } + } + #[test] fn instance_banner_rejects_unsafe_and_malformed_values() { // The link becomes the href of an anchor shown to every user of the instance, diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index f4884d736e..508379c3a4 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1354,6 +1354,14 @@ pub async fn sync_global_settings_declarative( .map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?, } + let accent_key = crate::global_settings::ACCENT_COLOR_SETTING; + match desired.get(accent_key) { + None | Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(s)) if s.trim().is_empty() => {} + Some(color) => crate::global_settings::validate_accent_color(color) + .map_err(|e| anyhow::anyhow!("{accent_key}: {e}"))?, + } + // An origin list that cannot be parsed is dropped at boot, leaving the // empty default — which is no restriction at all. Rejecting it here is what // keeps a typo in a ConfigMap from silently widening CORS instance-wide. diff --git a/frontend/src/lib/accentColor.test.ts b/frontend/src/lib/accentColor.test.ts new file mode 100644 index 0000000000..6d3b855240 --- /dev/null +++ b/frontend/src/lib/accentColor.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { accentStylesheet } from './accentColor' + +describe('accentStylesheet', () => { + it('refuses anything but #rrggbb, since the value lands in a stylesheet', () => { + for (const bad of ['#000;}body{display:none', '#fff', 'red', '1f9d55', 123, null]) { + expect(accentStylesheet(bad)).toBeUndefined() + } + }) + + it('overrides every accent token with an in-range r g b triplet in each theme', () => { + // A saturated color exercises the gamut clamp, a grey the zero-chroma path. + for (const color of ['#ff0000', '#808080']) { + const rules = accentStylesheet(color)!.split('\n') + expect(rules).toHaveLength(3) + for (const rule of rules) { + const triplets = [...rule.matchAll(/--color-[a-z-]+: (\d+) (\d+) (\d+);/g)] + expect(triplets).toHaveLength(10) + for (const [, ...channels] of triplets) { + for (const c of channels) expect(Number(c)).toBeLessThanOrEqual(255) + } + } + } + }) +}) diff --git a/frontend/src/lib/accentColor.ts b/frontend/src/lib/accentColor.ts new file mode 100644 index 0000000000..f11a96bb79 --- /dev/null +++ b/frontend/src/lib/accentColor.ts @@ -0,0 +1,138 @@ +import tokensFile from './assets/tokens/tokens.json' +import githubDarkTokens from './assets/tokens/githubDark.json' +import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig' + +/** Instance-wide accent color, `#rrggbb` (mirrors `ACCENT_COLOR_SETTING` in the backend). */ +export const ACCENT_COLOR_SETTING = 'accent_color' + +const ACCENT_TOKENS = [ + 'surface-accent-primary', + 'surface-accent-hover', + 'surface-accent-clicked', + 'surface-accent-selected', + 'surface-accent-secondary', + 'surface-accent-secondary-hover', + 'surface-accent-secondary-clicked', + 'text-accent', + 'border-accent', + 'border-selected' +] as const + +const STYLE_ID = 'wm-accent-color' +const ACCENT_ATTR = 'data-wm-accent' + +/** + * The stored setting as a color, or `undefined` for "use the default theme". + * + * The backend already rejects anything but `#rrggbb`, but the value is interpolated into a + * stylesheet, so this is the last place that can refuse a row written around the validator. + */ +export function parseAccentColor(value: unknown): string | undefined { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) + ? value.toLowerCase() + : undefined +} + +type Oklch = [l: number, c: number, h: number] + +function srgbToLinear(v: number): number { + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) +} + +function linearToSrgb(v: number): number { + return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055 +} + +function hexToOklch(hex: string): Oklch { + const [r, g, b] = [1, 3, 5].map((i) => srgbToLinear(parseInt(hex.slice(i, i + 2), 16) / 255)) + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + return [L, Math.hypot(A, B), Math.atan2(B, A)] +} + +/** Linear sRGB channels, possibly out of [0, 1] when the color is outside the gamut. */ +function oklchToLinearRgb([L, C, h]: Oklch): [number, number, number] { + const A = C * Math.cos(h) + const B = C * Math.sin(h) + const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3 + const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3 + const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3 + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + ] +} + +/** `r g b` triplet (the format the `--color-*` variables use), chroma reduced into gamut. */ +function oklchToRgbTriplet([L, C, h]: Oklch): string { + const inGamut = (c: number) => + oklchToLinearRgb([L, c, h]).every((v) => v >= -1e-4 && v <= 1 + 1e-4) + let lo = 0 + let hi = C + if (!inGamut(C)) { + for (let i = 0; i < 20; i++) { + const mid = (lo + hi) / 2 + if (inGamut(mid)) lo = mid + else hi = mid + } + } else lo = C + return oklchToLinearRgb([L, lo, h]) + .map((v) => Math.round(Math.min(1, Math.max(0, linearToSrgb(v))) * 255)) + .join(' ') +} + +// Below this chroma the chosen color reads as grey, and the accents fade with it. +const FULL_CHROMA = 0.1 + +/** + * Move each accent token to the chosen hue while keeping its own lightness, so every + * text/background pairing keeps the contrast the default palette was designed with. + * Taking the chosen color verbatim would put white text on a pale yellow button. + */ +function recolor(tokens: Record, accent: Oklch): string { + const chromaScale = Math.min(1, accent[1] / FULL_CHROMA) + return ACCENT_TOKENS.map((name) => { + const [l, c] = hexToOklch(tokens[name]) + return `--color-${name}: ${oklchToRgbTriplet([l, c * chromaScale, accent[2]])};` + }).join('') +} + +/** The accent overrides for every theme, or `undefined` when `value` is not `#rrggbb`. */ +export function accentStylesheet(value: unknown): string | undefined { + const color = parseAccentColor(value) + if (color == undefined) return undefined + const accent = hexToOklch(color) + // `:root[attr]` outranks the `html`, `html.dark` and `html.dark.github-dark` rules + // that define the default tokens, whatever order the stylesheets load in. + return [ + `:root[${ACCENT_ATTR}]{${recolor(tokensFile.tokens[lightModeName], accent)}` + + `--sidebar-bg-light:color-mix(in oklab, ${color} 18%, var(--sidebar-bg-light-base));}`, + `:root[${ACCENT_ATTR}].dark{${recolor(tokensFile.tokens[darkModeName], accent)}` + + `--sidebar-bg-dark:color-mix(in oklab, ${color} 22%, var(--sidebar-bg-dark-base));}`, + `:root[${ACCENT_ATTR}].dark.github-dark{${recolor(githubDarkTokens, accent)}}` + ].join('\n') +} + +/** Apply the instance accent to the whole document, or restore the default with `undefined`. */ +export function applyAccentColor(value: string | undefined): void { + const root = document.documentElement + let style = document.getElementById(STYLE_ID) + const css = accentStylesheet(value) + if (css == undefined) { + style?.remove() + root.removeAttribute(ACCENT_ATTR) + return + } + if (!style) { + style = document.createElement('style') + style.id = STYLE_ID + document.head.appendChild(style) + } + style.textContent = css + root.setAttribute(ACCENT_ATTR, '') +} diff --git a/frontend/src/lib/components/InstanceBanner.svelte b/frontend/src/lib/components/InstanceBanner.svelte index 9e47f9399a..0277cba4c1 100644 --- a/frontend/src/lib/components/InstanceBanner.svelte +++ b/frontend/src/lib/components/InstanceBanner.svelte @@ -3,60 +3,16 @@ import { classes as alertClasses, icons as alertIcons } from '$lib/components/common/alert/model' import { ExternalLink, X } from 'lucide-svelte' import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' - import { - fetchInstanceBanner, - isInstanceBannerVisible, - type ResolvedInstanceBanner - } from './instanceBanner' - import { instanceSettingsSaved } from './instanceSettings' + import { isInstanceBannerVisible, resolveInstanceBanner } from './instanceBanner' + import { instanceUi } from '$lib/instanceUi' - // Mounted only on EE (see the render site in the logged layout), so the poll costs - // nothing on CE. An announcement is only worth broadcasting while it is current, hence - // polling rather than waiting for the next full page load: a session left open all day - // is exactly the one that needs to hear about the maintenance window. - const POLL_MS = 60_000 - - let banner = $state(undefined) + // Fetched by `InstanceUiSync`, which also carries the accent color. + let banner = $derived(resolveInstanceBanner($instanceUi?.instance_banner)) // Per-viewer, per-announcement. Holds the fingerprint of the dismissed banner, so // a new announcement shows up again for everyone who dismissed the previous one. const dismissed = useLocalStorageValue('instance_banner_dismissed', '', 'string') - // The poll, the tab-focus refresh and the post-save refresh all call `load()` and can - // overlap. Responses are not ordered, so without this a slow earlier fetch lands last - // and puts a retracted announcement back on screen until the next successful poll. - let latestLoad = 0 - - async function load() { - const generation = ++latestLoad - try { - const next = await fetchInstanceBanner() - if (generation === latestLoad) banner = next - } catch (e) { - // Keep whatever is on screen: a transient failure must not silently retract - // an announcement that is still in force. - console.warn('Could not fetch the instance banner', e) - } - } - - $effect(() => { - // Re-runs on save so a superadmin who just published an announcement sees it - // immediately rather than on the next poll or page load. - $instanceSettingsSaved - load() - const interval = setInterval(() => { - if (!document.hidden) load() - }, POLL_MS) - const onVisible = () => { - if (!document.hidden) load() - } - document.addEventListener('visibilitychange', onVisible) - return () => { - clearInterval(interval) - document.removeEventListener('visibilitychange', onVisible) - } - }) - let shown = $derived(isInstanceBannerVisible(banner, dismissed.val)) let palette = $derived(alertClasses[banner?.severity ?? 'info']) let Icon = $derived(alertIcons[banner?.severity ?? 'info']) diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index aedd9cdf6b..0da63ffbb0 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -27,6 +27,7 @@ import WebhookBaseUrlSetting from './instanceSettings/WebhookBaseUrlSetting.svelte' import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte' import InstanceBannerSetting from './instanceSettings/InstanceBannerSetting.svelte' + import AccentColorSetting from './instanceSettings/AccentColorSetting.svelte' import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte' import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte' import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte' @@ -872,6 +873,8 @@ {:else if setting.fieldType == 'instance_banner'} + {:else if setting.fieldType == 'accent_color'} + {/if} {#if hasError} diff --git a/frontend/src/lib/components/InstanceUiSync.svelte b/frontend/src/lib/components/InstanceUiSync.svelte new file mode 100644 index 0000000000..c9ae00eb29 --- /dev/null +++ b/frontend/src/lib/components/InstanceUiSync.svelte @@ -0,0 +1,55 @@ + diff --git a/frontend/src/lib/components/instanceBanner.ts b/frontend/src/lib/components/instanceBanner.ts index f1693024b8..4cceb7ad59 100644 --- a/frontend/src/lib/components/instanceBanner.ts +++ b/frontend/src/lib/components/instanceBanner.ts @@ -1,5 +1,3 @@ -import { SettingService } from '$lib/gen' - /** Drives the banner palette and icon; the subset of `AlertType` that fits an announcement. */ export type InstanceBannerSeverity = 'info' | 'warning' | 'error' @@ -123,7 +121,3 @@ export function isInstanceBannerVisible( if (banner == undefined) return false return !banner.dismissible || dismissedFingerprint !== banner.fingerprint } - -export async function fetchInstanceBanner(): Promise { - return resolveInstanceBanner(await SettingService.getGlobal({ key: INSTANCE_BANNER_SETTING })) -} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 1fe45931a2..92988f53b5 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -2,6 +2,7 @@ import type { ButtonType } from './common/button/model' import { allowedOriginsSettingError } from './triggers/http/utils' import { z } from 'zod' import { instanceBannerFormError } from './instanceBanner' +import { ACCENT_COLOR_SETTING, parseAccentColor } from '$lib/accentColor' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' import { writable } from 'svelte/store' @@ -72,6 +73,7 @@ export interface Setting { | 'ws_connectivity' | 'retention_overrides' | 'instance_banner' + | 'accent_color' storage: SettingStorage advancedToggle?: { label: string @@ -334,6 +336,22 @@ export const settings: Record = { hideInQuickSetup: true, // Gates Save. The card renders the specific message itself, so no `error` here. isValid: (value: any) => instanceBannerFormError(value) == undefined + }, + { + label: 'Accent color', + description: + 'Recolors buttons, selections and the sidebar for every user, so each environment (e.g. staging vs. production) is recognizable at a glance.', + key: ACCENT_COLOR_SETTING, + fieldType: 'accent_color', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true, + error: 'Must be a hex color like #1f9d55', + isValid: (value: unknown) => + value === undefined || + value === null || + value === '' || + parseAccentColor(value) !== undefined } ], Jobs: [ diff --git a/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte b/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte new file mode 100644 index 0000000000..619d32a7a0 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte @@ -0,0 +1,51 @@ + + +

    + enabled, (v) => ($values[ACCENT_COLOR_SETTING] = v ? DEFAULT_PICK : undefined) + } + options={{ right: 'Use a custom accent color' }} + /> + {#if enabled} +
    + ($values[ACCENT_COLOR_SETTING] = e.currentTarget.value)} + /> +
    + raw ?? '', (v) => ($values[ACCENT_COLOR_SETTING] = String(v).trim() || undefined)} + /> +
    +
    + + Accents keep the default palette's lightness and take this color's hue, so text stays readable + whatever color you pick. Applies to every user after saving. + + {/if} +
    diff --git a/frontend/src/lib/components/sidebar/sidebarChrome.ts b/frontend/src/lib/components/sidebar/sidebarChrome.ts index 71ff703d27..4c58202416 100644 --- a/frontend/src/lib/components/sidebar/sidebarChrome.ts +++ b/frontend/src/lib/components/sidebar/sidebarChrome.ts @@ -1,6 +1,6 @@ // Sidebar rail background. Kept here as the single source for every host that // renders or mimics the sidebar (app layout, kitchen-sink harness). -// The dark value is a theme-driven CSS variable so it adapts across dark -// variants (default vs GitHub dark) — see tailwind.config.cjs `--sidebar-bg-dark`. -export const SIDEBAR_BG = '#F3F3F7' +// Both are CSS variables (see tailwind.config.cjs `--sidebar-bg-*`) so they adapt +// across dark variants and to the instance accent tint (accentColor.ts). +export const SIDEBAR_BG = 'var(--sidebar-bg-light)' export const SIDEBAR_BG_DARK = 'var(--sidebar-bg-dark)' diff --git a/frontend/src/lib/instanceUi.ts b/frontend/src/lib/instanceUi.ts new file mode 100644 index 0000000000..b9b87d139c --- /dev/null +++ b/frontend/src/lib/instanceUi.ts @@ -0,0 +1,9 @@ +import { writable } from 'svelte/store' +import type { GetInstanceUiResponse } from '$lib/gen' + +/** + * The last `/settings/instance_ui` response: the announcement banner and accent color. + * `undefined` until one lands, and left as is when a fetch fails, so a transient error + * never retracts an announcement still in force. Written only by `InstanceUiSync`. + */ +export const instanceUi = writable(undefined) diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index e9d4493e66..25d2fd156f 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -81,6 +81,7 @@ import { pruneMeaninglessDrafts } from '$lib/userDraftPrune' import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte' import InstanceBanner from '$lib/components/InstanceBanner.svelte' + import InstanceUiSync from '$lib/components/InstanceUiSync.svelte' import { onDestroy, setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -1437,11 +1438,13 @@ {/if}
    + + {#if $enterpriseLicense && !menuHidden} - + {/if} {#if $userStore?.is_service_account} diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index a5aea63715..5c623fbadc 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -603,6 +603,12 @@ const config = { backgroundColor: 'rgb(var(--color-surface-primary))', color: 'rgb(var(--color-text-primary))', + // Sidebar rail chrome — consumed via var(--sidebar-bg-light) (see sidebarChrome.ts). + // The `-base` vars hold the theme's color; the accent (accentColor.ts) tints + // `--sidebar-bg-*` from them, which a self-reference could not do. + '--sidebar-bg-light-base': '#F3F3F7', + '--sidebar-bg-light': 'var(--sidebar-bg-light-base)', + '--vscode-editorSuggestWidget-background': '#f3f3f3', '--vscode-editorHoverWidget-foreground': '#616161', '--vscode-editorHoverWidget-border': '#c8c8c8', @@ -622,7 +628,8 @@ const config = { ...darkColorVariables, // Sidebar rail chrome — consumed via var(--sidebar-bg-dark) (see sidebarChrome.ts). - '--sidebar-bg-dark': '#1e232e', + '--sidebar-bg-dark-base': '#1e232e', + '--sidebar-bg-dark': 'var(--sidebar-bg-dark-base)', '--vscode-editorSuggestWidget-background': '#252526', '--vscode-editorHoverWidget-foreground': '#cccccc', @@ -638,7 +645,7 @@ const config = { // GitHub's canvas.inset — the authentic recessed surface, sitting below // the canvas (#0d1117) like the sidebar-vs-primary relationship in the // default dark theme. - '--sidebar-bg-dark': '#010409', + '--sidebar-bg-dark-base': '#010409', // Monaco popup chrome — the &.dark defaults above are VS Code's greys; // override so the widgets match the GitHub Dark palette in this variant. From 0bac766756cf9ebcb615fe20d5a69c09c823c725 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 11:38:41 +0200 Subject: [PATCH 104/112] feat: refresh MCP tools when scripts and flows change (#11337) * feat: notify MCP clients when a workspace's scripts or flows change Advertise tools.listChanged and implement subscriptions/listen, so a 2026-07-28 client refreshes its tool list when scripts or flows are deployed, archived, renamed or deleted. Changes reach every replica via new statement-level notify_event triggers; MCP-originated changes also signal the serving replica inline. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: signal each workspace once per notify-event poll batch Co-Authored-By: Claude Opus 5.5 (1M context) * fix: signal script/flow path moves, keep unrelated updates trigger-free Row-level UPDATE OF archived/deleted/path triggers with a WHEN guard replace the statement-level ones, which built transition tables for every UPDATE on script and flow. Path moves from username changes and offboarding are now signalled. subscriptions/listen is refused when the client asks for nothing this server sends. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: signal path moves only for unarchived versions A username change or offboarding rewrites the path of every version, archived ones included, which would queue one notify_event per version. Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: detect MCP tool-list changes by polling a workspace fingerprint Replace the notify_event triggers and poller hook with a per-process, per-workspace poll of a hash over the live scripts and flows, run only while a subscriptions/listen stream watches that workspace. Every write path (UI, CLI, git sync, user renames, workspace moves) is covered with no migration; an MCP-originated change polls at once. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: announce the first fingerprint so a change before the baseline is not lost Also poll immediately after a script/flow mutation through a multi-workspace token, and pin the fingerprint test on a lock update, which is the unrelated write that actually happens. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: open every tool-list subscription with one notification A subscriber joining an existing watcher missed a change the watcher recorded before it subscribed. Also move the fingerprint query to a runnables module, since it spans scripts and flows. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-api/src/mcp/core.rs | 6 + backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/runnables.rs | 22 +++ .../tests/runnable_list_fingerprint.rs | 54 +++++++ backend/windmill-mcp/src/server/backend.rs | 5 + .../windmill-mcp/src/server/list_changes.rs | 104 +++++++++++++ backend/windmill-mcp/src/server/mod.rs | 1 + backend/windmill-mcp/src/server/runner.rs | 142 ++++++++++++++++-- 8 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 backend/windmill-common/src/runnables.rs create mode 100644 backend/windmill-common/tests/runnable_list_fingerprint.rs create mode 100644 backend/windmill-mcp/src/server/list_changes.rs diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 86fbec1806..472c7bbec9 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -386,6 +386,12 @@ impl McpBackend for WindmillBackend { }) } + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult { + windmill_common::runnables::runnable_list_fingerprint(&self.db, workspace_id) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None)) + } + fn all_endpoint_tools(&self) -> Vec { all_tools() } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 3e74d8e17f..1015948882 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -118,6 +118,7 @@ pub mod queue; pub mod queue_metrics; pub mod result_stream; pub mod runnable_settings; +pub mod runnables; pub mod schedule; pub mod schema; pub mod scripts; diff --git a/backend/windmill-common/src/runnables.rs b/backend/windmill-common/src/runnables.rs new file mode 100644 index 0000000000..579e5e248e --- /dev/null +++ b/backend/windmill-common/src/runnables.rs @@ -0,0 +1,22 @@ +//! Queries spanning both scripts and flows. + +use crate::{error::Error, DB}; + +/// An opaque value that changes whenever a workspace's live scripts or flows are added, +/// removed, moved or redeployed: a redeploy is a new script hash or a new last flow +/// version, so schema and description changes register too. Not filtered by caller, +/// so it reveals nothing beyond whether it changed. +pub async fn runnable_list_fingerprint(db: &DB, workspace_id: &str) -> Result { + Ok(sqlx::query_scalar::<_, String>( + "SELECT COALESCE(md5(string_agg(k, ',' ORDER BY k)), '') FROM ( + SELECT 's' || path || ':' || hash FROM script + WHERE workspace_id = $1 AND archived = false + UNION ALL + SELECT 'f' || path || ':' || COALESCE(versions[array_upper(versions, 1)], 0) FROM flow + WHERE workspace_id = $1 AND archived = false + ) t(k)", + ) + .bind(workspace_id) + .fetch_one(db) + .await?) +} diff --git a/backend/windmill-common/tests/runnable_list_fingerprint.rs b/backend/windmill-common/tests/runnable_list_fingerprint.rs new file mode 100644 index 0000000000..d7438f0008 --- /dev/null +++ b/backend/windmill-common/tests/runnable_list_fingerprint.rs @@ -0,0 +1,54 @@ +use sqlx::{Pool, Postgres}; +use windmill_common::runnables::runnable_list_fingerprint; + +/// MCP clients refresh their tool list when this fingerprint moves, so it must move on every +/// change to what the listing shows and stay put on writes it does not show. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn fingerprint_tracks_the_live_runnable_list(db: Pool) { + let fp = || async { + runnable_list_fingerprint(&db, "test-workspace") + .await + .unwrap() + }; + let exec = |sql: &'static str| { + let db = db.clone(); + async move { sqlx::query(sql).execute(&db).await.unwrap() } + }; + + let mut last = fp().await; + let mut assert_moved = async |what: &str, moved: bool| { + let now = fp().await; + assert_eq!(now != last, moved, "{what}"); + last = now; + }; + + exec( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, kind) + VALUES ('test-workspace', 1, 'f/test/a', '', '', 'def main(): pass', 'test-user', 'python3', 'script')", + ) + .await; + assert_moved("deploy", true).await; + + exec("UPDATE script SET lock = '' WHERE hash = 1").await; + assert_moved("lock update", false).await; + + exec("UPDATE script SET path = 'f/test/b' WHERE hash = 1").await; + assert_moved("path move", true).await; + + exec("UPDATE script SET archived = true WHERE hash = 1").await; + assert_moved("archive", true).await; + + exec( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, versions) + VALUES ('test-workspace', 'f/test/flow', '', '', '{}'::jsonb, 'test-user', '{}'::json, ARRAY[1]::bigint[])", + ) + .await; + assert_moved("flow create", true).await; + + exec("UPDATE flow SET versions = array_append(versions, 2::bigint) WHERE path = 'f/test/flow'") + .await; + assert_moved("flow redeploy", true).await; + + exec("DELETE FROM flow WHERE path = 'f/test/flow'").await; + assert_moved("flow delete", true).await; +} diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 7ca1eb08a1..4801bfaa99 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -208,6 +208,11 @@ pub trait McpBackend: Send + Sync + Clone + 'static { workspace_id: &str, ) -> BackendResult; + /// An opaque value that changes whenever the workspace's live scripts or flows are + /// added, removed, moved or redeployed, compared across polls to detect tool-list + /// changes. + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult; + // ───────────────────────────────────────────────────────────────── // Endpoint Tools // ───────────────────────────────────────────────────────────────── diff --git a/backend/windmill-mcp/src/server/list_changes.rs b/backend/windmill-mcp/src/server/list_changes.rs new file mode 100644 index 0000000000..8470cff630 --- /dev/null +++ b/backend/windmill-mcp/src/server/list_changes.rs @@ -0,0 +1,104 @@ +//! Detects changes to a workspace's scripts and flows for `subscriptions/listen`. +//! +//! Each process polls a fingerprint of every workspace one of its listeners watches, +//! so a change made on any replica, through any path (UI, CLI, git sync, a user +//! rename), reaches every listener within one poll interval with no cross-replica +//! signalling. + +use crate::server::backend::McpBackend; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::{broadcast, Notify}; + +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +struct Watcher { + changes: broadcast::Sender<()>, + poll_now: Arc, +} + +#[derive(Default)] +pub(crate) struct ListChanges { + watchers: Mutex>, +} + +impl ListChanges { + /// Subscribe to `workspace_id`'s changes, starting its poller if this is the + /// first listener for it in this process. + pub(crate) fn subscribe( + self: &Arc, + backend: &Arc, + workspace_id: &str, + ) -> broadcast::Receiver<()> { + let mut watchers = self.watchers.lock().unwrap(); + if let Some(watcher) = watchers.get(workspace_id) { + return watcher.changes.subscribe(); + } + let (changes, rx) = broadcast::channel(16); + let poll_now = Arc::new(Notify::new()); + watchers.insert( + workspace_id.to_string(), + Watcher { changes: changes.clone(), poll_now: poll_now.clone() }, + ); + tokio::spawn(self.clone().poll( + backend.clone(), + workspace_id.to_string(), + changes, + poll_now, + )); + rx + } + + /// Poll `workspace_id` now rather than at its next interval, after this process + /// changed it: the client that made the change is the one waiting on it. Polling + /// rather than signalling directly keeps the poller's baseline current, so the + /// same change is not reported again on the next tick. + pub(crate) fn poll_now(&self, workspace_id: &str) { + if let Some(watcher) = self.watchers.lock().unwrap().get(workspace_id) { + watcher.poll_now.notify_one(); + } + } + + async fn poll( + self: Arc, + backend: Arc, + workspace_id: String, + changes: broadcast::Sender<()>, + poll_now: Arc, + ) { + let mut interval = tokio::time::interval(POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last = None; + loop { + tokio::select! { + _ = interval.tick() => {} + _ = poll_now.notified() => {} + } + { + // Checked under the lock `subscribe` takes, so a listener cannot join a + // watcher that is about to stop. + let mut watchers = self.watchers.lock().unwrap(); + if changes.receiver_count() == 0 { + watchers.remove(&workspace_id); + return; + } + } + match backend.runnable_list_fingerprint(&workspace_id).await { + Ok(fingerprint) => { + // The first fingerprint also notifies: the listener that started this + // watcher has already sent its opening notification, and a change landing + // before this baseline would otherwise never be announced. + if last.as_ref() != Some(&fingerprint) { + let _ = changes.send(()); + } + last = Some(fingerprint); + } + Err(e) => tracing::warn!( + "MCP tool-list poll failed for workspace {workspace_id}: {}", + e.message + ), + } + } + } +} diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index b97e374e98..ad7c14d320 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -7,6 +7,7 @@ pub mod backend; pub mod endpoints; +mod list_changes; pub mod runner; pub mod tools; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index e27eaa8470..177989a82e 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -16,20 +16,22 @@ use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter}; use crate::server::endpoints::{ endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, EndpointTool, }; +use crate::server::list_changes::ListChanges; use crate::server::tools::create_tool_from_item; use rmcp::handler::server::ServerHandler; use rmcp::model::{ CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - Implementation, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, - ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, - ServerCapabilities, ServerInfo, + Implementation, InitializeRequestParams, InitializeResult, ListPromptsResult, + ListResourceTemplatesResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, + ProtocolVersion, ServerCapabilities, ServerInfo, SubscriptionFilter, }; -use rmcp::service::{RequestContext, RoleServer}; +use rmcp::service::{RequestContext, RoleServer, SubscriptionContext}; use rmcp::ErrorData; use serde_json::Value; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use tokio::sync::broadcast; /// Protocol revisions this server is willing to speak. `2026-07-28` is served /// statelessly with per-request metadata; the older revisions keep the @@ -46,15 +48,21 @@ const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ /// leaves them unset, and a strict client rejects the response without them. /// /// Zero because nothing here is cacheable: the listing is rebuilt from the -/// workspace's scripts and flows, which change at any time, and this server -/// advertises no `listChanged` capability, so a client that cached a stale list -/// would have no way to learn it had gone stale. +/// workspace's scripts and flows, which change at any time, and `listChanged` +/// covers only some of what the listing depends on (not resources, favorites or +/// permissions), lags by up to a poll interval, and reaches only clients that hold a +/// `subscriptions/listen` stream. const LIST_TTL_MS: u64 = 0; + /// Every listing is filtered by the caller's token scopes and workspace /// membership, so no two callers necessarily see the same tools — a shared /// cache entry would leak one token's view to another. const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; +/// How long a `subscriptions/listen` stream waits after a change before notifying, +/// collecting the rest of the burst into the same notification. +const LIST_CHANGE_COALESCE: std::time::Duration = std::time::Duration::from_millis(500); + // Re-export from http crate for extracting request parts use http::request::Parts as HttpParts; @@ -83,11 +91,12 @@ fn truncate_tool_result(text: String) -> String { /// to perform the actual operations (database queries, job execution, etc.) pub struct Runner { backend: Arc, + list_changes: Arc, } impl Clone for Runner { fn clone(&self) -> Self { - Self { backend: self.backend.clone() } + Self { backend: self.backend.clone(), list_changes: self.list_changes.clone() } } } @@ -113,7 +122,7 @@ struct McpContext { impl Runner { /// Create a new Runner with the given backend pub fn new(backend: B) -> Self { - Self { backend: Arc::new(backend) } + Self { backend: Arc::new(backend), list_changes: Arc::default() } } /// Extract authentication, the workspace mode and the HTTP request itself @@ -232,6 +241,22 @@ fn endpoint_path_policy(endpoint_name: &str) -> Option { } } +/// Endpoint tools that change which scripts/flows a workspace exposes, or their +/// schemas. The process serving the call polls the workspace right away rather than +/// at its next interval, so a client that just deployed a script can call it. +fn changes_runnable_list(endpoint_name: &str) -> bool { + matches!( + endpoint_name, + "createScript" + | "updateScript" + | "deleteScriptByPath" + | "deleteScriptByHash" + | "createFlow" + | "updateFlow" + | "deleteFlowByPath" + ) +} + /// Whether the token restricts `kind` ("script"/"flow") to specific paths. A /// `*` pattern grants every path (see `is_resource_allowed`), so it does not /// count as confinement. @@ -428,12 +453,17 @@ impl ServerHandler for Runner { .with_title("Windmill") .with_website_url("https://windmill.dev"); - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(server_info) - .with_instructions( - "This server provides a list of scripts and flows the user can run on Windmill. \ + InitializeResult::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + .with_server_info(server_info) + .with_instructions( + "This server provides a list of scripts and flows the user can run on Windmill. \ Each flow and script is a tool callable with their respective arguments.", - ) + ) } /// Pinned rather than left to rmcp's default (every version the SDK knows), so a @@ -444,6 +474,84 @@ impl ServerHandler for Runner { Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS) } + /// Only clients below `2026-07-28` send `initialize`, and those receive server + /// notifications on a session's standalone stream. The transport is sessionless, so + /// there is none: `listChanged` is withheld from them rather than promised and + /// never delivered. `2026-07-28` clients learn it from `server/discover` and + /// receive it on `subscriptions/listen` (see `listen`). + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + context.peer.set_peer_info(request.clone()); + let mut info = self.get_info(); + if let Some(tools) = info.capabilities.tools.as_mut() { + tools.list_changed = None; + } + if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) { + info.protocol_version = request.protocol_version; + } + Ok(info) + } + + /// `None` for a request that asks for nothing this server sends, so the client is + /// told the subscription is unsupported rather than acked with an empty filter. + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + (requested.tools_list_changed == Some(true)) + .then(|| SubscriptionFilter::builder().tools_list_changed().build()) + } + + async fn listen(&self, subscription: SubscriptionContext) -> Result<(), ErrorData> { + let McpContext { auth, mode, .. } = Self::extract_context(subscription.request_context())?; + // Multi-workspace mode and read-only tokens list no scripts or flows, so their + // tool list never changes. + let workspace_id = match mode { + McpMode::Single(workspace_id) if !auth.read_only() => workspace_id, + _ => { + subscription.cancelled().await; + return Ok(()); + } + }; + + let mut changes = self.list_changes.subscribe(&self.backend, &workspace_id); + // Every stream opens with one notification: a change the watcher recorded between + // this client's `tools/list` and this subscription is never broadcast again. + let mut opening = true; + loop { + if !std::mem::take(&mut opening) { + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + change = changes.recv() => match change { + Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return Ok(()), + }, + } + } + // A client creating several scripts in a row: collapse the burst into one + // notification, so it re-lists once. + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + _ = tokio::time::sleep(LIST_CHANGE_COALESCE) => {} + } + while !matches!( + changes.try_recv(), + Err(broadcast::error::TryRecvError::Empty | broadcast::error::TryRecvError::Closed) + ) {} + if subscription + .sink() + .notify_tool_list_changed() + .await + .is_err() + { + return Ok(()); + } + } + } + async fn list_tools( &self, _request: Option, @@ -714,6 +822,9 @@ impl Runner { .backend .call_endpoint(auth, workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(workspace_id); + } return Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( @@ -969,6 +1080,9 @@ impl Runner { .backend .call_endpoint(&resolved_auth, &workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(&workspace_id); + } Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( From d18d7043df6536e10b22ee9f3df77e926a0460b4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 12:00:49 +0200 Subject: [PATCH 105/112] feat: infer a script's schema when a deploy (e.g. MCP) sends none (#11339) * feat: infer a script's schema from its code when a deploy sends none Co-Authored-By: Claude Opus 5.5 (1M context) * fix: merge an inferred schema into the previous one the way the editor does Co-Authored-By: Claude Opus 5.5 (1M context) * fix: parse non-JSON TS defaults natively and keep the schema on a failed inference Co-Authored-By: Claude Opus 5.5 (1M context) * fix: type untyped TS params from their literal shape when the default can't be evaluated Co-Authored-By: Claude Opus 5.5 (1M context) * fix: read literal TS defaults off the AST so the server types them like the editor Co-Authored-By: Claude Opus 5.5 (1M context) * docs: word the script schema description for both create and update Co-Authored-By: Claude Opus 5.5 (1M context) * docs: note that dbt scripts derive their schema from the descriptor Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/Cargo.lock | 4 + .../generate_mcp_tools.py | 9 +- backend/parsers/windmill-parser-ts/src/lib.rs | 91 +++- .../parsers/windmill-parser-ts/tests/tests.rs | 32 ++ .../windmill-parser/src/json_schema.rs | 402 ++++++++++++++++++ backend/parsers/windmill-parser/src/lib.rs | 1 + backend/tests/script_schema_inference.rs | 115 +++++ backend/windmill-api-scripts/Cargo.toml | 4 + backend/windmill-api-scripts/src/scripts.rs | 124 ++++++ backend/windmill-api/openapi.yaml | 15 + .../src/mcp/auto_generated_endpoints.rs | 20 +- frontend/src/lib/mcpEndpointTools.ts | 20 +- 12 files changed, 817 insertions(+), 20 deletions(-) create mode 100644 backend/parsers/windmill-parser/src/json_schema.rs create mode 100644 backend/tests/script_schema_inference.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 57b15bc5a7..87bf7f64be 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15411,6 +15411,7 @@ dependencies = [ name = "windmill-api-scripts" version = "1.817.0" dependencies = [ + "anyhow", "axum 0.8.9", "chrono", "futures", @@ -15435,6 +15436,9 @@ dependencies = [ "windmill-native-triggers", "windmill-object-store", "windmill-parser", + "windmill-parser-bash", + "windmill-parser-go", + "windmill-parser-graphql", "windmill-parser-py", "windmill-parser-py-asset", "windmill-parser-sql", diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index 72feb5392e..4c78f0524e 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -128,11 +128,16 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt r for r in body_schema['required'] if r in include_fields ] - # Apply opaque_fields: simplify listed properties to {"type": "object"} + # Apply opaque_fields: simplify listed properties to {"type": "object"}, + # keeping the description, which is all a caller learns about the field if body_schema and opaque_fields and 'properties' in body_schema: for field in opaque_fields: if field in body_schema['properties']: - body_schema['properties'][field] = {"type": "object"} + opaque = {"type": "object"} + description = body_schema['properties'][field].get('description') + if description: + opaque['description'] = description + body_schema['properties'][field] = opaque # If we have required fields specified and a body schema, update the required array if body_schema and required_fields: diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index 271ad4835b..ac75080994 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -16,11 +16,13 @@ use windmill_parser::{ use swc_common::{sync::Lrc, FileName, SourceMap, SourceMapper, Span, Spanned}; use swc_ecma_ast::{ - ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, Ident, IdentName, Lit, - MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, ObjectLit, ObjectPat, Param, Pat, Stmt, - Str, TsArrayType, TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, - TsLitType, TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, - TsTypeAnn, TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, + ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, ExprOrSpread, Ident, + IdentName, KeyValueProp, Lit, MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, + ObjectLit, ObjectPat, Param, Pat, Prop, PropName, PropOrSpread, Stmt, Str, TsArrayType, + TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, TsLitType, + TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, TsTypeAnn, + TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, UnaryExpr, + UnaryOp, }; use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax}; @@ -578,6 +580,15 @@ fn parse_param( } }; + let literal = match &*right { + Expr::Object(_) | Expr::Array(_) if !skip_dflt => literal_json(&right), + _ => None, + }; + // Types an untyped object default that is not a plain literal (`{ a: x }`). + let literal_shape = match &*right { + Expr::Object(_) => Some(Typ::Object(ObjectType::new(None, None))), + _ => None, + }; let dflt = if skip_dflt { None } else { @@ -593,14 +604,19 @@ fn parse_param( Expr::Lit(Lit::Num(Number { value, .. })) => Some(serde_json::json!(value)), Expr::Lit(Lit::BigInt(BigInt { value, .. })) => Some(serde_json::json!(value)), Expr::Lit(Lit::Bool(Bool { value, .. })) => Some(Value::Bool(value)), - Expr::Object(ObjectLit { span, .. }) => eval_span(span, cm), - Expr::Array(ArrayLit { span, .. }) => eval_span(span, cm), + Expr::Object(ObjectLit { span, .. }) | Expr::Array(ArrayLit { span, .. }) => { + literal.or_else(|| eval_span(span, cm)) + } _ => None, } }; - if typ == Typ::Unknown && dflt.is_some() { - typ = json_to_typ(dflt.as_ref().unwrap(), false); + if typ == Typ::Unknown { + match &dflt { + Some(d) => typ = json_to_typ(d, false), + None if !skip_dflt => typ = literal_shape.unwrap_or(Typ::Unknown), + None => {} + } } Ok(Arg { otyp, @@ -636,6 +652,59 @@ fn parse_param( r } +/// A default written as a plain literal, read off the AST: `['a']` and `{ a: 1 }` are +/// not JSON, and outside wasm nothing can evaluate them (see `eval_sync`). +fn literal_json(expr: &Expr) -> Option { + let num = |v: f64| { + if v.fract() == 0.0 && v.abs() < 9007199254740992.0 { + Some(serde_json::json!(v as i64)) + } else { + serde_json::Number::from_f64(v).map(Value::Number) + } + }; + match expr { + Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())), + Expr::Lit(Lit::Num(n)) => num(n.value), + Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)), + Expr::Lit(Lit::Null(_)) => Some(Value::Null), + Expr::Unary(UnaryExpr { op: UnaryOp::Minus, arg, .. }) => match &**arg { + Expr::Lit(Lit::Num(n)) => num(-n.value), + _ => None, + }, + Expr::Paren(p) => literal_json(&p.expr), + Expr::Array(a) => a + .elems + .iter() + .map(|e| match e { + Some(ExprOrSpread { spread: None, expr }) => literal_json(expr), + _ => None, + }) + .collect::>>() + .map(Value::Array), + Expr::Object(o) => o + .props + .iter() + .map(|p| match p { + PropOrSpread::Prop(p) => match &**p { + Prop::KeyValue(KeyValueProp { key, value }) => { + let key = match key { + PropName::Ident(i) => i.sym.to_string(), + PropName::Str(s) => s.value.to_string(), + PropName::Num(n) => n.value.to_string(), + _ => return None, + }; + Some((key, literal_json(value)?)) + } + _ => None, + }, + _ => None, + }) + .collect::>>() + .map(Value::Object), + _ => None, + } +} + fn eval_span(span: Span, cm: &Lrc) -> Option { let expr = cm .span_to_snippet(span) @@ -1361,5 +1430,7 @@ pub fn eval_sync(code: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] pub fn eval_sync(_code: &str) -> Result { - panic!("eval_sync is only available in wasm32") + // The server parses signatures with defaults too: a default that is not JSON + // (`{ a: 1 }`, `['x']`) is then left out rather than failing the parse. + Err("evaluating a JavaScript default needs the wasm build".to_string()) } diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index 4206dd68f8..b4abc23488 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -7,6 +7,38 @@ mod tests { remove_pinned_import_specifiers, }; + /// The server parses defaults natively, where a non-JSON literal cannot be evaluated + /// but must still type and default the argument as the editor's wasm parse does. + #[test] + fn non_json_literal_defaults_parse_natively() { + let code = + "export function main(opts = { enabled: true, 'n': -1 }, tags = ['a'], o = { a: x }) {}"; + let sig = parse_deno_signature(code, false, false, None).unwrap(); + let defaults: Vec<_> = sig + .args + .iter() + .map(|a| (a.typ.clone(), a.default.clone())) + .collect(); + assert_eq!( + defaults, + vec![ + ( + Typ::Object(ObjectType::new( + None, + Some(vec![ + ObjectProperty::new("enabled".into(), Box::new(Typ::Bool)), + ObjectProperty::new("n".into(), Box::new(Typ::Int)), + ]) + )), + Some(json!({"enabled": true, "n": -1})) + ), + (Typ::List(Box::new(Typ::Str(None))), Some(json!(["a"]))), + // Not a plain literal: typed by its shape, with no default value. + (Typ::Object(ObjectType::new(None, None)), None), + ] + ); + } + #[test] fn test_imports_basic() { let code = r#" diff --git a/backend/parsers/windmill-parser/src/json_schema.rs b/backend/parsers/windmill-parser/src/json_schema.rs new file mode 100644 index 0000000000..6ca216dd89 --- /dev/null +++ b/backend/parsers/windmill-parser/src/json_schema.rs @@ -0,0 +1,402 @@ +use serde_json::{json, Map, Value}; + +use crate::{MainArgSignature, ObjectProperty, Typ}; + +/// The run-form schema of a script's `main`, derived the way the editor derives it +/// (`inferArgs` in the frontend, `argSigToJsonSchemaType` in `windmill-utils-internal`), +/// so a script deployed without a schema gets the one the editor would have sent. +/// `previous` is the schema of the version being superseded: as in the editor, it is +/// updated in place, so what the user set on it (argument order, descriptions, ...) +/// survives wherever the code still agrees with it. +pub fn main_arg_schema(sig: &MainArgSignature, previous: Option<&Value>) -> Value { + let mut schema = match previous { + Some(Value::Object(p)) => p.clone(), + _ => json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + }) + .as_object() + .cloned() + .unwrap_or_default(), + }; + let mut old_props = match schema.remove("properties") { + Some(Value::Object(p)) => p, + _ => Map::new(), + }; + let mut properties = Map::new(); + let mut required = vec![]; + for arg in &sig.args { + let mut prop = match old_props.remove(&arg.name) { + Some(Value::Object(p)) => p, + _ => fresh_property(), + }; + if !matches!(arg.typ, Typ::OneOf(_)) { + prop.remove("oneOf"); + } + apply_typ(&arg.typ, &mut prop); + // `T | T[]` marks an argument debouncing accumulates into. + if let Some(otyp) = arg + .otyp + .as_deref() + .filter(|o| o.contains('[') && o.contains('|')) + { + prop.insert("originalType".into(), json!(otyp)); + } + match &arg.default { + Some(d) => prop.insert("default".into(), d.clone()), + None => prop.remove("default"), + }; + if !arg.has_default { + required.push(json!(arg.name)); + } + properties.insert(arg.name.clone(), Value::Object(prop)); + } + schema.insert("properties".into(), Value::Object(properties)); + schema.insert("required".into(), Value::Array(required)); + if sig.has_cmd_binding == Some(true) { + schema.insert("x-windmill-ps-cmd-binding".into(), json!(true)); + schema.insert( + "x-windmill-ps-supports-should-process".into(), + json!(sig.supports_should_process.unwrap_or(false)), + ); + } else { + schema.remove("x-windmill-ps-cmd-binding"); + schema.remove("x-windmill-ps-supports-should-process"); + } + Value::Object(schema) +} + +/// Keys a user sets on a property in the schema editor, kept whatever the code says. +const USER_FIELDS: &[&str] = &[ + "description", + "pattern", + "min", + "max", + "currency", + "currencyLocale", + "multiselect", + "customErrorMessage", + "required", + "showExpr", + "password", + "order", + "dateFormat", + "title", + "placeholder", +]; + +/// Item keys a list the parser cannot see into keeps from the previous schema. +const ITEMS_USER_FIELDS: &[&str] = &[ + "properties", + "required", + "additionalProperties", + "enum", + "resourceType", + "contentEncoding", + "description", +]; + +fn fresh_property() -> Map { + let mut p = Map::new(); + p.insert("description".into(), json!("")); + p.insert("type".into(), json!("")); + p +} + +fn object_properties( + props: &[ObjectProperty], + old: Option<&Map>, +) -> Map { + props + .iter() + .map(|p| { + let mut prop = old + .and_then(|o| o.get(&p.key)) + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(fresh_property); + apply_typ(&p.typ, &mut prop); + (p.key.clone(), Value::Object(prop)) + }) + .collect() +} + +fn kept_items(old: &Map) -> Value { + let old_items = old.get("items").and_then(Value::as_object); + let mut items = Map::new(); + items.insert( + "type".into(), + old_items + .and_then(|i| i.get("type")) + .filter(|t| t.as_str().is_some_and(|t| !t.is_empty())) + .cloned() + .unwrap_or(json!("object")), + ); + if let Some(old_items) = old_items { + for f in ITEMS_USER_FIELDS { + if let Some(v) = old_items.get(*f) { + items.insert(f.to_string(), v.clone()); + } + } + } + Value::Object(items) +} + +/// Port of `argSigToJsonSchemaType`: rewrites `old` in place to carry `typ`. +fn apply_typ(typ: &Typ, old: &mut Map) { + let mut new = Map::new(); + // Keys the editor sets to `undefined`, which drops them from the stored JSON. + let mut unset: Vec<&str> = vec![]; + let mut set = |k: &str, v: Value| { + new.insert(k.to_string(), v); + }; + match typ { + Typ::Int => set("type", json!("integer")), + Typ::Float => set("type", json!("number")), + Typ::Bool => set("type", json!("boolean")), + Typ::Email => { + set("type", json!("string")); + set("format", json!("email")); + } + Typ::Sql => { + set("type", json!("string")); + set("format", json!("sql")); + } + Typ::Bytes => { + set("type", json!("string")); + set("contentEncoding", json!("base64")); + set("originalType", json!("bytes")); + } + Typ::Datetime => { + set("type", json!("string")); + set("format", json!("date-time")); + } + Typ::Date => { + set("type", json!("string")); + set("format", json!("date")); + } + Typ::OneOf(variants) => { + set("type", json!("object")); + let old_variants = old.get("oneOf").and_then(Value::as_array); + let one_of: Vec = variants + .iter() + .map(|v| { + let old_variant = old_variants + .and_then(|o| o.iter().find(|o| o.get("title") == Some(&json!(v.label)))) + .and_then(Value::as_object); + let old_props = old_variant + .and_then(|o| o.get("properties")) + .and_then(Value::as_object); + let mut variant = json!({ + "type": "object", + "title": v.label, + "properties": object_properties(&v.properties, old_props), + }); + if let Some(order) = old_variant.and_then(|o| o.get("order")) { + variant["order"] = order.clone(); + } + variant + }) + .collect(); + set("oneOf", Value::Array(one_of)); + } + Typ::Object(o) => { + set("type", json!("object")); + if let Some(name) = &o.name { + set("format", json!(format!("resource-{name}"))); + } + if let Some(props) = &o.props { + let old_props = old.get("properties").and_then(Value::as_object); + set( + "properties", + Value::Object(object_properties(props, old_props)), + ); + } + } + Typ::Str(variants) => { + set("type", json!("string")); + match variants { + Some(v) => { + set("originalType", json!("enum")); + set("enum", json!(v)); + } + None => { + set("originalType", json!("string")); + match old.get("enum") { + Some(e) if old.get("originalType") == Some(&json!("string")) => { + set("enum", e.clone()) + } + _ => unset.push("enum"), + } + } + } + } + Typ::Resource(name) => { + set("type", json!("object")); + set("format", json!(format!("resource-{name}"))); + } + Typ::DynSelect(name) => { + set("type", json!("object")); + set("format", json!(format!("dynselect-{name}"))); + } + Typ::DynMultiselect(name) => { + set("type", json!("object")); + set("format", json!(format!("dynmultiselect-{name}"))); + } + Typ::List(inner) => { + set("type", json!("array")); + let (items, original_type) = match inner.as_ref() { + Typ::Int | Typ::Float => (json!({"type": "number"}), "number[]"), + Typ::Bytes => ( + json!({"type": "string", "contentEncoding": "base64"}), + "bytes[]", + ), + Typ::Str(Some(v)) => (json!({"type": "string", "enum": v}), "enum[]"), + Typ::Str(None) => { + let mut items = json!({"type": "string"}); + if let Some(e) = old.get("items").and_then(|i| i.get("enum")) { + items["enum"] = e.clone(); + } + (items, "string[]") + } + Typ::Resource(r) => (json!({"type": "resource", "resourceType": r}), "resource[]"), + Typ::Object(o) => { + if let Some(name) = &o.name { + set("format", json!(format!("resource-{name}"))); + } + let items = match o.props.as_deref().filter(|p| !p.is_empty()) { + Some(props) => json!({ + "type": "object", + "properties": object_properties(props, None), + }), + None => kept_items(old), + }; + (items, "record[]") + } + _ => (kept_items(old), "object[]"), + }; + set("items", items); + set("originalType", json!(original_type)); + } + // The parser cannot tell: whatever type the user gave the argument stands. + Typ::Unknown => set("type", old.get("type").cloned().unwrap_or(json!("object"))), + } + + for f in USER_FIELDS { + if let Some(v) = old.get(*f) { + new.insert(f.to_string(), v.clone()); + } + } + + let old_type = old.get("type").cloned(); + let new_type = new.get("type").cloned(); + let old_format = old + .get("format") + .and_then(Value::as_str) + .map(str::to_string); + let new_format = new + .get("format") + .and_then(Value::as_str) + .map(str::to_string); + let is_date = |f: &Option| matches!(f.as_deref(), Some("date" | "date-time")); + let mut keep_old_format = false; + // A changed type takes every key `new` sets, so none of the adjustments below + // apply: running them would carry the old type's date format or items over. + if old_type != new_type { + } else if new_format.as_deref() == Some("date-time") && old_format.as_deref() == Some("date") { + new.insert("format".into(), json!("date")); + } else if is_date(&new_format) { + keep_old_format = true; + } else if old.get("items").and_then(|i| i.get("type")) + != new.get("items").and_then(|i| i.get("type")) + { + old.remove("items"); + } else if old_type == Some(json!("string")) && old_format.is_some() { + keep_old_format = true; + } + if keep_old_format { + match old.get("format") { + Some(f) => { + new.insert("format".into(), f.clone()); + } + None => { + new.remove("format"); + unset.push("format"); + } + } + } + if (old_type != new_type || new_type != Some(json!("string"))) && !new.contains_key("format") { + old.remove("format"); + } + + for k in unset { + if !new.contains_key(k) { + old.remove(k); + } + } + old.extend(new); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Arg, ObjectType}; + + fn arg(name: &str, typ: Typ, default: Option) -> Arg { + Arg { + name: name.to_string(), + has_default: default.is_some(), + default, + typ, + ..Default::default() + } + } + + #[test] + fn keeps_what_the_user_set_on_the_previous_version() { + let sig = MainArgSignature { + args: vec![ + arg("n", Typ::Int, None), + arg("db", Typ::Resource("postgresql".into()), None), + arg( + "tags", + Typ::List(Box::new(Typ::Str(None))), + Some(json!(["a"])), + ), + arg( + "opts", + Typ::Object(ObjectType::new(None, None)), + Some(json!({})), + ), + arg("any", Typ::Unknown, None), + ], + ..Default::default() + }; + let previous = json!({ + "type": "object", + "order": ["db", "n"], + "properties": { + "n": {"type": "integer", "description": "how many", "min": 1, "nullable": true}, + "tags": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}}, + "gone": {"type": "string"}, + }, + }); + let schema = main_arg_schema(&sig, Some(&previous)); + assert_eq!(schema["order"], json!(["db", "n"])); + assert_eq!(schema["required"], json!(["n", "db", "any"])); + assert_eq!( + schema["properties"]["n"], + json!({"type": "integer", "description": "how many", "min": 1, "nullable": true}) + ); + assert!(schema["properties"].get("gone").is_none()); + assert_eq!(schema["properties"]["db"]["format"], "resource-postgresql"); + assert_eq!( + schema["properties"]["tags"]["items"], + json!({"type": "string", "enum": ["a", "b"]}) + ); + assert_eq!(schema["properties"]["tags"]["default"], json!(["a"])); + assert_eq!(schema["properties"]["opts"]["type"], "object"); + // Untyped in the code and new: any JSON value, as the editor stores it. + assert_eq!(schema["properties"]["any"]["type"], ""); + } +} diff --git a/backend/parsers/windmill-parser/src/lib.rs b/backend/parsers/windmill-parser/src/lib.rs index 4270edcc79..7ca226b432 100644 --- a/backend/parsers/windmill-parser/src/lib.rs +++ b/backend/parsers/windmill-parser/src/lib.rs @@ -15,6 +15,7 @@ use serde_json::Value; pub mod asset_parser; pub mod duckdb_builtins; pub mod duckdb_macros; +pub mod json_schema; pub mod sql_materialize; /// S3 output format for SQL queries (moved here to avoid pulling sqlx into WASM via windmill-types) diff --git a/backend/tests/script_schema_inference.rs b/backend/tests/script_schema_inference.rs new file mode 100644 index 0000000000..083716f8df --- /dev/null +++ b/backend/tests/script_schema_inference.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use windmill_api_client::types::{NewScript, ScriptLang}; +use windmill_test_utils::init_client; + +const PATH: &str = "u/test-user/inferred_schema"; + +fn bun_ns(content: &str, parent_hash: Option) -> NewScript { + NewScript { + draft_only: None, + content: content.into(), + language: ScriptLang::Bun, + lock: None, + parent_hash, + path: PATH.into(), + concurrent_limit: None, + concurrency_time_window_s: None, + cache_ttl: None, + dedicated_worker: None, + description: "".to_string(), + envs: vec![], + is_template: None, + kind: None, + summary: "".to_string(), + tag: None, + // What this client sends for "no schema". + schema: HashMap::new(), + ws_error_handler_muted: Some(false), + priority: None, + delete_after_secs: None, + timeout: None, + restart_unless_cancelled: None, + deployment_message: None, + concurrency_key: None, + visible_to_runner_only: None, + auto_kind: None, + codebase: None, + has_preprocessor: None, + on_behalf_of_email: None, + assets: vec![], + modules: None, + } +} + +async fn head(db: &Pool) -> anyhow::Result<(i64, Value)> { + Ok(sqlx::query_as( + "SELECT hash, schema::jsonb FROM script \ + WHERE workspace_id = 'test-workspace' AND path = $1 AND archived = false", + ) + .bind(PATH) + .fetch_one(db) + .await?) +} + +/// A deploy without a schema (MCP, a bare API call) gets the one the editor would +/// have sent, and a new version keeps what the user wrote on the previous one's. +#[sqlx::test(fixtures("base"))] +async fn schemaless_deploy_infers_schema_keeping_annotations( + db: Pool, +) -> anyhow::Result<()> { + let (client, _port, _s) = init_client(db.clone()).await; + + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number, name = \"x\") { return n }", + None, + ), + ) + .await?; + let (hash, schema) = head(&db).await?; + assert_eq!(schema["required"], json!(["n"])); + assert_eq!(schema["properties"]["n"]["type"], "number"); + assert_eq!(schema["properties"]["name"]["default"], "x"); + + sqlx::query( + "UPDATE script SET schema = jsonb_set(schema::jsonb, '{properties,n,description}', \ + '\"how many\"')::json WHERE hash = $1", + ) + .bind(hash) + .execute(&db) + .await?; + + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number, flag: boolean) { return n }", + Some(format!("{hash:016x}")), + ), + ) + .await?; + let (hash, schema) = head(&db).await?; + assert_eq!(schema["required"], json!(["n", "flag"])); + assert_eq!(schema["properties"]["n"]["description"], "how many"); + assert_eq!(schema["properties"]["flag"]["type"], "boolean"); + assert!(schema["properties"].get("name").is_none()); + + // Code that does not parse keeps the schema rather than losing it for good. + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number {", + Some(format!("{hash:016x}")), + ), + ) + .await?; + assert_eq!(head(&db).await?.1, schema); + + Ok(()) +} diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index 8b34512872..72353c6f1b 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -30,7 +30,11 @@ windmill-parser-ts-asset.workspace = true windmill-parser-sql-asset.workspace = true windmill-parser-sql.workspace = true windmill-parser-yaml.workspace = true +windmill-parser-bash.workspace = true +windmill-parser-go.workspace = true +windmill-parser-graphql.workspace = true +anyhow.workspace = true axum.workspace = true futures.workspace = true http.workspace = true diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 51f2619805..631e8606e6 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1132,6 +1132,96 @@ async fn validate_dbt_relation( }) } +/// The schema the editor would have derived from `content`, for a deploy that sends +/// none (MCP, a bare API call). `None` for a language whose parser this build lacks, +/// or code that does not parse. +fn infer_main_schema( + language: &ScriptLang, + kind: Option<&ScriptKind>, + content: &str, + previous: Option<&serde_json::Value>, +) -> Option { + let entrypoint = + matches!(kind, Some(ScriptKind::Preprocessor)).then(|| "preprocessor".to_string()); + // The database a SQL script runs against is an argument unless the code names + // it (`-- database f/...`), as in the editor. + let with_db = |sig: anyhow::Result, resource: &str| { + sig.map(|mut sig| { + if windmill_parser_sql::parse_db_resource(content).is_none() { + sig.args.insert( + 0, + windmill_parser::Arg { + name: "database".to_string(), + typ: windmill_parser::Typ::Resource(resource.to_string()), + ..Default::default() + }, + ); + } + sig + }) + }; + let sig = match language { + ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { + windmill_parser_ts::parse_deno_signature(content, false, false, entrypoint) + } + #[cfg(feature = "python")] + ScriptLang::Python3 => { + windmill_parser_py::parse_python_signature(content, entrypoint, false) + } + ScriptLang::Go => windmill_parser_go::parse_go_sig(content), + ScriptLang::Bash => windmill_parser_bash::parse_bash_sig(content), + ScriptLang::Powershell => windmill_parser_bash::parse_powershell_sig(content), + ScriptLang::Postgresql => { + with_db(windmill_parser_sql::parse_pgsql_sig(content), "postgresql") + } + ScriptLang::Mysql => with_db(windmill_parser_sql::parse_mysql_sig(content), "mysql"), + ScriptLang::Bigquery => { + with_db(windmill_parser_sql::parse_bigquery_sig(content), "bigquery") + } + ScriptLang::Snowflake => with_db( + windmill_parser_sql::parse_snowflake_sig(content), + "snowflake", + ), + ScriptLang::Mssql => with_db( + windmill_parser_sql::parse_mssql_sig(content), + "ms_sql_server", + ), + ScriptLang::OracleDB => { + with_db(windmill_parser_sql::parse_oracledb_sig(content), "oracledb") + } + ScriptLang::DuckDb => windmill_parser_sql::parse_duckdb_sig(content), + ScriptLang::Graphql => { + windmill_parser_graphql::parse_graphql_sig(content).map(|mut sig| { + sig.args.insert( + 0, + windmill_parser::Arg { + name: "api".to_string(), + typ: windmill_parser::Typ::Resource("graphql".to_string()), + ..Default::default() + }, + ); + sig + }) + } + ScriptLang::Ansible => windmill_parser_yaml::parse_ansible_sig(content), + _ => return None, + }; + match sig { + Ok(sig) => serde_json::value::to_raw_value(&windmill_parser::json_schema::main_arg_schema( + &sig, previous, + )) + .ok() + .map(|v| Schema(sqlx::types::Json(v))), + Err(e) => { + tracing::warn!( + "could not infer the schema of a {} script: {e:#}", + language.as_str() + ); + None + } + } +} + async fn create_script_internal<'c>( mut ns: NewScript, w_id: String, @@ -1359,6 +1449,40 @@ async fn create_script_internal<'c>( parent_adopted_from_retired_path = ns.parent_hash.is_some(); } + // Before hashing, so the hash and the no-op check see the schema that gets stored. + // `{}` counts as absent: an agent filling every tool argument sends it for "none". + let schema_absent = ns.schema.as_ref().is_none_or(|s| { + serde_json::from_str::>(s.0.get()) + .is_ok_and(|m| m.is_empty()) + }); + if schema_absent && !matches!(ns.language, ScriptLang::Dbt) { + let previous = match &ns.parent_hash { + Some(p_hash) => sqlx::query_scalar::<_, Option>>( + "SELECT schema FROM script WHERE hash = $1 AND workspace_id = $2", + ) + .bind(p_hash.0) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await? + .flatten() + .map(|s| s.0), + None => None, + }; + // Code the server cannot parse keeps the previous version's schema, as it + // does in the editor, rather than losing it for good. + ns.schema = infer_main_schema( + &ns.language, + ns.kind.as_ref(), + &ns.content, + previous.as_ref(), + ) + .or_else(|| { + previous + .and_then(|p| serde_json::value::to_raw_value(&p).ok()) + .map(|v| Schema(sqlx::types::Json(v))) + }); + } + // Must stay below the parent resolution above: an auto_parent deploy hashed before // it carries a first deploy's lineage, so redeploying content the path has held // before collides with that archived version instead of superseding it. The diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 016342a6fe..4346dfcd0a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10685,6 +10685,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -10724,6 +10727,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -29742,6 +29748,15 @@ components: type: string schema: type: object + description: >- + JSON Schema of the arguments of `main`, which is what a run form and an + MCP tool offer. Omitted (or `{}`), it is inferred from `content` for + TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible + scripts. For other languages, or code that does not parse, a new script + gets none. A new version of an existing script also keeps what the + previous version's schema says about each argument, or that whole schema + when nothing can be inferred. A dbt script always takes its schema from its + descriptor, whatever is sent. is_template: type: boolean lock: diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index ffefaba2dd..1962710578 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -699,6 +699,10 @@ pub fn all_tools() -> Vec { "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -757,6 +761,10 @@ is, a different one moves it there and archives the old path"), "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -1024,10 +1032,12 @@ is, a different one moves it there and archives the old path"), "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "path": { "type": "string" @@ -1080,10 +1090,12 @@ is, a different one moves it there and archives the old path"), "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "tag": { "type": "string" diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index a9e2b9f3b2..2197998857 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -708,6 +708,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -764,6 +768,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -1031,10 +1039,12 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "path": { "type": "string" @@ -1087,10 +1097,12 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "tag": { "type": "string" From bc4f872f1093a59b9b8fbc3e11a6f00b42474f0c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 12:30:43 +0200 Subject: [PATCH 106/112] perf: skip the flow_env ancestor walk for sub-flows with nothing to inherit (#11349) * perf: skip the flow_env ancestor walk for sub-flows with nothing to inherit Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the flow_env mark out of sub-flow definitions replayed by restarts Co-Authored-By: Claude Opus 5.5 (1M context) * test: align the guest scopable-path test with app path validation Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/app_guest_execution_mode.rs | 27 ++--- backend/tests/flow_engine_parity.rs | 125 +++++++++++++++++++++ backend/tests/job_payload.rs | 1 + backend/windmill-queue/src/jobs.rs | 6 +- backend/windmill-types/src/flow_status.rs | 6 + backend/windmill-types/src/jobs.rs | 2 + backend/windmill-worker/src/worker_flow.rs | 86 +++++++++----- 7 files changed, 209 insertions(+), 44 deletions(-) diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index 7af2e0794e..105458847a 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -577,24 +577,13 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests"); - let resp = authed( - client().post(format!("{ws}/apps/update/{APP_PATH}")), - ADMIN_TOKEN, - ) - .json(&json!({ "path": "u/test-user/My App" })) - .send() - .await?; - assert_eq!( - resp.status(), - 200, - "a space is literal: {}", - resp.text().await? - ); - // Set on update: an app that already sits on such a path cannot be switched. + // Set on update: an app that already sits on such a path cannot be switched. Path + // validation now refuses these paths on every write, so plant the kind of app saved + // before it did. let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&json!({ - "path": "u/test-user/x:y", + "path": "u/test-user/legacy", "summary": "App", "value": {}, "policy": { "execution_mode": "publisher", "triggerables_v2": {} } @@ -602,6 +591,12 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 201, "{}", resp.text().await?); + sqlx::query( + "UPDATE app SET path = 'u/test-user/x:y' + WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/legacy'", + ) + .execute(&db) + .await?; let resp = authed( client().post(format!("{ws}/apps/update/u/test-user/x:y")), ADMIN_TOKEN, @@ -610,6 +605,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 400, "switched to Guests on a `:` path"); + let body = resp.text().await?; + assert!(body.contains("cannot be set to Guests"), "{body}"); Ok(()) } diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index b6a943a23b..7ea221c2e8 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2877,6 +2877,131 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> a Ok(()) } +// A branch/loop sub-flow of a deployed flow is marked at push when its parent has no flow_env, +// own or inherited, so its worker skips `fetch_root_flow_env`. With an env two scopes up (root +// → loop → imported flow without env → parallel loop → leaf) nothing may be marked and the +// root's value must reach the leaf; with no env anywhere every loop iteration is marked. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_marks_sub_flows_only_without_ancestor_env( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Deployed, so their loops run as `FlowNode` sub-flows. + let deploy = |path: &'static str, version: i64, value: serde_json::Value| { + let db = db.clone(); + async move { + sqlx::query( + "INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')", + ) + .bind(path) + .bind(version) + .bind(&value) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')", + ) + .bind(version) + .bind(path) + .bind(&value) + .execute(&db) + .await?; + RunJob::from(JobPayload::FlowDependencies { + path: path.to_string(), + dedicated_worker: None, + version, + debouncing_settings: Default::default(), + }) + .run_until_complete(&db, false, port) + .await; + anyhow::Ok(()) + } + }; + deploy( + "f/system/imported_loop_no_env", + 9991004, + json!({ "modules": [{ "id": "inner", "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1, 2]" }, + "skip_failures": false, + "parallel": true, + "modules": [{ "id": "leaf", "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(key: any) { return key ?? null; }", + "input_transforms": { "key": { "type": "javascript", "expr": "flow_env.KEY" } } + }}, { "id": "tail", "value": { + // A single simple step is inlined rather than stored as a flow node. + "type": "rawscript", + "language": "deno", + "content": "export function main(prev: any) { return prev; }", + "input_transforms": { "prev": { "type": "javascript", "expr": "results.leaf" } } + }}] + }}]}), + ) + .await?; + let root = |flow_env: serde_json::Value| { + json!({ "flow_env": flow_env, "modules": [{ "id": "outer", "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1]" }, + "skip_failures": false, + "parallel": false, + "modules": [{ "id": "import", "value": { + "type": "flow", + "path": "f/system/imported_loop_no_env", + "input_transforms": {} + }}] + }}]}) + }; + deploy( + "f/system/root_with_env", + 9991005, + root(json!({ "KEY": "from-root" })), + ) + .await?; + deploy("f/system/root_no_env", 9991006, root(json!(null))).await?; + + let run = |path: &'static str, version: i64| { + let db = db.clone(); + async move { + let completed = RunJob::from(JobPayload::Flow { + path: path.to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version, + labels: None, + }) + .run_until_complete(&db, false, port) + .await; + // The mark of every loop iteration pushed under the run. + let marks = sqlx::query_as::<_, (String, Option)>( + "SELECT j.kind::text, (c.flow_status->>'no_inherited_flow_env')::bool + FROM v2_job j JOIN v2_job_completed c ON c.id = j.id + WHERE COALESCE(j.root_job, j.parent_job) = $1 + AND j.flow_step_id IN ('outer', 'inner')", + ) + .bind(completed.id) + .fetch_all(&db) + .await?; + anyhow::Ok((completed.json_result().unwrap(), marks)) + } + }; + + let (result, marks) = run("f/system/root_with_env", 9991005).await?; + assert_eq!(result, json!([["from-root", "from-root"]])); + assert_eq!(marks, vec![("flownode".to_string(), None); 3]); + + let (result, marks) = run("f/system/root_no_env", 9991006).await?; + assert_eq!(result, json!([[null, null]])); + assert_eq!(marks, vec![("flownode".to_string(), Some(true)); 3]); + + Ok(()) +} + // stop_after_if predicate sees flow_env. Regression for the eval at line 614 // of `update_flow_status_after_job_completion_internal` which used to pass // `None` for flow_env unconditionally. diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index c538cbe6e1..1a973f6fe6 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -239,6 +239,7 @@ mod job_payload { let result = RunJob::from(JobPayload::FlowNode { id, path: "f/system/hello_with_nodes_flow/forloop-0".into(), + no_inherited_flow_env: false, }) .arg("iter", json!({ "value": "tests", "index": 0 })) .run_until_complete(&db, false, port) diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 50eb9c8b1f..18fe58becc 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5854,10 +5854,10 @@ async fn push_inner<'c, 'd>( dedicated_worker, ..Default::default() }, - JobPayload::FlowNode { id, path } => { + JobPayload::FlowNode { id, path, no_inherited_flow_env } => { let data = cache::flow::fetch_flow(db, id).await?; let value = data.value(); - let status = Some(FlowStatus::new(value)); + let status = Some(FlowStatus { no_inherited_flow_env, ..FlowStatus::new(value) }); // Keep inserting `value` if not all workers are updated. // Starting at `v1.440`, the value is fetched on pull from the flow node id. let value_o = if !MIN_VERSION_IS_AT_LEAST_1_440.met().await { @@ -6094,6 +6094,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, } } _ => { @@ -6488,6 +6489,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, }; let value = flow_data.value(); let priority = value.priority; diff --git a/backend/windmill-types/src/flow_status.rs b/backend/windmill-types/src/flow_status.rs index 79890e3c6e..6dff8430b7 100644 --- a/backend/windmill-types/src/flow_status.rs +++ b/backend/windmill-types/src/flow_status.rs @@ -41,6 +41,11 @@ pub struct FlowStatus { pub chat_input_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory_id: Option, + /// Set at push on a branch/loop sub-flow whose enclosing flow had no `flow_env`, own or + /// inherited, so the worker can skip the ancestor walk for it. `false` only means unknown: + /// the worker then walks the ancestors as usual. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub no_inherited_flow_env: bool, } #[derive(Serialize, Deserialize, Debug, Clone, Default)] @@ -568,6 +573,7 @@ impl FlowStatus { stream_job: None, chat_input_enabled: f.chat_input_enabled, memory_id: None, + no_inherited_flow_env: false, } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 5190efe380..4e0c5340f5 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -519,6 +519,8 @@ pub enum JobPayload { FlowNode { id: FlowNodeId, path: String, + /// See [`crate::flow_status::FlowStatus::no_inherited_flow_env`]. + no_inherited_flow_env: bool, }, FlowScript { id: FlowNodeId, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 0ab4e2871c..6e01378448 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -537,15 +537,22 @@ pub async fn update_flow_status_after_job_completion_internal( if let Some(cached) = RESOLVED_FLOW_ENV_CACHE.get(&flow) { Some(cached) } else { - resolve_flow_env_for_status_update(db, client, flow, w_id, flow_value) - .await - .map(|(env, is_cacheable)| { - let arc = Arc::new(env); - if is_cacheable { - RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); - } - arc - }) + resolve_flow_env_for_status_update( + db, + client, + flow, + w_id, + flow_value, + old_status.no_inherited_flow_env, + ) + .await + .map(|(env, is_cacheable)| { + let arc = Arc::new(env); + if is_cacheable { + RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); + } + arc + }) } } else { None @@ -2521,6 +2528,7 @@ async fn resolve_flow_env_for_status_update( flow_job_id: Uuid, workspace_id: &str, flow_value: &FlowValue, + no_inherited_flow_env: bool, ) -> Option<(HashMap>, bool)> { // Fetch the env source. For the inherited path, we first need to know whether the // flow even has a parent — `fetch_root_flow_env` runs a recursive CTE on `v2_job` @@ -2529,6 +2537,8 @@ async fn resolve_flow_env_for_status_update( let (env, mini): (HashMap>, Option) = if let Some(ref e) = flow_value.flow_env { (e.clone(), None) + } else if no_inherited_flow_env { + return None; } else { let mini = match get_mini_pulled_job(db, &flow_job_id).await { Ok(Some(j)) => j, @@ -2542,7 +2552,13 @@ async fn resolve_flow_env_for_status_update( // No own flow_env and no parent to inherit from — nothing to resolve. return None; } - let env = fetch_root_flow_env(db, flow_job_id, workspace_id).await?; + let env = match fetch_root_flow_env(db, flow_job_id, workspace_id).await { + Ok(env) => env?, + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + return None; + } + }; (env, Some(mini)) }; if env.is_empty() { @@ -2608,7 +2624,7 @@ async fn fetch_root_flow_env( db: &DB, flow_job_id: Uuid, workspace_id: &str, -) -> Option>> { +) -> Result>>, sqlx::Error> { sqlx::query_scalar!( r#"WITH RECURSIVE chain(id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, depth) AS ( SELECT id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, 0 @@ -2645,10 +2661,7 @@ async fn fetch_root_flow_env( ) .fetch_optional(db) .await - .ok() - .flatten() - .flatten() - .map(|json| json.0) + .map(|row| row.flatten().map(|json| json.0)) } struct FailureContext { @@ -2793,18 +2806,31 @@ pub async fn handle_flow( ) -> anyhow::Result<()> { let flow = flow_data.value(); + let status = flow_job + .parse_flow_status() + .with_context(|| "Unable to parse flow status")?; + // Sub-flows spawned by `payload_from_modules` for branches/loops don't // carry the parent's `flow_env` in their own FlowValue. Fall back to the // nearest enclosing scope's `flow_env` so predicates like `skip_if`, // `stop_after_if`, and branch conditions see the same env as input // transforms. - let inherited_env: Option>> = - if flow.flow_env.is_none() && flow_job.parent_job.is_some() { - fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await - } else { - None - }; + let (inherited_env, inherited_env_known) = if flow.flow_env.is_none() + && flow_job.parent_job.is_some() + && !status.no_inherited_flow_env + { + match fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await { + Ok(env) => (env, true), + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + (None, false) + } + } + } else { + (None, true) + }; let env_source = flow.flow_env.as_ref().or(inherited_env.as_ref()); + let no_flow_env = inherited_env_known && env_source.is_none_or(|e| e.is_empty()); // Resolve $var: and $res: references in flow_env. // We resolve into a separate variable to avoid cloning the entire FlowValue @@ -2850,10 +2876,6 @@ pub async fn handle_flow( } } - let status = flow_job - .parse_flow_status() - .with_context(|| "Unable to parse flow status")?; - let schedule_path = flow_job.schedule_path(); if !flow_job.is_flow_step() && status.retry.fail_count == 0 @@ -2997,6 +3019,7 @@ pub async fn handle_flow( status, flow, flow_env, + no_flow_env, db, client, last_result.clone(), @@ -3240,6 +3263,8 @@ async fn push_next_flow_job( mut status: FlowStatus, flow: &FlowValue, flow_env: Option<&HashMap>>, + // Neither this flow nor any ancestor defines `flow_env`. + no_flow_env: bool, db: &sqlx::Pool, client: &AuthedClient, last_job_result: Option>>, @@ -4229,7 +4254,14 @@ async fn push_next_flow_job( let mut tx = db.begin().warn_after_seconds(3).await?; let nargs = args.as_ref(); - for (i, payload_tag) in job_payloads.into_iter().enumerate() { + for (i, mut payload_tag) in job_payloads.into_iter().enumerate() { + // A `FlowNode` here is a branch/loop sub-flow from `payload_from_modules`: it never defines + // its own `flow_env`, so it inherits exactly this flow's. The mark lives in the child's + // status, not its definition: a `RawFlow` child's stored definition is replayed by nested + // restarts, whose root may have gained a `flow_env` since. + if let JobPayload::FlowNode { no_inherited_flow_env, .. } = &mut payload_tag.payload { + *no_inherited_flow_env = no_flow_env; + } if i % 100 == 0 && i != 0 { tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}"); // Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the @@ -5218,7 +5250,7 @@ fn payload_from_modules<'a>( } if let Some(id) = modules_node { - return Some(JobPayload::FlowNode { id, path: path() }); + return Some(JobPayload::FlowNode { id, path: path(), no_inherited_flow_env: false }); } add_virtual_items_if_necessary(&mut modules); From 245628210f7d75aedb9e0a289d1dee63f1513b5b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 12:41:27 +0200 Subject: [PATCH 107/112] perf: skip parent status write when a parallel loop iteration starts (#11348) * perf: skip parent status write when a parallel loop iteration starts Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the loop viewer off the parent job while a parallel loop runs Co-Authored-By: Claude Opus 5.5 (1M context) * docs: state what a parallel module's job would hold Co-Authored-By: Claude Opus 5.5 (1M context) * perf: test the parallel module with one containment check Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json} | 4 ++-- backend/windmill-queue/src/flow_status.rs | 9 ++++++++- .../src/lib/components/FlowStatusViewerInner.svelte | 11 ++++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) rename backend/.sqlx/{query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json => query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json} (60%) diff --git a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json similarity index 60% rename from backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json rename to backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json index 3d61a47eb1..f631078e76 100644 --- a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json +++ b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2", + "query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2\n AND NOT COALESCE(\n flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}',\n false\n )", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf" + "hash": "cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b" } diff --git a/backend/windmill-queue/src/flow_status.rs b/backend/windmill-queue/src/flow_status.rs index e6b64c659d..55d2f6ec90 100644 --- a/backend/windmill-queue/src/flow_status.rs +++ b/backend/windmill-queue/src/flow_status.rs @@ -15,6 +15,9 @@ pub async fn update_flow_status_in_progress( let step = get_step_of_flow_status(db, flow).await?; match step { Step::Step { idx: step, .. } => { + // Children of a parallel module start concurrently and are tracked in `flow_jobs`, so its + // `job` would only hold whichever child started last. Writing it rewrites the parent's + // whole flow_status under its row lock once per child, serializing large parallel loops. sqlx::query!( "UPDATE v2_job_status SET flow_status = jsonb_set( @@ -22,7 +25,11 @@ pub async fn update_flow_status_in_progress( ARRAY['modules', $3::INTEGER::TEXT, 'type'], to_jsonb('InProgress'::text) ) - WHERE id = $2", + WHERE id = $2 + AND NOT COALESCE( + flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}', + false + )", job_in_progress, flow, step as i32 diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 05ad6c7bb2..1d56c20b38 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -944,6 +944,15 @@ } } + // A running parallel module keeps this flow's own id in `job` (its children are only in + // `flow_jobs`), so passing it through would make the nested viewer watch this flow again. + function innerViewerJobId(mod: FlowStatusModule): string { + if (mod.job && mod.job == job?.id && mod.flow_jobs?.length) { + return mod.flow_jobs[0] + } + return mod.job ?? '' + } + function onJobsLoadedInner(mod: FlowStatusModule, job: Job, force?: boolean): void { let id = mod.id if (id && ((mod.flow_jobs ?? []).length == 0 || force)) { @@ -1783,7 +1792,7 @@ {subflowParentsGlobalModuleStates} {subflowParentsDurationStatuses} {isSelectedBranch} - jobId={mod.job ?? ''} + jobId={innerViewerJobId(mod)} {reducedPolling} innerModule={mod.flow_jobs ? job.raw_flow?.modules[i]?.value : undefined} flowJobIds={mod.flow_jobs From 4b09558e13fadca6fe604c231a5cdfb896d4d0f8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 25 Sep 2026 12:43:46 +0200 Subject: [PATCH 108/112] feat: start a deferred queued job now without changing its id (#11347) * feat: start a deferred queued job now without changing its id Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse starting a schedule's upcoming tick early Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide run now on upcoming schedule ticks and register its audit op Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...751e1317e7398dfaf1dfde5c57169cc146c9d.json | 23 ++++ ...b30d71c74fffbba3f5ac6197c985fd73e5c11.json | 23 ++++ ...61af624ab6de67a5c82967f4df8117fe50bc7.json | 47 ++++++++ backend/tests/jobs_read_auth.rs | 93 ++++++++++++++- backend/windmill-api/openapi.yaml | 24 ++++ backend/windmill-api/src/jobs.rs | 112 +++++++++++++++++- .../auditLogs/AuditLogsFilters.svelte | 1 + .../(root)/(logged)/run/[...run]/+page.svelte | 36 +++++- 8 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json create mode 100644 backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json create mode 100644 backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json diff --git a/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json new file mode 100644 index 0000000000..46b7d2509c --- /dev/null +++ b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue q SET scheduled_for = now()\n FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev\n WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL\n AND q.scheduled_for > now()\n RETURNING prev.scheduled_for", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scheduled_for", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d" +} diff --git a/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json new file mode 100644 index 0000000000..f5a2ac20ed --- /dev/null +++ b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11" +} diff --git a/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json new file mode 100644 index 0000000000..4ed2afc15b --- /dev/null +++ b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone\n FROM v2_job j\n JOIN v2_job_queue q USING (id)\n JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger\n WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'\n AND j.parent_job IS NULL AND q.scheduled_for > now()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "schedule", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cron_version", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "timezone", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7" +} diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index c8c154c250..8c5bfee0eb 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -516,10 +516,14 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul Some("SECRET_TOKEN_2"), ) .await; - assert!(status.is_success(), "owner must mint a resume secret: {secret}"); + assert!( + status.is_success(), + "owner must mint a resume secret: {secret}" + ); let secret = secret.trim().trim_matches('"').to_string(); - let approval_result = - format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}"); + let approval_result = format!( + "completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}" + ); let (status, body) = get(&base, &approval_result, None).await; assert!( status.is_success(), @@ -991,6 +995,89 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul !status.is_success(), "a public token must not let an anonymous caller cancel the run (got {status}): {body}" ); + // ---- RUN_NOW is gated like cancel, and only moves a job still waiting for a + // future start. ---- + let run_now = format!("queue/run_now/{RUNNING_JOB}"); + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_3")).await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must not start another user's job early (got {status}): {body}" + ); + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "a running job has no future start to move (got {status}): {body}" + ); + sqlx::query( + "UPDATE v2_job_queue SET running = false, scheduled_for = now() + interval '1 hour' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert!( + status.is_success(), + "owner must start their deferred job now (got {status}): {body}" + ); + let due: bool = + sqlx::query_scalar("SELECT scheduled_for <= now() FROM v2_job_queue WHERE id = $1::uuid") + .bind(RUNNING_JOB) + .fetch_one(&db) + .await?; + assert!(due, "run_now must make the job due immediately"); + + // A schedule tick that is not due yet is refused: the schedule would queue that same + // tick again on completion and run twice. + sqlx::query( + "INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, permissioned_as) + VALUES ('test-workspace', 'u/test-user-2/daily', 'test-user-2', '0 0 3 * * *', + 'u/test-user-2/running_secret', 'u/test-user-2')", + ) + .execute(&db) + .await?; + sqlx::query( + "UPDATE v2_job SET trigger_kind = 'schedule', trigger = 'u/test-user-2/daily' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + sqlx::query( + "UPDATE v2_job_queue SET scheduled_for = + (date_trunc('day', now() AT TIME ZONE 'UTC') + interval '1 day 3 hours') AT TIME ZONE 'UTC' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "an upcoming schedule tick must not be started early (got {status}): {body}" + ); + // A tick a concurrency limit pushed past its time is off the cron occurrences and + // may still be started early. + sqlx::query( + "UPDATE v2_job_queue SET scheduled_for = now() + interval '1 hour 37 seconds 123 milliseconds' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert!( + status.is_success(), + "a deferred schedule tick must still start now (got {status}): {body}" + ); + sqlx::query("UPDATE v2_job SET trigger_kind = NULL, trigger = NULL WHERE id = $1::uuid") + .bind(RUNNING_JOB) + .execute(&db) + .await?; + // The owner still cancels their own job (no over-blocking). Keep this last: it // takes RUNNING_JOB out of the queue. let (status, body) = post( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4346dfcd0a..21ca421af3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -16310,6 +16310,29 @@ paths: items: type: string + /w/{workspace}/jobs/queue/run_now/{id}: + post: + summary: start a queued job scheduled for later now + description: | + Moves the start of a queued job that is waiting for a future time (scheduled, debounced, + re-scheduled by a concurrency limit, or a flow step waiting on a sleep or a retry delay) + to now, keeping the same job id. A job with a + concurrency limit is still checked against its limit when it is pulled. A schedule's + upcoming tick is refused, since the schedule would queue that tick again. + operationId: runQueuedJobNow + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: job id + content: + text/plain: + schema: + type: string + /w/{workspace}/jobs/queue/cancel_selection: post: summary: cancel jobs based on the given uuids @@ -31177,6 +31200,7 @@ components: - "jobs" - "jobs.cancel" - "jobs.force_cancel" + - "jobs.run_now" - "jobs.disapproval" - "jobs.delete" - "account.delete" diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 052c9df4bb..b9e71271d4 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -116,7 +116,9 @@ use windmill_common::{ query_builders, scripts::{ScriptHash, ScriptLang}, users::username_to_permissioned_as, - utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath}, + utils::{ + not_found_if_none, now_from_db, paginate, require_admin, Pagination, ScheduleType, StripPath, + }, }; use windmill_common::{ @@ -304,6 +306,7 @@ pub fn workspaced_service() -> Router { .route("/queue/position/{timestamp}", get(get_queue_position)) .route("/queue/scheduled_for/{id}", get(get_scheduled_for)) .route("/queue/cancel_selection", post(cancel_selection)) + .route("/queue/run_now/{id}", post(run_queued_job_now)) .route("/completed/count", get(count_completed_jobs)) .route("/completed/count_jobs", get(count_completed_jobs_detail)) .route( @@ -835,6 +838,113 @@ async fn force_cancel( } } +/// Moves a queued job's `scheduled_for` to now so the next pull picks it up, keeping its id. +/// A job with a concurrency limit still goes through the usual check at pull time and is +/// re-scheduled again if its key is still saturated. +async fn run_queued_job_now( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::Result { + require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?; + refuse_upcoming_schedule_tick(&db, &w_id, id).await?; + + let mut tx = db.begin().await?; + let previous = sqlx::query_scalar!( + "UPDATE v2_job_queue q SET scheduled_for = now() + FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev + WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL + AND q.scheduled_for > now() + RETURNING prev.scheduled_for", + id, + w_id, + ) + .fetch_optional(&mut *tx) + .await?; + + let Some(previous) = previous else { + tx.commit().await?; + return Err( + if sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", + id, + w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false) + { + Error::BadRequest(format!( + "job {id} is not waiting for a future start: it is running, suspended, \ + canceled or already due" + )) + } else { + Error::NotFound(format!("queued job id {id} does not exist")) + }, + ); + }; + + audit_log( + &mut *tx, + &authed, + "jobs.run_now", + ActionKind::Update, + &w_id, + Some(&id.to_string()), + Some([("previous_scheduled_for", previous.to_rfc3339().as_str())].into()), + ) + .await?; + tx.commit().await?; + + windmill_queue::append_logs( + &id, + &w_id, + format!( + "\nStart moved from {previous} to now by {}\n", + authed.display_username() + ), + &db.clone().into(), + ) + .await; + + Ok(id.to_string()) +} + +/// A schedule queues its next tick when the current one completes, computed from the +/// completion time. Starting a tick that is not yet due would therefore queue that same +/// tick again and run the schedule twice. A tick pushed past its time by a concurrency +/// limit no longer sits on a cron occurrence, so it may still be started early. +async fn refuse_upcoming_schedule_tick(db: &DB, w_id: &str, id: Uuid) -> error::Result<()> { + let Some(tick) = sqlx::query!( + "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone + FROM v2_job j + JOIN v2_job_queue q USING (id) + JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger + WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule' + AND j.parent_job IS NULL AND q.scheduled_for > now()", + id, + w_id, + ) + .fetch_optional(db) + .await? + else { + return Ok(()); + }; + let sched = ScheduleType::from_str(&tick.schedule, tick.cron_version.as_deref(), false)?; + let tz = + chrono_tz::Tz::from_str(&tick.timezone).map_err(|e| Error::BadRequest(e.to_string()))?; + let just_before = (tick.scheduled_for - chrono::Duration::milliseconds(1)).with_timezone(&tz); + if sched.find_next(&just_before)? == tick.scheduled_for { + return Err(Error::BadRequest(format!( + "job {id} is the upcoming tick of schedule {}; run the schedule's script or flow \ + directly instead", + tick.path + ))); + } + Ok(()) +} + #[derive(Serialize)] struct QueuePosition { position: Option, diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index 96d341dc72..4090a39dbb 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -149,6 +149,7 @@ JOBS: 'jobs', JOBS_CANCEL: 'jobs.cancel', JOBS_FORCE_CANCEL: 'jobs.force_cancel', + JOBS_RUN_NOW: 'jobs.run_now', JOBS_DISAPPROVAL: 'jobs.disapproval', JOBS_DELETE: 'jobs.delete', JOBS_SHARE_PUBLICLY: 'jobs.share_publicly', diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 932a76ae94..5fc7c1209d 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -40,8 +40,10 @@ EllipsisVertical, Share2, Globe, - Users + Users, + Play } from 'lucide-svelte' + import { forLater } from '$lib/forLater' import { isJobResolvable } from '$lib/utils' import { @@ -280,6 +282,27 @@ } } + // A schedule's upcoming tick sits on a whole second and the backend refuses it; a tick + // deferred by a concurrency limit lands on a sub-second instant and can still start now. + let canRunNow = $derived( + job?.type === 'QueuedJob' && + !job.running && + !job.suspend && + !!job.scheduled_for && + forLater(job.scheduled_for) && + !(job.schedule_path && new Date(job.scheduled_for).getMilliseconds() === 0) + ) + + async function runJobNow(id: string) { + try { + await JobService.runQueuedJobNow({ workspace: $workspaceStore!, id }) + sendUserToast(`job ${id} will start as soon as a worker is available`) + getJob() + } catch (err) { + sendUserToast(`could not start job now: ${err?.body ?? err}`, true) + } + } + // Initialize view tab to logs since result is now outside tabs function initView(): void { // Result is now displayed outside tabs, so always default to logs @@ -886,6 +909,17 @@ Current runs {/if} + {#if canRunNow} + + {/if} {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} {#if !forceCancel}