From 6cd70a8e08cf4869cb52ef554bc89d5d13fadf5f Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:59:54 +0200 Subject: [PATCH 01/13] feat: filter ai session tools to the user's workspace capabilities (#10719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: filter ai session tools to the user's workspace capabilities Co-Authored-By: Claude Opus 5 (1M context) * docs: correct and tighten comments on the session capability filter Co-Authored-By: Claude Opus 5 (1M context) * fix: gate session deploy tools on DisableDirectDeployment and the pipeline prompt * fix: gate create_folder on the deploy capability * refactor: assemble session prompt and tools through one seam * docs: state the capability filter as best-effort, not a guarantee * refactor: take the whole deploy gate from the shared preflight `checkDeployPermission` now evaluates `DisableDirectDeployment` and folds superadmin into the admin bypass itself, so the resolver's local composition of those two terms is redundant. Delegate outright and drop the protection-rule fetch it needed, along with the two tests that restated rule semantics the preflight's own suite now pins. The preflight's per-kind narrowing stays unused: the filter runs on tool names, before the model has named a kind, so a direct-deployment lock withholds the deploy tools for schedules and triggers too. * fix: address review findings on the session capability filter Six findings from the Claude and Codex review rounds. - `discard_local_draft` is ungated. The backend exempts discarding your OWN draft from `require_can_write_path` precisely so drafts stay cleanable after a role change; gating it stranded that cleanup. - `deploy` splits into `deploy` and `deploy_gated_kinds`, mirroring `deployPermissionForKind`. A direct-deployment lock stops only the kinds that reach `check_deploy_rules`, so schedules and triggers stay deployable and the two kind-taking deploy tools survive the lock; `create_folder` does not, folder being a gated kind. The prompt now names the lock and what it leaves deployable, instead of implying nothing can be deployed. - `COVERED_ENDPOINTS` keyed `createApp` / `updateApp`, which the MCP catalog does not expose; the app-authoring endpoints it does expose, `createAppRawSource` and `updateAppRawSource`, were uncovered and reachable through `call_api_endpoint`. - The YOLO tooltip listed tools a restricted session never ships. Both it and the token estimate now read one `shippedTools`, and `sessionAccess` is reactive so the UI follows the resolution. * fix: restore the covered API-catalog names for the raw-app endpoints `COVERED_ENDPOINTS` is matched against `EndpointTool.name`, which openapi.yaml overrides with `x-mcp-tool-name` for these two operations: `createAppRawSource` and `updateAppRawSource` are served as `createApp` and `updateApp` (`mcp/auto_generated_endpoints.rs`). Keying them by operationId left both raw-app POST endpoints discoverable and callable through the API catalog tools. Restore the exposed names and record why they differ from the operationIds. * docs: state each capability invariant once, and document the draft discard The asymmetric admin/operator precedence was restated three times in sessionAccess.ts and again in its test, the fail-open rationale twice, and the deploy split across four sites. Each now lives at the one place someone would break it, within the four-line budget, with the other sites pointing at it. Ungating discard_local_draft left it undocumented for the read-only profile, which is the profile the backend exemption exists for: the only bullet naming it sits under the draft-writing gate, beneath an opener saying no change is possible. Add the one line that profile needs. * docs: record why the session tool filter runs unconditionally The filter would strip everything from a non-GLOBAL toolset, whose names carry no policy entries. That cannot happen — `changeMode` refuses to move a session chat out of GLOBAL, and `sessionAccess` is only ever set for session chats — but the dependency was not visible at the filter itself. * fix: match the server's deploy gate exactly, never exceed it The filter must be as strict as the server and no stricter. Schedules and triggers reach no deploy rule — `check_deploy_rules` runs only from the gated kinds' handlers — so no workspace refuses `deploy_workspace_item` or `delete_workspace_item` outright, whatever refusal `checkDeployPermission` reports. Gating them on a deploy capability withheld operations the server performs. Neither tool now requires a capability. Deploying still needs a draft to deploy, so it keeps the authoring relevance; deleting a deployed item does not, so it is ungated. `deploy` returns to one capability, covering the kinds the rules gate, and `create_folder` — whose kind is one of them — is the only tool that names it. The session-state note now states which kinds a refusing workspace still accepts. * feat: gate the new app-runnable preview tool on run_preview Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: fail open when whoami resolves without a role Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: count plan-mode tools in the shipped toolset Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * docs: drop the dead capability assertion and the repeated deploy rationale Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: drop dead code and a duplicated invariant from the session filter Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: reduce SessionAccess to the capability set it is read for Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: gate session tools on permission alone, never on relevance Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * test: pin the filter to the outbound request and widen the description sweep Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: collapse SessionAccess to a capability set and merge adjacent gates Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: gate get_db_schema on run_preview, it runs a query script Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: reuse the cached workspace role and derive the exhaustiveness list from assembly Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * revert: keep tool names in descriptions that ship with them Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: let an admin who is also an operator deploy, as the server does Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: derive the deploy capability from the protection rules alone Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: stop the datatable instructions naming a tool a session may not have Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: keep the prompt and tool results honest for a profile that cannot draft Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: move tool policies onto the tools and gate kinds per handler Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * chore: tighten stale comments and name deploy in the operator prompt Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: keep the assembled tool list raw so narrowing can clone its defs Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: point an operator at a workspace admin for code the role refuses Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: resolve session permissions when the assistant settings modal opens Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: return per-chat tool schemas instead of writing them to shared tools Co-Authored-By: Claude Opus 5.5 (1M context) * fix: forward this through the eval tool wrapper so tools see their sent def Co-Authored-By: Claude Opus 5.5 (1M context) * test: pin identity and contents in the session filter and schema tests Co-Authored-By: Claude Opus 5.5 (1M context) * docs: note why the overhead estimate skips per-chat tool schemas Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../frontend/core/shared/baseEvalRunner.ts | 6 +- .../copilot/chat/AIChatManager.svelte.ts | 69 ++-- .../copilot/chat/AIChatManager.test.ts | 74 ++++- .../chat/AssistantSettingsModal.svelte | 1 + .../copilot/chat/artifacts/artifactTools.ts | 10 +- .../components/copilot/chat/chatLoop.test.ts | 26 ++ .../lib/components/copilot/chat/chatLoop.ts | 15 +- .../components/copilot/chat/datatableTools.ts | 8 +- .../lib/components/copilot/chat/docs/core.ts | 8 +- .../components/copilot/chat/ducklakeTools.ts | 7 +- .../copilot/chat/files/fileTools.ts | 11 +- .../lib/components/copilot/chat/flow/core.ts | 11 +- .../copilot/chat/global/core.test.ts | 26 +- .../components/copilot/chat/global/core.ts | 306 +++++++++++++++--- .../copilot/chat/global/globalAssembly.ts | 8 +- .../copilot/chat/global/mcpTools.ts | 9 +- .../copilot/chat/global/sessionAccess.test.ts | 91 ++++++ .../copilot/chat/global/sessionAccess.ts | 68 ++++ .../chat/global/sessionToolset.test.ts | 219 +++++++++++++ .../copilot/chat/global/sessionToolset.ts | 46 +++ .../components/copilot/chat/pipeline/core.ts | 33 +- .../copilot/chat/planModeController.svelte.ts | 16 +- .../components/copilot/chat/script/core.ts | 9 +- .../copilot/chat/sessionCapabilities.ts | 52 +++ .../src/lib/components/copilot/chat/shared.ts | 7 +- frontend/src/lib/utils_workspace_deploy.ts | 52 ++- 26 files changed, 1040 insertions(+), 148 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/sessionAccess.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/sessionToolset.ts create mode 100644 frontend/src/lib/components/copilot/chat/sessionCapabilities.ts diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts index 2e29b9064d..3d0c6d6734 100644 --- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts @@ -98,9 +98,11 @@ export async function runEval( // Wrap tools to intercept fn calls for tracking. // Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type // but the actual callbacks passed at runtime will satisfy both interfaces. + // `fn` forwards `this`: the chat loop calls it on a per-iteration copy carrying the def the + // model was sent, and a tool reading `this.def` must see that one, as it does in production. const wrappedTools = tools.map((tool) => ({ ...tool, - fn: async (p: any) => { + fn: async function (this: unknown, p: any) { toolCallsCount++; toolsCalled.push(tool.def.function.name); let argumentsText = ""; @@ -120,7 +122,7 @@ export async function runEval( toolName: tool.def.function.name, argumentsText, }); - return tool.fn(p); + return tool.fn.call(this, p); }, })); diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 74a8b4d627..9803735571 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -125,6 +125,9 @@ import { isWebSearchEnabledForProvider } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' +import { resolveSessionAccess } from './global/sessionAccess' +import type { SessionAccess } from './sessionCapabilities' +import { filterSessionTools } from './global/sessionToolset' import { loadWorkspaceSkills, resolveGlobalPromptIdentity, @@ -696,6 +699,10 @@ export class AIChatManager implements ChatViewHost { // needs the preview pane; the global side-panel chat leaves it false. Reactive because // `planModeAvailable` derives from it. isSessionChat = $state(false) + // Undefined until a send or the assistant settings modal resolves it. Reactive: both + // tool views derive from it. + private sessionAccess = $state(undefined) + private sessionAccessGeneration = 0 autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) autoAcceptEditsActive = $derived( this.autoAcceptEditsAvailable && @@ -719,15 +726,21 @@ export class AIChatManager implements ChatViewHost { }) /** 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[]>([]) + * carries. Raw: every write replaces the list, and a deep proxy would reach the tool + * defs, which the session filter copies with `structuredClone` — that throws on a proxy. */ + #assembledTools = $state.raw[]>([]) + /** Both views narrow the same way — concatenate every source, then filter — so a tool + * reaching either one can never arrive unfiltered. A session withholds what its user's + * capabilities do not cover; elsewhere `sessionAccess` is unset and nothing is dropped. */ + #shipped = (planTools: Tool[]): Tool[] => + filterSessionTools([...this.#assembledTools, ...planTools], this.sessionAccess) /** 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]) + tools: Tool[] = $derived(this.#shipped(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]) + availableTools: Tool[] = $derived(this.#shipped(this.planMode.availableTools)) helpers = $state(undefined) scriptEditorOptions = $state(undefined) @@ -1427,10 +1440,12 @@ export class AIChatManager implements ChatViewHost { typeof this.systemMessage.content === 'string' ? this.systemMessage.content.length / tokenPerCharacter : 0 + const tools = this.tools + // Counts each tool's shared `def`, not the per-chat one `schemaFor` builds at send time, + // so a flow's input schema is missed. Accepted: the estimate only stands in until the + // provider reports usage, and the compaction trigger's headroom absorbs the gap. const toolTokens = - this.tools.length > 0 - ? JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter - : 0 + tools.length > 0 ? JSON.stringify(tools.map((t) => t.def)).length / tokenPerCharacter : 0 return systemTokens + toolTokens } @@ -2332,8 +2347,9 @@ export class AIChatManager implements ChatViewHost { } ) { if (!isAIModeVisible(mode)) return - // A session chat is GLOBAL for its whole life, and the plan gate reads that mode: moving - // it lifts the gate on a session the user still has set to Plan. + // A session chat is GLOBAL for its whole life, and two things read that mode: moving it + // lifts the plan gate on a session the user still has set to Plan, and hands the + // capability filter tools that declare no `requires`, so it fails closed to nothing. if (this.isSessionChat && mode !== AIMode.GLOBAL) { console.error(`Refusing to move a session chat to ${mode} mode: sessions are GLOBAL-only.`) return @@ -2435,6 +2451,7 @@ export class AIChatManager implements ChatViewHost { sessionId: this.sessionId, operatingWorkspace: this.operatingWorkspace, artifacts: this.artifacts, + access: this.sessionAccess, getChatId: () => this.historyManager.getCurrentChatId(), openArtifact: this.openArtifact } @@ -2466,6 +2483,7 @@ export class AIChatManager implements ChatViewHost { user: this.globalIdentity, skills: this.globalSkills, mcpServers: this.mcpServers, + access: this.sessionAccess, sessionContext: this.sessionContextResolver?.(), pipelineContext: this.pipelineAiChatHelpers?.getPipelineContext() }) @@ -2540,6 +2558,23 @@ export class AIChatManager implements ChatViewHost { this.systemMessage = { ...target, content: `${target.content}\n\n${section}` } } + // Same shape as refreshGlobalSkills. Re-resolved per send rather than once for the + // session's life: the operating workspace can change between sends, and a transient + // failure resolves fail-open, so the next message re-asks rather than keeping that answer. + refreshSessionAccess = async (workspace = this.operatingWorkspace ?? '') => { + if (!this.isSessionChat || !workspace) { + this.sessionAccess = undefined + return + } + const generation = ++this.sessionAccessGeneration + const access = await resolveSessionAccess(workspace) + if (generation !== this.sessionAccessGeneration) return + this.sessionAccess = workspace === (this.operatingWorkspace ?? '') ? access : undefined + if (this.mode === AIMode.GLOBAL) { + this.configureGlobalMode() + } + } + // Rebuild the GLOBAL system message in place so an updated user instruction (persisted by // the update_user_instructions tool) is picked up on the next chat-loop iteration, which // re-reads this.systemMessage via a getter. @@ -3043,13 +3078,8 @@ export class AIChatManager implements ChatViewHost { console.error('Failed to record AI usage', e) } }, - onBeforeIteration: async (tools, _helpers, modelProvider) => { + onBeforeIteration: async (modelProvider) => { this.lastIterationModel = modelProvider - for (const tool of tools) { - if (tool.setSchema) { - await tool.setSchema(this.helpers) - } - } } }) if (this.isSessionChat && this.sessionId && result.tokenUsage.total > 0) { @@ -3553,15 +3583,16 @@ export class AIChatManager implements ChatViewHost { return false } } - // Session chats commit their workspace in beforeSend; the identity, skills and - // MCP servers must all match the committed workspace before the system prompt is - // sent. Settling them here rather than mid-turn also keeps the prompt — the + // 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. if (this.mode === AIMode.GLOBAL) { await Promise.all([ this.refreshGlobalIdentity(this.operatingWorkspace ?? ''), this.refreshGlobalSkills(this.operatingWorkspace ?? ''), - this.refreshMcpServers(this.operatingWorkspace ?? '') + this.refreshMcpServers(this.operatingWorkspace ?? ''), + this.refreshSessionAccess(this.operatingWorkspace ?? '') ]) } // Stop/Escape during the beforeSend pre-flight aborted this send before any diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 2962925844..3b8470fc2e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { writable } from 'svelte/store' import type { FlowAIChatHelpers } from './flow/core' import type { PipelineAIChatHelpers } from './pipeline/core' @@ -1679,7 +1679,7 @@ describe('AIChatManager queued messages', () => { mocks.tryGetCurrentModel.mockReturnValue(a) mocks.runChatLoop.mockImplementation(async (config: any) => { // an iteration starts on B... - await config.onBeforeIteration?.([], config.helpers, b) + await config.onBeforeIteration?.(b) // ...the user switches to C while B's request is in flight... mocks.getCurrentModel.mockReturnValue(c) mocks.tryGetCurrentModel.mockReturnValue(c) @@ -1792,7 +1792,7 @@ describe('AIChatManager queued messages', () => { // mid-loop switch to a model the deny-list doesn't know... mocks.getCurrentModel.mockReturnValue(unlistedBlind) mocks.tryGetCurrentModel.mockReturnValue(unlistedBlind) - await config.onBeforeIteration?.([], config.helpers, unlistedBlind) + await config.onBeforeIteration?.(unlistedBlind) // ...its request carries the images and the provider rejects them throw new Error('400 this model does not support image input') }) @@ -4565,6 +4565,7 @@ describe('AIChatManager tool views', () => { vi.clearAllMocks() mocks.getCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' }) mocks.tryGetCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' }) + clearWorkspaceRoleCache() }) // Every mode assigns the private base separately. A missed site leaves it empty on a fresh @@ -4584,6 +4585,73 @@ describe('AIChatManager tool views', () => { expect(manager.availableTools.length).toBeGreaterThan(0) }) + const OPERATOR_WHOAMI = { + username: 'op', + email: 'admin@test', + is_admin: false, + is_super_admin: false, + operator: true, + groups: [], + folders: [], + folders_read: [] + } + + // The capability filter is unit-tested against a profile handed to it directly; what is + // untested there is that the manager ever hands it one. This drives a real send and reads + // the toolset off the request, so moving the resolve out of the pre-flight, or dropping + // the rebuild when it lands, fails here rather than shipping an operator write tools. + it('withholds write and preview tools from an operator for the whole request', async () => { + onTestFinished(() => mocks.whoami.mockReset()) + mocks.whoami.mockResolvedValue(OPERATOR_WHOAMI) + let sent: { tools: string[]; prompt: string; deployKinds: string[] } | undefined + mocks.runChatLoop.mockImplementation(async (config: any) => { + const deploy = config.tools.find((t: any) => t.def.function.name === 'deploy_workspace_item') + sent = { + tools: config.tools.map((t: any) => t.def.function.name), + prompt: config.systemMessage.content, + deployKinds: deploy?.def.function.parameters.properties.type.enum ?? [] + } + return { + addedMessages: [], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + hitMaxIterations: false + } + }) + + const manager = new AIChatManager() + manager.isSessionChat = true + await manager.sendRequest({ instructions: 'add a script', mode: AIMode.GLOBAL }) + + expect(sent?.tools).toEqual(expect.arrayContaining(['list_workspace_items', 'run_script'])) + // Deploying ships — a draft can predate the role change — but only for the kinds an + // operator's token can land. + expect(sent?.deployKinds).toContain('schedule') + expect(sent?.deployKinds).not.toContain('script') + for (const withheld of ['write_script', 'test_run_script']) { + expect(sent?.tools).not.toContain(withheld) + // The prompt ships beside the tools, so the rebuild must have run after the + // profile landed — otherwise it still instructs the model to call these. + expect(sent?.prompt).not.toContain(withheld) + } + }) + + // The assistant settings modal resolves the profile on open, before any send, so the + // list it shows is the one the first request will carry, prompt included. + it('narrows the toolset and prompt when the profile resolves outside a send', async () => { + onTestFinished(() => mocks.whoami.mockReset()) + mocks.whoami.mockResolvedValue(OPERATOR_WHOAMI) + const manager = new AIChatManager() + manager.mode = AIMode.GLOBAL + manager.isSessionChat = true + manager.configureGlobalMode() + expect(manager.availableTools.map((t) => t.def.function.name)).toContain('write_script') + + await manager.refreshSessionAccess() + + expect(manager.availableTools.map((t) => t.def.function.name)).not.toContain('write_script') + expect(manager.systemMessage.content).not.toContain('write_script') + }) + // 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 () => { diff --git a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte index 1591b3f1b5..8e299a4d3e 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte @@ -149,6 +149,7 @@ callers that already know which section they mean, such as the "+" menu's Manage if (!aiChatManager.loading && !aiChatManager.sendInFlight) { void aiChatManager.refreshGlobalSkills() void aiChatManager.refreshMcpServers() + void aiChatManager.refreshSessionAccess() } } 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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