mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
4296a6ae1f
* docs: add global AI chat context-optimization plan for raw apps Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ai-evals): add global raw-app debugging cases on a large fixture Adds a ~20-file analytics_dashboard raw-app fixture (incl. a 5k-line data module and a planted wrong-totals bug), two global cases (read-heavy debug + small-edit baseline), app-seed support in the mock backend, directory-fixture loading, and a decorateHelpers seam so read-dedupe is measurable. Records tokenUsage for before/ after comparison of the read-tool optimization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai-chat): cap and dedupe read_app_file to bound context in large apps read_app_file now defaults to a head slice (1500 lines / 50k chars) with offset/ limit to page further, and skips resending a file whose earlier read is still in context (per-conversation ledger keyed off the originating tool-call id, so it self-heals after compaction). Bounds the file-content portion of global-chat context when working in large raw apps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ai-evals): add read-heavy raw-app debug case (large data module) global-test31 induces the model to inspect the 5k-line seedData module, exercising the read_app_file cap/offset path. Baseline ~262k tokens vs ~200k with the cap+dedupe change (-24%). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: record A+B benchmark results and fixed-overhead finding Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai-chat): clearer read_app_file past-EOF message + unit tests for cap/dedupe Addresses local-review nits: out-of-range offset now reports 'offset N is past the end of the file' instead of a backwards 'lines 11-10' label; adds unit coverage for the slicing (line cap, offset/limit window, char budget, past-EOF) and re-read dedupe (hit + miss-when-not-retained). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai-chat): char-level paging + per-range dedupe for read_app_file Adds char_offset/char_limit so minified/long-line files can be paged within a line window, keys the re-read ledger by range (so reading different ranges no longer collides), and dedupes on the full-file hash (a cached range stub is invalidated when any byte of the file changes, not just the returned range). Tests updated for the char-slice behavior plus single-line capping, char paging, and out-of-window change detection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ai-chat): add read_app_file context micro-benchmark + re-read eval case Adds a deterministic micro-benchmark (no LLM) that drives read_app_file through a realistic big-project read pattern (large file, re-read, minified bundle, paging) and asserts the cap+dedupe cut returned context >50% vs the old whole-file behavior — isolating the feature's effect from model nondeterminism and guarding against silent weakening. Adds global-test32, a cross-file consistency investigation that revisits overlapping files so re-read dedupe is exercised in a real run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ai-evals): clarify test32 measures the read cap, not dedupe Verified: sonnet and haiku both read each file once per conversation and retain it, so test32 never triggers read_app_file re-read dedupe. Dedupe is measured deterministically by the micro-benchmark instead. Comment corrected to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ai-chat): drop read_app_file re-read dedupe, ship the cap only Benchmarking showed the per-conversation re-read dedupe never fires in practice: across sonnet/opus/gpt-5.5/haiku, every model reads each file once per conversation and keeps it in context (0 within-conversation re-reads). It was a correct but unused guard, so this removes the ledger, full-file hash, retention predicate, the AIChatManager wiring, and the eval decorateHelpers seam — keeping the read cap + offset/limit/char paging (A), which is the lever that actually bounds context. The micro-benchmark is now cap-only; test32 is kept as a multi-file read-load case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai-chat): add search_app grep tool for global raw-app chat (experimental) Client-side grep over a raw app's frontend files and inline runnables (literal, case-insensitive, optional file_glob/context_lines/max_matches, head-capped). Completes the list -> search -> ranged-read triad. Includes the eval A/B gate (WMILL_AI_EVAL_DISABLE_SEARCH_APP), unit tests + micro-benchmark, and a find-all-usages eval case (global-test33). Experimental: A/B benchmarking shows it is not an unconditional win — it helps on find-all-usages but adds agentic iterations on navigable apps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ai-evals): accept search_app as a valid file-inspection tool in raw-app cases Add requiredToolsAnyOf alternatives-group to ToolValidationSpec and switch global-test29..32 to it so a model that locates files via search_app instead of read_app_file no longer false-fails the tool assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: remove stale ai-chat context-optimization planning doc Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ai-chat): drop read_app_file char paging for a hard char cap The char_offset/char_limit params guarded minified files (a single line over the char budget) but were effectively unused in benchmarks. Remove them and the in-window char paging; keep the hard 50k-char budget and, when a read hits it, tell the model to narrow the line limit (or treat the file as unreadable if a single line exceeds the budget). Proper long-line handling is left as a TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ai-chat): bake search_app context to 1 line, clarify query is literal Drop the context_lines param (models varied it to little effect) for a fixed SEARCH_APP_CONTEXT_LINES=1, and cap on matching lines instead of pushed rows so max_matches stays accurate with context always on. Sharpen the query description to state it is a literal (non-regex) substring and to suggest the call form (e.g. formatCurrency() to hit call sites and skip formatCurrencyPrecise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ai-chat): widen baked search_app context to 2 lines Models that set the old context_lines param leaned to 2; match the lean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai-chat): count every file with a match in search_app header Move fileHadMatch ahead of the render cap so files whose matches fall past max_matches are still counted (with a regression test). Also swap the raw NUL globstar sentinel for a printable escape (the NUL bytes made core.ts read as binary to grep) and reword two comments to describe current constraints instead of drafting history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai-chat): drop redundant input echoes from app tool results read_app_file and search_app no longer prefix results with the tool name or echo back the caller's own inputs (file path, query, file_glob) — the model already has them from the call args, and the unbounded query echo could push the search result past its output budget. Keeps the useful signals (line range, match/file counts, truncation) and the actionable advice. Also reword max_matches to 'matching lines' since it caps lines (each expands to context rows). Unit tests updated to the new format. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
675 lines
20 KiB
TypeScript
675 lines
20 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import type {
|
|
AppWithLastVersion,
|
|
CompletedJob,
|
|
Flow,
|
|
Job,
|
|
ListableApp,
|
|
Script
|
|
} from '../../../frontend/src/lib/gen'
|
|
import type {
|
|
DataTableTables,
|
|
DataTableTableSchema,
|
|
GetDraftForUserResponse,
|
|
ListDraftsResponse,
|
|
ScriptLang,
|
|
UpdateDraftResponse,
|
|
UserDraftItemKind
|
|
} from '../../../frontend/src/lib/gen/types.gen'
|
|
import { buildScriptLintResult } from './core/script/preview'
|
|
import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine'
|
|
|
|
export type { BenchmarkDatatableSeed, BenchmarkDatatableTableSeed } from './datatableSqlEngine'
|
|
|
|
const BENCHMARK_TIMESTAMP = '1970-01-01T00:00:00.000Z'
|
|
|
|
export interface BenchmarkWorkspaceScript {
|
|
path: string
|
|
summary: string
|
|
description?: string
|
|
language: Script['language']
|
|
schema?: Record<string, unknown>
|
|
content: string
|
|
}
|
|
|
|
export interface BenchmarkWorkspaceFlow {
|
|
path: string
|
|
summary: string
|
|
description?: string
|
|
schema?: Record<string, unknown>
|
|
value: Flow['value']
|
|
}
|
|
|
|
export interface BenchmarkWorkspaceApp {
|
|
path: string
|
|
summary: string
|
|
value: {
|
|
files: Record<string, string>
|
|
runnables: Record<string, unknown>
|
|
data?: unknown
|
|
policy?: unknown
|
|
custom_path?: unknown
|
|
}
|
|
}
|
|
|
|
export interface BenchmarkWorkspaceJob {
|
|
/** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */
|
|
id?: string
|
|
jobKind?: CompletedJob['job_kind']
|
|
scriptPath?: string
|
|
createdBy?: string
|
|
label?: string
|
|
success?: boolean
|
|
logs?: string
|
|
}
|
|
|
|
export interface BenchmarkWorkspaceRunnables {
|
|
scripts?: BenchmarkWorkspaceScript[]
|
|
flows?: BenchmarkWorkspaceFlow[]
|
|
apps?: BenchmarkWorkspaceApp[]
|
|
datatables?: BenchmarkDatatableSeed[]
|
|
jobs?: BenchmarkWorkspaceJob[]
|
|
}
|
|
|
|
type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' }
|
|
|
|
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 }>()
|
|
|
|
function benchmarkJobKey(workspace: string, jobId: string): string {
|
|
return `${workspace}::${jobId}`
|
|
}
|
|
|
|
export function resetBenchmarkMockBackend(): void {
|
|
benchmarkWorkspaces.clear()
|
|
benchmarkWorkspaceRunnables.clear()
|
|
benchmarkJobs.clear()
|
|
benchmarkDrafts.clear()
|
|
}
|
|
|
|
export function registerBenchmarkWorkspace(workspace: string): void {
|
|
benchmarkWorkspaces.add(workspace)
|
|
}
|
|
|
|
export function registerBenchmarkWorkspaceRunnables(
|
|
workspace: string,
|
|
runnables: BenchmarkWorkspaceRunnables
|
|
): void {
|
|
benchmarkWorkspaces.add(workspace)
|
|
// Fresh case: drop any drafts left from a prior run on this workspace id.
|
|
clearBenchmarkDrafts(workspace)
|
|
// Datatables are mutated in place by exec_datatable_sql (a write must be visible
|
|
// to later reads), so store an isolated deep copy — never mutate the caller's seed.
|
|
benchmarkWorkspaceRunnables.set(workspace, {
|
|
...runnables,
|
|
datatables: runnables.datatables ? structuredClone(runnables.datatables) : undefined
|
|
})
|
|
// Seed any fixture jobs so list_runs / get_job_logs have data to return.
|
|
for (const seed of runnables.jobs ?? []) {
|
|
createBenchmarkCompletedJob({
|
|
workspace,
|
|
id: seed.id,
|
|
jobKind: seed.jobKind ?? 'script',
|
|
success: seed.success,
|
|
scriptPath: seed.scriptPath,
|
|
createdBy: seed.createdBy,
|
|
label: seed.label,
|
|
logs: seed.logs
|
|
})
|
|
}
|
|
}
|
|
|
|
export function unregisterBenchmarkWorkspace(workspace: string): void {
|
|
benchmarkWorkspaces.delete(workspace)
|
|
benchmarkWorkspaceRunnables.delete(workspace)
|
|
clearBenchmarkDrafts(workspace)
|
|
for (const [jobId, entry] of benchmarkJobs.entries()) {
|
|
if (entry.workspace === workspace) {
|
|
benchmarkJobs.delete(jobId)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function unregisterBenchmarkWorkspaceRunnables(workspace: string): void {
|
|
unregisterBenchmarkWorkspace(workspace)
|
|
}
|
|
|
|
export function hasBenchmarkWorkspace(workspace: string): boolean {
|
|
return benchmarkWorkspaces.has(workspace)
|
|
}
|
|
|
|
export function listBenchmarkScripts(workspace: string): Script[] | null {
|
|
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
|
if (!runnables) {
|
|
return null
|
|
}
|
|
return (runnables.scripts ?? []).map(buildBenchmarkScript)
|
|
}
|
|
|
|
export function listBenchmarkFlows(workspace: string): Flow[] | null {
|
|
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
|
if (!runnables) {
|
|
return null
|
|
}
|
|
return (runnables.flows ?? []).map(buildBenchmarkFlow)
|
|
}
|
|
|
|
export function getBenchmarkScriptByPath(workspace: string, path: string): Script | null {
|
|
const script = benchmarkWorkspaceRunnables
|
|
.get(workspace)
|
|
?.scripts?.find((entry) => entry.path === path)
|
|
|
|
return script ? buildBenchmarkScript(script) : null
|
|
}
|
|
|
|
export function getBenchmarkScriptByHash(workspace: string, hash: string): Script | null {
|
|
const script = benchmarkWorkspaceRunnables
|
|
.get(workspace)
|
|
?.scripts?.find((entry) => buildBenchmarkScriptHash(entry.path) === hash)
|
|
|
|
return script ? buildBenchmarkScript(script) : null
|
|
}
|
|
|
|
export function getBenchmarkFlowByPath(workspace: string, path: string): Flow | null {
|
|
const flow = benchmarkWorkspaceRunnables
|
|
.get(workspace)
|
|
?.flows?.find((entry) => entry.path === path)
|
|
|
|
return flow ? buildBenchmarkFlow(flow) : null
|
|
}
|
|
|
|
export function listBenchmarkApps(workspace: string): ListableApp[] | null {
|
|
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
|
if (!runnables) {
|
|
return null
|
|
}
|
|
return (runnables.apps ?? []).map(buildBenchmarkListableApp)
|
|
}
|
|
|
|
export function getBenchmarkAppByPath(workspace: string, path: string): AppWithLastVersion | null {
|
|
const app = benchmarkWorkspaceRunnables
|
|
.get(workspace)
|
|
?.apps?.find((entry) => entry.path === path)
|
|
|
|
return app ? buildBenchmarkApp(app) : null
|
|
}
|
|
|
|
export function createBenchmarkCompletedJob(input: {
|
|
workspace: string
|
|
jobKind: CompletedJob['job_kind']
|
|
success?: boolean
|
|
result?: unknown
|
|
logs?: string
|
|
scriptPath?: string
|
|
scriptHash?: string
|
|
args?: Record<string, unknown>
|
|
id?: string
|
|
createdBy?: string
|
|
label?: string
|
|
}): string {
|
|
const jobId = input.id ?? `benchmark-job-${randomUUID()}`
|
|
const now = new Date().toISOString()
|
|
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,
|
|
success: input.success ?? true,
|
|
script_path: input.scriptPath,
|
|
script_hash: input.scriptHash,
|
|
args: input.args,
|
|
result: input.result,
|
|
logs: input.logs,
|
|
canceled: false,
|
|
job_kind: input.jobKind,
|
|
permissioned_as: 'u/ai-evals',
|
|
is_flow_step: false,
|
|
is_skipped: false,
|
|
email: 'ai-evals@local',
|
|
visible_to_owner: true,
|
|
tag: 'benchmark',
|
|
labels: input.label ? [input.label] : undefined
|
|
}
|
|
|
|
benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { workspace: input.workspace, job })
|
|
return jobId
|
|
}
|
|
|
|
export function getBenchmarkCompletedJob(
|
|
workspace: string,
|
|
jobId: string
|
|
): BenchmarkCompletedJob | null {
|
|
const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId))
|
|
if (!entry) {
|
|
return null
|
|
}
|
|
return structuredClone(entry.job)
|
|
}
|
|
|
|
/**
|
|
* List seeded/recorded jobs for a benchmark workspace, most recent first —
|
|
* the shape `JobService.listJobs` returns. Returns `null` for a non-benchmark
|
|
* workspace so the caller can fall through to the real backend. Server-side
|
|
* filters (path/creator/status/limit) are intentionally not applied: global
|
|
* eval cases assert on the recorded `list_runs` tool call, not on filtering.
|
|
*/
|
|
export function listBenchmarkJobs(workspace: string): Job[] | null {
|
|
if (!hasBenchmarkWorkspace(workspace)) {
|
|
return null
|
|
}
|
|
return [...benchmarkJobs.values()]
|
|
.filter((entry) => entry.workspace === workspace)
|
|
.map((entry) => structuredClone(entry.job) as Job)
|
|
.sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
|
|
}
|
|
|
|
/**
|
|
* Mirror `JobService.getJobLogs` (response is the raw log string). Throws a
|
|
* "not found" error for an unknown id, matching the backend 404.
|
|
*/
|
|
export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
|
|
const job = getBenchmarkCompletedJob(workspace, jobId)
|
|
if (!job) {
|
|
throw new Error(`Job Logs not found for "${jobId}"`)
|
|
}
|
|
return job.logs ?? ''
|
|
}
|
|
|
|
// ============= Drafts (per-user, DB-backed in production) =============
|
|
|
|
/**
|
|
* In-memory stand-in for the per-user draft backend (`DraftService`). The global
|
|
* AI chat now persists and reads drafts through the backend DB instead of an
|
|
* in-tab `UserDraft` cell, so the eval mocks the three draft endpoints it
|
|
* exercises (`updateDraft` / `getDraftForUser` / `listDrafts`) and keeps the
|
|
* saved values here, keyed by workspace + draft kind + storage path. Mirrors the
|
|
* semantics of the production unit test's mock in
|
|
* `frontend/src/lib/components/copilot/chat/global/core.test.ts`.
|
|
*/
|
|
const benchmarkDrafts = new Map<
|
|
string,
|
|
{ workspace: string; kind: UserDraftItemKind; path: string; value: unknown }
|
|
>()
|
|
|
|
// Fixed timestamp so artifacts stay deterministic. No eval simulates a
|
|
// concurrent writer, so every save is accepted and the conflict branch is
|
|
// never taken — the syncer just records this as its `last_sync` baseline.
|
|
const BENCHMARK_DRAFT_TIMESTAMP = '1970-01-01T00:00:00.000Z'
|
|
|
|
function benchmarkDraftKey(workspace: string, kind: string, path: string): string {
|
|
return `${workspace}::${kind}::${path}`
|
|
}
|
|
|
|
export function clearBenchmarkDrafts(workspace: string): void {
|
|
for (const [key, entry] of benchmarkDrafts.entries()) {
|
|
if (entry.workspace === workspace) {
|
|
benchmarkDrafts.delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Seed a draft straight into the store — used by the eval's live-editor draft
|
|
* fixtures, which model "the user already has this draft open/saved". Writing it
|
|
* here (instead of through `UserDraft.save`) keeps it a backend draft row with no
|
|
* shadowing in-tab cell, so a model edit that persists to the backend is what the
|
|
* output read-back captures — not the stale seed.
|
|
*/
|
|
export function seedBenchmarkDraft(
|
|
workspace: string,
|
|
kind: UserDraftItemKind,
|
|
path: string,
|
|
value: unknown
|
|
): void {
|
|
benchmarkDrafts.set(benchmarkDraftKey(workspace, kind, path), {
|
|
workspace,
|
|
kind,
|
|
path,
|
|
value
|
|
})
|
|
}
|
|
|
|
/** Mirror `DraftService.updateDraft`: a `null`/omitted value deletes the row. */
|
|
export function updateBenchmarkDraft(input: {
|
|
workspace: string
|
|
kind: UserDraftItemKind
|
|
path: string
|
|
requestBody?: { value?: unknown }
|
|
}): UpdateDraftResponse {
|
|
const key = benchmarkDraftKey(input.workspace, input.kind, input.path)
|
|
const value = input.requestBody?.value
|
|
if (value == null) {
|
|
benchmarkDrafts.delete(key)
|
|
} else {
|
|
benchmarkDrafts.set(key, {
|
|
workspace: input.workspace,
|
|
kind: input.kind,
|
|
path: input.path,
|
|
value
|
|
})
|
|
}
|
|
return { status: 'saved', current_timestamp: BENCHMARK_DRAFT_TIMESTAMP }
|
|
}
|
|
|
|
/** Mirror `DraftService.getDraftForUser`: 404-shaped throw when absent so the
|
|
* adapter's narrowed catch treats it as "no draft" instead of re-throwing. */
|
|
export function getBenchmarkDraftForUser(input: {
|
|
workspace: string
|
|
kind: UserDraftItemKind
|
|
path: string
|
|
}): GetDraftForUserResponse {
|
|
const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path))
|
|
if (!entry) {
|
|
throw Object.assign(new Error(`no draft for "${input.path}"`), { status: 404 })
|
|
}
|
|
return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP }
|
|
}
|
|
|
|
/** Mirror `DraftService.listDrafts`: metadata rows (no value) for a workspace. */
|
|
export function listBenchmarkDrafts(workspace: string): ListDraftsResponse {
|
|
return [...benchmarkDrafts.values()]
|
|
.filter((entry) => entry.workspace === workspace)
|
|
.map((entry) => ({
|
|
kind: entry.kind,
|
|
path: entry.path,
|
|
summary: (entry.value as { summary?: string } | null)?.summary,
|
|
draft_only: true,
|
|
legacy_draft: false,
|
|
created_at: BENCHMARK_DRAFT_TIMESTAMP
|
|
}))
|
|
}
|
|
|
|
// ============= Datatables (best-effort in-memory SQL) =============
|
|
|
|
/**
|
|
* Project the seeded datatables down to the `list_datatable_tables` response:
|
|
* `datatable_name` + `schema -> table_names`, with no column detail.
|
|
* Returns `null` for a non-benchmark workspace so callers can fall through to
|
|
* the real backend; an empty seed yields `[]`.
|
|
*/
|
|
export function listBenchmarkDatatables(workspace: string): DataTableTables[] | null {
|
|
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
|
if (!runnables) {
|
|
return null
|
|
}
|
|
return (runnables.datatables ?? []).map((datatable) => ({
|
|
datatable_name: datatable.datatable_name,
|
|
schemas: Object.fromEntries(
|
|
Object.entries(datatable.schemas).map(([schema, tables]) => [schema, Object.keys(tables)])
|
|
)
|
|
}))
|
|
}
|
|
|
|
export function getBenchmarkDatatableSchema(input: {
|
|
workspace: string
|
|
datatableName: string
|
|
schemaName: string
|
|
tableName: string
|
|
}): DataTableTableSchema {
|
|
const runnables = benchmarkWorkspaceRunnables.get(input.workspace)
|
|
const datatable = (runnables?.datatables ?? []).find(
|
|
(entry) => entry.datatable_name === input.datatableName
|
|
)
|
|
if (!datatable) {
|
|
// Message MUST match the production `isDatatableNotConfiguredError` regex
|
|
// (/datatable\s+\S+\s+not found/i in datatableTools.ts) so the
|
|
// get_datatable_table_schema not-configured mapping is actually exercised.
|
|
throw new Error(`datatable "${input.datatableName}" not found`)
|
|
}
|
|
const table = datatable.schemas?.[input.schemaName]?.[input.tableName]
|
|
if (!table) {
|
|
throw new Error(
|
|
`table "${input.schemaName}.${input.tableName}" not found in datatable "${input.datatableName}"`
|
|
)
|
|
}
|
|
return {
|
|
datatable_name: input.datatableName,
|
|
schema_name: input.schemaName,
|
|
table_name: input.tableName,
|
|
columns: table.columns
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute SQL against a seeded datatable through the best-effort in-memory engine
|
|
* (`applyDatatableSql`). Writes (CREATE/INSERT/UPDATE/DELETE/DROP) mutate the
|
|
* stored datatable in place so a later list/schema/SELECT reflects them; SELECT
|
|
* (and RETURNING) yield rows, other statements yield `[]`. Creates a benchmark
|
|
* completed job and returns its id, like `runBenchmarkScriptPreview`.
|
|
*/
|
|
export function runBenchmarkDatatableSql(input: {
|
|
workspace: string
|
|
datatableName: string
|
|
sql: string
|
|
}): string {
|
|
const runnables = benchmarkWorkspaceRunnables.get(input.workspace)
|
|
const datatable = (runnables?.datatables ?? []).find(
|
|
(entry) => entry.datatable_name === input.datatableName
|
|
)
|
|
const rows = datatable ? applyDatatableSql(datatable, input.sql).rows : []
|
|
return createBenchmarkCompletedJob({
|
|
workspace: input.workspace,
|
|
jobKind: 'preview',
|
|
success: true,
|
|
args: { database: `datatable://${input.datatableName}` },
|
|
result: rows
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mirror `JobService.getCompletedJobResultMaybe` for benchmark workspaces — the
|
|
* shape `pollJobResult` consumes. The job is created synchronously before
|
|
* polling, so it is always present and completed.
|
|
*/
|
|
export function getBenchmarkCompletedJobResultMaybe(input: {
|
|
workspace: string
|
|
id: string
|
|
}): { success: boolean; completed: boolean; result: unknown } {
|
|
const job = getBenchmarkCompletedJob(input.workspace, input.id)
|
|
if (!job) {
|
|
throw new Error(`Job "${input.id}" not found in benchmark workspace`)
|
|
}
|
|
return { success: job.success, completed: true, result: job.result }
|
|
}
|
|
|
|
export function runBenchmarkScriptPreview(input: {
|
|
workspace: string
|
|
requestBody: {
|
|
content?: string
|
|
language?: ScriptLang | 'bunnative'
|
|
args?: Record<string, unknown>
|
|
path?: string
|
|
}
|
|
}): string {
|
|
const content = input.requestBody.content ?? ''
|
|
const language = input.requestBody.language ?? 'bun'
|
|
const lintResult = buildScriptLintResult(content, language)
|
|
const success = lintResult.errorCount === 0
|
|
|
|
return createBenchmarkCompletedJob({
|
|
workspace: input.workspace,
|
|
jobKind: 'preview',
|
|
success,
|
|
scriptPath: input.requestBody.path,
|
|
args: input.requestBody.args,
|
|
result: success
|
|
? {
|
|
path: input.requestBody.path,
|
|
args: input.requestBody.args ?? {},
|
|
validated: true
|
|
}
|
|
: {
|
|
path: input.requestBody.path,
|
|
args: input.requestBody.args ?? {},
|
|
errorCount: lintResult.errorCount,
|
|
errors: lintResult.errors.map((entry) => ({
|
|
line: entry.startLineNumber,
|
|
message: entry.message
|
|
}))
|
|
}
|
|
})
|
|
}
|
|
|
|
export function runBenchmarkFlowByPath(input: {
|
|
workspace: string
|
|
path: string
|
|
args?: Record<string, unknown>
|
|
}): string {
|
|
const flow = getBenchmarkFlowByPath(input.workspace, input.path)
|
|
return createBenchmarkCompletedJob({
|
|
workspace: input.workspace,
|
|
jobKind: 'flowpreview',
|
|
success: flow !== null,
|
|
args: input.args,
|
|
result:
|
|
flow !== null
|
|
? {
|
|
path: input.path,
|
|
args: input.args ?? {},
|
|
mocked: true
|
|
}
|
|
: {
|
|
error: `Flow "${input.path}" not found in benchmark workspace`
|
|
},
|
|
logs:
|
|
flow !== null
|
|
? 'Mock benchmark flow run completed successfully.'
|
|
: `Flow "${input.path}" not found in benchmark workspace.`
|
|
})
|
|
}
|
|
|
|
export function previewBenchmarkSchedule(input: {
|
|
requestBody?: Record<string, unknown>
|
|
}): Record<string, unknown> {
|
|
const schedule = input.requestBody?.schedule
|
|
if (typeof schedule !== 'string' || schedule.trim().split(/\s+/).length !== 6) {
|
|
throw new Error(`schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}`)
|
|
}
|
|
|
|
return {
|
|
next_runs: ['1970-01-02T00:00:00.000Z']
|
|
}
|
|
}
|
|
|
|
export function createBenchmarkSchedule(input: {
|
|
workspace: string
|
|
requestBody: Record<string, unknown>
|
|
}): Record<string, unknown> {
|
|
assertBenchmarkWorkspacePath('schedule', input.requestBody.path)
|
|
assertBenchmarkWorkspacePath('target', input.requestBody.script_path)
|
|
return {
|
|
path: input.requestBody.path,
|
|
target_path: input.requestBody.script_path,
|
|
is_flow: input.requestBody.is_flow,
|
|
mocked: true
|
|
}
|
|
}
|
|
|
|
export function createBenchmarkHttpTrigger(input: {
|
|
workspace: string
|
|
requestBody: Record<string, unknown>
|
|
}): Record<string, unknown> {
|
|
assertBenchmarkWorkspacePath('trigger', input.requestBody.path)
|
|
assertBenchmarkWorkspacePath('target', input.requestBody.script_path)
|
|
if (
|
|
typeof input.requestBody.route_path === 'string' &&
|
|
input.requestBody.route_path.startsWith('/')
|
|
) {
|
|
throw new Error(`HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"`)
|
|
}
|
|
return {
|
|
path: input.requestBody.path,
|
|
target_path: input.requestBody.script_path,
|
|
route_path: input.requestBody.route_path,
|
|
is_flow: input.requestBody.is_flow,
|
|
mocked: true
|
|
}
|
|
}
|
|
|
|
function assertBenchmarkWorkspacePath(label: string, value: unknown): void {
|
|
if (typeof value !== 'string' || (!value.startsWith('f/') && !value.startsWith('u/'))) {
|
|
throw new Error(`${label} path must start with f/ or u/, got ${JSON.stringify(value)}`)
|
|
}
|
|
}
|
|
|
|
function buildBenchmarkScriptHash(path: string): string {
|
|
return `benchmark:${path}`
|
|
}
|
|
|
|
function buildBenchmarkScript(script: BenchmarkWorkspaceScript): Script {
|
|
return {
|
|
workspace_id: 'benchmark',
|
|
hash: buildBenchmarkScriptHash(script.path),
|
|
path: script.path,
|
|
parent_hashes: [],
|
|
summary: script.summary,
|
|
description: script.description ?? '',
|
|
content: script.content,
|
|
created_by: 'benchmark',
|
|
created_at: BENCHMARK_TIMESTAMP,
|
|
archived: false,
|
|
schema: script.schema ?? {},
|
|
deleted: false,
|
|
is_template: false,
|
|
extra_perms: {},
|
|
language: script.language,
|
|
kind: 'script',
|
|
starred: false,
|
|
has_preprocessor: false,
|
|
modules: null
|
|
}
|
|
}
|
|
|
|
function buildBenchmarkFlow(flow: BenchmarkWorkspaceFlow): Flow {
|
|
return {
|
|
path: flow.path,
|
|
summary: flow.summary,
|
|
description: flow.description ?? '',
|
|
value: flow.value,
|
|
schema: flow.schema ?? {},
|
|
edited_by: 'benchmark',
|
|
edited_at: BENCHMARK_TIMESTAMP,
|
|
archived: false,
|
|
extra_perms: {}
|
|
} as Flow
|
|
}
|
|
|
|
function buildBenchmarkListableApp(app: BenchmarkWorkspaceApp): ListableApp {
|
|
return {
|
|
id: 0,
|
|
workspace_id: 'benchmark',
|
|
path: app.path,
|
|
summary: app.summary,
|
|
version: 1,
|
|
extra_perms: {},
|
|
edited_at: BENCHMARK_TIMESTAMP,
|
|
execution_mode: 'viewer',
|
|
raw_app: true
|
|
}
|
|
}
|
|
|
|
function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
|
|
return {
|
|
id: 0,
|
|
workspace_id: 'benchmark',
|
|
path: app.path,
|
|
summary: app.summary,
|
|
versions: [1],
|
|
created_by: 'benchmark',
|
|
created_at: BENCHMARK_TIMESTAMP,
|
|
value: app.value,
|
|
policy: (app.value.policy ?? {}) as AppWithLastVersion['policy'],
|
|
execution_mode: 'viewer',
|
|
extra_perms: {},
|
|
custom_path: app.value.custom_path as string | undefined,
|
|
raw_app: true
|
|
}
|
|
}
|