From ebeea514926f60295f3f11f516f4ccda3448f829 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Fri, 23 Oct 2026 15:06:37 +0200 Subject: [PATCH] feat: composer pills that teach Windmill from the workspace (#11889) * feat: open empty ai sessions with the home build-with-ai composer * feat: vertically center the empty session composer * fix: drop the stray indent on the session composer's rotating placeholder * fix: give the starter composer a stable accessible name * feat: pick workspace, fork and permission mode from the home composer * fix: hand off the workspace home displays, not only an explicit pick * feat: drop the starter prompt chips from the empty session * feat: home and empty ai sessions share one session composer * fix: keep session draft attachments across home and session page Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop superseded home ai-hidden responses Co-Authored-By: Claude Opus 5.5 (1M context) * fix: load home session starter lazily to keep monaco out of home Co-Authored-By: Claude Opus 5.5 (1M context) * feat: redesign home composer and items list Co-Authored-By: Claude Opus 5.5 (1M context) * feat: align the composer chat icon, share the folder row, add a home preview page Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: drop home tech debt, fix phone layout and keyboard access, remove the home preview page Co-Authored-By: Claude Opus 5.5 (1M context) * feat: suggest composer prompts from workspace signals Co-Authored-By: Claude Opus 5.5 (1M context) * fix: pass dev page placeholders through inputProps, key settled signals by role Co-Authored-By: Claude Opus 5.5 (1M context) * perf: read filter chip positions before hiding any in clampRows Co-Authored-By: Claude Opus 5.5 (1M context) * fix: scope composer signal caches to the account, drop stale workspace answers Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to dd0c7087f9b2453260f3d73b0d589042b4393a1d This commit updates the EE repository reference after PR #900 was merged in windmill-ee-private. Previous ee-repo-ref: e922599943c472a6d0f736f629ae04081f866686 New ee-repo-ref: dd0c7087f9b2453260f3d73b0d589042b4393a1d Automated by sync-ee-ref workflow. * chore: update ee-repo-ref to dd0c7087f9b2453260f3d73b0d589042b4393a1d This commit updates the EE repository reference after PR #900 was merged in windmill-ee-private. Previous ee-repo-ref: e922599943c472a6d0f736f629ae04081f866686 New ee-repo-ref: dd0c7087f9b2453260f3d73b0d589042b4393a1d Automated by sync-ee-ref workflow. * feat: name workspace items and connections in composer pills Co-Authored-By: Claude Opus 5.5 (1M context) * feat: teach one Windmill concept per composer pill from the workspace Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop the failed-deploy pill, which reports a run outcome Co-Authored-By: Claude Opus 5.5 (1M context) * fix: read favorites from the workspace's own item list Co-Authored-By: Claude Opus 5.5 (1M context) * chore: regenerate mcp session guidance for the data table row Co-Authored-By: Claude Opus 5.5 (1M context) * fix: address cubic review of the composer signals and data table guidance Co-Authored-By: Claude Opus 5.5 (1M context) * test: make the billing fixture's receipt step send what its summary says Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep loaded item kinds and drop stale loads in the composer playground Co-Authored-By: Claude Opus 5.5 (1M context) * fix: preview an instance admin who is a plain member in the composer playground Co-Authored-By: Claude Opus 5.5 (1M context) * fix: scope the enterprise note to external_instance and check receipt sends Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Diego Imbert Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- ai_evals/adapters/frontend/mockBackend.ts | 326 ++++++++- .../adapters/frontend/vitestAdapter.test.ts | 86 ++- ai_evals/cases/global.yaml | 623 ++++++++++++++++++ .../initial/composer_connected_workspace.json | 313 +++++++++ .../initial/composer_fresh_workspace.json | 22 + .../global/initial/composer_incidents.json | 83 +++ .../src/mcp/session/gen/instructions.json | 2 +- .../src/mcp/session/gen/tools.json | 2 +- cli/src/guidance/skills.gen.ts | 2 +- .../chat/starter/composerSuggestions.test.ts | 110 ++++ .../chat/starter/composerSuggestions.ts | 387 +++++++++++ .../chat/starter/starterPrompts.svelte.ts | 324 ++++++--- .../chat/starter/starterPrompts.test.ts | 73 ++ .../src/lib/components/home/HomeAIChat.svelte | 9 +- .../dev/composer-suggestions/+page.svelte | 364 ++++++++++ system_prompts/auto-generated/prompts.ts | 2 +- .../skills/workspace-settings/SKILL.md | 2 +- system_prompts/base/workspace-settings.md | 2 +- 18 files changed, 2601 insertions(+), 131 deletions(-) create mode 100644 ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json create mode 100644 ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json create mode 100644 ai_evals/fixtures/frontend/global/initial/composer_incidents.json create mode 100644 frontend/src/lib/components/copilot/chat/starter/composerSuggestions.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts create mode 100644 frontend/src/lib/components/copilot/chat/starter/starterPrompts.test.ts create mode 100644 frontend/src/routes/(root)/(logged)/dev/composer-suggestions/+page.svelte diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 69398555f0..1de463690b 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -11,8 +11,13 @@ import type { ListableApp, ListableResource, ListableVariable, + Folder, + Group, + QueuedJob, Resource, - Script + Schedule, + Script, + WorkerPing } from '../../../frontend/src/lib/gen' import type { DataMetric, @@ -97,6 +102,14 @@ export interface BenchmarkWorkspaceResource { resource_type: string value?: Record description?: string + /** A database's tables, as `get_db_schema` reads them: schema, table, then column. */ + dbSchema?: Record>> +} + +export interface BenchmarkDbColumn { + type: string + required: boolean + default?: string } /** A resource type the instance holds. */ @@ -117,6 +130,48 @@ export interface BenchmarkWorkspaceJob { logs?: string args?: Record result?: unknown + /** Defaults to `completed`. `suspended` is a running flow parked on an approval step. */ + state?: 'completed' | 'queued' | 'running' | 'suspended' + tag?: string + schedulePath?: string + /** When the run was created, relative to the case start. Unset = now. */ + createdMinutesAgo?: number + /** Completed runs only: how long the run took. */ + durationMs?: number +} + +/** A worker of the instance, as `list_workers` reports it. */ +export interface BenchmarkWorkspaceWorker { + worker: string + worker_group: string + custom_tags?: string[] + lastPingSecondsAgo?: number + jobs_executed?: number +} + +/** A folder of the benchmark workspace, as `read_workspace_item` reads it. */ +export interface BenchmarkWorkspaceFolder { + name: string + summary?: string + owners?: string[] + /** Who else may use the folder, as `u/` or `g/`: `true` grants write. */ + extra_perms?: Record +} + +export interface BenchmarkWorkspaceGroup { + name: string + summary?: string + members?: string[] +} + +export interface BenchmarkWorkspaceSchedule { + path: string + /** Six-field cron. */ + schedule: string + script_path: string + is_flow?: boolean + enabled?: boolean + summary?: string } export interface BenchmarkWorkspaceRunnables { @@ -133,15 +188,28 @@ export interface BenchmarkWorkspaceRunnables { ducklakes?: string[] dataMetrics?: DataMetric[] jobs?: BenchmarkWorkspaceJob[] + /** The instance's workers. Unset falls through to the real backend's; `[]` is none. */ + workers?: BenchmarkWorkspaceWorker[] + schedules?: BenchmarkWorkspaceSchedule[] + folders?: BenchmarkWorkspaceFolder[] + groups?: BenchmarkWorkspaceGroup[] + /** The workspace settings document (`get_workspace_settings` reads it). */ + workspaceSettings?: Record } type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' } +type BenchmarkQueuedJob = QueuedJob & { type: 'QueuedJob' } const benchmarkWorkspaces = new Set() const benchmarkWorkspaceRunnables = new Map() // Keyed by `${workspace}::${jobId}` so concurrent attempts (or distinct cases) // can seed the same fixed job id without clobbering each other's entry. -const benchmarkJobs = new Map() +const benchmarkJobs = new Map< + string, + { workspace: string; job: BenchmarkCompletedJob | BenchmarkQueuedJob } +>() +// What set_workspace_settings applied, per workspace, so get_workspace_settings reads it back. +const benchmarkWorkspaceSettings = new Map>() function benchmarkJobKey(workspace: string, jobId: string): string { return `${workspace}::${jobId}` @@ -152,11 +220,12 @@ export function resetBenchmarkMockBackend(): void { benchmarkWorkspaceRunnables.clear() benchmarkJobs.clear() benchmarkDrafts.clear() + benchmarkWorkspaceSettings.clear() } // Stand-in for FolderService.createFolder so the global create_folder tool runs in -// memory instead of mutating the real backend. Folders aren't otherwise modelled -// (no folder-listing in evals), so this just echoes the created name. +// memory instead of mutating the real backend. It echoes the name without adding the +// folder to the fixture folders that `listBenchmarkFolders` reads. export function createBenchmarkFolder(_workspace: string, name: string): string { return name } @@ -180,6 +249,14 @@ export function registerBenchmarkWorkspaceRunnables( }) // Seed any fixture jobs so list_runs / get_run have data to return. for (const seed of runnables.jobs ?? []) { + const createdAt = + seed.createdMinutesAgo === undefined + ? undefined + : new Date(Date.now() - seed.createdMinutesAgo * 60_000).toISOString() + if (seed.state && seed.state !== 'completed') { + createBenchmarkQueuedJob(workspace, seed, createdAt) + continue + } createBenchmarkCompletedJob({ workspace, id: seed.id, @@ -190,14 +267,24 @@ export function registerBenchmarkWorkspaceRunnables( label: seed.label, logs: seed.logs, args: seed.args, - result: seed.result + result: seed.result, + tag: seed.tag, + schedulePath: seed.schedulePath, + createdAt, + durationMs: seed.durationMs }) } + if (runnables.workspaceSettings) { + benchmarkWorkspaceSettings.set(workspace, structuredClone(runnables.workspaceSettings)) + } else { + benchmarkWorkspaceSettings.delete(workspace) + } } export function unregisterBenchmarkWorkspace(workspace: string): void { benchmarkWorkspaces.delete(workspace) benchmarkWorkspaceRunnables.delete(workspace) + benchmarkWorkspaceSettings.delete(workspace) clearBenchmarkDrafts(workspace) for (const [jobId, entry] of benchmarkJobs.entries()) { if (entry.workspace === workspace) { @@ -389,6 +476,15 @@ export function queryBenchmarkResourceTypes( .slice(0, limit) } +/** The tables a fixture seeds for a database resource; `null` when it seeds none. */ +export function getBenchmarkDbSchemaSeed( + workspace: string, + path: string +): { resourceType: string; schema: NonNullable } | null { + const seed = benchmarkWorkspaceRunnables.get(workspace)?.resources?.find((r) => r.path === path) + return seed?.dbSchema ? { resourceType: seed.resource_type, schema: seed.dbSchema } : null +} + /** A seeded resource with its value, as `ResourceService.getResource` returns it. Covers both * seed kinds, so it agrees with `existsResource` and `listResource` — both of those report AI * providers too, and a case that lists resources and then reads one by path would otherwise get @@ -481,18 +577,24 @@ export function createBenchmarkCompletedJob(input: { id?: string createdBy?: string label?: string + tag?: string + schedulePath?: string + createdAt?: string + durationMs?: number }): string { const jobId = input.id ?? `benchmark-job-${randomUUID()}` - const now = new Date().toISOString() + const createdAt = input.createdAt ?? new Date().toISOString() + const durationMs = input.durationMs ?? 0 const job: BenchmarkCompletedJob = { type: 'CompletedJob', id: jobId, workspace_id: input.workspace, created_by: input.createdBy ?? 'ai-evals', - created_at: now, - started_at: now, - completed_at: now, - duration_ms: 0, + created_at: createdAt, + started_at: createdAt, + completed_at: new Date(Date.parse(createdAt) + durationMs).toISOString(), + duration_ms: durationMs, + schedule_path: input.schedulePath, success: input.success ?? true, script_path: input.scriptPath, script_hash: input.scriptHash, @@ -506,7 +608,7 @@ export function createBenchmarkCompletedJob(input: { is_skipped: false, email: 'ai-evals@local', visible_to_owner: true, - tag: 'benchmark', + tag: input.tag ?? 'benchmark', labels: input.label ? [input.label] : undefined } @@ -517,17 +619,58 @@ export function createBenchmarkCompletedJob(input: { return jobId } +/** A run still in the queue: waiting for a worker, running, or a flow parked on an + * approval step (running with a suspend outstanding, as the backend reports it). */ +function createBenchmarkQueuedJob( + workspace: string, + seed: BenchmarkWorkspaceJob, + createdAt = new Date().toISOString() +): string { + const jobId = seed.id ?? `benchmark-job-${randomUUID()}` + const started = seed.state === 'running' || seed.state === 'suspended' + const job: BenchmarkQueuedJob = { + type: 'QueuedJob', + id: jobId, + workspace_id: workspace, + created_by: seed.createdBy ?? 'ai-evals', + created_at: createdAt, + scheduled_for: createdAt, + started_at: started ? createdAt : undefined, + running: started, + suspend: seed.state === 'suspended' ? 1 : undefined, + script_path: seed.scriptPath, + schedule_path: seed.schedulePath, + args: seed.args, + logs: seed.logs, + canceled: false, + job_kind: seed.jobKind ?? 'script', + permissioned_as: 'u/ai-evals', + is_flow_step: false, + email: 'ai-evals@local', + visible_to_owner: true, + tag: seed.tag ?? 'benchmark' + } + benchmarkJobs.set(benchmarkJobKey(workspace, jobId), { workspace, job }) + return jobId +} + export function getBenchmarkCompletedJob( workspace: string, jobId: string ): BenchmarkCompletedJob | null { const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId)) - if (!entry) { + if (!entry || entry.job.type !== 'CompletedJob') { return null } return structuredClone(entry.job) } +/** Any seeded run, finished or not, as `JobService.getJob` returns it. */ +export function getBenchmarkJob(workspace: string, jobId: string): Job | null { + const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId)) + return entry ? (structuredClone(entry.job) as Job) : null +} + /** * List seeded/recorded jobs for a benchmark workspace, most recent first — * the shape `JobService.listJobs` returns. Returns `null` for a non-benchmark @@ -550,7 +693,7 @@ export function listBenchmarkJobs(workspace: string): Job[] | null { * "not found" error for an unknown id, matching the backend 404. */ export function getBenchmarkJobLogs(workspace: string, jobId: string): string { - const job = getBenchmarkCompletedJob(workspace, jobId) + const job = getBenchmarkJob(workspace, jobId) if (!job) { throw new Error(`Job Logs not found for "${jobId}"`) } @@ -562,7 +705,7 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string { * tree. Fixture jobs are single runs with no steps, so only the root entry. */ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { - const job = getBenchmarkCompletedJob(workspace, jobId) + const job = getBenchmarkJob(workspace, jobId) if (!job) { throw new Error(`Job "${jobId}" not found in benchmark workspace`) } @@ -575,8 +718,9 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { depth: 0, sibling_index: 1, sibling_count: 1, - status: job.success ? 'success' : 'failure', - success: job.success + ...('success' in job + ? { status: job.success ? 'success' : 'failure', success: job.success } + : { status: job.suspend ? 'suspended' : job.running ? 'running' : 'queued' }) } ], truncated: false, @@ -584,6 +728,156 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { } } +// ============= Workers, schedules ============= + +/** Mirror `WorkerService.listWorkers`; `null` when no case seeded workers. Workers name no + * workspace, so this answers from whichever case seeded them: run cases whose worker seeds + * differ one at a time. */ +export function listBenchmarkWorkers(): WorkerPing[] | null { + let workers: BenchmarkWorkspaceWorker[] | undefined + for (const workspace of benchmarkWorkspaces) { + workers ??= benchmarkWorkspaceRunnables.get(workspace)?.workers + } + if (!workers) return null + return workers.map((w) => ({ + worker: w.worker, + worker_instance: `${w.worker}-instance`, + worker_group: w.worker_group, + custom_tags: w.custom_tags, + last_ping: w.lastPingSecondsAgo ?? 2, + started_at: BENCHMARK_TIMESTAMP, + ip: '10.0.0.1', + jobs_executed: w.jobs_executed ?? 0, + wm_version: 'benchmark' + })) +} + +function buildBenchmarkSchedule(workspace: string, s: BenchmarkWorkspaceSchedule): Schedule { + return { + path: s.path, + edited_by: 'benchmark', + edited_at: BENCHMARK_TIMESTAMP, + schedule: s.schedule, + timezone: 'Etc/UTC', + enabled: s.enabled ?? true, + script_path: s.script_path, + is_flow: s.is_flow ?? false, + extra_perms: {}, + email: 'ai-evals@local', + summary: s.summary, + workspace_id: workspace + } as unknown as Schedule +} + +export function listBenchmarkSchedules(workspace: string): Schedule[] { + return (benchmarkWorkspaceRunnables.get(workspace)?.schedules ?? []).map((s) => + buildBenchmarkSchedule(workspace, s) + ) +} + +export function getBenchmarkSchedule(workspace: string, path: string): Schedule | null { + const s = benchmarkWorkspaceRunnables.get(workspace)?.schedules?.find((e) => e.path === path) + return s ? buildBenchmarkSchedule(workspace, s) : null +} + +// ============= Folders, groups, workspace settings ============= + +function buildBenchmarkFolder(workspace: string, f: BenchmarkWorkspaceFolder): Folder { + return { + workspace_id: workspace, + name: f.name, + summary: f.summary, + owners: f.owners ?? [], + extra_perms: f.extra_perms ?? {} + } as Folder +} + +export function listBenchmarkFolders(workspace: string): Folder[] { + return (benchmarkWorkspaceRunnables.get(workspace)?.folders ?? []).map((f) => + buildBenchmarkFolder(workspace, f) + ) +} + +export function getBenchmarkFolder(workspace: string, name: string): Folder | null { + const f = benchmarkWorkspaceRunnables.get(workspace)?.folders?.find((e) => e.name === name) + return f ? buildBenchmarkFolder(workspace, f) : null +} + +export function listBenchmarkGroups(workspace: string): Group[] { + return (benchmarkWorkspaceRunnables.get(workspace)?.groups ?? []).flatMap((g) => { + const group = getBenchmarkGroup(workspace, g.name) + return group ? [group] : [] + }) +} + +export function getBenchmarkGroup(workspace: string, name: string): Group | null { + const g = benchmarkWorkspaceRunnables.get(workspace)?.groups?.find((e) => e.name === name) + return g + ? ({ + workspace_id: workspace, + name: g.name, + summary: g.summary, + members: g.members ?? [], + extra_perms: {} + } as Group) + : null +} + +const DATA_TABLE_DATABASE_TYPES = ['postgresql', 'instance', 'external_instance'] + +/** Refuse a data table entry the backend's `DataTableDatabase` would not deserialize. */ +function assertBenchmarkDataTables(value: unknown): void { + if (value === undefined || value === null) return + const datatables = (value as { datatables?: Record }).datatables ?? {} + for (const [name, entry] of Object.entries(datatables)) { + const { database, reference } = (entry ?? {}) as { + database?: { resource_type?: unknown; resource_path?: unknown } + reference?: unknown + } + // A fork's pointer at its parent's entry owns no database. + if (reference && !database) continue + if (!DATA_TABLE_DATABASE_TYPES.includes(String(database?.resource_type))) { + throw new Error( + `datatable.datatables.${name}.database.resource_type must be one of ${DATA_TABLE_DATABASE_TYPES.join(', ')}` + ) + } + if (typeof database?.resource_path !== 'string' || database.resource_path === '') { + throw new Error(`datatable.datatables.${name}.database.resource_path is required`) + } + } +} + +/** Mirror `WorkspaceService.getWorkspaceSettingsDocument`; an unseeded workspace has an + * empty document. */ +export function getBenchmarkWorkspaceSettingsDocument(workspace: string): Record { + return structuredClone(benchmarkWorkspaceSettings.get(workspace) ?? {}) +} + +/** Mirror `WorkspaceService.applyWorkspaceSettingsDocument` for a partial document: each + * top-level key replaces the stored one, `null` clears it. */ +export function applyBenchmarkWorkspaceSettingsDocument(input: { + workspace: string + requestBody: { document: Record; dry_run?: boolean } +}): { + changes: Array<{ key: string; before: unknown; after: unknown }> + applied: boolean +} { + assertBenchmarkDataTables(input.requestBody.document.datatable) + const current = benchmarkWorkspaceSettings.get(input.workspace) ?? {} + const changes = Object.entries(input.requestBody.document) + .filter(([key, after]) => JSON.stringify(current[key] ?? null) !== JSON.stringify(after ?? null)) + .map(([key, after]) => ({ key, before: current[key] ?? null, after: after ?? null })) + if (!input.requestBody.dry_run) { + const next = { ...current } + for (const { key, after } of changes) { + if (after === null) delete next[key] + else next[key] = structuredClone(after) + } + benchmarkWorkspaceSettings.set(input.workspace, next) + } + return { changes, applied: !input.requestBody.dry_run } +} + // ============= Drafts (per-user, DB-backed in production) ============= /** diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index b24ccbe5e4..754de224ac 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -85,12 +85,52 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ vi.mock('$lib/components/vscode', () => ({})) +// get_db_schema reads a database's tables by running a query job; a benchmark database +// answers with the tables its fixture seeds instead. +vi.mock('$lib/components/apps/components/display/dbtable/metadata', async () => { + const actual = await vi.importActual( + '$lib/components/apps/components/display/dbtable/metadata' + ) + const { getBenchmarkDbSchemaSeed } = await import('./mockBackend') + const { stringifySchema } = await import('$lib/components/copilot/lib') + const { resourceTypeToLang } = await import( + '$lib/components/apps/components/display/dbtable/utils' + ) + return { + ...actual, + getDbSchemas: async ( + resourceType: string, + resourcePath: string, + workspace: string | undefined, + ...rest: unknown[] + ) => { + const seed = workspace ? getBenchmarkDbSchemaSeed(workspace, resourcePath) : null + if (!seed) return actual.getDbSchemas(resourceType, resourcePath, workspace, ...rest) + const dbSchema = { + lang: resourceTypeToLang(seed.resourceType), + schema: seed.schema, + publicOnly: !!seed.schema.public + } + return { ...dbSchema, stringified: stringifySchema(dbSchema) } + } + } +}) + vi.mock('$lib/gen', async () => { const actual = await vi.importActual('$lib/gen') const { getBenchmarkAppByPath, - getBenchmarkCompletedJob, + getBenchmarkJob, getBenchmarkCompletedJobResultMaybe, + listBenchmarkWorkers, + listBenchmarkSchedules, + getBenchmarkSchedule, + listBenchmarkFolders, + getBenchmarkFolder, + getBenchmarkGroup, + listBenchmarkGroups, + getBenchmarkWorkspaceSettingsDocument, + applyBenchmarkWorkspaceSettingsDocument, getBenchmarkDatatableSchema, getBenchmarkDatatableMigrationsStatus, createBenchmarkDatatableMigration, @@ -175,7 +215,29 @@ vi.mock('$lib/gen', async () => { createFolder: async (data: { workspace: string; requestBody: { name: string } }) => hasBenchmarkWorkspace(data.workspace) ? createBenchmarkFolder(data.workspace, data.requestBody.name) - : actual.FolderService.createFolder(data) + : actual.FolderService.createFolder(data), + listFolders: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? listBenchmarkFolders(data.workspace) + : actual.FolderService.listFolders(data), + getFolder: async (data: { workspace: string; name: string }) => { + if (!hasBenchmarkWorkspace(data.workspace)) return actual.FolderService.getFolder(data) + const folder = getBenchmarkFolder(data.workspace, data.name) + if (!folder) throw new Error(`Folder "${data.name}" not found in benchmark workspace`) + return folder + } + }), + GroupService: wrapService(actual.GroupService, { + listGroups: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? listBenchmarkGroups(data.workspace) + : actual.GroupService.listGroups(data), + getGroup: async (data: { workspace: string; name: string }) => { + if (!hasBenchmarkWorkspace(data.workspace)) return actual.GroupService.getGroup(data) + const group = getBenchmarkGroup(data.workspace, data.name) + if (!group) throw new Error(`Group "${data.name}" not found in benchmark workspace`) + return group + } }), ScriptService: wrapService(actual.ScriptService, { listScripts: async (data: { workspace: string }) => @@ -357,7 +419,7 @@ vi.mock('$lib/gen', async () => { : actual.JobService.runFlowByPath(data), getJob: async (data: { workspace: string; id: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { - const job = getBenchmarkCompletedJob(data.workspace, data.id) + const job = getBenchmarkJob(data.workspace, data.id) if (!job) { throw new Error(`Job "${data.id}" not found in benchmark workspace`) } @@ -383,6 +445,14 @@ vi.mock('$lib/gen', async () => { : actual.JobService.getFlowAllResults(data) }), WorkspaceService: wrapService(actual.WorkspaceService, { + getWorkspaceSettingsDocument: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkWorkspaceSettingsDocument(data.workspace) + : actual.WorkspaceService.getWorkspaceSettingsDocument(data), + applyWorkspaceSettingsDocument: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? applyBenchmarkWorkspaceSettingsDocument(data) + : actual.WorkspaceService.applyWorkspaceSettingsDocument(data), getCopilotInfo: async (data: { workspace: string }) => hasBenchmarkWorkspace(data.workspace) ? (getBenchmarkAiConfig(data.workspace) ?? {}) @@ -436,13 +506,21 @@ vi.mock('$lib/gen', async () => { ? { metrics: listBenchmarkDataMetrics(data.workspace) ?? [] } : actual.DataMetricService.listDataMetrics(data) }), + WorkerService: wrapService(actual.WorkerService, { + listWorkers: async (data: Record) => + listBenchmarkWorkers() ?? actual.WorkerService.listWorkers(data) + }), ScheduleService: wrapService(actual.ScheduleService, { existsSchedule: async (data: { workspace: string; path: string }) => hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data), listSchedules: async (data: { workspace: string }) => - hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data), + hasBenchmarkWorkspace(data.workspace) + ? listBenchmarkSchedules(data.workspace) + : actual.ScheduleService.listSchedules(data), getSchedule: async (data: { workspace: string; path: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { + const schedule = getBenchmarkSchedule(data.workspace, data.path) + if (schedule) return schedule throw new Error(`Schedule "${data.path}" not found in benchmark workspace`) } return actual.ScheduleService.getSchedule(data) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 136f125e59..426fa54013 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -3283,3 +3283,626 @@ - Agents page - agent editor skipJudge: true + +# --- Composer suggestions --- +# The prompts the pills under Home's composer put in the box +# (frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts), word for word. +# A pill is only worth offering if the session chat can act on its prompt, so each one is +# pinned here against a workspace where the prompt has something to find. + +- id: global-composer-run-queue + prompt: |- + Is anything stuck in this workspace's queue? Which jobs are waiting or suspended, and why? + initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json + runtime: + maxTurns: 10 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_runs + - list_workers + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - gpu-renderer + - - refund_approval + - approval + - approve + skipJudge: true + judgeChecklist: + - finds the 12 gpu_render runs queued on the gpu-renderer tag that no connected worker serves + - finds the refund_approval flow suspended on its approval step, and says approving it unblocks it + +- id: global-composer-worker-health + prompt: |- + Are the workers healthy and keeping up with the queue? Compare how long jobs wait before they start with how busy each worker group is. + initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json + runtime: + maxTurns: 10 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_workers + - list_runs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - gpu-renderer + skipJudge: true + judgeChecklist: + - reads the connected workers and the queued runs instead of guessing + - points out that the gpu-renderer runs wait because no worker serves that tag + +- id: global-composer-schedules + prompt: |- + Do any schedules in this workspace keep failing? If so, which ones and why? + initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json + runtime: + maxTurns: 10 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_runs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - charge_cards_every_minute + - - insufficient funds + - declined + skipJudge: true + judgeChecklist: + - names f/billing/charge_cards_every_minute as the failing schedule, not import_orders_hourly + - gives the cause from its runs' logs (card declined, insufficient funds) + +- id: global-composer-recent-runs + prompt: |- + Look at the recent runs in this workspace: did any fail, and why? Is anything getting slower? + initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_runs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - charge_cards + - - insufficient funds + - declined + skipJudge: true + judgeChecklist: + - names charge_cards as the failing script and gives the cause from its logs + - does not invent a slowdown the run durations do not show + +# The empty-workspace examples, sent as the composer sends them. The workspace holds nothing, +# so the model has to stage what it needs; one answer covers a question about the Slack setup. +- id: global-composer-example-daily-metrics + prompt: |- + Every weekday at 9am, query postgres for yesterday's signups and revenue and post a summary to the #metrics Slack channel + initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json + runtime: + maxTurns: 16 + sessionChat: true + userAnswers: + - Use placeholders for the postgres and Slack connections, I'll fill them in later + validate: + draftCountAtLeast: 1 + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - stages a script or flow that queries postgres for the previous day's signups and revenue + - posts the summary to the #metrics Slack channel + - runs it on a schedule at 9am on weekdays only + +- id: global-composer-example-refund-approval + prompt: |- + Build a flow that asks for approval in Slack before issuing a refund in Stripe, and only refunds if someone approves + initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json + runtime: + maxTurns: 16 + sessionChat: true + userAnswers: + - Use placeholders for the Stripe and Slack connections, I'll fill them in later + validate: + requiredDrafts: + - type: flow + valueIncludes: + - suspend + toolExpect: + requiredToolsUsed: + - write_flow + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - posts an approval request to Slack before any refund is issued + - suspends the flow on an approval step and continues only once someone approves + - issues the refund through Stripe only on approval, never when it is rejected + +- id: global-composer-example-ticket-triage + prompt: |- + When a support ticket arrives by webhook, classify its urgency with an LLM and post the urgent ones to the #support Slack channel + initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json + runtime: + maxTurns: 16 + sessionChat: true + userAnswers: + - Use placeholders for the LLM and Slack connections, I'll fill them in later + validate: + draftCountAtLeast: 1 + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - stages a script or flow that takes the ticket as the input a webhook delivers + - classifies the ticket's urgency with an LLM call; an `aidecision` step (Windmill's built-in LLM decision, with `choice` or `noul` questions) counts + - posts only the urgent tickets to the #support Slack channel + +# The pills that name what an established workspace holds: a teammate's flow, a starred +# script, a connected database and channel. +- id: global-composer-walk-through-flow + prompt: |- + Walk me through what the flow f/billing/charge_customers does, step by step. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - write_flow + - patch_flow_json + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - due today + - invoices due + - fetch_due + - - receipt + skipJudge: true + judgeChecklist: + - reads the flow and describes its three steps in order + +- id: global-composer-run-favorite + prompt: |- + Run the script f/ops/check_ssl_expiry for me. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + userAnswers: + - windmill.dev + validate: + draftCountExactly: 0 + # The script needs a domain the prompt does not give. Opening the run form for the user to + # fill and asking for the domain in chat are both right, so neither is pinned; what is pinned + # is that the deployed script is read and nothing is previewed or written. + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - test_run_script + - write_script + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - domain + # Running produces no draft, and the judge cannot observe runs; validate via tool use. + skipJudge: true + judgeChecklist: + - reads the deployed script and either opens its run form or asks for the domain it needs + +- id: global-composer-daily-summary + prompt: |- + Every weekday at 9am, summarize yesterday's activity in the Postgres database f/data/warehouse and post it to Slack with the resource f/team/slack + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 16 + sessionChat: true + userAnswers: + - Post it in #metrics, with new signups, new invoices and what they add up to + validate: + draftCountAtLeast: 1 + toolExpect: + # "Yesterday's activity" means whatever the tables hold, so they are read first. + requiredToolsUsed: + - get_db_schema + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - queries the warehouse's real tables (signups, invoices) for the previous day through f/data/warehouse + - posts the summary to Slack through the resource f/team/slack; a channel parameter with a default is fine, since the prompt names none + - runs on a schedule at 9am on weekdays only + +# Pills that teach the piece a workspace is missing. +- id: global-composer-setup-datatable + prompt: |- + This workspace has no data table yet. Walk me through setting one up in the workspace settings, then show me how a script writes to it. + initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json + runtime: + maxTurns: 20 + sessionChat: true + userAnswers: + - Back it with the Postgres resource f/data/warehouse and call it main + # Opening the data table settings for the admin and writing them after a confirmation are + # both a way through; either way the script example is what teaches the concept. + toolExpect: + requiredToolsAnyOf: + - - open_page + - set_workspace_settings + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - datatable( + - datatable() + skipJudge: true + judgeChecklist: + - shows where data tables are set up and a script writing to one with wmill.datatable() + +- id: global-composer-store-data + prompt: |- + How should my scripts store data in Windmill? Explain data tables and show me how a script reads and writes one. + initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + # The pill is for developers: an admin is offered "Set up a data table" instead. + profile: + operator: false + toolExpect: + forbiddenToolsUsed: + - set_workspace_settings + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - datatable( + - datatable() + skipJudge: true + judgeChecklist: + - explains data tables and shows a script reading and writing one with wmill.datatable() + +- id: global-composer-connect-slack + prompt: |- + Help me connect Slack to this workspace so my scripts can post messages. + initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json + runtime: + maxTurns: 12 + sessionChat: true + userAnswers: + - Use a bot token, I will paste it myself + # A Slack resource whose token is a secret variable, or the workspace's own Slack connection + # in settings: both connect Slack, so either path is accepted. + toolExpect: + requiredToolsAnyOf: + - - write_resource + - open_page + forbiddenToolsUsed: + - deploy_workspace_item + skipJudge: true + judgeChecklist: + - stages a Slack resource with its token in a secret variable, or opens the workspace's Slack settings + +- id: global-composer-store-secret + prompt: |- + I have an API key my scripts need. Store it as a secret and show me how a script reads it with wmill.getVariable. + initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json + runtime: + maxTurns: 10 + sessionChat: true + userAnswers: + - It is for the Stripe API; I will paste the value myself + validate: + requiredDrafts: + - type: variable + valueIncludes: + - '"is_secret": true' + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - getVariable + - get_variable + - "$var:" + judgeChecklist: + - stages a secret variable with a placeholder value instead of inventing the key + - any example script reads the variable rather than embedding the key; an example given only in the chat is checked separately, so do not score its absence here + +# Pills that name what an established workspace holds, read from composer_connected_workspace. +- id: global-composer-explore-datatable + prompt: |- + List the tables in the data table main and tell me what each one holds. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsAnyOf: + - - list_datatables + - get_datatable_table_schema + assistantExpect: + requiredMentionsAnyOf: + - - feedback + - - onboarding_tasks + skipJudge: true + judgeChecklist: + - names the feedback and onboarding_tasks tables and what each holds + +- id: global-composer-app-on-datatable + prompt: |- + Build a full-code app to browse and edit the rows of a table in the data table main. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 30 + sessionChat: true + userAnswers: + - The feedback table + validate: + requiredDrafts: + - type: app + toolExpect: + requiredToolsUsed: + - init_app + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - builds a full-code app whose backend reads the rows of a table in the main data table through wmill.datatable(); `sql.raw` is the documented way to inline a table or column name + - lets the user edit a row and saves the change back to the table + +- id: global-composer-add-app-page + prompt: |- + Add a page to the app f/ops/console, and ask me what it should show. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 24 + sessionChat: true + userAnswers: + - A page listing the latest feedback from the main data table + # The pill asks the chat to ask: in chat or through askUserQuestion. A question in chat ends + # the run before the page is written, so the page itself is not pinned. + toolExpect: + requiredToolsAnyOf: + - - read_app_file + - read_workspace_item + forbiddenToolsUsed: + - init_app + - deploy_workspace_item + skipJudge: true + judgeChecklist: + - reads the app, then asks what the new page should show before writing it + +- id: global-composer-walk-through-agent + prompt: |- + Walk me through what the AI agent f/support/triage_agent does and which tools it can use. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - read_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - billing + - - no tools + - any tools + - no tool + skipJudge: true + judgeChecklist: + - explains that the agent labels tickets as billing, bug or question and has no tools + +- id: global-composer-channel-in-script + prompt: |- + Show me how a script uses the resource f/team/slack to post a Slack message, with a short example. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - chat.postMessage + skipJudge: true + judgeChecklist: + - shows a script that takes the f/team/slack resource and posts a message with chat.postMessage + +- id: global-composer-postgres-trigger + prompt: |- + Run a script whenever a row is inserted into a table of the Postgres database f/data/warehouse, and explain how Postgres triggers work. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 20 + sessionChat: true + userAnswers: + - The signups table + validate: + requiredDrafts: + - type: trigger + triggerKind: postgres + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - publication + - replication + judgeChecklist: + - stages a Postgres trigger on f/data/warehouse that fires on inserts, limited to one table rather than every table + - the triggered script takes the inserted row as its input + +- id: global-composer-http-endpoint + prompt: |- + Expose the script f/ops/check_ssl_expiry as an HTTP API endpoint that requires a Windmill token, and show me how to call it. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 12 + sessionChat: true + # "Expose" asks for a live route, so deploying it, behind the user's confirmation, is right. + validate: + requiredDrafts: + - type: trigger + triggerKind: http + valueIncludes: + - f/ops/check_ssl_expiry + assistantExpect: + requiredMentionsAnyOf: + - - curl + - fetch( + judgeChecklist: + - stages an HTTP route that runs f/ops/check_ssl_expiry and passes it the domain + - the route requires a Windmill token (authentication_method windmill), so it is not public + - how to call it is explained in the chat, which is checked separately; do not score it + +- id: global-composer-schedule-script + prompt: |- + Run the script f/ops/check_ssl_expiry every weekday at 9am. Ask me for any input it needs, and explain how schedules work. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 12 + sessionChat: true + userAnswers: + - windmill.dev + # The script needs a domain the prompt does not give, so the chat reads the script and asks + # for it: in chat, which ends the run, or with askUserQuestion, which goes on to the schedule. + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - domain + - - cron + - schedule + skipJudge: true + judgeChecklist: + - reads the script, asks for the domain it needs, and explains how schedules work + +- id: global-composer-webhook + prompt: |- + How do I start the flow f/billing/charge_customers from another app with its webhook? Show me the URL and an example request. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + assistantExpect: + requiredMentionsAnyOf: + - - jobs/run + - - token + - Authorization + skipJudge: true + judgeChecklist: + - gives the flow's webhook URL and an example request with a token + +- id: global-composer-who-can-see + prompt: |- + Who can see and edit the flow f/billing/charge_customers, and why? + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - read_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - finance + - - bob + skipJudge: true + judgeChecklist: + - explains that access comes from the billing folder - its owner ada, bob with write, the finance group with read + +- id: global-composer-operator-what-can-i-run + prompt: |- + Which scripts and flows can I run in this workspace, and what does each one do? + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 6 + sessionChat: true + profile: + operator: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_workspace_items + assistantExpect: + requiredMentionsAnyOf: + - - list_open_orders + - List open orders + skipJudge: true + judgeChecklist: + - lists the runnable scripts and flows with what each does + +- id: global-composer-hub-scripts + prompt: |- + Find scripts on the Windmill Hub for Slack that I could use with the resource f/team/slack. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + skipJudge: true + judgeChecklist: + - lists Slack scripts from the Hub that take a Slack resource + +- id: global-composer-mcp + prompt: |- + How do I connect Claude to this workspace with MCP so it can run my scripts and flows? + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsAnyOf: + - - search_docs + - read_docs_page + assistantExpect: + requiredMentionsAnyOf: + - - token + - MCP URL + skipJudge: true + judgeChecklist: + - explains the MCP setup from the docs: the MCP URL with a token, added to Claude diff --git a/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json b/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json new file mode 100644 index 0000000000..db30ec00e8 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json @@ -0,0 +1,313 @@ +{ + "user": { + "username": "admin", + "is_admin": true + }, + "workspace": { + "flows": [ + { + "path": "f/billing/charge_customers", + "summary": "Charge customers with due invoices", + "description": "Every night, charges each customer whose invoice is due and emails a receipt.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "dry_run": { + "type": "boolean", + "default": false + } + }, + "required": [] + }, + "value": { + "modules": [ + { + "id": "fetch_due", + "summary": "Fetch invoices due today from the warehouse", + "value": { + "type": "rawscript", + "language": "postgresql", + "content": "-- database f/data/warehouse\nSELECT id, customer_id, amount_cents FROM invoices WHERE due_date = current_date AND paid = false;", + "input_transforms": { + "database": { + "type": "static", + "value": "$res:f/data/warehouse" + } + } + } + }, + { + "id": "charge", + "summary": "Charge each invoice", + "value": { + "type": "forloopflow", + "iterator": { + "type": "javascript", + "expr": "results.fetch_due" + }, + "skip_failures": false, + "modules": [ + { + "id": "charge_one", + "summary": "Charge one invoice through the payment API", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(invoice: { id: number; amount_cents: number }) {\n const res = await fetch('https://payments.example.com/charge', { method: 'POST', body: JSON.stringify(invoice) })\n if (!res.ok) throw new Error(`charge failed for invoice ${invoice.id}`)\n return await res.json()\n}\n", + "input_transforms": { + "invoice": { + "type": "javascript", + "expr": "flow_input.iter.value" + } + } + } + } + ] + } + }, + { + "id": "email_receipts", + "summary": "Email a receipt to each charged customer", + "value": { + "type": "rawscript", + "language": "bun", + "content": "// Each charge is what the payment API answered, which carries the customer to email.\nexport async function main(charges: { id: string; customer_id: number }[]) {\n for (const charge of charges) {\n const res = await fetch('https://mail.example.com/receipts', { method: 'POST', body: JSON.stringify(charge) })\n if (!res.ok) throw new Error(`receipt failed for charge ${charge.id}`)\n }\n return { sent: charges.length }\n}\n", + "input_transforms": { + "charges": { + "type": "javascript", + "expr": "results.charge" + } + } + } + } + ] + } + } + ], + "scripts": [ + { + "path": "f/ops/check_ssl_expiry", + "summary": "Check SSL certificate expiry", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "The domain whose certificate to check" + } + }, + "required": [ + "domain" + ] + }, + "content": "export async function main(domain: string) {\n const res = await fetch(`https://ssl-check.example.com/${domain}`)\n return await res.json()\n}\n" + } + ], + "resources": [ + { + "path": "f/data/warehouse", + "resource_type": "postgresql", + "description": "Production warehouse", + "value": { + "host": "db.internal", + "port": 5432, + "dbname": "warehouse", + "user": "windmill", + "password": "$var:f/data/warehouse_password" + }, + "dbSchema": { + "public": { + "customers": { + "id": { + "type": "integer", + "required": true + }, + "email": { + "type": "text", + "required": true + }, + "plan": { + "type": "text", + "required": false + }, + "created_at": { + "type": "timestamp", + "required": true, + "default": "now()" + } + }, + "invoices": { + "id": { + "type": "integer", + "required": true + }, + "customer_id": { + "type": "integer", + "required": true + }, + "amount_cents": { + "type": "integer", + "required": true + }, + "due_date": { + "type": "date", + "required": true + }, + "paid": { + "type": "boolean", + "required": true, + "default": "false" + }, + "created_at": { + "type": "timestamp", + "required": true, + "default": "now()" + } + }, + "signups": { + "id": { + "type": "integer", + "required": true + }, + "email": { + "type": "text", + "required": true + }, + "source": { + "type": "text", + "required": false + }, + "created_at": { + "type": "timestamp", + "required": true, + "default": "now()" + } + } + } + } + }, + { + "path": "f/team/slack", + "resource_type": "slack", + "description": "Team Slack bot", + "value": { + "token": "$var:f/team/slack_token" + } + }, + { + "path": "f/support/triage_agent", + "resource_type": "ai_agent", + "description": "Labels incoming support tickets", + "value": { + "provider": { + "kind": "anthropic", + "model": "claude-sonnet-5", + "resource": "" + }, + "output_type": "text", + "system_prompt": "Read the support ticket and answer with one label: billing, bug or question.", + "memory": { + "kind": "off" + }, + "tools": [] + } + } + ], + "apps": [ + { + "path": "f/ops/console", + "summary": "Ops console", + "value": { + "files": { + "/App.tsx": "import { useState } from 'react'\nimport { backend } from './wmill'\n\nexport default function App() {\n\tconst [domain, setDomain] = useState('')\n\tconst [result, setResult] = useState()\n\treturn (\n\t\t
\n\t\t\t

Ops console

\n\t\t\t setDomain(e.target.value)} />\n\t\t\t\n\t\t\t
{JSON.stringify(result, null, 2)}
\n\t\t
\n\t)\n}\n" + }, + "runnables": { + "check_ssl": { + "type": "path", + "runType": "script", + "path": "f/ops/check_ssl_expiry", + "fields": {} + } + } + } + } + ], + "datatables": [ + { + "datatable_name": "main", + "schemas": { + "public": { + "feedback": { + "columns": { + "id": "int4", + "customer_email": "text", + "rating": "int4", + "comment": "text", + "created_at": "timestamptz" + }, + "rows": [ + { + "id": 1, + "customer_email": "ada@example.com", + "rating": 5, + "comment": "Fast support", + "created_at": "2026-10-01T09:00:00Z" + } + ] + }, + "onboarding_tasks": { + "columns": { + "id": "int4", + "customer_email": "text", + "task": "text", + "done": "bool" + }, + "rows": [ + { + "id": 1, + "customer_email": "ada@example.com", + "task": "Connect billing", + "done": false + } + ] + } + } + } + } + ], + "folders": [ + { + "name": "billing", + "summary": "Billing jobs", + "owners": [ + "u/ada" + ], + "extra_perms": { + "g/finance": false, + "u/bob": true + } + }, + { + "name": "ops", + "summary": "Operations", + "owners": [ + "u/admin" + ], + "extra_perms": {} + } + ], + "groups": [ + { + "name": "finance", + "summary": "Finance team", + "members": [ + "carol", + "dan" + ] + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json b/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json new file mode 100644 index 0000000000..b719ba0751 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json @@ -0,0 +1,22 @@ +{ + "user": { + "username": "admin", + "is_admin": true + }, + "workspace": { + "resources": [ + { + "path": "f/data/warehouse", + "resource_type": "postgresql", + "description": "Production warehouse", + "value": { + "host": "db.internal", + "port": 5432, + "dbname": "warehouse", + "user": "windmill", + "password": "$var:f/data/warehouse_password" + } + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/composer_incidents.json b/ai_evals/fixtures/frontend/global/initial/composer_incidents.json new file mode 100644 index 0000000000..4799b895ea --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/composer_incidents.json @@ -0,0 +1,83 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/billing/charge_cards", + "summary": "Charge cards", + "language": "bash", + "content": "echo \"charging card for customer $1\"\ncurl -sf https://payments.example.com/charge || { echo \"card declined: insufficient funds\" >&2; exit 1; }\n" + }, + { + "path": "f/sales/import_orders", + "summary": "Import orders", + "language": "bash", + "content": "echo \"imported $(date +%s) orders\"\n" + }, + { + "path": "f/media/gpu_render", + "summary": "GPU render", + "language": "bash", + "content": "echo \"rendering frame\"\n" + } + ], + "flows": [ + { + "path": "f/billing/refund_approval", + "summary": "Refund approval", + "value": { + "modules": [ + { + "id": "a", + "summary": "Ask a manager to approve the refund", + "value": { "type": "rawscript", "language": "bash", "content": "echo \"refund requested\"", "input_transforms": {} }, + "suspend": { "required_events": 1, "timeout": 86400 } + }, + { + "id": "b", + "summary": "Issue the refund", + "value": { "type": "rawscript", "language": "bash", "content": "echo \"refunded\"", "input_transforms": {} } + } + ] + } + } + ], + "schedules": [ + { "path": "f/billing/charge_cards_every_minute", "schedule": "0 * * * * *", "script_path": "f/billing/charge_cards", "summary": "Charge pending cards every minute" }, + { "path": "f/sales/import_orders_hourly", "schedule": "0 0 * * * *", "script_path": "f/sales/import_orders", "summary": "Import orders every hour" } + ], + "workers": [ + { + "worker": "wk-default-7f3a2", + "worker_group": "default", + "custom_tags": ["bash", "python3", "bun", "deno", "go", "flow", "dependency"], + "lastPingSecondsAgo": 3, + "jobs_executed": 214 + } + ], + "jobs": [ + { "id": "01930000-0000-7000-8000-00000000c001", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 1, "durationMs": 41, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c002", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 2, "durationMs": 38, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c003", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 3, "durationMs": 44, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c004", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 4, "durationMs": 40, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c005", "scriptPath": "f/billing/charge_cards", "createdBy": "alice", "success": false, "createdMinutesAgo": 30, "durationMs": 52, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c006", "scriptPath": "f/billing/charge_cards", "createdBy": "alice", "success": false, "createdMinutesAgo": 31, "durationMs": 47, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000d001", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 10, "durationMs": 820, "tag": "bash", "logs": "imported 112 orders" }, + { "id": "01930000-0000-7000-8000-00000000d002", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 70, "durationMs": 790, "tag": "bash", "logs": "imported 98 orders" }, + { "id": "01930000-0000-7000-8000-00000000d003", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 130, "durationMs": 805, "tag": "bash", "logs": "imported 104 orders" }, + { "id": "01930000-0000-7000-8000-00000000d004", "scriptPath": "f/sales/import_orders", "createdBy": "bob", "success": true, "createdMinutesAgo": 45, "durationMs": 811, "tag": "bash", "logs": "imported 3 orders" }, + { "id": "01930000-0000-7000-8000-00000000e001", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e002", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e003", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e004", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e005", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e006", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e007", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e008", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e009", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e010", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e011", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e012", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000f001", "scriptPath": "f/billing/refund_approval", "jobKind": "flow", "createdBy": "bob", "state": "suspended", "createdMinutesAgo": 40, "tag": "flow", "logs": "refund requested" } + ] + } +} diff --git a/backend/windmill-api/src/mcp/session/gen/instructions.json b/backend/windmill-api/src/mcp/session/gen/instructions.json index 796bdb7ffd..df6f1f6917 100644 --- a/backend/windmill-api/src/mcp/session/gen/instructions.json +++ b/backend/windmill-api/src/mcp/session/gen/instructions.json @@ -232,6 +232,6 @@ "datatable.python3": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role `, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables 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.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", "datatable.go": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role `, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables 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.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch(\n params?: FetchParams // The union is for auto-completion\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit, \"resultCollection\">\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit, \"resultCollection\">\n ): Promise;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n (strings: TemplateStringsArray, ...values: any[]): SqlStatement;\n query(sql: string, ...params: any[]): SqlStatement;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@param opts.role - Connect as this data table role instead of the data table's default one.\n Only meaningful on a data table under roles, and only for a role you are a tenant of.\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n@example\n// Read through a restricted role\nlet sql = wmill.datatable(\"main\", { role: \"analytics\" })\n```typescript\nfunction datatable(name: string = \"main\",\n opts?: DatatableOptions): DatatableSqlTemplateFunction\n```\n\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", "pipeline": "# Data pipeline authoring\n\nA **data pipeline** is NOT a flow. A flow is one runnable that orchestrates steps internally. A data pipeline is a set of **independent scripts**, each deployed on its own, that form a DAG by reading and writing shared **storage assets** (DuckLake tables, data tables, S3 objects, volumes, resources) and by declaring execution **triggers**. The pipeline is visualized and edited at `/pipeline/`; every node is a normal workspace script that happens to carry pipeline annotations. When the user asks for a \"data pipeline\" (or to \"ingest / transform / materialize\" data across steps), build pipeline-annotated scripts — do NOT build a flow.\n\n## Default to DuckDB + DuckLake\n\nA pipeline node that produces a table should almost always be a **`duckdb`** node that materializes its output into a **DuckLake** table with `-- materialize ducklake:///` (in a DuckDB node the annotation uses SQL `--` comment syntax; write the body as a bare `SELECT` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else:\n\n- `postgresql` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live).\n- `bun` / `python3` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see \"Ingesting from Python / TypeScript\" below — rather than inventing a parallel store.\n\nDo not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal.\n\n## Ingesting from Python / TypeScript\n\nA node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with `wmill.ducklake()`, or into a data table with `wmill.datatable()`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second \"load the raw file\" node. One ingestion node, one table.\n\nPass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema:\n\n```python\n# pipeline\n# on schedule\nimport wmill\n\ndef main():\n rows = fetch_charges() # list[dict]\n lake = wmill.ducklake()\n lake.query(\"\"\"\n CREATE OR REPLACE TABLE stripe_charges AS\n SELECT unnest(from_json($rows, '[{\"id\":\"VARCHAR\",\"amount\":\"BIGINT\",\"created\":\"BIGINT\"}]'), recursive := true)\n \"\"\", rows=rows).fetch()\n```\n\n```ts\n// pipeline\n// on schedule\nimport * as wmill from \"windmill-client\"\n\nexport async function main() {\n const rows = await fetchCharges()\n const lake = wmill.ducklake()\n await lake`CREATE OR REPLACE TABLE stripe_charges AS\n SELECT unnest(from_json(${rows}, '[{\"id\":\"VARCHAR\",\"amount\":\"BIGINT\",\"created\":\"BIGINT\"}]'), recursive := true)`.fetch()\n}\n```\n\n- Use `INSERT INTO
SELECT ...` instead of `CREATE OR REPLACE` to append (an incremental pull), with a one-time `CREATE TABLE IF NOT EXISTS` first.\n- Assign the client to a variable (`lake = wmill.ducklake()`) and keep the SQL a **string literal** (Python) or a template without `sql.raw` (TS): that is what lets the graph record the node's write to `ducklake://main/
`. A chained `wmill.ducklake().query(...)` or SQL built in a variable records no output, so the downstream edge never forms.\n- `wmill.ducklake(\"\")` targets a non-default catalog; `wmill.datatable(\"\")` writes a Postgres data table the same way (Python `db.query(sql, *args)` with `$1` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above).\n- The downstream node triggers on the table itself: `-- on ducklake://main/stripe_charges`.\n\n## Storage prerequisites\n\nA DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and `s3://` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build.\nDrafting the annotated scripts does not require storage, but the pipeline can't ingest, materialize, or read its assets until it exists. So if there is none (or the user hits \"storage not configured\" errors), say so and give the right next step **by role**:\n\n- a workspace **admin** sets it up in Workspace settings → Object Storage (add an S3/Azure/GCS storage), then adds a DuckLake catalog on top of it;\n- anyone **without admin rights** should ask a workspace admin to configure object storage + a DuckLake catalog.\n\nNever hand back a DuckLake pipeline that cannot run without flagging the missing storage and pointing to who sets it up.\n\n## What makes a script a pipeline node\n\nA script joins the pipeline when its source begins with the `pipeline` annotation as a top-of-file comment, **written in the script's own comment syntax** — `//` for TS/JS (bun), `--` for SQL (DuckDB/Postgres), `#` for Python/Bash. So it's `-- pipeline` in a DuckDB node, `# pipeline` in a Python node, `// pipeline` in a bun node. Every annotation below uses that same prefix (the `//` shown is the TS form). All other wiring is expressed as annotation comments near the top of the file:\n\n- `// on ` — declares an execution-DAG **input** (what triggers/feeds this node). `` is either:\n - an **asset URI** (the node runs when that asset is produced upstream): `ducklake://main/orders`, `datatable://main/users`, `$res:f/folder/my_resource`, `volume://name/path`, or an S3 object (see the S3 storage-form rule below).\n - a **native trigger kind**: `schedule`, `webhook`, `email`, `kafka`, `mqtt`, `amqp`, `nats`, `postgres`, `sqs`, `gcp`, or `data_upload` (a user-uploaded S3 file). For these the actual trigger row (cron, topic, …) is created separately; the annotation only declares the binding. **`data_upload` is special**: there is no trigger row — the node instead declares an **`S3Object` input parameter** fed by the auto-generated upload picker; it never hard-codes a key. Any language can be the `data_upload` node:\n - Python (has `import wmill`): `def main(file: wmill.S3Object):` then `wmill.load_s3_file(file)`; TS (has `import * as wmill from \"windmill-client\"`): `export async function main(file: wmill.S3Object)`. Qualify the type as `wmill.S3Object` (or add `from wmill import S3Object` / `import { S3Object } from \"windmill-client\"`) — a bare `S3Object` is undefined.\n - **DuckDB** takes the s3object arg via a `-- $ (s3object)` declaration and reads it directly, so a single DuckDB node can ingest **and** materialize:\n ```\n -- pipeline\n -- on data_upload\n -- materialize ducklake://main/raw_uploads\n -- $file (s3object)\n SELECT * FROM read_csv($file)\n ```\n- **Outputs** are inferred from what the body writes — a `CREATE TABLE`, a `wmill.writeS3File(...)`, a DuckLake/datatable write. To declare a managed output explicitly, use `// materialize `.\n- Optional badges: `// partitioned `, `// freshness ` (e.g. `1h`), `// tag `, `// retry [delay]`, `// data_test ...` (managed DuckLake targets only — deploy rejects it beside a `dbt://` one), `// measure = [where ]` and `// dimension = ` (see \"Declared metrics\" below).\n\n## S3 object wiring (storage form matters)\n\nAn `s3://` URI's first slashes select the **storage**, not part of the key — get this wrong and the producer/consumer edge silently won't connect:\n\n- `s3:///` (**triple** slash, empty first segment) = the **default** workspace storage. A downstream node reading or triggering on that object uses `s3:///` — e.g. DuckDB `-- on s3:///orders/2024.parquet` and `read_parquet('s3:///orders/2024.parquet')`.\n- `s3:///` (**double** slash, non-empty first segment) = a **named secondary** storage called `` — so `s3://ingest/x` means storage `ingest`, key `x`, NOT key `ingest/x`. Only use this when the object genuinely lives in a configured secondary storage; never invent a bucket/storage name for a default-storage object (it breaks the edge).\n\nTo make the producer side visible to lineage, a Python/TS node MUST pass the **`S3Object` form**, not a bare key string: Python `wmill.write_s3_file(wmill.S3Object(s3=\"\"), data)` (or the import-free dict `{\"s3\": \"\"}`), TS `wmill.writeS3File({ s3: \"\" }, data)`. That records the default-storage asset `/`, which a downstream `s3:///` reader connects to (same key both sides). A bare `write_s3_file(\"\", ...)` records **no** asset and produces **no** edge. Add `storage=\"\"` only for a named secondary storage.\n\nThe key must be a **string literal** — the graph parser is static and cannot follow a variable, f-string, or computed path, so `write_s3_file(wmill.S3Object(s3=key_var), ...)` records no edge. Inline the literal (`s3=\"events/user_events.parquet\"`) on both the writing and reading node. The same rule applies to every asset URI in an annotation or SDK call (`ducklake://`, `datatable://`, `s3://`): write them literally, not via a variable.\n\n## Materialize (the managed output)\n\n> **A MANAGED `// materialize` is DuckDB-only**, and its target must be a DuckLake table (`ducklake:///
`). Deploy **rejects** a `ducklake://` `// materialize` on any other language (`python3`, `bun`, `postgresql`). For a non-DuckDB node writing the lake, do **not** use `// materialize` — write the table itself with `wmill.ducklake()` (see \"Ingesting from Python / TypeScript\") and let the output be inferred. Use `duckdb` when a node should materialize a DuckLake table.\n>\n> The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: `// materialize manual dbt:////`, where `` is a warehouse the workspace configures under Settings → dbt. `manual` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql `CREATE TABLE` / `INSERT`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a `source`: the declared relation and the dbt model land on ONE graph node, and a downstream `// on dbt:////` fires when the ingestion node completes.\n\nA managed `// materialize ducklake:///
` tells the runtime to write the node's output table **for you**: write the body as a single `SELECT` and the runtime wraps it in the create/replace — do **not** also write your own `CREATE TABLE` / `INSERT`. (The opposite holds for the `dbt://` target above: there the node writes its own DDL and the strategies below do not apply.) Write strategy:\n\n- no option → **replace** the whole table each run (full refresh; the only mode whose output columns may change);\n- `// materialize append` → INSERT-append rows (incremental);\n- `// materialize key=` → merge/upsert on ``.\n\n`// materialize manual ` opts **out** of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage.\n\n`materialize` pairs with partitioning for incremental pipelines: a `// partitioned ` node runs **once per partition** (append/merge into a fixed-schema table). The `{partition}` token, usable in any asset URI **and** in the body SQL, is replaced at run time by the current partition's **identity string**:\n\n- 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 in exactly the identity format the runtime uses for daily/hourly/weekly/monthly, so it always matches; never hand-write a `strftime` format.\n- Do NOT write `= TIMESTAMP {partition}`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at run time.\n- For `dynamic` partitioning the identity is the caller-supplied key (not a timestamp, no macro), so filter on it directly: `WHERE = {partition}`.\n\n`materialize` is an output **declaration** on a node — not a command. There is no \"materialize run\".\n\n## Declared metrics (`measure` / `dimension`)\n\nOn 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 reuse the definition instead of re-deriving it and silently disagreeing.\n\n- 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 share one GROUP BY.\n- DuckLake-only, and only meaningful next to `// materialize`.\n- 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.\n- To use a metric another node declares, read that node and reuse its exact expression rather than guessing it.\n\n## How to build one\n\n1. Put every node in the **same folder**: `f//`. The folder is the pipeline.\n2. Write each node as its own script. Default to `duckdb` materializing into DuckLake (see \"Default to DuckDB + DuckLake\" above); pick `postgresql`, `bun`, or `python3` only when that section says the work calls for it.\n3. Start each body with `// pipeline`, then the `// on` input declarations, then the transform that writes the output.\n4. **Chain nodes by asset URI**: read an upstream node's output asset, then `// on ` in the downstream node so the edge forms. Reuse exact asset paths from existing nodes rather than inventing parallel ones.\n5. Don't deploy nodes unless the user asks to. A pipeline only \"runs\" once its scripts are deployed and their triggers exist.\n\n## Example (DuckDB → DuckLake, scheduled ingest + downstream transform)\n\nNode `f/sales/orders_ingest` (runs on a schedule, materializes a DuckLake table):\n\n```sql\n-- pipeline\n-- on schedule\n-- materialize ducklake://main/orders\nSELECT * FROM read_csv('s3:///raw/orders/*.csv')\n```\n\nNode `f/sales/orders_daily` (runs when `orders` is produced, writes a rollup):\n\n```sql\n-- pipeline\n-- on ducklake://main/orders\n-- materialize ducklake://main/orders_daily\nSELECT date_trunc('day', ts) AS day, count(*) AS n\nFROM ducklake.main.orders GROUP BY 1\n```\n", - "workspace_settings": "# Workspace settings\n\nEverything an admin sets on the Workspace settings page is one document: a mapping of top-level\nkeys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can\nread or change it.\n\n## Applying a document\n\n- A partial document changes only the keys it states; every key it omits is left as it is.\n- Each key is replaced whole, so read its current value and send it back edited: a nested object\n given with one field replaces the stored object with that one field.\n- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`.\n- Each setting is written through the same path the settings page uses, with the same validation\n and audit log. The writes are not one transaction: when one fails the apply stops, and the\n error says what was already written.\n- A dry run reports the changes, as `before` / `after` per key, without writing anything.\n- `name` is never applied: rename a workspace on the settings page. `color` is applied only when\n set to a value, so `null` does not clear it.\n- A key the server does not know is ignored and named in the warnings: check them, since a typo\n otherwise reads as \"nothing to change\".\n- A setting this edition does not offer (Enterprise only) is refused, by the dry run too.\n\n## Secrets\n\n`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place\nof a set secret; sending that placeholder back keeps the stored value. A value starting with\n`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key\nis left unchanged with a warning; any other value is taken as plaintext. Never\nwrite a secret you were not given: keep the placeholder.\n\n## Keys\n\n| Key | Value |\n|---|---|\n| `auto_invite` | `{ enabled, mode: invite \\| add, operator, instance_groups?, instance_groups_roles? }`. The instance groups are managed only when `instance_groups` is stated; clearing a group removes the members it added. |\n| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/` or `flow/`. |\n| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. |\n| `webhook` | URL POSTed a JSON event on every change to the workspace's items. |\n| `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. |\n| `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. |\n| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |\n| `datatable` | `{ datatables: { : { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. |\n| `datatable_permissions` | `{ : { default_role, roles: { : [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/`, `g/`, `f/` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |\n| `protection_rules` | `{ : { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |\n| `ducklake` | `{ ducklakes: { : { catalog, storage, ... } } }`. |\n| `dbt_warehouses` | dbt warehouse connections. |\n| `default_app` | Path of the app shown as the workspace home. |\n| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. |\n| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. |\n| `operator_settings` | What operators see and do: page visibility flags (`runs`, `schedules`, `resources`, `variables`, `assets`, `audit_logs`, `groups`, `folders`, `workers`, `triggers`) and write rights (`manage_schedules`, `manage_triggers`, `builder_flows`, `builder_apps`). A write right the value omits keeps its stored value. |\n| `mute_critical_alerts` | Mute the workspace's critical alerts. |\n| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. |\n| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. |\n| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. |\n| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. |\n| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. |\n| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. |\n| `color` | Workspace color, `#rrggbb`. |\n" + "workspace_settings": "# Workspace settings\n\nEverything an admin sets on the Workspace settings page is one document: a mapping of top-level\nkeys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can\nread or change it.\n\n## Applying a document\n\n- A partial document changes only the keys it states; every key it omits is left as it is.\n- Each key is replaced whole, so read its current value and send it back edited: a nested object\n given with one field replaces the stored object with that one field.\n- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`.\n- Each setting is written through the same path the settings page uses, with the same validation\n and audit log. The writes are not one transaction: when one fails the apply stops, and the\n error says what was already written.\n- A dry run reports the changes, as `before` / `after` per key, without writing anything.\n- `name` is never applied: rename a workspace on the settings page. `color` is applied only when\n set to a value, so `null` does not clear it.\n- A key the server does not know is ignored and named in the warnings: check them, since a typo\n otherwise reads as \"nothing to change\".\n- A setting this edition does not offer (Enterprise only) is refused, by the dry run too.\n\n## Secrets\n\n`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place\nof a set secret; sending that placeholder back keeps the stored value. A value starting with\n`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key\nis left unchanged with a warning; any other value is taken as plaintext. Never\nwrite a secret you were not given: keep the placeholder.\n\n## Keys\n\n| Key | Value |\n|---|---|\n| `auto_invite` | `{ enabled, mode: invite \\| add, operator, instance_groups?, instance_groups_roles? }`. The instance groups are managed only when `instance_groups` is stated; clearing a group removes the members it added. |\n| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/` or `flow/`. |\n| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. |\n| `webhook` | URL POSTed a JSON event on every change to the workspace's items. |\n| `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. |\n| `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. |\n| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |\n| `datatable` | `{ datatables: { : { database: { resource_type, resource_path } } } }`, both required: `resource_type: postgresql` with `resource_path` a Postgres resource's path, or `instance`, or `external_instance` (Enterprise only), with `resource_path` the name of a database a superadmin set up. A fork's entry can instead be `{ reference: { workspace_id, datatable } }`, pointing at its parent's data table; keep it as is. `main` is the one scripts use by default. Role permissions are `datatable_permissions`. |\n| `datatable_permissions` | `{ : { default_role, roles: { : [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/`, `g/`, `f/` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |\n| `protection_rules` | `{ : { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |\n| `ducklake` | `{ ducklakes: { : { catalog, storage, ... } } }`. |\n| `dbt_warehouses` | dbt warehouse connections. |\n| `default_app` | Path of the app shown as the workspace home. |\n| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. |\n| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. |\n| `operator_settings` | What operators see and do: page visibility flags (`runs`, `schedules`, `resources`, `variables`, `assets`, `audit_logs`, `groups`, `folders`, `workers`, `triggers`) and write rights (`manage_schedules`, `manage_triggers`, `builder_flows`, `builder_apps`). A write right the value omits keeps its stored value. |\n| `mute_critical_alerts` | Mute the workspace's critical alerts. |\n| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. |\n| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. |\n| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. |\n| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. |\n| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. |\n| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. |\n| `color` | Workspace color, `#rrggbb`. |\n" } } diff --git a/backend/windmill-api/src/mcp/session/gen/tools.json b/backend/windmill-api/src/mcp/session/gen/tools.json index 4db0f3e4a4..ffab17090a 100644 --- a/backend/windmill-api/src/mcp/session/gen/tools.json +++ b/backend/windmill-api/src/mcp/session/gen/tools.json @@ -1372,7 +1372,7 @@ }, { "name": "rebase_draft", - "description": "Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when deploy_workspace_item reports the draft was started from an older deployed version.", + "description": "Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when a deploy reports the draft was started from an older deployed version.", "parameters": { "type": "object", "properties": { diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index d932957a20..6a0d6f47f4 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -8364,7 +8364,7 @@ write a secret you were not given: keep the placeholder. | \`ai_config\` | Providers, default and completion models, custom prompts, \`copilot_disabled\`. | | \`large_file_storage\` | Workspace object storage: \`{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }\`. | | \`git_sync\` | \`{ repositories: [...] }\`, each with \`git_repo_resource_path\`, \`script_path\` and \`settings\` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. | -| \`datatable\` | \`{ datatables: { : { database: { resource_type, resource_path? } } } }\`. Role permissions are \`datatable_permissions\`. | +| \`datatable\` | \`{ datatables: { : { database: { resource_type, resource_path } } } }\`, both required: \`resource_type: postgresql\` with \`resource_path\` a Postgres resource's path, or \`instance\`, or \`external_instance\` (Enterprise only), with \`resource_path\` the name of a database a superadmin set up. A fork's entry can instead be \`{ reference: { workspace_id, datatable } }\`, pointing at its parent's data table; keep it as is. \`main\` is the one scripts use by default. Role permissions are \`datatable_permissions\`. | | \`datatable_permissions\` | \`{ : { default_role, roles: { : [tenants] } } }\`: who may connect to each data table as which role. Tenants are \`u/\`, \`g/\`, \`f/\` or \`*\`; \`admin\` is always a role, and \`default_role: null\` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. | | \`protection_rules\` | \`{ : { rules: [...], bypass_groups: [...], bypass_users: [...] } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`, \`RequireIsolatedForks\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. | | \`ducklake\` | \`{ ducklakes: { : { catalog, storage, ... } } }\`. | diff --git a/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.test.ts b/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.test.ts new file mode 100644 index 0000000000..1c74750a43 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { composerSuggestions, type ComposerRole } from './composerSuggestions' + +const developer: ComposerRole = { operator: false, workspaceAdmin: false, instanceAdmin: false } +const operator: ComposerRole = { ...developer, operator: true } +const workspaceAdmin: ComposerRole = { ...developer, workspaceAdmin: true } + +const myDraft = { kind: 'flow', path: 'f/a/sync', name: 'Sync' } +const sharedFlow = { kind: 'flow', path: 'f/billing/charge', name: 'Charge customers' } +const favorite = { kind: 'script', path: 'f/ops/check_ssl', name: 'Check SSL expiry' } +const script = favorite +const app = { kind: 'app', path: 'f/ops/console', name: 'Ops console' } +const folderItem = sharedFlow +const database = { type: 'postgresql', path: 'f/data/main_db', name: 'main_db' } +const slack = { type: 'slack', path: 'f/team/slack', name: 'slack' } + +const labels = (...args: Parameters) => + composerSuggestions(...args).map((s) => s.label) + +describe('composerSuggestions', () => { + it('offers the build examples in an empty workspace, but not to an operator', () => { + expect(labels({ itemCount: 0 }, developer)).toEqual([ + 'Daily metrics to Slack', + 'Approve refunds in Slack', + 'Triage tickets to Slack' + ]) + expect(labels({ itemCount: 0 }, operator)).toEqual([]) + }) + + it("teaches the piece a workspace is missing first, as the reader's role allows", () => { + const fresh = { itemCount: 0, dataTables: [], database: null, channel: null } + expect(labels(fresh, workspaceAdmin)).toEqual([ + 'Set up a data table', + 'Daily metrics to Slack', + 'Approve refunds in Slack', + 'Triage tickets to Slack' + ]) + expect(labels(fresh, developer)[0]).toBe('How do I store data?') + expect(labels({ ...fresh, dataTables: ['main'] }, developer)[0]).toBe('Connect Slack') + // Past its first items a workspace may keep data and Slack where these lists don't look. + const established = { ...fresh, itemCount: 25 } + expect(labels(established, workspaceAdmin)[0]).toBe('Set up a data table') + expect(labels(established, developer)).not.toContain('How do I store data?') + expect(labels(established, developer)).not.toContain('Connect Slack') + }) + + it('builds a new workspace from its own connections, naming their paths', () => { + const s = composerSuggestions( + { itemCount: 0, dataTables: ['main'], database, channel: slack }, + developer + ) + expect(s.map((x) => [x.mode, x.label])).toEqual([ + ['build', 'Daily main_db summary to Slack'], + ['use', "What's in the main data table?"], + ['build', 'Build an app on the main data table'], + ['use', 'Post a Slack message from a script'] + ]) + expect(s[0].prompt).toContain('f/data/main_db') + expect(s[0].prompt).toContain('f/team/slack') + }) + + it("ranks the reader's own work first, then what the workspace holds, once per concept", () => { + const established = { + itemCount: 25, + sharedFlow, + favorite, + script, + app, + folderItem, + database, + channel: slack, + dataTables: ['main'] + } + expect(labels({ ...established, myDraft }, workspaceAdmin)).toEqual([ + 'Finish my draft: Sync', + "What's in the main data table?", + 'Walk me through Charge customers', + 'Run Check SSL expiry' + ]) + expect(labels(established, developer)).toEqual([ + "What's in the main data table?", + 'Walk me through Charge customers', + 'Run Check SSL expiry', + 'Add a page to Ops console' + ]) + }) + + it('keeps an operator to running and reading', () => { + expect( + labels( + { itemCount: 25, myDraft, sharedFlow, favorite, folderItem, dataTables: [], channel: null }, + operator + ) + ).toEqual([ + 'Run Check SSL expiry', + 'Walk me through Charge customers', + 'What can I run here?', + 'Who can see Charge customers?' + ]) + }) + + it('fills with the checks for the role', () => { + expect(labels({ itemCount: 10 }, workspaceAdmin)).toEqual([ + 'Check the schedules', + 'Check recent runs', + 'Check the run queue', + 'Use my scripts from Claude' + ]) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts b/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts new file mode 100644 index 0000000000..a6a6a00c90 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts @@ -0,0 +1,387 @@ +import type { StarterPrompt } from '$lib/onboardingProfile' + +/** A prompt offered under the composer: `label` is the pill, `prompt` what it puts in the + * box. `use` looks at, asks about or runs what exists; `build` creates or changes things. */ +export type ComposerSuggestion = StarterPrompt & { mode: 'use' | 'build' } + +/** What the reader may do where the session acts. An instance admin is not assumed to be an + * admin of the workspace. */ +export type ComposerRole = { operator: boolean; workspaceAdmin: boolean; instanceAdmin: boolean } + +export type ComposerItem = { kind: string; path: string; name: string } + +/** A connection the workspace holds; `type` is its resource type. */ +export type ComposerResource = { type: string; path: string; name: string } + +/** Below this many items the workspace is new enough that building leads. */ +export const FEW_ITEMS = 10 + +/** The databases whose tables the chat can read (`get_db_schema`), by resource type. */ +export const DATABASE_TYPES: Record = { + postgresql: 'Postgres', + mysql: 'MySQL', + ms_sql_server: 'SQL Server', + snowflake: 'Snowflake', + bigquery: 'BigQuery' +} + +/** Where a job can send a message, by resource type, most preferred first. */ +export const CHANNEL_TYPES: Record< + string, + { name: string; to: string; send: string; message: string } +> = { + slack: { + name: 'Slack', + to: 'to Slack', + send: 'post it to Slack', + message: 'post a Slack message' + }, + ms_teams_webhook: { + name: 'Microsoft Teams', + to: 'to Teams', + send: 'post it to Microsoft Teams', + message: 'post a Teams message' + }, + discord_webhook: { + name: 'Discord', + to: 'to Discord', + send: 'post it to Discord', + message: 'post a Discord message' + }, + telegram: { + name: 'Telegram', + to: 'to Telegram', + send: 'send it on Telegram', + message: 'send a Telegram message' + }, + gmail: { name: 'Gmail', to: 'by email', send: 'email it', message: 'send an email' }, + smtp: { name: 'email', to: 'by email', send: 'email it', message: 'send an email' } +} + +/** What the workspace looks like now: read from the composer's item, resource and data table + * lists. An absent field fires no rule. A `null` channel and an + * empty `dataTables` mean the list answered and holds none, which fires the pills that teach + * the missing piece. */ +export type ComposerSignals = { + /** Capped at the page the list call reads. */ + itemCount?: number + /** The reader's draft among the items the list call reads, the most recently updated. */ + myDraft?: ComposerItem + /** Among the same items, the most recently updated flow outside the reader's user folder. */ + sharedFlow?: ComposerItem + /** Among the same items, the most recently updated deployed script with a main function. */ + script?: ComposerItem + /** Among the same items the reader may edit, the most recently updated full-code app. */ + app?: ComposerItem + /** Among the same items, the most recently updated AI agent. */ + agent?: ComposerItem + /** Among the same items, the most recently updated one in a folder. */ + folderItem?: ComposerItem + /** Among the same items, a script or flow the reader starred. The sidebar's favorites are + * not used: they name no workspace, so after a switch they can still be the last one's. */ + favorite?: ComposerItem + /** Among the connections the resource list reads (one page, ordered by path), the most + * recently edited database. */ + database?: ComposerResource + /** Among the same connections, the one a job would send a message through, the most + * preferred type first. */ + channel?: ComposerResource | null + /** The workspace's data tables, `main` first. */ + dataTables?: string[] +} + +/** Each example shows a different piece of Windmill: a schedule, an approval step, a webhook. */ +const BUILD_EXAMPLES: ComposerSuggestion[] = [ + { + mode: 'build', + label: 'Daily metrics to Slack', + prompt: + "Every weekday at 9am, query postgres for yesterday's signups and revenue and post a summary to the #metrics Slack channel" + }, + { + mode: 'build', + label: 'Approve refunds in Slack', + prompt: + 'Build a flow that asks for approval in Slack before issuing a refund in Stripe, and only refunds if someone approves' + }, + { + mode: 'build', + label: 'Triage tickets to Slack', + prompt: + 'When a support ticket arrives by webhook, classify its urgency with an LLM and post the urgent ones to the #support Slack channel' + } +] + +const MAX_SUGGESTIONS = 4 +const MAX_NAME = 32 + +/** Up to four suggestions that each teach one Windmill concept on the reader's own workspace, + * most pressing first: their unfinished work, the next piece the workspace is missing, then + * what it already holds, the build examples while it is empty, and checks for the role. A + * concept is offered once. A prompt names the exact item or connection path: the chat cannot + * list resources by type, so "our database" would leave it guessing. A pill reads what the + * workspace holds, never how its runs went: one that names a failure would announce it and + * offer the fix at once. The checks claim nothing, so they read the same whether or not + * anything is wrong. */ +export function composerSuggestions( + signals: ComposerSignals, + role: ComposerRole +): ComposerSuggestion[] { + const out: ComposerSuggestion[] = [] + const taught = new Set() + const add = ( + concept: string, + mode: ComposerSuggestion['mode'], + label: string, + prompt: string + ) => { + if (taught.has(concept)) return + taught.add(concept) + out.push({ mode, label, prompt }) + } + const empty = signals.itemCount === 0 + const few = signals.itemCount !== undefined && signals.itemCount < FEW_ITEMS + const { database, channel, dataTables, script, sharedFlow } = signals + const dataTable = dataTables?.[0] + // Operators run what others built and author nothing. + const builder = !role.operator + + if (builder) { + const draft = signals.myDraft + if (draft) + add( + 'draft', + 'build', + `Finish my draft: ${shortName(draft.name)}`, + `Help me finish my draft of the ${draft.kind} ${draft.path}` + ) + + // The next piece the workspace is missing, one at a time: where data lives, then a + // channel to send messages through. Past its first items a workspace may keep its data + // in its own database and reach Slack through workspace settings, neither of which these + // lists see, so only the admin's data table setup is offered there. + if (dataTables?.length === 0) { + // Only an admin can set up a data table. + if (role.workspaceAdmin) + add( + 'next', + 'build', + 'Set up a data table', + 'This workspace has no data table yet. Walk me through setting one up in the workspace settings, then show me how a script writes to it.' + ) + else if (few) + add( + 'next', + 'use', + 'How do I store data?', + 'How should my scripts store data in Windmill? Explain data tables and show me how a script reads and writes one.' + ) + } + if (channel === null && few) + add( + 'next', + 'build', + 'Connect Slack', + 'Help me connect Slack to this workspace so my scripts can post messages.' + ) + } + + const send = channel ? CHANNEL_TYPES[channel.type] : undefined + const summary = () => { + if (!few || !builder || !database || !channel || !send) return + add( + 'summary', + 'build', + `Daily ${shortName(database.name)} summary ${send.to}`, + `Every weekday at 9am, summarize yesterday's activity in the ${DATABASE_TYPES[database.type]} database ${database.path} and ${send.send} with the resource ${channel.path}` + ) + } + const exploreTable = () => { + if (!dataTable) return + add( + 'datatable', + 'use', + `What's in the ${shortName(dataTable)} data table?`, + `List the tables in the data table ${dataTable} and tell me what each one holds.` + ) + } + const walkThrough = () => { + const flow = sharedFlow + if (flow) + add( + 'walk', + 'use', + `Walk me through ${shortName(flow.name)}`, + `Walk me through what the flow ${flow.path} does, step by step.` + ) + const agent = signals.agent + if (agent) + add( + 'walk', + 'use', + `Walk me through ${shortName(agent.name)}`, + `Walk me through what the AI agent ${agent.path} does and which tools it can use.` + ) + } + const run = () => { + const favorite = signals.favorite + if (favorite) + add( + 'run', + 'use', + `Run ${shortName(favorite.name)}`, + `Run the ${favorite.kind} ${favorite.path} for me.` + ) + } + const access = () => { + const item = signals.folderItem + if (item) + add( + 'access', + 'use', + `Who can see ${shortName(item.name)}?`, + `Who can see and edit the ${item.kind} ${item.path}, and why?` + ) + } + + if (role.operator) { + run() + walkThrough() + exploreTable() + if (!empty) + add( + 'operator', + 'use', + 'What can I run here?', + 'Which scripts and flows can I run in this workspace, and what does each one do?' + ) + access() + } else { + summary() + exploreTable() + walkThrough() + run() + const app = signals.app + if (app) + add( + 'app', + 'build', + `Add a page to ${shortName(app.name)}`, + `Add a page to the app ${app.path}, and ask me what it should show.` + ) + else if (dataTable) + add( + 'app', + 'build', + `Build an app on the ${shortName(dataTable)} data table`, + `Build a full-code app to browse and edit the rows of a table in the data table ${dataTable}.` + ) + if (channel && send) + add( + 'channel', + 'use', + `${capitalize(send.message)} from a script`, + `Show me how a script uses the resource ${channel.path} to ${send.message}, with a short example.` + ) + if (database?.type === 'postgresql') + add( + 'postgres', + 'build', + `React to new rows in ${shortName(database.name)}`, + `Run a script whenever a row is inserted into a table of the Postgres database ${database.path}, and explain how Postgres triggers work.` + ) + if (script) { + add( + 'endpoint', + 'build', + `Make ${shortName(script.name)} an API endpoint`, + `Expose the script ${script.path} as an HTTP API endpoint that requires a Windmill token, and show me how to call it.` + ) + if (few) + add( + 'schedule', + 'build', + `Run ${shortName(script.name)} every morning`, + `Run the script ${script.path} every weekday at 9am. Ask me for any input it needs, and explain how schedules work.` + ) + } + if (sharedFlow) + add( + 'webhook', + 'use', + `Call ${shortName(sharedFlow.name)} by webhook`, + `How do I start the flow ${sharedFlow.path} from another app with its webhook? Show me the URL and an example request.` + ) + access() + // While the workspace is new; an empty one needs its build examples more. + if (few && !empty) + add( + 'secret', + 'build', + 'Store an API key as a secret', + 'I have an API key my scripts need. Store it as a secret and show me how a script reads it with wmill.getVariable.' + ) + if (channel && send) + add( + 'hub', + 'use', + `Find Hub scripts for ${send.name}`, + `Find scripts on the Windmill Hub for ${send.name} that I could use with the resource ${channel.path}.` + ) + } + + if (empty && builder) { + const [dailyMetrics, ...others] = BUILD_EXAMPLES + out.push(...(taught.has('summary') ? others : [dailyMetrics, ...others])) + } + + // A workspace with nothing in it has nothing to check yet. Keep these to what the chat's + // tools can answer: its run listing has no date filter and stops at 100 runs, so a prompt + // naming a period ("today", "this week") gets a guess. + if (!empty) { + if (role.instanceAdmin) + add( + 'workers', + 'use', + 'Check worker health', + 'Are the workers healthy and keeping up with the queue? Compare how long jobs wait before they start with how busy each worker group is.' + ) + if (role.workspaceAdmin) + add( + 'schedules', + 'use', + 'Check the schedules', + 'Do any schedules in this workspace keep failing? If so, which ones and why?' + ) + add( + 'runs', + 'use', + 'Check recent runs', + 'Look at the recent runs in this workspace: did any fail, and why? Is anything getting slower?' + ) + add( + 'queue', + 'use', + 'Check the run queue', + "Is anything stuck in this workspace's queue? Which jobs are waiting or suspended, and why?" + ) + } + + if (builder && !empty) + add( + 'mcp', + 'use', + 'Use my scripts from Claude', + 'How do I connect Claude to this workspace with MCP so it can run my scripts and flows?' + ) + + return out.slice(0, MAX_SUGGESTIONS) +} + +function shortName(name: string): string { + return name.length > MAX_NAME ? `${name.slice(0, MAX_NAME - 1)}…` : name +} + +function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1) +} diff --git a/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts index 9bb9341a0b..b02ce5c558 100644 --- a/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts @@ -1,80 +1,46 @@ import { onboardingProfile, type StarterPrompt } from '$lib/onboardingProfile' -import { roleCapabilities } from '$lib/stores' -import { fromStore } from 'svelte/store' +import { roleCapabilities, userStore } from '$lib/stores' +import { canEditItem } from '$lib/editRights' +import { scopedKey } from '$lib/userScopedStorage' +import { fromStore, get } from 'svelte/store' import { resource } from 'runed' -import { ScriptService } from '$lib/gen' +import { + ResourceService, + ScriptService, + WorkspaceService, + type ListableResource, + type RunnableItem +} from '$lib/gen' +import { + CHANNEL_TYPES, + composerSuggestions, + DATABASE_TYPES, + FEW_ITEMS, + type ComposerItem, + type ComposerResource, + type ComposerRole, + type ComposerSignals, + type ComposerSuggestion +} from './composerSuggestions' -/** A prompt offered under the composer: `label` is the pill, `prompt` what it puts in the - * box. `use` looks at, asks about or runs what exists; `build` creates or changes things. */ -export type ComposerSuggestion = StarterPrompt & { mode: 'use' | 'build' } +export type { ComposerSuggestion } -const USE_EXAMPLES: ComposerSuggestion[] = [ - { - mode: 'use', - label: 'What failed today?', - prompt: 'Which runs failed in this workspace today, and why?' - }, - { - mode: 'use', - label: 'Which schedules run the most?', - prompt: - 'Which schedules ran the most today, and does any of them run more often than it should?' - } -] - -const BUILD_EXAMPLES: ComposerSuggestion[] = [ - { - mode: 'build', - label: 'Sync Salesforce', - prompt: 'Sync new Salesforce leads into a postgres table every hour' - }, - { - mode: 'build', - label: 'Weekly Slack report', - prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday' - }, - { - mode: 'build', - label: 'Ban Discord users', - prompt: - 'Build a workflow that triggers on a Discord message, checks for offensive language using an LLM, and possibly block them' - } -] - -/** A session for a role that cannot code can run what is deployed, read runs and call MCP - * servers, but cannot author scripts, so none of these asks it to build anything. */ -const OPERATOR_EXAMPLES: ComposerSuggestion[] = [ - USE_EXAMPLES[0], - { - mode: 'use', - label: 'Rerun failed jobs', - prompt: 'List the jobs that failed today, tell me why, and rerun the ones that timed out' - }, - { - mode: 'use', - label: 'Run a flow', - prompt: 'Find the flow that onboards a new employee and run it for Jane Doe, starting Monday' - }, - { - mode: 'use', - label: 'Summarize GitHub issues', - prompt: "Read this week's open GitHub issues and summarize the ones that mention billing" - } -] - -/** From this many items on, Home's composer rests collapsed: below it the workspace is new - * enough that the composer is the page's main way in. */ -const FEW_ITEMS = 10 +/** The newest items read: enough to find a shared flow beside the reader's own work. */ +const ITEMS_READ = 25 +/** The connections read. A full page may have cut the channel off, so only a page shorter + * than this proves the workspace has none. */ +const RESOURCES_READ = 20 /** The suggestions for the reader's role and the workspace the session acts on, and whether * that workspace is still small. An invite that wrote prompts for this person replaces the - * examples outright: a prompt written for someone's own stack next to "Ban Discord users" - * reads as the generic one. */ + * suggestions outright: a prompt written for someone's own stack next to "Approve refunds in + * Slack" reads as the generic one. */ export class StarterPrompts { #invite = $state() + #user = fromStore(userStore) #capabilities = fromStore(roleCapabilities) #workspace: () => string | undefined - #itemCount: { readonly current: number | undefined } + #signals: { readonly current: Tagged | undefined } /** `workspace`: where the session acts. */ constructor(workspace: () => string | undefined) { @@ -82,66 +48,222 @@ export class StarterPrompts { void onboardingProfile().then((p) => { if (p?.starter_prompts?.length) this.#invite = p.starter_prompts }) - this.#itemCount = resource(workspace, async (ws) => (ws ? workspaceItemCount(ws) : undefined)) + // runed keeps whichever fetch settles last, so after a workspace or account switch an + // earlier fetch can land on top of the current one: each answer carries what it is for. + this.#signals = resource( + () => this.#tag, + async (tag) => { + const ws = this.#workspace() + return ws ? { tag, value: await readComposerSignals(ws) } : undefined + } + ) } - /** At most `FEW_ITEMS`; undefined until the workspace has answered. A count already read - * this page load stands in while it is asked again, so a remount does not wait on it. */ - get #count(): number | undefined { + get #tag(): string { + return `${this.#workspace()}\0${this.#user.current?.email}` + } + + /** Undefined until the user is known. The user is read for Home's workspace, so a session + * acting on another one (a fork) keeps only what is instance-wide. */ + get #role(): ComposerRole | undefined { + const user = this.#user.current + if (!user) return undefined + const own = user.workspace_id === this.#workspace() + return { + operator: own && !this.#capabilities.current.code, + workspaceAdmin: own && user.is_admin, + instanceAdmin: user.is_super_admin + } + } + + /** Signals already read this page load stand in while they are asked again, so a remount + * does not wait on them. */ + get #currentSignals(): ComposerSignals | undefined { + const current = this.#signals.current + if (current?.tag === this.#tag) return current.value const ws = this.#workspace() - return this.#itemCount.current ?? (ws ? itemCounts.get(ws) : undefined) + const key = ws && signalsKey(ws) + return key ? settled.get(key) : undefined } - /** Up to four: two Use then two Build. An empty workspace has nothing to ask about yet. */ readonly suggestions: ComposerSuggestion[] = $derived.by(() => { if (this.#invite) return this.#invite.map((p) => ({ ...p, mode: 'build' as const })) - if (!this.#capabilities.current.code) return OPERATOR_EXAMPLES - if (this.#count === 0) return BUILD_EXAMPLES - return [...USE_EXAMPLES, ...BUILD_EXAMPLES.slice(0, 2)] + const signals = this.#currentSignals + const role = this.#role + return signals && role ? composerSuggestions(signals, role) : [] }) - /** Whether the workspace holds fewer than `FEW_ITEMS`; undefined until known. */ + /** Whether the workspace holds fewer than `FEW_ITEMS`, below which Home's composer rests + * open as the page's main way in; undefined until known. */ get fewItems(): boolean | undefined { - const count = this.#count + const count = this.#currentSignals?.itemCount return count === undefined ? undefined : count < FEW_ITEMS } } -const itemCounts = new Map() -const itemCountRequests = new Map>() +type Tagged = { tag: string; value: T } -/** Starts the count for a page about to mount the starter; the starter then takes this - * request rather than sending its own. */ -export function prefetchItemCount(workspace: string): void { - void workspaceItemCount(workspace) +/** Per account: the signals name the reader's private draft. A key is taken when a request + * starts, so one that settles after an account switch cannot answer for the next account; with + * no account known yet, nothing is cached. */ +const signalsKey = (workspace: string) => scopedKey(`composerSignals:${workspace}`) + +const settled = new Map() +const requests = new Map }>() +/** How long an answer is reused before a mount asks again: long enough to cover the prefetch + * that preceded the mount, short enough that returning to Home sees what changed. */ +const REUSE_MS = 30_000 + +/** Starts the reads for a page about to mount the starter; the starter then takes these + * requests rather than sending its own. */ +export function prefetchComposerSignals(workspace: string): void { + void readComposerSignals(workspace) } -function workspaceItemCount(workspace: string): Promise { - let request = itemCountRequests.get(workspace) - if (!request) { - request = fetchItemCount(workspace).finally(() => itemCountRequests.delete(workspace)) - itemCountRequests.set(workspace, request) - } - return request -} - -async function fetchItemCount(workspace: string): Promise { - let count = FEW_ITEMS +/** What the starter reads for `workspace`: one set of requests, shared by every caller and + * reused for `REUSE_MS`; a failure is not kept. */ +export async function readComposerSignals(workspace: string): Promise { + const key = signalsKey(workspace) try { + let request: Promise + const kept = key && requests.get(key) + if (kept && Date.now() - kept.at < REUSE_MS) request = kept.request + else { + request = fetchSignals(workspace) + if (key) { + requests.set(key, { at: Date.now(), request }) + request.catch(() => { + if (requests.get(key)?.request === request) requests.delete(key) + }) + } + } + const signals = await request + if (!key) return signals + // A request that outlived the reuse window can settle after the one that replaced it. + if (requests.get(key)?.request === request) settled.set(key, signals) + return settled.get(key) ?? signals + } catch (e) { + // Unknown reads as a full workspace: a collapsed composer is the quieter mistake. + console.error(`Could not list the items of workspace ${workspace}:`, e) + return { itemCount: FEW_ITEMS } + } +} + +async function fetchSignals(workspace: string): Promise { + // Read before the requests: a workspace switch while they are pending moves the user store + // to the next workspace's membership. + const member = get(userStore) + const user = member?.workspace_id === workspace ? member : undefined + const capabilities = get(roleCapabilities) + const [res, resources, dataTables] = await Promise.all([ // What Home's list counts: library scripts and the user's own drafts included. - const res = await ScriptService.listRunnables({ + ScriptService.listRunnables({ workspace, includeWithoutMain: true, includeDraftOnly: true, - perPage: FEW_ITEMS + orderBy: 'updated', + orderDesc: true, + perPage: ITEMS_READ + }), + readConnections(workspace), + readDataTables(workspace) + ]) + const items = res.items ?? [] + const deployed = items.filter((i) => !i.draft_only) + const username = user?.username + const draft = items.find( + (i) => + i.is_draft || + i.draft_only || + (username && i.draft_users?.some((u) => u['username'] === username)) + ) + const editable = (i: RunnableItem) => + canEditItem( + i.type === 'app' && i.raw_app ? 'raw_app' : i.type, + i.path, + i.extra_perms, + user, + capabilities + ) + const shared = deployed.find( + (i) => i.type === 'flow' && !(username && i.path.startsWith(`u/${username}/`)) + ) + // Only plain scripts run on their own: a library has no main function, a pipeline script + // runs as its pipeline, and test and preprocessor scripts serve another runnable. + const script = deployed.find( + (i) => + i.type === 'script' && (!i.auto_kind || i.auto_kind === 'script' || i.auto_kind === 'wac') + ) + const starred = deployed.find((i) => i.starred && (i.type === 'script' || i.type === 'flow')) + const pick = (item: RunnableItem | undefined) => item && composerItem(item) + const complete = resources !== undefined && resources.length < RESOURCES_READ + return { + itemCount: items.length, + myDraft: draft && composerItem(draft, draft.draft_path), + sharedFlow: pick(shared), + script: pick(script), + app: pick(deployed.find((i) => i.type === 'app' && i.raw_app && editable(i))), + agent: pick(deployed.find((i) => i.type === 'agent')), + folderItem: pick(deployed.find((i) => i.path.startsWith('f/'))), + favorite: pick(starred), + database: + resources && newest(resources.filter((r) => Object.hasOwn(DATABASE_TYPES, r.resource_type))), + channel: resources && (preferredChannel(resources) ?? (complete ? null : undefined)), + dataTables + } +} + +/** The workspace's databases and channels; unknown when the list fails, which fires none of + * the pills that read them. */ +async function readConnections(workspace: string): Promise { + try { + return await ResourceService.listResource({ + workspace, + resourceType: [...Object.keys(DATABASE_TYPES), ...Object.keys(CHANNEL_TYPES)].join(','), + perPage: RESOURCES_READ }) - count = res.items?.length ?? 0 } catch (e) { - // Unknown reads as a full workspace: a collapsed composer is the quieter mistake. - console.error(`Could not count the items of workspace ${workspace}:`, e) + console.error(`Could not list the connections of workspace ${workspace}:`, e) + return undefined + } +} + +/** The workspace's data table names, `main` (the one scripts use by default) first; unknown + * when the list fails. */ +async function readDataTables(workspace: string): Promise { + try { + const names = (await WorkspaceService.listDataTables({ workspace })).map((d) => d.name) + return names.includes('main') ? ['main', ...names.filter((n) => n !== 'main')] : names + } catch (e) { + console.error(`Could not list the data tables of workspace ${workspace}:`, e) + return undefined + } +} + +function preferredChannel(resources: ListableResource[]): ComposerResource | undefined { + for (const type of Object.keys(CHANNEL_TYPES)) { + const channel = newest(resources.filter((r) => r.resource_type === type)) + if (channel) return channel + } + return undefined +} + +function newest(resources: ListableResource[]): ComposerResource | undefined { + const r = resources.reduce( + (a, b) => (!a || (b.edited_at ?? '') > (a.edited_at ?? '') ? b : a), + undefined + ) + return r?.path && r.resource_type + ? { type: r.resource_type, path: r.path, name: r.path.split('/').pop() || r.path } + : undefined +} + +function composerItem(item: RunnableItem, path = item.path): ComposerItem { + return { + kind: item.type, + path, + name: item.summary || path.split('/').pop() || path } - itemCounts.set(workspace, count) - return count } /** What turns an empty chat into the block that starts a session. */ diff --git a/frontend/src/lib/components/copilot/chat/starter/starterPrompts.test.ts b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.test.ts new file mode 100644 index 0000000000..62713dccd9 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const account = vi.hoisted(() => ({ email: 'ada@example.com' as string | undefined })) +const listRunnables = vi.hoisted(() => vi.fn()) +const listResource = vi.hoisted(() => vi.fn()) +const listDataTables = vi.hoisted(() => vi.fn()) + +vi.mock('$lib/userScopedStorage', () => ({ + scopedKey: (base: string) => (account.email ? `${base}::${account.email}` : undefined) +})) +vi.mock('$lib/onboardingProfile', () => ({ onboardingProfile: async () => undefined })) +vi.mock('$lib/gen', () => ({ + ScriptService: { listRunnables }, + ResourceService: { listResource }, + WorkspaceService: { listDataTables } +})) + +import { readComposerSignals } from './starterPrompts.svelte' + +const draftOf = (owner: string) => ({ + items: [{ type: 'flow', path: `u/${owner}/secret_plan`, summary: '', is_draft: true }] +}) + +beforeEach(() => { + listRunnables.mockReset() + listResource.mockReset() + listResource.mockResolvedValue([]) + listDataTables.mockReset() + listDataTables.mockResolvedValue([]) +}) + +describe('readComposerSignals', () => { + it("never answers one account's signals for another", async () => { + listRunnables.mockResolvedValueOnce(draftOf('ada')) + expect((await readComposerSignals('acme')).myDraft?.path).toBe('u/ada/secret_plan') + + // Another account in the same browser, well inside the reuse window. + account.email = 'bob@example.com' + listRunnables.mockResolvedValueOnce({ items: [] }) + expect((await readComposerSignals('acme')).myDraft).toBeUndefined() + expect(listRunnables).toHaveBeenCalledTimes(2) + }) + + it('picks the newest database and the preferred channel', async () => { + listRunnables.mockResolvedValueOnce({ items: [] }) + listResource.mockResolvedValueOnce([ + { path: 'f/mail/smtp', resource_type: 'smtp', edited_at: '2026-10-03' }, + { path: 'f/data/old_db', resource_type: 'postgresql', edited_at: '2026-01-01' }, + { path: 'f/team/slack', resource_type: 'slack', edited_at: '2026-02-01' }, + { path: 'f/data/main_db', resource_type: 'mysql', edited_at: '2026-09-01' } + ]) + listDataTables.mockResolvedValueOnce([{ name: 'analytics' }, { name: 'main' }]) + const signals = await readComposerSignals('globex') + expect(signals.database).toEqual({ type: 'mysql', path: 'f/data/main_db', name: 'main_db' }) + expect(signals.channel?.path).toBe('f/team/slack') + expect(signals.dataTables).toEqual(['main', 'analytics']) + }) + + it('tells a list that holds nothing from one that failed or was cut off', async () => { + listRunnables.mockResolvedValueOnce({ items: [] }) + listDataTables.mockRejectedValueOnce(new Error('offline')) + const signals = await readComposerSignals('initech') + expect(signals.channel).toBeNull() + expect(signals.dataTables).toBeUndefined() + + // A full page of databases may have pushed the channel past it. + listRunnables.mockResolvedValueOnce({ items: [] }) + listResource.mockResolvedValueOnce( + Array.from({ length: 20 }, (_, i) => ({ path: `u/ada/db_${i}`, resource_type: 'postgresql' })) + ) + expect((await readComposerSignals('hooli')).channel).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 0aad0adb1a..9d5247081e 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -32,7 +32,7 @@ import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' - import { prefetchItemCount } from '../copilot/chat/starter/starterPrompts.svelte' + import { prefetchComposerSignals } from '../copilot/chat/starter/starterPrompts.svelte' import { useWorkspaceAiHidden } from '../copilot/workspaceAiHidden.svelte' import { homeQuickAccessSettings } from './homeQuickAccessSettings.svelte' @@ -98,9 +98,10 @@ let HomeSessionStarter = $state(loadedStarter) if (!loadedStarter) void loadStarter().then((c) => (HomeSessionStarter = c)) - // The starter waits on the workspace's item count before it mounts; asked alongside its code - // rather than once the code has loaded, it is no extra wait. - if ($workspaceStore) prefetchItemCount($workspaceStore) + // The starter waits on the workspace's item count before it mounts, and its suggestions on + // the other signals; asked alongside its code rather than once the code has loaded, they + // are no extra wait. + if ($workspaceStore) prefetchComposerSignals($workspaceStore) // Whether Home's own workspace hides the assistant, read for that workspace: once the block // shows, the shared copilot config is the draft's, so a draft acting on a workspace that hides diff --git a/frontend/src/routes/(root)/(logged)/dev/composer-suggestions/+page.svelte b/frontend/src/routes/(root)/(logged)/dev/composer-suggestions/+page.svelte new file mode 100644 index 0000000000..7053fa8d3f --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/dev/composer-suggestions/+page.svelte @@ -0,0 +1,364 @@ + + +{#if import.meta.env.DEV} +
+
+

Composer suggestions

+

+ The pills composerSuggestions(signals, role) returns. A blank path means no such + item. Invitation prompts, which replace all of these, are not simulated. +

+
+ +
+
+
+

Role

+ + {#snippet children({ item })} + + + + + + {/snippet} + +

An instance admin here is also a workspace admin.

+
+ +
+

Load a real workspace

+
+ + +
+
+
+ Channel (type and path) +
+