mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
504 lines
15 KiB
TypeScript
504 lines
15 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import type { BackendValidationSettings } from '../../core/backendValidation'
|
|
|
|
interface CompletedJobResultMaybe {
|
|
completed: boolean
|
|
result: unknown
|
|
success?: boolean
|
|
started?: boolean
|
|
}
|
|
|
|
interface ScriptDeploymentStatus {
|
|
lock?: unknown
|
|
lock_error_logs?: string | null
|
|
}
|
|
|
|
export interface CompletedPreviewJob {
|
|
id: string
|
|
success: boolean
|
|
result: unknown
|
|
logs?: string | null
|
|
raw: Record<string, unknown>
|
|
}
|
|
|
|
const tokenCache = new Map<string, Promise<string>>()
|
|
const sharedWorkspaceQueue = new Map<string, Promise<void>>()
|
|
const managedSharedWorkspacePrefixes = ['f/evals/']
|
|
const DEFAULT_WORKSPACE_PREFIX = 'ai-evals'
|
|
|
|
export class BackendPreviewClient {
|
|
constructor(private readonly settings: BackendValidationSettings) {}
|
|
|
|
async withWorkspace<T>(
|
|
caseId: string,
|
|
attempt: number,
|
|
body: (workspaceId: string) => Promise<T>
|
|
): Promise<T> {
|
|
const workspaceId =
|
|
this.settings.workspaceOverride ??
|
|
buildWorkspaceId(caseId, attempt)
|
|
|
|
const run = async () => {
|
|
await this.ensureWorkspace(workspaceId)
|
|
if (this.settings.workspaceOverride) {
|
|
await this.clearManagedSharedWorkspaceAssets(workspaceId)
|
|
}
|
|
|
|
try {
|
|
return await body(workspaceId)
|
|
} finally {
|
|
if (!this.settings.workspaceOverride) {
|
|
await this.deleteWorkspace(workspaceId).catch(() => undefined)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.settings.workspaceOverride) {
|
|
return await withSharedWorkspaceLock(workspaceId, run)
|
|
}
|
|
|
|
return await run()
|
|
}
|
|
|
|
async createScript(input: {
|
|
workspaceId: string
|
|
path: string
|
|
summary: string
|
|
description?: string
|
|
schema?: Record<string, unknown>
|
|
content: string
|
|
language: string
|
|
}): Promise<void> {
|
|
await this.ensureFolderForPath(input.workspaceId, input.path)
|
|
|
|
const payload = {
|
|
path: input.path,
|
|
summary: input.summary,
|
|
description: input.description ?? '',
|
|
content: input.content,
|
|
schema: input.schema ?? { type: 'object', properties: {}, required: [] },
|
|
is_template: false,
|
|
language: input.language,
|
|
kind: 'script'
|
|
}
|
|
|
|
const response = await this.request(`/w/${encodeURIComponent(input.workspaceId)}/scripts/create`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
|
|
if (response.ok) {
|
|
await this.waitForScriptDeployment(input.workspaceId, input.path, (await response.text()).trim())
|
|
return
|
|
}
|
|
|
|
const message = await response.text()
|
|
if (!isConflictMessage(message)) {
|
|
throw new Error(`create script ${input.path} failed: ${response.status} ${response.statusText} - ${message}`)
|
|
}
|
|
|
|
const currentScript = await this.getScriptByPath(input.workspaceId, input.path)
|
|
const currentHash = readStringField(currentScript, 'hash', `script ${input.path}`)
|
|
const updateResponse = await this.request(
|
|
`/w/${encodeURIComponent(input.workspaceId)}/scripts/create`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
...payload,
|
|
parent_hash: currentHash
|
|
})
|
|
}
|
|
)
|
|
await expectOk(updateResponse, `update script ${input.path}`)
|
|
await this.waitForScriptDeployment(input.workspaceId, input.path, (await updateResponse.text()).trim())
|
|
}
|
|
|
|
async createFlow(input: {
|
|
workspaceId: string
|
|
path: string
|
|
summary: string
|
|
description?: string
|
|
schema?: Record<string, unknown>
|
|
value: Record<string, unknown>
|
|
}): Promise<void> {
|
|
await this.ensureFolderForPath(input.workspaceId, input.path)
|
|
|
|
const payload = {
|
|
path: input.path,
|
|
summary: input.summary,
|
|
description: input.description ?? '',
|
|
schema: input.schema ?? { type: 'object', properties: {}, required: [] },
|
|
value: input.value
|
|
}
|
|
|
|
const response = await this.request(`/w/${encodeURIComponent(input.workspaceId)}/flows/create`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
|
|
if (response.ok) {
|
|
return
|
|
}
|
|
|
|
const message = await response.text()
|
|
if (!isConflictMessage(message)) {
|
|
throw new Error(`create flow ${input.path} failed: ${response.status} ${response.statusText} - ${message}`)
|
|
}
|
|
|
|
const updateResponse = await this.request(
|
|
`/w/${encodeURIComponent(input.workspaceId)}/flows/update/${input.path}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}
|
|
)
|
|
await expectOk(updateResponse, `update flow ${input.path}`)
|
|
}
|
|
|
|
async runScriptPreview(input: {
|
|
workspaceId: string
|
|
content: string
|
|
args: Record<string, unknown>
|
|
language: string
|
|
path?: string
|
|
timeoutSeconds?: number
|
|
}): Promise<CompletedPreviewJob> {
|
|
const response = await this.request(
|
|
withQuery(`/w/${encodeURIComponent(input.workspaceId)}/jobs/run/preview`, {
|
|
timeout: input.timeoutSeconds
|
|
}),
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
content: input.content,
|
|
args: input.args,
|
|
language: input.language,
|
|
path: input.path
|
|
})
|
|
}
|
|
)
|
|
|
|
await expectOk(response, 'start script preview')
|
|
const jobId = (await response.text()).trim()
|
|
return await this.waitForCompletedJob(input.workspaceId, jobId)
|
|
}
|
|
|
|
async runFlowPreview(input: {
|
|
workspaceId: string
|
|
value: Record<string, unknown>
|
|
args: Record<string, unknown>
|
|
timeoutSeconds?: number
|
|
path?: string
|
|
}): Promise<CompletedPreviewJob> {
|
|
const response = await this.request(
|
|
withQuery(`/w/${encodeURIComponent(input.workspaceId)}/jobs/run/preview_flow`, {
|
|
timeout: input.timeoutSeconds
|
|
}),
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
value: input.value,
|
|
args: input.args,
|
|
path: input.path
|
|
})
|
|
}
|
|
)
|
|
|
|
await expectOk(response, 'start flow preview')
|
|
const jobId = (await response.text()).trim()
|
|
return await this.waitForCompletedJob(input.workspaceId, jobId)
|
|
}
|
|
|
|
private async ensureWorkspace(workspaceId: string): Promise<void> {
|
|
const existsResponse = await this.request('/workspaces/exists', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: workspaceId })
|
|
})
|
|
await expectOk(existsResponse, `check workspace ${workspaceId}`)
|
|
|
|
if ((await existsResponse.text()).trim() === 'true') {
|
|
return
|
|
}
|
|
|
|
const createResponse = await this.request('/workspaces/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: workspaceId, name: workspaceId })
|
|
})
|
|
try {
|
|
await expectOk(createResponse, `create workspace ${workspaceId}`)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
if (message.includes('maximum number of workspaces')) {
|
|
throw new Error(
|
|
`${message}. Reuse an existing workspace with WMILL_AI_EVAL_BACKEND_WORKSPACE=<workspace-id>.`
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private async deleteWorkspace(workspaceId: string): Promise<void> {
|
|
const response = await this.request(`/workspaces/delete/${encodeURIComponent(workspaceId)}`, {
|
|
method: 'DELETE'
|
|
})
|
|
await expectOk(response, `delete workspace ${workspaceId}`)
|
|
}
|
|
|
|
private async ensureFolderForPath(workspaceId: string, path: string): Promise<void> {
|
|
const folderName = extractFolderName(path)
|
|
if (!folderName) {
|
|
return
|
|
}
|
|
|
|
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/folders/create`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: folderName })
|
|
})
|
|
|
|
if (response.ok) {
|
|
return
|
|
}
|
|
|
|
const message = await response.text()
|
|
if (!message.toLowerCase().includes('already exists')) {
|
|
throw new Error(`Failed to create folder ${folderName}: ${message}`)
|
|
}
|
|
}
|
|
|
|
private async waitForCompletedJob(
|
|
workspaceId: string,
|
|
jobId: string
|
|
): Promise<CompletedPreviewJob> {
|
|
const deadline = Date.now() + this.settings.maxWaitMs
|
|
|
|
while (Date.now() < deadline) {
|
|
const maybeResponse = await this.request(
|
|
`/w/${encodeURIComponent(workspaceId)}/jobs_u/completed/get_result_maybe/${encodeURIComponent(jobId)}?get_started=false`
|
|
)
|
|
await expectOk(maybeResponse, `poll job ${jobId}`)
|
|
const maybeResult = (await maybeResponse.json()) as CompletedJobResultMaybe
|
|
|
|
if (maybeResult.completed) {
|
|
const completedResponse = await this.request(
|
|
`/w/${encodeURIComponent(workspaceId)}/jobs_u/completed/get/${encodeURIComponent(jobId)}`
|
|
)
|
|
await expectOk(completedResponse, `get completed job ${jobId}`)
|
|
const completedJob = (await completedResponse.json()) as Record<string, unknown>
|
|
return {
|
|
id: jobId,
|
|
success: Boolean(maybeResult.success),
|
|
result: maybeResult.result,
|
|
logs:
|
|
typeof completedJob.logs === 'string' || completedJob.logs === null
|
|
? (completedJob.logs as string | null)
|
|
: null,
|
|
raw: completedJob
|
|
}
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, this.settings.pollIntervalMs))
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for preview job ${jobId} to complete`)
|
|
}
|
|
|
|
private async getScriptByPath(workspaceId: string, path: string): Promise<Record<string, unknown>> {
|
|
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/get/p/${path}`)
|
|
await expectOk(response, `get script ${path}`)
|
|
return (await response.json()) as Record<string, unknown>
|
|
}
|
|
|
|
private async clearManagedSharedWorkspaceAssets(workspaceId: string): Promise<void> {
|
|
const flowPaths = await this.listFlowPaths(workspaceId)
|
|
for (const path of flowPaths.filter(isManagedSharedWorkspacePath)) {
|
|
await this.deleteFlowByPath(workspaceId, path)
|
|
}
|
|
|
|
const scriptPaths = await this.listScriptPaths(workspaceId)
|
|
for (const path of scriptPaths.filter(isManagedSharedWorkspacePath)) {
|
|
await this.deleteScriptByPath(workspaceId, path)
|
|
}
|
|
}
|
|
|
|
private async listFlowPaths(workspaceId: string): Promise<string[]> {
|
|
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/flows/list_paths`)
|
|
await expectOk(response, `list flows in workspace ${workspaceId}`)
|
|
return await response.json()
|
|
}
|
|
|
|
private async listScriptPaths(workspaceId: string): Promise<string[]> {
|
|
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/list_paths`)
|
|
await expectOk(response, `list scripts in workspace ${workspaceId}`)
|
|
return await response.json()
|
|
}
|
|
|
|
private async deleteFlowByPath(workspaceId: string, path: string): Promise<void> {
|
|
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/flows/delete/${path}`, {
|
|
method: 'DELETE'
|
|
})
|
|
await expectOk(response, `delete flow ${path}`)
|
|
}
|
|
|
|
private async deleteScriptByPath(workspaceId: string, path: string): Promise<void> {
|
|
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/delete/p/${path}`, {
|
|
method: 'POST'
|
|
})
|
|
await expectOk(response, `delete script ${path}`)
|
|
}
|
|
|
|
private async waitForScriptDeployment(
|
|
workspaceId: string,
|
|
path: string,
|
|
hash: string
|
|
): Promise<void> {
|
|
const deadline = Date.now() + this.settings.maxWaitMs
|
|
|
|
while (Date.now() < deadline) {
|
|
const response = await this.request(
|
|
`/w/${encodeURIComponent(workspaceId)}/scripts/deployment_status/h/${encodeURIComponent(hash)}`
|
|
)
|
|
await expectOk(response, `check deployment status for script ${path}`)
|
|
const deployment = (await response.json()) as ScriptDeploymentStatus
|
|
if (deployment.lock != null) {
|
|
return
|
|
}
|
|
if (deployment.lock_error_logs) {
|
|
throw new Error(`Script deployment failed for ${path}: ${deployment.lock_error_logs}`)
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, this.settings.pollIntervalMs))
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for script ${path} (${hash}) to deploy`)
|
|
}
|
|
|
|
private async request(path: string, init?: RequestInit): Promise<Response> {
|
|
const token = await this.getToken()
|
|
return await fetch(`${this.settings.baseUrl}/api${path}`, {
|
|
...init,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
...(init?.headers ?? {})
|
|
}
|
|
})
|
|
}
|
|
|
|
private async getToken(): Promise<string> {
|
|
const cacheKey = `${this.settings.baseUrl}|${this.settings.email}`
|
|
let tokenPromise = tokenCache.get(cacheKey)
|
|
if (!tokenPromise) {
|
|
tokenPromise = this.login().catch((error) => {
|
|
if (tokenCache.get(cacheKey) === tokenPromise) {
|
|
tokenCache.delete(cacheKey)
|
|
}
|
|
throw error
|
|
})
|
|
tokenCache.set(cacheKey, tokenPromise)
|
|
}
|
|
return await tokenPromise
|
|
}
|
|
|
|
private async login(): Promise<string> {
|
|
const response = await fetch(`${this.settings.baseUrl}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: this.settings.email,
|
|
password: this.settings.password
|
|
})
|
|
})
|
|
await expectOk(response, 'login for backend validation')
|
|
return (await response.text()).trim()
|
|
}
|
|
}
|
|
|
|
async function withSharedWorkspaceLock<T>(workspaceId: string, body: () => Promise<T>): Promise<T> {
|
|
const previous = sharedWorkspaceQueue.get(workspaceId) ?? Promise.resolve()
|
|
let releaseCurrent: (() => void) | undefined
|
|
const current = new Promise<void>((resolve) => {
|
|
releaseCurrent = resolve
|
|
})
|
|
const tail = previous.catch(() => undefined).then(() => current)
|
|
sharedWorkspaceQueue.set(workspaceId, tail)
|
|
|
|
await previous.catch(() => undefined)
|
|
|
|
try {
|
|
return await body()
|
|
} finally {
|
|
releaseCurrent?.()
|
|
if (sharedWorkspaceQueue.get(workspaceId) === tail) {
|
|
sharedWorkspaceQueue.delete(workspaceId)
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildWorkspaceId(caseId: string, attempt: number): string {
|
|
const caseSlug = caseId
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 30)
|
|
const suffix = randomUUID().slice(0, 8)
|
|
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || 'case'}-a${attempt}-${suffix}`
|
|
}
|
|
|
|
function extractFolderName(path: string): string | null {
|
|
if (!path.startsWith('f/')) {
|
|
return null
|
|
}
|
|
const segments = path.split('/').slice(1, -1)
|
|
return segments.length > 0 ? segments.join('/') : null
|
|
}
|
|
|
|
function withQuery(
|
|
path: string,
|
|
params: Record<string, string | number | undefined>
|
|
): string {
|
|
const query = new URLSearchParams()
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined) {
|
|
continue
|
|
}
|
|
query.set(key, String(value))
|
|
}
|
|
const suffix = query.toString()
|
|
return suffix ? `${path}?${suffix}` : path
|
|
}
|
|
|
|
async function expectOk(response: Response, context: string): Promise<void> {
|
|
if (response.ok) {
|
|
return
|
|
}
|
|
throw new Error(`${context} failed: ${response.status} ${response.statusText} - ${await response.text()}`)
|
|
}
|
|
|
|
function readStringField(
|
|
value: Record<string, unknown>,
|
|
field: string,
|
|
context: string
|
|
): string {
|
|
const candidate = value[field]
|
|
if (typeof candidate === 'string' && candidate.length > 0) {
|
|
return candidate
|
|
}
|
|
throw new Error(`${context} is missing string field ${field}`)
|
|
}
|
|
|
|
function isConflictMessage(message: string): boolean {
|
|
const normalized = message.toLowerCase()
|
|
return normalized.includes('already exists') || normalized.includes('path conflict')
|
|
}
|
|
|
|
function isManagedSharedWorkspacePath(path: string): boolean {
|
|
return managedSharedWorkspacePrefixes.some((prefix) => path.startsWith(prefix))
|
|
}
|