mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 16:02:57 +00:00
* feat(ai-chat): add list_workers and list_data_metrics global tools Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ai-evals): keep the generated eval workspace id valid for the backend Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(ai-chat): trim the worker truncation note and added comments Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * fix(ai-chat): only hedge an empty worker list when workers can be hidden Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * fix(ai-chat): show the empty-worker message in the tool card Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * fix(ai-chat): stop advertising workers in the api catalog tool Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * test(ai-evals): move the workspace id guard beside its function Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * fix(ai-chat): keep the empty metric note true for every filter shape Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * fix(ai-chat): resolve the caller role before judging an empty worker list Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * fix(ai-evals): collapse doubled hyphens in the generated workspace id Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * docs(ai-chat): describe what the ducklake tools and the worker hedge do Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * fix(ai-chat): name the missing lake when a metric table filter has none Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * fix(ai-chat): strip the ducklake scheme from the table retry hint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq * test(ai-evals): add a global case for the declared measure path Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
5.7 KiB
TypeScript
189 lines
5.7 KiB
TypeScript
import type { WindmillBackendSettings } from "../../core/windmillBackendSettings";
|
|
import { buildWorkspaceId } from "./workspaceId";
|
|
|
|
const tokenCache = new Map<string, Promise<string>>();
|
|
const sharedWorkspaceQueue = new Map<string, Promise<void>>();
|
|
|
|
export class WindmillBackendClient {
|
|
constructor(private readonly settings: WindmillBackendSettings) {}
|
|
|
|
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);
|
|
|
|
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 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 ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async upsertResource(input: {
|
|
workspaceId: string;
|
|
path: string;
|
|
resourceType: string;
|
|
value: Record<string, unknown>;
|
|
}): Promise<void> {
|
|
const response = await this.request(
|
|
`/w/${encodeURIComponent(input.workspaceId)}/resources/create?update_if_exists=true`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
path: input.path,
|
|
resource_type: input.resourceType,
|
|
value: input.value,
|
|
}),
|
|
},
|
|
);
|
|
await expectOk(response, `upsert resource ${input.path}`);
|
|
}
|
|
|
|
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 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 to Windmill backend");
|
|
return (await response.text()).trim();
|
|
}
|
|
}
|
|
|
|
export async function assertWindmillBackendReachable(
|
|
settings: WindmillBackendSettings,
|
|
): Promise<void> {
|
|
try {
|
|
await new WindmillBackendClient(settings).getToken();
|
|
} catch (error) {
|
|
const details = error instanceof Error ? error.message : String(error);
|
|
throw new Error(
|
|
[
|
|
`Could not initialize the Windmill backend for AI eval proxy at ${settings.baseUrl}.`,
|
|
"Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=<url>.",
|
|
`Using login ${settings.email}; if authentication failed, set WMILL_AI_EVAL_BACKEND_EMAIL and WMILL_AI_EVAL_BACKEND_PASSWORD.`,
|
|
`Details: ${details}`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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()}`,
|
|
);
|
|
}
|