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) <noreply@anthropic.com>

* fix: drop superseded home ai-hidden responses

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

* fix: load home session starter lazily to keep monaco out of home

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

* feat: redesign home composer and items list

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

* feat: align the composer chat icon, share the folder row, add a home preview page

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

* 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) <noreply@anthropic.com>

* feat: suggest composer prompts from workspace signals

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

* fix: pass dev page placeholders through inputProps, key settled signals by role

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

* perf: read filter chip positions before hiding any in clampRows

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

* fix: scope composer signal caches to the account, drop stale workspace answers

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

* 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) <noreply@anthropic.com>

* feat: teach one Windmill concept per composer pill from the workspace

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

* fix: drop the failed-deploy pill, which reports a run outcome

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

* fix: read favorites from the workspace's own item list

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

* chore: regenerate mcp session guidance for the data table row

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

* fix: address cubic review of the composer signals and data table guidance

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

* test: make the billing fixture's receipt step send what its summary says

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

* fix: keep loaded item kinds and drop stale loads in the composer playground

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

* fix: preview an instance admin who is a plain member in the composer playground

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

* fix: scope the enterprise note to external_instance and check receipt sends

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

---------

Co-authored-by: Diego Imbert <diego@windmill.dev>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Guilhem
2026-10-23 15:06:37 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 Diego Imbert windmill-internal-app[bot]
parent e5dedd147e
commit ebeea51492
18 changed files with 2601 additions and 131 deletions
+310 -16
View File
@@ -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<string, unknown>
description?: string
/** A database's tables, as `get_db_schema` reads them: schema, table, then column. */
dbSchema?: Record<string, Record<string, Record<string, BenchmarkDbColumn>>>
}
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<string, unknown>
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/<user>` or `g/<group>`: `true` grants write. */
extra_perms?: Record<string, boolean>
}
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<string, unknown>
}
type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' }
type BenchmarkQueuedJob = QueuedJob & { type: 'QueuedJob' }
const benchmarkWorkspaces = new Set<string>()
const benchmarkWorkspaceRunnables = new Map<string, BenchmarkWorkspaceRunnables>()
// 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<string, { workspace: string; job: BenchmarkCompletedJob }>()
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<string, Record<string, unknown>>()
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<BenchmarkWorkspaceResource['dbSchema']> } | 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<string, unknown> }).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<string, unknown> {
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<string, unknown>; 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) =============
/**
@@ -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<any>(
'$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<any>('$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<string, unknown>) =>
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)
+623
View File
@@ -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
@@ -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<unknown>()\n\treturn (\n\t\t<div>\n\t\t\t<h1>Ops console</h1>\n\t\t\t<input value={domain} onChange={(e) => setDomain(e.target.value)} />\n\t\t\t<button onClick={async () => setResult(await backend.check_ssl({ domain }))}>Check SSL</button>\n\t\t\t<pre>{JSON.stringify(result, null, 2)}</pre>\n\t\t</div>\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"
]
}
]
}
}
@@ -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"
}
}
]
}
}
@@ -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" }
]
}
}
File diff suppressed because one or more lines are too long
@@ -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": {
+1 -1
View File
@@ -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: { <name>: { database: { resource_type, resource_path? } } } }\`. Role permissions are \`datatable_permissions\`. |
| \`datatable\` | \`{ datatables: { <name>: { 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\` | \`{ <data table>: { default_role, roles: { <role>: [tenants] } } }\`: who may connect to each data table as which role. Tenants are \`u/<user>\`, \`g/<group>\`, \`f/<folder>\` 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\` | \`{ <rule name>: { 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: { <name>: { catalog, storage, ... } } }\`. |
@@ -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<typeof composerSuggestions>) =>
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'
])
})
})
@@ -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<string, string> = {
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<string>()
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)
}
@@ -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<StarterPrompt[]>()
#user = fromStore(userStore)
#capabilities = fromStore(roleCapabilities)
#workspace: () => string | undefined
#itemCount: { readonly current: number | undefined }
#signals: { readonly current: Tagged<ComposerSignals> | 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<string, number>()
const itemCountRequests = new Map<string, Promise<number>>()
type Tagged<T> = { 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<string, ComposerSignals>()
const requests = new Map<string, { at: number; request: Promise<ComposerSignals> }>()
/** 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<number> {
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<number> {
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<ComposerSignals> {
const key = signalsKey(workspace)
try {
let request: Promise<ComposerSignals>
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<ComposerSignals> {
// 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<ListableResource[] | undefined> {
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<string[] | undefined> {
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<ListableResource | undefined>(
(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. */
@@ -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()
})
})
@@ -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
@@ -0,0 +1,364 @@
<script lang="ts">
// DEV-ONLY playground for the composer's suggestion rules: pick a role, edit the signals by
// hand or load a workspace's real ones, and see the pills the starter would show. Not
// linked anywhere; open at /dev/composer-suggestions. Only reachable on dev builds.
import { Badge, Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Select from '$lib/components/select/Select.svelte'
import ComposerSuggestions from '$lib/components/copilot/chat/starter/ComposerSuggestions.svelte'
import {
CHANNEL_TYPES,
composerSuggestions,
DATABASE_TYPES,
type ComposerRole,
type ComposerSignals
} from '$lib/components/copilot/chat/starter/composerSuggestions'
import { readComposerSignals } from '$lib/components/copilot/chat/starter/starterPrompts.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { Download } from 'lucide-svelte'
type RoleName =
| 'operator'
| 'developer'
| 'workspaceAdmin'
| 'instanceAdmin'
| 'instanceAdminMember'
const ROLES: Record<RoleName, ComposerRole> = {
operator: { operator: true, workspaceAdmin: false, instanceAdmin: false },
developer: { operator: false, workspaceAdmin: false, instanceAdmin: false },
workspaceAdmin: { operator: false, workspaceAdmin: true, instanceAdmin: false },
// Not a member: `whoami` makes them the workspace's admin.
instanceAdmin: { operator: false, workspaceAdmin: true, instanceAdmin: true },
// A member keeps the role of their member row.
instanceAdminMember: { operator: false, workspaceAdmin: false, instanceAdmin: true }
}
// Every field as the form holds it: a blank field is an absent signal, which fires no rule.
// "none" is a list that answered and holds none, which fires the pills that teach it.
type Form = {
itemCount: string
draftPath: string
sharedFlowPath: string
scriptPath: string
appPath: string
agentPath: string
folderItemPath: string
favoritePath: string
dataTables: string
databasePath: string
databaseType: string
channelPath: string
channelType: string
}
const BLANK: Form = {
itemCount: '',
draftPath: '',
sharedFlowPath: '',
scriptPath: '',
appPath: '',
agentPath: '',
folderItemPath: '',
favoritePath: '',
dataTables: '',
databasePath: '',
databaseType: 'postgresql',
channelPath: '',
channelType: 'slack'
}
const PRESETS: Record<string, Partial<Form>> = {
'Empty workspace': { itemCount: '0' },
'Fresh workspace': {
itemCount: '0',
dataTables: 'none',
channelType: 'none'
},
'Empty, connected': {
itemCount: '0',
dataTables: 'main',
databasePath: 'f/data/main_db',
channelPath: 'f/team/slack'
},
'Has items': { itemCount: '25' },
'Own work': {
itemCount: '25',
draftPath: 'f/billing/sync_invoices'
},
Established: {
itemCount: '25',
sharedFlowPath: 'f/billing/charge_customers',
scriptPath: 'f/ops/check_ssl_expiry',
appPath: 'f/ops/console',
folderItemPath: 'f/billing/charge_customers',
favoritePath: 'f/ops/check_ssl_expiry',
dataTables: 'main',
databasePath: 'f/data/main_db',
channelPath: 'f/team/slack'
}
}
type PathKey =
| 'draftPath'
| 'sharedFlowPath'
| 'scriptPath'
| 'appPath'
| 'agentPath'
| 'folderItemPath'
| 'favoritePath'
| 'dataTables'
const PATH_FIELDS: { key: PathKey; label: string; placeholder?: string }[] = [
{ key: 'draftPath', label: 'My latest draft (flow path)' },
{ key: 'sharedFlowPath', label: 'Newest shared flow (path)' },
{ key: 'scriptPath', label: 'Newest script (path)' },
{ key: 'appPath', label: 'Newest full-code app (path)' },
{ key: 'agentPath', label: 'Newest AI agent (path)' },
{ key: 'folderItemPath', label: 'Newest item in a folder (path)' },
{ key: 'favoritePath', label: 'Favorite (script path)' },
{ key: 'dataTables', label: 'Data tables (names, or none)', placeholder: 'unknown' }
]
const NONE = { value: 'none', label: 'None connected' }
const DATABASE_ITEMS = Object.entries(DATABASE_TYPES).map(([value, label]) => ({ value, label }))
const CHANNEL_ITEMS = [
NONE,
...Object.entries(CHANNEL_TYPES).map(([value, { name }]) => ({ value, label: name }))
]
let roleName = $state<RoleName>('instanceAdmin')
let form = $state<Form>({ ...BLANK, ...PRESETS['Has items'] })
let workspace = $state<string | undefined>($workspaceStore ?? undefined)
let loading = $state(false)
let picked = $state<string>()
/** The kinds a loaded workspace reported, which the form's path fields do not hold. */
let loadedKinds = $state<{ draft?: string; folderItem?: string; favorite?: string }>({})
const num = (s: string) => (s.trim() === '' || isNaN(Number(s)) ? undefined : Number(s))
const item = (kind: string, path: string) =>
path.trim() ? { kind, path: path.trim(), name: path.trim().split('/').pop()! } : undefined
const connection = (type: string, path: string) => {
const i = item(type, path)
return i && { type: i.kind, path: i.path, name: i.name }
}
const names = (text: string) => {
const t = text.trim()
if (t === '') return undefined
if (t === 'none') return []
return t
.split(',')
.map((n) => n.trim())
.filter(Boolean)
}
const signals: ComposerSignals = $derived({
itemCount: num(form.itemCount),
myDraft: item(loadedKinds.draft ?? 'flow', form.draftPath),
sharedFlow: item('flow', form.sharedFlowPath),
script: item('script', form.scriptPath),
app: item('app', form.appPath),
agent: item('agent', form.agentPath),
folderItem: item(loadedKinds.folderItem ?? 'flow', form.folderItemPath),
favorite: item(loadedKinds.favorite ?? 'script', form.favoritePath),
database: connection(form.databaseType, form.databasePath),
channel: form.channelType === 'none' ? null : connection(form.channelType, form.channelPath),
dataTables: names(form.dataTables)
})
const suggestions = $derived(composerSuggestions(signals, ROLES[roleName]))
function applyPreset(name: string) {
form = { ...BLANK, ...PRESETS[name] }
loadedKinds = {}
picked = undefined
}
async function loadWorkspace() {
const requested = workspace
if (!requested) return
loading = true
// A failed list reads as 10 items with nothing of yours; the console says why.
const s = await readComposerSignals(requested)
loading = false
// The selection changed while this was loading: these signals describe another workspace.
if (workspace !== requested) return
loadedKinds = {
draft: s.myDraft?.kind,
folderItem: s.folderItem?.kind,
favorite: s.favorite?.kind
}
form = {
itemCount: String(s.itemCount ?? ''),
draftPath: s.myDraft?.path ?? '',
sharedFlowPath: s.sharedFlow?.path ?? '',
scriptPath: s.script?.path ?? '',
appPath: s.app?.path ?? '',
agentPath: s.agent?.path ?? '',
folderItemPath: s.folderItem?.path ?? '',
favoritePath: s.favorite?.path ?? '',
dataTables: s.dataTables === undefined ? '' : s.dataTables.join(', ') || 'none',
databasePath: s.database?.path ?? '',
databaseType: s.database?.type ?? BLANK.databaseType,
channelPath: s.channel?.path ?? '',
channelType: s.channel === null ? 'none' : (s.channel?.type ?? BLANK.channelType)
}
picked = undefined
}
</script>
{#if import.meta.env.DEV}
<div class="max-w-6xl mx-auto px-6 py-8 flex flex-col gap-8">
<div class="flex flex-col gap-1">
<h1 class="text-lg font-semibold text-emphasis">Composer suggestions</h1>
<p class="text-xs text-secondary">
The pills <code>composerSuggestions(signals, role)</code> returns. A blank path means no such
item. Invitation prompts, which replace all of these, are not simulated.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-8">
<div class="flex flex-col gap-6">
<section class="flex flex-col gap-2">
<h2 class="text-xs font-semibold text-emphasis">Role</h2>
<ToggleButtonGroup bind:selected={roleName} wrap>
{#snippet children({ item })}
<ToggleButton value="operator" label="Operator" {item} />
<ToggleButton value="developer" label="Developer" {item} />
<ToggleButton value="workspaceAdmin" label="Workspace admin" {item} />
<ToggleButton value="instanceAdmin" label="Instance admin" {item} />
<ToggleButton
value="instanceAdminMember"
label="Instance admin, developer member"
{item}
/>
{/snippet}
</ToggleButtonGroup>
<p class="text-2xs text-hint">An instance admin here is also a workspace admin.</p>
</section>
<section class="flex flex-col gap-2">
<h2 class="text-xs font-semibold text-emphasis">Load a real workspace</h2>
<div class="flex flex-row gap-2 items-center">
<Select
class="grow"
items={$userWorkspaces.map((w) => ({ label: `${w.name} (${w.id})`, value: w.id }))}
bind:value={workspace}
placeholder="Workspace"
/>
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: Download }}
disabled={!workspace}
{loading}
onClick={loadWorkspace}
>
Load signals
</Button>
</div>
<p class="text-2xs text-hint">
Fills the form with what the starter would read there. Answers are reused for 30 s.
</p>
</section>
<section class="flex flex-col gap-2">
<h2 class="text-xs font-semibold text-emphasis">Presets</h2>
<div class="flex flex-row flex-wrap gap-2">
{#each Object.keys(PRESETS) as name (name)}
<Button variant="subtle" unifiedSize="sm" onClick={() => applyPreset(name)}>
{name}
</Button>
{/each}
<Button variant="subtle" unifiedSize="sm" onClick={() => applyPreset('')}>Clear</Button>
</div>
</section>
<section class="flex flex-col gap-3">
<h2 class="text-xs font-semibold text-emphasis">Signals</h2>
<label class="flex flex-col gap-1">
<span class="text-xs text-primary">Items</span>
<TextInput
bind:value={() => form.itemCount, (v) => (form.itemCount = String(v ?? ''))}
size="sm"
inputProps={{ type: 'number', min: 0, placeholder: 'absent' }}
/>
<span class="text-2xs text-hint">0 is empty, under 10 is new; the list reads 25</span>
</label>
{#each PATH_FIELDS as field (field.key)}
<label class="flex flex-col gap-1">
<span class="text-xs text-primary">{field.label}</span>
<TextInput
bind:value={() => form[field.key], (v) => (form[field.key] = String(v ?? ''))}
size="sm"
inputProps={{ placeholder: field.placeholder ?? 'none' }}
/>
</label>
{/each}
<div class="flex flex-col gap-1">
<span class="text-xs text-primary">Database (type and path)</span>
<div class="flex flex-row gap-2">
<Select class="w-40" items={DATABASE_ITEMS} bind:value={form.databaseType} />
<TextInput
class="grow"
bind:value={form.databasePath}
size="sm"
inputProps={{ placeholder: 'none' }}
/>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-primary">Channel (type and path)</span>
<div class="flex flex-row gap-2">
<Select class="w-40" items={CHANNEL_ITEMS} bind:value={form.channelType} />
<TextInput
class="grow"
bind:value={form.channelPath}
size="sm"
inputProps={{ placeholder: 'none' }}
/>
</div>
</div>
</section>
</div>
<div class="flex flex-col gap-6">
<section class="flex flex-col gap-2">
<h2 class="text-xs font-semibold text-emphasis">Pills</h2>
<div class="rounded-md border border-border-light bg-surface p-3 min-h-12">
{#if suggestions.length}
<ComposerSuggestions {suggestions} onPick={(p) => (picked = p)} />
{:else}
<p class="text-xs text-hint">No pills.</p>
{/if}
</div>
{#if picked}
<div class="flex flex-col gap-1">
<span class="text-2xs text-hint">Clicking that pill puts this in the box:</span>
<p class="text-xs text-primary whitespace-pre-wrap">{picked}</p>
</div>
{/if}
</section>
<section class="flex flex-col gap-2">
<h2 class="text-xs font-semibold text-emphasis">In order</h2>
<ol class="flex flex-col gap-3">
{#each suggestions as s, i (s.label)}
<li class="flex flex-col gap-1">
<div class="flex flex-row gap-2 items-center">
<span class="text-2xs text-hint w-3">{i + 1}</span>
<Badge color={s.mode === 'use' ? 'blue' : 'green'}>{s.mode}</Badge>
<span class="text-xs font-medium text-emphasis">{s.label}</span>
</div>
<p class="text-2xs text-secondary pl-5">{s.prompt}</p>
</li>
{/each}
</ol>
</section>
<section class="flex flex-col gap-2">
<h2 class="text-xs font-semibold text-emphasis">Signals passed</h2>
<pre class="text-2xs text-secondary bg-surface-secondary rounded-md p-3 overflow-x-auto"
>{JSON.stringify(signals, null, 2)}</pre
>
</section>
</div>
</div>
</div>
{/if}
+1 -1
View File
@@ -1483,7 +1483,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: { <name>: { database: { resource_type, resource_path? } } } }\`. Role permissions are \`datatable_permissions\`. |
| \`datatable\` | \`{ datatables: { <name>: { 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\` | \`{ <data table>: { default_role, roles: { <role>: [tenants] } } }\`: who may connect to each data table as which role. Tenants are \`u/<user>\`, \`g/<group>\`, \`f/<folder>\` 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\` | \`{ <rule name>: { 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: { <name>: { catalog, storage, ... } } }\`. |
@@ -44,7 +44,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: { <name>: { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. |
| `datatable` | `{ datatables: { <name>: { 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` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` 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` | `{ <rule name>: { 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: { <name>: { catalog, storage, ... } } }`. |
+1 -1
View File
@@ -39,7 +39,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: { <name>: { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. |
| `datatable` | `{ datatables: { <name>: { 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` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` 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` | `{ <rule name>: { 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: { <name>: { catalog, storage, ... } } }`. |