mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 00:02:35 +00:00
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:
co-authored by
Claude Opus 5.5
Diego Imbert
windmill-internal-app[bot]
parent
e5dedd147e
commit
ebeea51492
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+1
-1
@@ -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}
|
||||
@@ -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, ... } } }`. |
|
||||
|
||||
@@ -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, ... } } }`. |
|
||||
|
||||
Reference in New Issue
Block a user