143 lines
5.4 KiB
TypeScript
143 lines
5.4 KiB
TypeScript
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p1-agentrun-incremental-cursor; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0.
|
|
// Responsibility: AgentRun manager HTTP client, internal service URL authority, and upstream timing metrics.
|
|
|
|
import { backendPerformanceRouteTemplate, currentBackendPerformanceContext } from "./backend-performance.ts";
|
|
import { truthyFlag } from "./server-http-utils.ts";
|
|
|
|
export const DEFAULT_AGENTRUN_MGR_URL = "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080";
|
|
|
|
const AGENTRUN_MANAGER_HOST_PATTERN = /^agentrun-mgr\.[a-z0-9]([-a-z0-9]*[a-z0-9])?\.svc\.cluster\.local$/u;
|
|
|
|
export async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body, timeoutMs = 20_000, env = process.env } = {}) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
const performanceContext = currentBackendPerformanceContext();
|
|
const startedAt = nowMs();
|
|
let statusCode = 0;
|
|
let outcome = "ok";
|
|
try {
|
|
const headers: Record<string, string> = {};
|
|
const apiKey = resolveAgentRunApiKey(env);
|
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
const response = await fetchImpl(`${managerUrl}${path}`, {
|
|
method,
|
|
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: controller.signal
|
|
});
|
|
statusCode = Number(response.status ?? 0);
|
|
const text = await response.text();
|
|
const parsed = text ? JSON.parse(text) : {};
|
|
if (!response.ok || parsed?.ok === false) {
|
|
outcome = "error";
|
|
throw Object.assign(new Error(parsed?.message ?? `AgentRun ${method} ${path} failed with HTTP ${response.status}`), {
|
|
code: parsed?.failureKind ?? "agentrun_request_failed",
|
|
statusCode: response.status,
|
|
agentRunError: parsed
|
|
});
|
|
}
|
|
return parsed?.ok === true && Object.hasOwn(parsed, "data") ? parsed.data : parsed;
|
|
} catch (error) {
|
|
if (error?.name === "AbortError") {
|
|
outcome = "timeout";
|
|
statusCode = 504;
|
|
throw Object.assign(new Error(`AgentRun ${method} ${path} timed out after ${timeoutMs}ms`), agentRunHttpErrorContext({ managerUrl, path, method, timeoutMs, code: "agentrun_timeout", statusCode: 504, category: "upstream-timeout" }));
|
|
}
|
|
if (outcome === "ok") outcome = "error";
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
performanceContext?.recordUpstream?.({
|
|
component: "agentrun",
|
|
operation: agentRunOperationName(method, path),
|
|
route: backendPerformanceRouteTemplate(path),
|
|
method,
|
|
statusClass: statusClass(statusCode),
|
|
outcome,
|
|
durationMs: nowMs() - startedAt
|
|
});
|
|
}
|
|
}
|
|
|
|
export function resolveAgentRunManagerUrl(env = process.env, override = null) {
|
|
const raw = firstNonEmpty(override, env.AGENTRUN_MGR_URL, env.HWLAB_CODE_AGENT_AGENTRUN_MGR_URL, DEFAULT_AGENTRUN_MGR_URL);
|
|
const url = new URL(raw);
|
|
const host = url.hostname.toLowerCase();
|
|
const allowedByTest = truthyFlag(env.HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL) && ["127.0.0.1", "localhost"].includes(host);
|
|
if (url.protocol !== "http:" || (!isAgentRunManagerInternalServiceHost(host) && !allowedByTest)) {
|
|
throw Object.assign(new Error(`AGENTRUN_MGR_URL must use internal k3s Service DNS agentrun-mgr.<namespace>.svc.cluster.local; got ${redactUrl(raw)}`), {
|
|
code: "agentrun_internal_url_required",
|
|
statusCode: 500
|
|
});
|
|
}
|
|
url.pathname = url.pathname.replace(/\/+$/u, "");
|
|
url.search = "";
|
|
url.hash = "";
|
|
return url.toString().replace(/\/+$/u, "");
|
|
}
|
|
|
|
export function isAgentRunManagerInternalServiceHost(host) {
|
|
return AGENTRUN_MANAGER_HOST_PATTERN.test(String(host ?? "").trim().toLowerCase());
|
|
}
|
|
|
|
function resolveAgentRunApiKey(env = process.env) {
|
|
return firstNonEmpty(env.AGENTRUN_API_KEY, env.HWLAB_CODE_AGENT_AGENTRUN_API_KEY, env.HWLAB_API_KEY);
|
|
}
|
|
|
|
function agentRunOperationName(method, path) {
|
|
const route = backendPerformanceRouteTemplate(path).replace(/^\/api\/v1\//u, "").replace(/\/:id/gu, "");
|
|
return `${String(method ?? "GET").toLowerCase()}_${route || "root"}`.replace(/[^a-z0-9]+/giu, "_").replace(/^_+|_+$/gu, "").toLowerCase();
|
|
}
|
|
|
|
function statusClass(value) {
|
|
const status = Number(value);
|
|
if (!Number.isFinite(status) || status <= 0) return "unknown";
|
|
return `${Math.floor(status / 100)}xx`;
|
|
}
|
|
|
|
function nowMs() {
|
|
if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
|
|
return Date.now();
|
|
}
|
|
|
|
function agentRunHttpErrorContext({ managerUrl, path, method, timeoutMs, code, statusCode, category }) {
|
|
return {
|
|
code,
|
|
statusCode,
|
|
layer: "agentrun",
|
|
category,
|
|
retryable: true,
|
|
method,
|
|
route: path,
|
|
path,
|
|
timeoutMs,
|
|
timeoutStage: "agentrun-http",
|
|
managerHost: safeUrlHost(managerUrl),
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
function safeUrlHost(value) {
|
|
try { return new URL(value).host; } catch { return null; }
|
|
}
|
|
|
|
function firstNonEmpty(...values) {
|
|
for (const value of values) {
|
|
const text = String(value ?? "").trim();
|
|
if (text) return text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function redactUrl(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.username) url.username = "***";
|
|
if (url.password) url.password = "***";
|
|
return url.toString();
|
|
} catch {
|
|
return String(value ?? "").replace(/([?&](?:token|key|secret|password)=)[^&]+/giu, "$1***");
|
|
}
|
|
}
|