diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index f093a32c..aeacb6dc 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1,7 +1,7 @@ // SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p1-agentrun-incremental-cursor; draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // Responsibility: AgentRun v0.1 adapter, incremental Workbench projection cursor sync, and upstream timing projection for Cloud API observability. -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { decorateCodeAgentSession } from "./code-agent-session-lifecycle.ts"; @@ -11,30 +11,55 @@ import { safeOpaqueId, safeSessionId, safeTraceId, - text, - truthyFlag + text } from "./server-http-utils.ts"; -import { backendPerformanceRouteTemplate, currentBackendPerformanceContext } from "./backend-performance.ts"; +import { currentBackendPerformanceContext } from "./backend-performance.ts"; +import { + DEFAULT_AGENTRUN_MGR_URL, + agentRunJson, + isAgentRunManagerInternalServiceHost, + resolveAgentRunManagerUrl +} from "./code-agent-agentrun-runtime.ts"; +import { + agentRunAlreadyTerminalCancelPayload, + agentRunCancelBlockedPayload, + agentRunCancelTerminalStatus +} from "./code-agent-agentrun-cancel.ts"; +import { + DEFAULT_AGENTRUN_REPO_URL, + agentRunAvailabilityBlocker, + agentRunProviderIdOrNull, + agentRunTraceMeta, + backendForBackendProfile, + modelForBackendProfile, + providerForBackendProfile, + requireAgentRunSourceCommit, + resolveAgentRunBackendProfile, + resolveAgentRunProviderId, + resolveAgentRunRepoUrl +} from "./code-agent-agentrun-profile.ts"; +import { + agentRunPromptEventFields, + agentRunPromptFields, + agentRunPromptMetadataFromParams, + agentRunPromptMetadataFromSession, + agentRunPromptMetadataFromText, + messageAuthorityTextValue +} from "./code-agent-agentrun-prompt.ts"; import { agentRunResultSyncDeferred, buildAgentRunProjectionEventsFetchPlan, buildWorkbenchProjectionStateUpdate } from "./workbench-projection-cursor.ts"; import { codeAgentOtelTraceFields, codeAgentOtelTraceContext, emitCodeAgentOtelSpan } from "./otel-trace.ts"; const ADAPTER_ID = "agentrun-v01"; -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; const DEFAULT_TENANT_ID = "hwlab"; const DEFAULT_PROJECT_ID = "pikasTech/HWLAB"; -const DEFAULT_REPO_URL = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; const DEFAULT_RUNNER_NAMESPACE = "agentrun-v01"; const DEFAULT_TIMEOUT_MS = 1_200_000; -const AGENTRUN_BACKEND_PREFIX = ADAPTER_ID; const AGENTRUN_RUNNER_KIND = "agentrun-v01-shared-runner"; const AGENTRUN_SESSION_MODE = "agentrun-v01-durable-session"; const AGENTRUN_IMPLEMENTATION_TYPE = "agentrun-v01-shared-execution-infra"; const AGENTRUN_CAPABILITY_LEVEL = "agentrun-v01-shared-code-agent-session"; const AGENTRUN_PROVIDER_TRACE_PROTOCOL = "agentrun-v01-jsonrpc"; const AGENTRUN_PROVIDER_TRACE_WIRE_API = "agentrun-v01-command-result"; -const AGENTRUN_BACKEND_ALIASES = Object.freeze({ "codex-api": "codex", codex: "codex" }); -const AGENTRUN_BACKEND_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u; const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread"; const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse"; const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]); @@ -932,7 +957,7 @@ export async function refreshAgentRunTrace({ traceId, result = null, options = { } export async function cancelAgentRunChatTurn({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore }) { - const mapped = currentResult ?? await loadPersistedAgentRunResult(traceId, options); + let mapped = currentResult ?? await loadPersistedAgentRunResult(traceId, options); if (!mapped?.agentRun?.commandId) return null; const env = options.env ?? process.env; const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); @@ -947,12 +972,25 @@ export async function cancelAgentRunChatTurn({ traceId, currentResult = null, op }; const fetchImpl = options.fetchImpl ?? globalThis.fetch; const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl); - await agentRunJson(fetchImpl, managerUrl, `/api/v1/commands/${encodeURIComponent(mapped.agentRun.commandId)}/cancel`, { - method: "POST", - body: { reason: "hwlab-user-cancel", traceId }, - timeoutMs: cancelTimeoutMs, - env - }); + try { + const synced = await syncAgentRunChatResult({ traceId, currentResult: mapped, options: cancelOptions, traceStore, forceResultSync: true }); + mapped = synced?.result ?? synced ?? mapped; + } catch { + // Cancel must still expose the command cancel disposition; result refresh failures are reported by the normal trace/result paths. + } + const terminalStatus = agentRunCancelTerminalStatus(mapped); + if (terminalStatus) return agentRunAlreadyTerminalCancelPayload({ traceId, payload: mapped, terminalStatus, options: cancelOptions, traceStore }); + const cancelPath = `/api/v1/commands/${encodeURIComponent(mapped.agentRun.commandId)}/cancel`; + try { + await agentRunJson(fetchImpl, managerUrl, cancelPath, { + method: "POST", + body: { reason: "hwlab-user-cancel", traceId }, + timeoutMs: cancelTimeoutMs, + env + }); + } catch (error) { + return agentRunCancelBlockedPayload({ traceId, payload: mapped, error, cancelPath, options: cancelOptions, traceStore }); + } traceStore.append(traceId, agentRunTraceEvent({ type: "cancel", status: "canceled", @@ -989,45 +1027,6 @@ export async function cancelAgentRunChatTurn({ traceId, currentResult = null, op return payload; } -function agentRunCancelTerminalStatus(payload = null) { - if (!payload || typeof payload !== "object") return null; - for (const value of [payload.agentRun?.terminalStatus, payload.agentRun?.commandState, payload.agentRun?.commandStatus]) { - const status = normalizeAgentRunStatus(value); - if (isAgentRunTerminalStatus(status)) return status; - } - return null; -} - -function agentRunAlreadyTerminalCancelPayload({ traceId, payload, terminalStatus, options = {}, traceStore = defaultCodeAgentTraceStore } = {}) { - const normalizedTerminalStatus = normalizeAgentRunStatus(terminalStatus); - const agentRun = payload?.agentRun && typeof payload.agentRun === "object" ? payload.agentRun : {}; - traceStore.append(traceId, agentRunTraceEvent({ - type: "cancel", - status: normalizedTerminalStatus || "completed", - label: "agentrun:cancel:already_terminal", - message: "AgentRun command is already terminal; HWLAB did not forward cancel or overwrite the terminal Workbench projection.", - runId: agentRun.runId, - commandId: agentRun.commandId, - terminal: true, - valuesPrinted: false - }, agentRun)); - const payloadStatus = normalizeAgentRunStatus(payload?.status); - const status = isAgentRunTerminalStatus(payloadStatus) ? payloadStatus : normalizedTerminalStatus || payloadStatus || "completed"; - const result = { - ...payload, - status, - canceled: false, - alreadyTerminal: true, - cancelStatus: "already_terminal", - updatedAt: nowIso(options.now), - runnerTrace: traceStore.snapshot(traceId), - agentRun: { ...agentRun, terminalStatus: agentRun.terminalStatus ?? normalizedTerminalStatus, valuesPrinted: false }, - valuesPrinted: false - }; - options.codeAgentChatResults?.set?.(traceId, result); - return result; -} - export async function steerAgentRunChatTurn({ traceId, currentResult = null, params = {}, options = {}, traceStore = defaultCodeAgentTraceStore }) { const mapped = currentResult ?? await loadPersistedAgentRunResult(traceId, options); if (!safeTraceId(traceId) || !mapped?.agentRun?.runId || !mapped?.agentRun?.commandId) return null; @@ -1197,7 +1196,7 @@ export function agentRunSessionEvidence(payload = {}) { namespace: payload.agentRun.namespace ?? null, backendProfile: payload.agentRun.backendProfile ?? null, managerUrl: payload.agentRun.managerUrl ?? DEFAULT_AGENTRUN_MGR_URL, - repoUrl: payload.agentRun.repoUrl ?? DEFAULT_REPO_URL, + repoUrl: payload.agentRun.repoUrl ?? DEFAULT_AGENTRUN_REPO_URL, status: payload.agentRun.status ?? null, runStatus: payload.agentRun.runStatus ?? null, commandState: payload.agentRun.commandState ?? null, @@ -1386,147 +1385,6 @@ function rejectRemovedResourceWorkspaceFiles(value) { throw adapterError("legacy_workspace_files_removed", "workspaceFiles/resourceWorkspaceFiles are removed; use AgentRun ResourceBundleRef kind=gitbundle with bundles[]"); } -function agentRunPromptMetadataFromParams(params = {}, traceId = null) { - return agentRunPromptMetadataFromText(firstNonEmpty(params.message, params.prompt, params.text), traceId, "hwlab-code-agent-request", { fullText: true }); -} - -function agentRunPromptMetadataFromSession(session = null, traceId = null) { - const snapshot = session?.session && typeof session.session === "object" ? session.session : null; - const fromTraceResult = agentRunPromptMetadataFromPayload(snapshot?.traceResults?.[traceId], traceId); - if (fromTraceResult) return fromTraceResult; - const messageText = agentRunPromptTextFromMessages(snapshot?.messages, traceId); - if (messageText) return agentRunPromptMetadataFromText(messageText, traceId, "agent-session-owner-message", { fullText: true }); - const preview = firstNonEmpty(snapshot?.firstUserMessagePreview); - return preview ? agentRunPromptMetadataFromText(preview, traceId, "agent-session-first-user-preview", { fullText: false }) : null; -} - -function agentRunPromptMetadataFromPayload(payload = null, traceId = null) { - if (!payload || typeof payload !== "object") return null; - const prompt = payload.prompt && typeof payload.prompt === "object" ? payload.prompt : null; - const textValue = firstNonEmpty(prompt?.text, prompt?.summary, prompt?.preview, payload.promptSummary, payload.promptPreview); - const fromText = agentRunPromptMetadataFromText(textValue, traceId, prompt?.source ?? payload.promptSource ?? "agentrun-prompt-metadata", { fullText: Boolean(prompt?.text) }); - return agentRunPromptMetadataFromParts({ - traceId, - source: prompt?.source ?? payload.promptSource ?? fromText?.prompt?.source ?? "agentrun-prompt-metadata", - promptId: firstNonEmpty(payload.promptId, prompt?.id, fromText?.promptId), - promptSha256: firstNonEmpty(payload.promptSha256, prompt?.sha256, fromText?.promptSha256), - promptSummary: firstNonEmpty(payload.promptSummary, prompt?.summary, fromText?.promptSummary), - promptPreview: firstNonEmpty(payload.promptPreview, prompt?.preview, fromText?.promptPreview), - textChars: Number.isInteger(prompt?.textChars) ? prompt.textChars : fromText?.prompt?.textChars - }); -} - -function agentRunPromptTextFromMessages(messages = [], traceId = null) { - const list = Array.isArray(messages) ? messages : []; - const resolvedTraceId = safeTraceId(traceId); - const exact = list.find((message) => String(message?.role ?? "").toLowerCase() === "user" && (!resolvedTraceId || safeTraceId(message?.traceId) === resolvedTraceId)); - const fallback = list.find((message) => String(message?.role ?? "").toLowerCase() === "user"); - return firstNonEmpty(exact?.text, exact?.content, fallback?.text, fallback?.content); -} - -function agentRunPromptMetadataFromText(value, traceId = null, source = "hwlab-code-agent-request", options = {}) { - const promptText = promptTextValue(value); - if (!promptText) return null; - return agentRunPromptMetadataFromParts({ - traceId, - source, - promptSha256: options.fullText === true ? sha256Text(promptText) : undefined, - promptSummary: promptText, - promptPreview: promptText, - textChars: options.fullText === true ? promptText.length : undefined - }); -} - -function agentRunPromptMetadataFromParts({ traceId = null, source = "hwlab-code-agent-request", promptId = null, promptSha256 = null, promptSummary = null, promptPreview = null, textChars = undefined } = {}) { - const digest = firstNonEmpty(promptSha256); - const summary = clippedPromptText(firstNonEmpty(promptSummary, promptPreview), 240); - const preview = clippedPromptText(firstNonEmpty(promptPreview, promptSummary), 500); - const explicitId = firstNonEmpty(promptId); - if (!explicitId && !digest && !summary && !preview) return null; - const id = explicitId || agentRunPromptId(traceId, digest); - const prompt = compactObject({ - id, - sha256: digest || undefined, - summary, - preview, - textChars: Number.isInteger(textChars) ? textChars : undefined, - source, - valuesPrinted: false - }); - return compactObject({ - promptId: id, - promptSha256: digest || undefined, - promptSummary: summary, - promptPreview: preview, - prompt, - valuesPrinted: false - }) ?? null; -} - -function agentRunPromptFields(metadata = null) { - if (!metadata) return {}; - return compactObject({ - promptId: metadata.promptId, - promptSha256: metadata.promptSha256, - promptSummary: metadata.promptSummary, - promptPreview: metadata.promptPreview, - prompt: metadata.prompt - }) ?? {}; -} - -function agentRunPromptEventFields(metadata = null) { - if (!metadata) return {}; - return compactObject({ - promptId: metadata.promptId, - promptSha256: metadata.promptSha256, - promptSummary: metadata.promptSummary, - promptPreview: metadata.promptPreview - }) ?? {}; -} - -function agentRunPromptId(traceId = null, digest = null) { - const traceSuffix = safeTraceId(traceId)?.slice(4).replace(/[^A-Za-z0-9_.:-]/gu, "_"); - const digestSuffix = firstNonEmpty(digest)?.slice(0, 16); - const suffix = firstNonEmpty(traceSuffix, digestSuffix); - return suffix ? `prm_${suffix}` : undefined; -} - -function clippedPromptText(value, limit) { - const promptText = promptTextValue(value); - if (!promptText) return undefined; - return promptText.length > limit ? `${promptText.slice(0, limit)}...` : promptText; -} - -function promptTextValue(value) { - const extracted = promptTextRaw(value).replace(/\s+/gu, " ").trim(); - if (!extracted || extracted === "[object Object]") return ""; - return extracted; -} - -function messageAuthorityTextValue(value) { - const extracted = promptTextRaw(value).replace(/\r\n?/gu, "\n"); - if (!extracted.trim() || extracted.trim() === "[object Object]") return ""; - return extracted; -} - -function promptTextRaw(value) { - if (value === undefined || value === null) return ""; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") return String(value); - if (Array.isArray(value)) return value.map(promptTextRaw).filter(Boolean).join("\n"); - if (typeof value === "object") { - for (const key of ["text", "content", "message", "prompt", "value", "summary", "preview", "title"]) { - const text = promptTextRaw(value[key]); - if (text) return text; - } - } - return ""; -} - -function sha256Text(value) { - return createHash("sha256").update(String(value)).digest("hex"); -} - function adapterError(code, message, details = {}) { return Object.assign(new Error(message), { code, @@ -2765,129 +2623,11 @@ function agentRunCommandExecutionEvent(base, payload = {}) { }; } -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 = {}; - 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 - }); - } -} - -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 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..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, ""); -} - -function resolveAgentRunApiKey(env = process.env) { - return firstNonEmpty(env.AGENTRUN_API_KEY, env.HWLAB_CODE_AGENT_AGENTRUN_API_KEY, env.HWLAB_API_KEY); -} - -function isAgentRunManagerInternalServiceHost(host) { - return AGENTRUN_MANAGER_HOST_PATTERN.test(String(host ?? "").trim().toLowerCase()); -} - -function resolveAgentRunBackendProfile(env = process.env, params = {}) { - const requested = String(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_AGENTRUN_BACKEND_PROFILE ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE ?? "deepseek").trim().toLowerCase(); - const fallback = String(env.HWLAB_CODE_AGENT_AGENTRUN_DEFAULT_BACKEND_PROFILE ?? "deepseek").trim().toLowerCase() || "deepseek"; - const resolved = requested === "runtime-default" ? fallback : requested; - if (!resolved || resolved === "runtime-default") return "deepseek"; - const mapped = AGENTRUN_BACKEND_ALIASES[resolved] ?? resolved; - return AGENTRUN_BACKEND_PROFILE_ID_PATTERN.test(mapped) ? mapped : "deepseek"; -} - function agentRunMapping({ env, managerUrl, backendProfile, run, command, runnerJob, traceId, startedAt, params = {} }) { const threadReused = Boolean(safeOpaqueId(params.threadId)); const runnerJobCreated = Boolean(runnerJob); @@ -3096,110 +2836,6 @@ function agentRunRunnerSummary(mapping = {}) { }; } -function agentRunTraceMeta(env = process.env) { - return { - runnerKind: AGENTRUN_RUNNER_KIND, - workspace: resolveAgentRunRepoUrl(env), - sandbox: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_SANDBOX, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "danger-full-access"), - protocol: AGENTRUN_PROVIDER_TRACE_PROTOCOL, - sessionMode: AGENTRUN_SESSION_MODE, - implementationType: AGENTRUN_IMPLEMENTATION_TYPE - }; -} - -function backendForBackendProfile(profile) { - return `${AGENTRUN_BACKEND_PREFIX}/${resolveAgentRunBackendProfile({}, { providerProfile: profile })}`; -} - -function resolveAgentRunRepoUrl(env = process.env) { - const raw = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_REPO_URL, env.HWLAB_BOOT_READ_URL, DEFAULT_REPO_URL); - const url = new URL(raw); - const host = url.hostname.toLowerCase(); - const allowedHost = "git-mirror-http.devops-infra.svc.cluster.local"; - const allowedByTest = truthyFlag(env.HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL) && ["127.0.0.1", "localhost"].includes(host); - if (url.protocol !== "http:" || (host !== allowedHost && !allowedByTest)) { - throw Object.assign(new Error(`HWLAB_CODE_AGENT_AGENTRUN_REPO_URL must use internal k3s git mirror ${DEFAULT_REPO_URL}; got ${redactUrl(raw)}`), { - code: "agentrun_internal_repo_url_required", - statusCode: 500 - }); - } - url.pathname = url.pathname.replace(/\/+$/u, ""); - url.search = ""; - url.hash = ""; - return url.toString().replace(/\/+$/u, ""); -} - -function requireAgentRunSourceCommit(env) { - const text = String(env.HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT ?? "").trim().toLowerCase(); - if (/^[0-9a-f]{40}$/u.test(text)) return text; - throw Object.assign(new Error("HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT must be a full 40-character source commit before AgentRun dispatch"), { - code: "agentrun_bundle_source_commit_invalid", - statusCode: 500 - }); -} - -function resolveAgentRunProviderId(env = process.env) { - const providerId = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID, env.AGENTRUN_PROVIDER_ID); - if (!providerId) { - throw Object.assign(new Error("HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID must be configured from the runtime lane node id before AgentRun dispatch"), { - code: "agentrun_provider_id_required", - statusCode: 500 - }); - } - if (!/^[A-Za-z0-9._-]+$/u.test(providerId)) { - throw Object.assign(new Error("HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID must be a simple node id"), { - code: "agentrun_provider_id_invalid", - statusCode: 500 - }); - } - return providerId; -} - -function agentRunProviderIdOrNull(env = process.env) { - try { - return resolveAgentRunProviderId(env); - } catch { - return null; - } -} - -function modelForBackendProfile(profile, env = process.env) { - const resolved = resolveAgentRunBackendProfile({}, { providerProfile: profile }); - if (resolved === "codex") return firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_API_MODEL, env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5"); - if (resolved === "minimax-m3") return firstNonEmpty(env.HWLAB_CODE_AGENT_MINIMAX_M3_MODEL, "MiniMax-M3"); - if (resolved === "deepseek") return firstNonEmpty(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, "deepseek-chat"); - if (resolved === "dsflash-go") return firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_DSFLASH_GO_MODEL, env.HWLAB_CODE_AGENT_DSFLASH_GO_MODEL, "deepseek-v4-flash"); - const profileEnvKey = dynamicBackendProfileEnvKey(resolved); - return firstNonEmpty(env[`HWLAB_CODE_AGENT_AGENTRUN_${profileEnvKey}_MODEL`], env[`HWLAB_CODE_AGENT_${profileEnvKey}_MODEL`], resolved); -} - -function providerForBackendProfile(profile) { - const resolved = resolveAgentRunBackendProfile({}, { providerProfile: profile }); - if (resolved === "codex") return "codex-api"; - return resolved; -} - -function dynamicBackendProfileEnvKey(profile) { - return String(profile ?? "") - .trim() - .toUpperCase() - .replace(/[^A-Z0-9]+/gu, "_") - .replace(/^_+|_+$/gu, "") || "PROFILE"; -} - -function agentRunAvailabilityBlocker(error, scope) { - return { - code: error?.code ?? `agentrun_${String(scope).replace(/[^a-z0-9]+/giu, "_")}_blocked`, - type: "agent_blocker", - scope: `agentrun-${scope}`, - status: "open", - sourceIssue: "pikasTech/HWLAB#879", - summary: error?.message ?? `AgentRun ${scope} is not configured correctly.` , - secretMaterialRead: false, - valuesRedacted: true - }; -} - function hostForUrl(value) { if (!value) return null; try { diff --git a/internal/cloud/code-agent-agentrun-cancel.test.ts b/internal/cloud/code-agent-agentrun-cancel.test.ts new file mode 100644 index 00000000..6a55cb14 --- /dev/null +++ b/internal/cloud/code-agent-agentrun-cancel.test.ts @@ -0,0 +1,56 @@ +// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-24-p1-opencode-message-part-authority. +// Responsibility: AgentRun cancel control-path regressions without adding more cases to the legacy large server-agent-chat test file. + +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { cancelAgentRunChatTurn } from "./code-agent-agentrun-adapter.ts"; +import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts"; + +test("AgentRun cancel downstream failure returns visible blocked disposition instead of throwing", async () => { + const traceId = "trc_cancel_downstream_blocked"; + const codeAgentChatResults = new Map(); + const traceStore = createCodeAgentTraceStore(); + const currentResult = { + traceId, + status: "running", + conversationId: "cnv_cancel_downstream_blocked", + sessionId: "ses_cancel_downstream_blocked", + agentRun: { + runId: "run_cancel_downstream_blocked", + commandId: "cmd_cancel_downstream_blocked", + managerUrl: "http://127.0.0.1:65535", + status: "running", + commandState: "running" + } + }; + const payload = await cancelAgentRunChatTurn({ + traceId, + currentResult, + traceStore, + options: { + codeAgentChatResults, + env: { + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS: "5000", + HWLAB_CODE_AGENT_CANCEL_TIMEOUT_MS: "5000" + }, + async fetchImpl(url, init) { + assert.equal(url, "http://127.0.0.1:65535/api/v1/commands/cmd_cancel_downstream_blocked/cancel"); + assert.equal(init.method, "POST"); + return new Response(JSON.stringify({ ok: false, failureKind: "command_not_cancelable", message: "command is not cancelable" }), { status: 409 }); + } + } + }); + + assert.equal(payload.status, "blocked"); + assert.equal(payload.canceled, false); + assert.equal(payload.cancelStatus, "blocked"); + assert.equal(payload.cancelDisposition.status, "blocked"); + assert.equal(payload.cancelDisposition.code, "command_not_cancelable"); + assert.equal(payload.cancelDisposition.terminal, false); + assert.equal(payload.error.route, "/v1/agent/chat/cancel"); + assert.equal(payload.error.upstreamRoute, "/api/v1/commands/cmd_cancel_downstream_blocked/cancel"); + assert.equal(codeAgentChatResults.get(traceId)?.cancelDisposition?.code, "command_not_cancelable"); + assert.ok(traceStore.snapshot(traceId).events.some((event) => event.label === "agentrun:cancel:blocked" && event.status === "blocked")); +}); diff --git a/internal/cloud/code-agent-agentrun-cancel.ts b/internal/cloud/code-agent-agentrun-cancel.ts new file mode 100644 index 00000000..84c17ad4 --- /dev/null +++ b/internal/cloud/code-agent-agentrun-cancel.ts @@ -0,0 +1,134 @@ +// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-24-p1-opencode-message-part-authority. +// Responsibility: AgentRun cancel disposition payloads and cancel trace diagnostics. + +export function agentRunCancelTerminalStatus(payload = null) { + if (!payload || typeof payload !== "object") return null; + for (const value of [payload.agentRun?.terminalStatus, payload.agentRun?.commandState, payload.agentRun?.commandStatus]) { + const status = normalizeAgentRunStatus(value); + if (isAgentRunTerminalStatus(status)) return status; + } + return null; +} + +export function agentRunAlreadyTerminalCancelPayload({ traceId, payload, terminalStatus, options = {}, traceStore } = {}) { + const normalizedTerminalStatus = normalizeAgentRunStatus(terminalStatus); + const agentRun = payload?.agentRun && typeof payload.agentRun === "object" ? payload.agentRun : {}; + traceStore?.append?.(traceId, agentRunCancelTraceEvent({ + type: "cancel", + status: normalizedTerminalStatus || "completed", + label: "agentrun:cancel:already-terminal", + message: "AgentRun command is already terminal; HWLAB did not forward cancel or overwrite the terminal Workbench projection.", + runId: agentRun.runId, + commandId: agentRun.commandId, + terminal: true, + valuesPrinted: false + }, agentRun)); + const payloadStatus = normalizeAgentRunStatus(payload?.status); + const status = isAgentRunTerminalStatus(payloadStatus) ? payloadStatus : normalizedTerminalStatus || payloadStatus || "completed"; + const result = { + ...payload, + status, + canceled: false, + alreadyTerminal: true, + cancelStatus: "already_terminal", + cancelDisposition: { + status: "already_terminal", + forwarded: false, + terminal: true, + traceId, + runId: agentRun.runId ?? null, + commandId: agentRun.commandId ?? null, + route: "/v1/agent/chat/cancel", + valuesPrinted: false + }, + updatedAt: nowIso(options.now), + runnerTrace: traceStore?.snapshot?.(traceId), + agentRun: { ...agentRun, terminalStatus: agentRun.terminalStatus ?? normalizedTerminalStatus, valuesPrinted: false }, + valuesPrinted: false + }; + options.codeAgentChatResults?.set?.(traceId, result); + return result; +} + +export function agentRunCancelBlockedPayload({ traceId, payload, error, cancelPath, options = {}, traceStore } = {}) { + const agentRun = payload?.agentRun && typeof payload.agentRun === "object" ? payload.agentRun : {}; + const code = firstNonEmpty(error?.code, error?.agentRunError?.failureKind, "agentrun_cancel_failed"); + const statusCode = Number.isFinite(Number(error?.statusCode)) ? Number(error.statusCode) : 502; + const message = firstNonEmpty(error?.message, error?.agentRunError?.message, "AgentRun command cancel API did not accept the cancel request."); + const userMessage = "取消请求未被 AgentRun 接受;已保留当前 trace/run/command 证据,页面不能把本轮伪装成已取消。"; + const now = nowIso(options.now); + const nextAgentRun = { ...agentRun, cancelStatus: "blocked", cancelErrorCode: code, cancelStatusCode: statusCode, updatedAt: now, valuesPrinted: false }; + traceStore?.append?.(traceId, agentRunCancelTraceEvent({ + type: "cancel", + status: "blocked", + label: "agentrun:cancel:blocked", + errorCode: code, + message: `AgentRun cancel was not accepted by the command cancel API: ${message}`, + runId: agentRun.runId, + commandId: agentRun.commandId, + waitingFor: "agentrun-cancel-disposition", + terminal: false, + valuesPrinted: false + }, nextAgentRun)); + const cancelDisposition = { + status: "blocked", + code, + statusCode, + message, + userMessage, + retryable: true, + terminal: false, + route: "/v1/agent/chat/cancel", + upstreamRoute: cancelPath, + traceId, + runId: agentRun.runId ?? null, + commandId: agentRun.commandId ?? null, + valuesPrinted: false + }; + const result = { + ...payload, + status: "blocked", + canceled: false, + cancelStatus: "blocked", + cancelDisposition, + updatedAt: now, + agentRun: nextAgentRun, + runnerTrace: traceStore?.snapshot?.(traceId), + error: { code, layer: "agentrun", category: "cancel-blocked", retryable: true, message, userMessage, traceId, route: "/v1/agent/chat/cancel", upstreamRoute: cancelPath, statusCode, toolName: "agentrun.command.cancel", valuesPrinted: false }, + userMessage, + valuesPrinted: false + }; + options.codeAgentChatResults?.set?.(traceId, result); + return result; +} + +function agentRunCancelTraceEvent(event = {}, agentRun = {}) { + return { + ...event, + backend: "agentrun-v01", + adapter: "agentrun-v01", + runId: event.runId ?? agentRun.runId ?? null, + commandId: event.commandId ?? agentRun.commandId ?? null, + valuesPrinted: false + }; +} + +function isAgentRunTerminalStatus(value) { + return ["completed", "failed", "blocked", "cancelled", "canceled"].includes(normalizeAgentRunStatus(value)); +} + +function normalizeAgentRunStatus(value) { + return String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"); +} + +function firstNonEmpty(...values) { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) return text; + } + return null; +} + +function nowIso(now) { + return typeof now === "function" ? now() : new Date().toISOString(); +} diff --git a/internal/cloud/code-agent-agentrun-profile.ts b/internal/cloud/code-agent-agentrun-profile.ts new file mode 100644 index 00000000..f5ef7a0c --- /dev/null +++ b/internal/cloud/code-agent-agentrun-profile.ts @@ -0,0 +1,149 @@ +// SPEC: PJ2026-010205 HWLAB接入 draft-2026-06-17-r0; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p1-agentrun-incremental-cursor. +// Responsibility: AgentRun backend profile, internal repo source authority, provider identity, and trace metadata. + +import { truthyFlag } from "./server-http-utils.ts"; + +export const DEFAULT_AGENTRUN_REPO_URL = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; + +const ADAPTER_ID = "agentrun-v01"; +const AGENTRUN_BACKEND_PREFIX = ADAPTER_ID; +const AGENTRUN_RUNNER_KIND = "agentrun-v01-shared-runner"; +const AGENTRUN_SESSION_MODE = "agentrun-v01-durable-session"; +const AGENTRUN_IMPLEMENTATION_TYPE = "agentrun-v01-shared-execution-infra"; +const AGENTRUN_PROVIDER_TRACE_PROTOCOL = "agentrun-v01-jsonrpc"; +const AGENTRUN_BACKEND_ALIASES = Object.freeze({ "codex-api": "codex", codex: "codex" }); +const AGENTRUN_BACKEND_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u; + +export function resolveAgentRunBackendProfile(env = process.env, params = {}) { + const requested = String(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_AGENTRUN_BACKEND_PROFILE ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE ?? "deepseek").trim().toLowerCase(); + const fallback = String(env.HWLAB_CODE_AGENT_AGENTRUN_DEFAULT_BACKEND_PROFILE ?? "deepseek").trim().toLowerCase() || "deepseek"; + const resolved = requested === "runtime-default" ? fallback : requested; + if (!resolved || resolved === "runtime-default") return "deepseek"; + const mapped = AGENTRUN_BACKEND_ALIASES[resolved] ?? resolved; + return AGENTRUN_BACKEND_PROFILE_ID_PATTERN.test(mapped) ? mapped : "deepseek"; +} + +export function agentRunTraceMeta(env = process.env) { + return { + runnerKind: AGENTRUN_RUNNER_KIND, + workspace: resolveAgentRunRepoUrl(env), + sandbox: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_SANDBOX, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "danger-full-access"), + protocol: AGENTRUN_PROVIDER_TRACE_PROTOCOL, + sessionMode: AGENTRUN_SESSION_MODE, + implementationType: AGENTRUN_IMPLEMENTATION_TYPE + }; +} + +export function backendForBackendProfile(profile) { + return `${AGENTRUN_BACKEND_PREFIX}/${resolveAgentRunBackendProfile({}, { providerProfile: profile })}`; +} + +export function resolveAgentRunRepoUrl(env = process.env) { + const raw = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_REPO_URL, env.HWLAB_BOOT_READ_URL, DEFAULT_AGENTRUN_REPO_URL); + const url = new URL(raw); + const host = url.hostname.toLowerCase(); + const allowedHost = "git-mirror-http.devops-infra.svc.cluster.local"; + const allowedByTest = truthyFlag(env.HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL) && ["127.0.0.1", "localhost"].includes(host); + if (url.protocol !== "http:" || (host !== allowedHost && !allowedByTest)) { + throw Object.assign(new Error(`HWLAB_CODE_AGENT_AGENTRUN_REPO_URL must use internal k3s git mirror ${DEFAULT_AGENTRUN_REPO_URL}; got ${redactUrl(raw)}`), { + code: "agentrun_internal_repo_url_required", + statusCode: 500 + }); + } + url.pathname = url.pathname.replace(/\/+$/u, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/u, ""); +} + +export function requireAgentRunSourceCommit(env) { + const text = String(env.HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT ?? "").trim().toLowerCase(); + if (/^[0-9a-f]{40}$/u.test(text)) return text; + throw Object.assign(new Error("HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT must be a full 40-character source commit before AgentRun dispatch"), { + code: "agentrun_bundle_source_commit_invalid", + statusCode: 500 + }); +} + +export function resolveAgentRunProviderId(env = process.env) { + const providerId = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID, env.AGENTRUN_PROVIDER_ID); + if (!providerId) { + throw Object.assign(new Error("HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID must be configured from the runtime lane node id before AgentRun dispatch"), { + code: "agentrun_provider_id_required", + statusCode: 500 + }); + } + if (!/^[A-Za-z0-9._-]+$/u.test(providerId)) { + throw Object.assign(new Error("HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID must be a simple node id"), { + code: "agentrun_provider_id_invalid", + statusCode: 500 + }); + } + return providerId; +} + +export function agentRunProviderIdOrNull(env = process.env) { + try { + return resolveAgentRunProviderId(env); + } catch { + return null; + } +} + +export function modelForBackendProfile(profile, env = process.env) { + const resolved = resolveAgentRunBackendProfile({}, { providerProfile: profile }); + if (resolved === "codex") return firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_API_MODEL, env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5"); + if (resolved === "minimax-m3") return firstNonEmpty(env.HWLAB_CODE_AGENT_MINIMAX_M3_MODEL, "MiniMax-M3"); + if (resolved === "deepseek") return firstNonEmpty(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, "deepseek-chat"); + if (resolved === "dsflash-go") return firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_DSFLASH_GO_MODEL, env.HWLAB_CODE_AGENT_DSFLASH_GO_MODEL, "deepseek-v4-flash"); + const profileEnvKey = dynamicBackendProfileEnvKey(resolved); + return firstNonEmpty(env[`HWLAB_CODE_AGENT_AGENTRUN_${profileEnvKey}_MODEL`], env[`HWLAB_CODE_AGENT_${profileEnvKey}_MODEL`], resolved); +} + +export function providerForBackendProfile(profile) { + const resolved = resolveAgentRunBackendProfile({}, { providerProfile: profile }); + if (resolved === "codex") return "codex-api"; + return resolved; +} + +export function agentRunAvailabilityBlocker(error, scope) { + return { + code: error?.code ?? `agentrun_${String(scope).replace(/[^a-z0-9]+/giu, "_")}_blocked`, + type: "agent_blocker", + scope: `agentrun-${scope}`, + status: "open", + sourceIssue: "pikasTech/HWLAB#879", + summary: error?.message ?? `AgentRun ${scope} is not configured correctly.` , + secretMaterialRead: false, + valuesRedacted: true + }; +} + +function dynamicBackendProfileEnvKey(profile) { + return String(profile ?? "") + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/gu, "_") + .replace(/^_+|_+$/gu, "") || "PROFILE"; +} + +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.password) { + url.username = "***"; + url.password = ""; + } + return url.toString(); + } catch { + return ""; + } +} diff --git a/internal/cloud/code-agent-agentrun-prompt.ts b/internal/cloud/code-agent-agentrun-prompt.ts new file mode 100644 index 00000000..a3703e93 --- /dev/null +++ b/internal/cloud/code-agent-agentrun-prompt.ts @@ -0,0 +1,167 @@ +// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-24-p1-opencode-message-part-authority. +// Responsibility: AgentRun prompt evidence, message-authority text normalization, and prompt hash/preview DTO fields. + +import { createHash } from "node:crypto"; + +import { safeTraceId } from "./server-http-utils.ts"; + +export function agentRunPromptMetadataFromParams(params = {}, traceId = null) { + return agentRunPromptMetadataFromText(firstNonEmpty(params.message, params.prompt, params.text), traceId, "hwlab-code-agent-request", { fullText: true }); +} + +export function agentRunPromptMetadataFromSession(session = null, traceId = null) { + const snapshot = session?.session && typeof session.session === "object" ? session.session : null; + const fromTraceResult = agentRunPromptMetadataFromPayload(snapshot?.traceResults?.[traceId], traceId); + if (fromTraceResult) return fromTraceResult; + const messageText = agentRunPromptTextFromMessages(snapshot?.messages, traceId); + if (messageText) return agentRunPromptMetadataFromText(messageText, traceId, "agent-session-owner-message", { fullText: true }); + const preview = firstNonEmpty(snapshot?.firstUserMessagePreview); + return preview ? agentRunPromptMetadataFromText(preview, traceId, "agent-session-first-user-preview", { fullText: false }) : null; +} + +export function agentRunPromptMetadataFromText(value, traceId = null, source = "hwlab-code-agent-request", options = {}) { + const promptText = promptTextValue(value); + if (!promptText) return null; + return agentRunPromptMetadataFromParts({ + traceId, + source, + promptSha256: options.fullText === true ? sha256Text(promptText) : undefined, + promptSummary: promptText, + promptPreview: promptText, + textChars: options.fullText === true ? promptText.length : undefined + }); +} + +export function agentRunPromptFields(metadata = null) { + if (!metadata) return {}; + return compactObject({ + promptId: metadata.promptId, + promptSha256: metadata.promptSha256, + promptSummary: metadata.promptSummary, + promptPreview: metadata.promptPreview, + prompt: metadata.prompt + }) ?? {}; +} + +export function agentRunPromptEventFields(metadata = null) { + if (!metadata) return {}; + return compactObject({ + promptId: metadata.promptId, + promptSha256: metadata.promptSha256, + promptSummary: metadata.promptSummary, + promptPreview: metadata.promptPreview + }) ?? {}; +} + +export function messageAuthorityTextValue(value) { + const extracted = promptTextRaw(value).replace(/\r\n?/gu, "\n"); + if (!extracted.trim() || extracted.trim() === "[object Object]") return ""; + return extracted; +} + +function agentRunPromptMetadataFromPayload(payload = null, traceId = null) { + if (!payload || typeof payload !== "object") return null; + const prompt = payload.prompt && typeof payload.prompt === "object" ? payload.prompt : null; + const textValue = firstNonEmpty(prompt?.text, prompt?.summary, prompt?.preview, payload.promptSummary, payload.promptPreview); + const fromText = agentRunPromptMetadataFromText(textValue, traceId, prompt?.source ?? payload.promptSource ?? "agentrun-prompt-metadata", { fullText: Boolean(prompt?.text) }); + return agentRunPromptMetadataFromParts({ + traceId, + source: prompt?.source ?? payload.promptSource ?? fromText?.prompt?.source ?? "agentrun-prompt-metadata", + promptId: firstNonEmpty(payload.promptId, prompt?.id, fromText?.promptId), + promptSha256: firstNonEmpty(payload.promptSha256, prompt?.sha256, fromText?.promptSha256), + promptSummary: firstNonEmpty(payload.promptSummary, prompt?.summary, fromText?.promptSummary), + promptPreview: firstNonEmpty(payload.promptPreview, prompt?.preview, fromText?.promptPreview), + textChars: Number.isInteger(prompt?.textChars) ? prompt.textChars : fromText?.prompt?.textChars + }); +} + +function agentRunPromptTextFromMessages(messages = [], traceId = null) { + const list = Array.isArray(messages) ? messages : []; + const resolvedTraceId = safeTraceId(traceId); + const exact = list.find((message) => String(message?.role ?? "").toLowerCase() === "user" && (!resolvedTraceId || safeTraceId(message?.traceId) === resolvedTraceId)); + const fallback = list.find((message) => String(message?.role ?? "").toLowerCase() === "user"); + return firstNonEmpty(exact?.text, exact?.content, fallback?.text, fallback?.content); +} + +function agentRunPromptMetadataFromParts({ traceId = null, source = "hwlab-code-agent-request", promptId = null, promptSha256 = null, promptSummary = null, promptPreview = null, textChars = undefined } = {}) { + const digest = firstNonEmpty(promptSha256); + const summary = clippedPromptText(firstNonEmpty(promptSummary, promptPreview), 240); + const preview = clippedPromptText(firstNonEmpty(promptPreview, promptSummary), 500); + const explicitId = firstNonEmpty(promptId); + if (!explicitId && !digest && !summary && !preview) return null; + const id = explicitId || agentRunPromptId(traceId, digest); + const prompt = compactObject({ + id, + sha256: digest || undefined, + summary, + preview, + textChars: Number.isInteger(textChars) ? textChars : undefined, + source, + valuesPrinted: false + }); + return compactObject({ + promptId: id, + promptSha256: digest || undefined, + promptSummary: summary, + promptPreview: preview, + prompt, + valuesPrinted: false + }) ?? null; +} + +function agentRunPromptId(traceId = null, digest = null) { + const traceSuffix = safeTraceId(traceId)?.slice(4).replace(/[^A-Za-z0-9_.:-]/gu, "_"); + const digestSuffix = firstNonEmpty(digest)?.slice(0, 16); + const suffix = firstNonEmpty(traceSuffix, digestSuffix); + return suffix ? `prm_${suffix}` : undefined; +} + +function clippedPromptText(value, limit) { + const promptText = promptTextValue(value); + if (!promptText) return undefined; + return promptText.length > limit ? `${promptText.slice(0, limit)}...` : promptText; +} + +function promptTextValue(value) { + const extracted = promptTextRaw(value).replace(/\s+/gu, " ").trim(); + if (!extracted || extracted === "[object Object]") return ""; + return extracted; +} + +function promptTextRaw(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map(promptTextRaw).filter(Boolean).join("\n"); + if (typeof value === "object") { + for (const key of ["text", "content", "message", "prompt", "value", "summary", "preview", "title"]) { + const text = promptTextRaw(value[key]); + if (text) return text; + } + } + return ""; +} + +function sha256Text(value) { + return createHash("sha256").update(String(value)).digest("hex"); +} + +function compactObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = {}; + for (const [key, item] of Object.entries(value)) { + if (item === undefined || item === null) continue; + if (Array.isArray(item) && item.length === 0) continue; + if (typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0) continue; + result[key] = item; + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function firstNonEmpty(...values) { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) return text; + } + return null; +} diff --git a/internal/cloud/code-agent-agentrun-runtime.ts b/internal/cloud/code-agent-agentrun-runtime.ts new file mode 100644 index 00000000..666da70d --- /dev/null +++ b/internal/cloud/code-agent-agentrun-runtime.ts @@ -0,0 +1,142 @@ +// 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 = {}; + 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..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***"); + } +}