mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 08:01:38 +00:00
feat(ai): gate data pipelines in sessions behind a dev flag (#10178)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1624,6 +1624,7 @@ export class AIChatManager {
|
||||
// workspace instead, so leave it unset there — allowedOpenPages reads the store.
|
||||
...(this.isSessionChat
|
||||
? {
|
||||
isSessionChat: true,
|
||||
sessionId: this.sessionId,
|
||||
operatingWorkspace: this.operatingWorkspace,
|
||||
artifacts: this.artifacts,
|
||||
|
||||
@@ -3416,6 +3416,71 @@ describe('folder tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('session pipeline gate', () => {
|
||||
const FLAG = 'wm_dev_session_pipelines'
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(FLAG)
|
||||
})
|
||||
|
||||
it('replaces the session prompt pipeline guidance with the alpha notice by default', () => {
|
||||
const content = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string
|
||||
expect(content).toContain('Data pipelines are in alpha and NOT yet available in this chat')
|
||||
expect(content).not.toContain('call get_instructions with subject "pipeline"')
|
||||
expect(content).not.toContain('Building a data pipeline: call open_preview')
|
||||
})
|
||||
|
||||
it('restores the session pipeline guidance when the dev flag is set', () => {
|
||||
localStorage.setItem(FLAG, '1')
|
||||
const content = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string
|
||||
expect(content).toContain('call get_instructions with subject "pipeline"')
|
||||
expect(content).toContain('Building a data pipeline: call open_preview')
|
||||
expect(content).not.toContain('Data pipelines are in alpha and NOT yet available in this chat')
|
||||
})
|
||||
|
||||
it('leaves the standalone (non-session) chat pipeline guidance ungated', () => {
|
||||
const content = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string
|
||||
expect(content).toContain('call get_instructions with subject "pipeline"')
|
||||
expect(content).not.toContain('Data pipelines are in alpha and NOT yet available in this chat')
|
||||
})
|
||||
|
||||
it('refuses get_instructions(pipeline) in a session but not outside one', async () => {
|
||||
const inSession = await callGlobalTool('get_instructions', { subject: 'pipeline' }, undefined, {
|
||||
isSessionChat: true,
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
expect(inSession).toContain('data pipelines are in alpha')
|
||||
|
||||
const outsideSession = await callGlobalTool('get_instructions', { subject: 'pipeline' })
|
||||
expect(outsideSession).not.toContain('data pipelines are in alpha')
|
||||
|
||||
// The eval harness passes a sessionId to standalone (non-session) chats;
|
||||
// only the explicit isSessionChat marker may engage the gate.
|
||||
const standaloneWithSessionId = await callGlobalTool(
|
||||
'get_instructions',
|
||||
{ subject: 'pipeline' },
|
||||
undefined,
|
||||
{ sessionId: 'eval-session' }
|
||||
)
|
||||
expect(standaloneWithSessionId).not.toContain('data pipelines are in alpha')
|
||||
})
|
||||
|
||||
it('refuses open_preview(kind=pipeline) while gated', async () => {
|
||||
const handler = vi.fn(() => 'opened')
|
||||
setOpenPreviewHandler(handler)
|
||||
try {
|
||||
const gated = await callGlobalTool('open_preview', { kind: 'pipeline', path: 'my_folder' })
|
||||
expect(gated).toContain('data pipelines are in alpha')
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
|
||||
localStorage.setItem(FLAG, '1')
|
||||
const opened = await callGlobalTool('open_preview', { kind: 'pipeline', path: 'my_folder' })
|
||||
expect(opened).toBe('opened')
|
||||
} finally {
|
||||
setOpenPreviewHandler(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareGlobalSystemMessage', () => {
|
||||
it('keeps global chat draft instructions concise and user-facing', () => {
|
||||
const message = prepareGlobalSystemMessage()
|
||||
|
||||
@@ -152,6 +152,7 @@ import {
|
||||
setEphemeralSecretVariableDraftValue,
|
||||
type DraftPersistResult
|
||||
} from './userDraftAdapter'
|
||||
import { isSessionPipelinesEnabled, SESSION_PIPELINES_GATED_MESSAGE } from './pipelineGate'
|
||||
|
||||
const ITEM_TYPES = [
|
||||
'script',
|
||||
@@ -903,6 +904,12 @@ const buildGlobalSystemPrompt = (
|
||||
) => {
|
||||
const folderGuidance = buildFolderGuidance(username, folderCtx)
|
||||
const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : ''
|
||||
// `previewTools` doubles as "this is a session chat" — sessions are the only
|
||||
// chats that get the preview tool set, and the only surface the gate covers.
|
||||
const pipelinesGated = previewTools && !isSessionPipelinesEnabled()
|
||||
const pipelineBullet = pipelinesGated
|
||||
? `- Data pipelines are in alpha and NOT yet available in this chat. A "data pipeline" is a DAG of independent annotated scripts wired by storage assets — it is not a flow. If the user asks for a data pipeline (or to ingest/transform/materialize data across steps in one), tell them that data pipelines are in alpha and will be handled by the chat soon — do not build one (no annotated pipeline scripts, no get_instructions with subject "pipeline"), and do not build a flow as a substitute.`
|
||||
: `- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on <ref>\` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow.`
|
||||
return `You are Windmill's global workspace assistant.
|
||||
|
||||
The current user's workspace username is "${username}".
|
||||
@@ -925,7 +932,7 @@ Rules:
|
||||
- Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable".
|
||||
- Use search_resource_types before write_resource.
|
||||
- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
|
||||
- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on <ref>\` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow.
|
||||
${pipelineBullet}
|
||||
- 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.
|
||||
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run.
|
||||
- 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"). 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.
|
||||
@@ -934,8 +941,12 @@ Rules:
|
||||
- Keep context targeted.${
|
||||
previewTools
|
||||
? `
|
||||
- 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="<folder>") 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.
|
||||
- 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.${
|
||||
pipelinesGated
|
||||
? ''
|
||||
: `
|
||||
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") 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.`
|
||||
}
|
||||
- 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").
|
||||
- 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.<id> call), call list_app_runs to get that run's job_id from the live preview, then get_job_logs with it. Use this when a backend call errors or returns something unexpected.
|
||||
${
|
||||
@@ -2127,8 +2138,19 @@ export const globalTools: Tool<{}>[] = [
|
||||
'get_instructions',
|
||||
'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or the datatable SQL SDK (wmill.datatable()) used inside runnables.'
|
||||
),
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
fn: async (ctx) => {
|
||||
const { args, toolId, toolCallbacks } = ctx
|
||||
const parsed = getInstructionsSchema.parse(args)
|
||||
// Session chats (explicit isSessionChat helper) don't get pipeline
|
||||
// authoring while pipelines are gated; the standalone global chat keeps it.
|
||||
if (
|
||||
parsed.subject === 'pipeline' &&
|
||||
isSessionChatFromCtx(ctx) &&
|
||||
!isSessionPipelinesEnabled()
|
||||
) {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Data pipelines are in alpha' })
|
||||
return SESSION_PIPELINES_GATED_MESSAGE
|
||||
}
|
||||
const label =
|
||||
parsed.subject === 'script' && parsed.language
|
||||
? `${parsed.subject} (${parsed.language})`
|
||||
@@ -3047,12 +3069,20 @@ export type GlobalToolHelpers = SessionToolHelpers & {
|
||||
artifacts?: SessionArtifactsStore
|
||||
getChatId?: () => string | undefined
|
||||
openArtifact?: (artifactId: string, name: string) => void
|
||||
// Explicit "this chat is an AI session" marker for session-scoped gating
|
||||
// (the pipeline gate). Do NOT infer it from `sessionId`: the eval harness
|
||||
// passes a sessionId to its standalone (non-session) chats too.
|
||||
isSessionChat?: boolean
|
||||
}
|
||||
|
||||
function sessionIdFromCtx(ctx: { helpers?: unknown }): string | undefined {
|
||||
return (ctx.helpers as GlobalToolHelpers | undefined)?.sessionId
|
||||
}
|
||||
|
||||
function isSessionChatFromCtx(ctx: { helpers?: unknown }): boolean {
|
||||
return (ctx.helpers as GlobalToolHelpers | undefined)?.isSessionChat === true
|
||||
}
|
||||
|
||||
function operatingWorkspaceFromHelpers(helpers: unknown): string | undefined {
|
||||
return (helpers as GlobalToolHelpers | undefined)?.operatingWorkspace
|
||||
}
|
||||
@@ -3087,6 +3117,10 @@ function openSessionPreview(
|
||||
if (!openPreviewHandler) {
|
||||
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
|
||||
}
|
||||
// open_preview only exists in sessions, so no sessionId check is needed here.
|
||||
if (args.kind === 'pipeline' && !isSessionPipelinesEnabled()) {
|
||||
return SESSION_PIPELINES_GATED_MESSAGE
|
||||
}
|
||||
return openPreviewHandler({ ...args, sessionId })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Dev-only gate for data pipelines inside AI sessions.
|
||||
*
|
||||
* Data pipelines are in alpha: a session chat must not build them for regular
|
||||
* users yet — it tells the user pipelines will be handled by the chat soon
|
||||
* instead. Developers/QA enable the session pipeline surface in their browser
|
||||
* with:
|
||||
*
|
||||
* localStorage.setItem('wm_dev_session_pipelines', '1')
|
||||
*
|
||||
* and reload the page. To disable, remove the key or set it to anything else.
|
||||
*
|
||||
* Only the session surface is gated: the standalone global side-panel chat and
|
||||
* the full-page /pipeline editor keep their pipeline support (the eval harness
|
||||
* exercises the former in Node, where this gate must never engage). When
|
||||
* pipelines ship in sessions, replace every call to
|
||||
* `isSessionPipelinesEnabled()` with `true` and delete this file. The
|
||||
* references are intentionally narrow (the session system-prompt bullets, the
|
||||
* get_instructions/open_preview execution guards, and the session preview
|
||||
* router) so the rip-out is a small grep.
|
||||
*/
|
||||
const STORAGE_KEY = 'wm_dev_session_pipelines'
|
||||
|
||||
export function isSessionPipelinesEnabled(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Tool result returned when a gated session chat still tries a pipeline
|
||||
* action — instructs the model to deliver the alpha notice instead. */
|
||||
export const SESSION_PIPELINES_GATED_MESSAGE =
|
||||
'Not available: data pipelines are in alpha and this chat cannot build them yet. Tell the user that data pipelines are in alpha and will be handled by the chat soon. Do not write annotated pipeline scripts manually, and do not build a flow as a substitute.'
|
||||
@@ -93,12 +93,21 @@ describe('resolvePreviewTab', () => {
|
||||
expect(resolvePreviewTab('/apps/edit/f/a/b')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('routes a pipeline folder to the pipeline editor kind', () => {
|
||||
expect(resolvePreviewTab('/pipeline/my_folder')).toEqual({
|
||||
kind: 'editor',
|
||||
editorKind: 'pipeline',
|
||||
path: 'my_folder'
|
||||
})
|
||||
it('routes a pipeline folder to the pipeline editor kind when session pipelines are enabled', () => {
|
||||
localStorage.setItem('wm_dev_session_pipelines', '1')
|
||||
try {
|
||||
expect(resolvePreviewTab('/pipeline/my_folder')).toEqual({
|
||||
kind: 'editor',
|
||||
editorKind: 'pipeline',
|
||||
path: 'my_folder'
|
||||
})
|
||||
} finally {
|
||||
localStorage.removeItem('wm_dev_session_pipelines')
|
||||
}
|
||||
})
|
||||
|
||||
it('routes a pipeline folder to the iframe fallback while session pipelines are gated', () => {
|
||||
expect(resolvePreviewTab('/pipeline/my_folder')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('routes the bare pipeline list page to the iframe fallback', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'lucide-svelte'
|
||||
import type { DrillIcon } from '$lib/components/drillPicker'
|
||||
import type { WorkspaceItem, WorkspaceItemKind } from '$lib/components/workspacePicker'
|
||||
import { isSessionPipelinesEnabled } from '$lib/components/copilot/chat/global/pipelineGate'
|
||||
import type { SessionTargetKind } from './sessionRuntime.svelte'
|
||||
|
||||
/** What the preview breadcrumb picker can route to: a static workspace page
|
||||
@@ -185,8 +186,13 @@ export type PreviewSlot =
|
||||
export function resolvePreviewTab(url: string): PreviewSlot {
|
||||
const artifact = parseArtifactRoute(url)
|
||||
if (artifact) return { kind: 'artifact', id: artifact.id }
|
||||
// While session pipelines are gated, a /pipeline/<folder> tab stays a plain
|
||||
// iframe: mounting the in-realm pipeline editor would register the pipeline
|
||||
// canvas tools on the session chat, un-gating it through the back door.
|
||||
const pipelineFolder = parsePipelineRoute(url)
|
||||
if (pipelineFolder) return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
|
||||
if (pipelineFolder && isSessionPipelinesEnabled()) {
|
||||
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
|
||||
}
|
||||
const route = parsePreviewItemRoute(url)
|
||||
if (!route) return { kind: 'iframe' }
|
||||
const editorKind: SessionTargetKind | undefined =
|
||||
|
||||
Reference in New Issue
Block a user