diff --git a/deploy/deploy.json b/deploy/deploy.json index 2c49e5e2..c9981666 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -153,6 +153,12 @@ "HWLAB_BOOTSTRAP_ADMIN_USERNAME": "admin", "HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME": "HWLAB v0.2 Admin", "HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH": "secretRef:hwlab-v02-bootstrap-admin/password-hash", + "HWLAB_CODE_AGENT_ADAPTER": "agentrun-v01", + "AGENTRUN_MGR_URL": "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080", + "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID": "G14", + "HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE": "agentrun-v01", + "HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE": "agentrun-v01", + "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL": "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git", "HWLAB_DEVICE_POD_INTERNAL_TOKEN": "secretRef:hwlab-v02-device-pod-internal/token" } }, diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 9514e609..401ae878 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -358,6 +358,39 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac } }); +test("access controller restores AgentRun mapping by Code Agent traceId", async () => { + const accessController = createAccessController({ now: () => "2026-06-01T00:00:00.000Z" }); + await accessController.recordAgentSessionOwner({ + ownerUserId: "usr_agent_owner", + sessionId: "ses_agentrun_trace_lookup", + projectId: "prj_v02_code_agent", + conversationId: "cnv_agentrun_trace_lookup", + threadId: "thread-agentrun-trace-lookup", + traceId: "trc_agentrun_trace_lookup", + status: "running", + session: { + source: "hwlab-cloud-api-agentrun-v01-adapter", + agentRun: { + adapter: "agentrun-v01", + runId: "run_trace_lookup", + commandId: "cmd_trace_lookup", + jobName: "agentrun-v01-runner-trace-lookup", + namespace: "agentrun-v01", + backendProfile: "deepseek", + valuesPrinted: false + }, + valuesRedacted: true + } + }); + + const restored = await accessController.getAgentSessionByTraceId("trc_agentrun_trace_lookup"); + assert.equal(restored.id, "ses_agentrun_trace_lookup"); + assert.equal(restored.lastTraceId, "trc_agentrun_trace_lookup"); + assert.equal(restored.session.agentRun.runId, "run_trace_lookup"); + assert.equal(restored.session.agentRun.commandId, "cmd_trace_lookup"); + assert.equal(restored.session.agentRun.namespace, "agentrun-v01"); +}); + test("cloud api protects device-pod routes when access control is required", async () => { const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 3c65b32d..58d84851 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -70,6 +70,7 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS updated_at TEXT`, `CREATE INDEX IF NOT EXISTS idx_agent_sessions_owner ON agent_sessions(owner_user_id)`, `CREATE INDEX IF NOT EXISTS idx_agent_sessions_conversation ON agent_sessions(conversation_id)`, + `CREATE INDEX IF NOT EXISTS idx_agent_sessions_last_trace ON agent_sessions(last_trace_id)`, `CREATE TABLE IF NOT EXISTS device_pods ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', @@ -558,6 +559,13 @@ class AccessController { }) ?? null; } + async getAgentSessionByTraceId(traceId) { + await this.ensureBootstrap(); + const id = textOr(traceId, ""); + if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(id)) return null; + return this.store.getAgentSessionByTraceId?.(id) ?? null; + } + async listAgentConversations(request, response, url) { await this.ensureBootstrap(); const auth = await this.authenticate(request, { required: true }); @@ -1203,6 +1211,11 @@ class MemoryAccessStore { return session; } async getAgentSession(id) { return this.agentSessions.get(id) ?? null; } + async getAgentSessionByTraceId(traceId) { + return [...this.agentSessions.values()] + .filter((session) => session.lastTraceId === traceId) + .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")))[0] ?? null; + } async listAgentSessionsForUser(input = {}) { const ownerUserId = textOr(input.ownerUserId, ""); const role = textOr(input.actorRole, "user"); @@ -1299,6 +1312,7 @@ class PostgresAccessStore extends MemoryAccessStore { return pgAgentSession(result.rows?.[0]); } async getAgentSession(id) { await this.ensureSchema(); const result = await this.query("SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions WHERE id = $1 LIMIT 1", [id]); return pgAgentSession(result.rows?.[0]); } + async getAgentSessionByTraceId(traceId) { await this.ensureSchema(); const result = await this.query("SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions WHERE last_trace_id = $1 ORDER BY updated_at DESC NULLS LAST LIMIT 1", [traceId]); return pgAgentSession(result.rows?.[0]); } async listAgentSessionsForUser(input = {}) { await this.ensureSchema(); const role = textOr(input.actorRole, "user"); diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts new file mode 100644 index 00000000..e089b309 --- /dev/null +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -0,0 +1,764 @@ +import { randomUUID } from "node:crypto"; + +import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; +import { + parsePositiveInteger, + safeConversationId, + safeOpaqueId, + safeSessionId, + safeTraceId, + truthyFlag +} from "./server-http-utils.ts"; + +const ADAPTER_ID = "agentrun-v01"; +const DEFAULT_AGENTRUN_MGR_URL = "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080"; +const DEFAULT_TENANT_ID = "hwlab"; +const DEFAULT_PROJECT_ID = "pikasTech/HWLAB"; +const DEFAULT_PROVIDER_ID = "G14"; +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_BACKENDS = Object.freeze(["codex", "deepseek"]); + +export function codeAgentAgentRunAdapterEnabled(env = process.env) { + const value = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase(); + return value === ADAPTER_ID || value === "agentrun" || value === "agentrun-v0.1"; +} + +export function initialAgentRunChatResult({ params = {}, options = {}, traceId }) { + const env = options.env ?? process.env; + const timestamp = nowIso(options.now); + const backendProfile = resolveAgentRunBackendProfile(env, params); + return { + accepted: true, + status: "running", + shortConnection: true, + traceId, + conversationId: safeConversationId(params.conversationId) || null, + sessionId: agentRunSessionId(params, traceId), + threadId: safeOpaqueId(params.threadId) || null, + messageId: `msg_${safeTraceId(traceId)?.slice(4) || randomUUID()}`, + createdAt: timestamp, + updatedAt: timestamp, + provider: ADAPTER_ID, + model: modelForBackendProfile(backendProfile, env), + backend: `agentrun-v01/${backendProfile}`, + capabilityLevel: "agentrun-v01-manual-dispatch", + sessionMode: "agentrun-v01-session-ref", + implementationType: "hwlab-cloud-api-agentrun-v01-adapter", + agentRun: { + adapter: ADAPTER_ID, + status: "pending-dispatch", + backendProfile, + providerId: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID, env.AGENTRUN_PROVIDER_ID, DEFAULT_PROVIDER_ID), + managerUrl: resolveAgentRunManagerUrl(env), + repoUrl: resolveAgentRunRepoUrl(env), + valuesPrinted: false + }, + valuesPrinted: false + }; +} + +export async function submitAgentRunChatTurn({ params = {}, options = {}, traceId, traceStore = defaultCodeAgentTraceStore, results }) { + const env = options.env ?? process.env; + const managerUrl = resolveAgentRunManagerUrl(env); + const backendProfile = resolveAgentRunBackendProfile(env, params); + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); + const startedAt = nowIso(options.now); + + traceStore.ensure(traceId, agentRunTraceMeta(env, params)); + traceStore.append(traceId, { + type: "request", + status: "accepted", + label: "agentrun:request:accepted", + message: "HWLAB Code Agent request accepted by the AgentRun v0.1 adapter; hwlab-cloud-api will manually create run/command/runner-job over the k3s Service DNS.", + waitingFor: "agentrun-run-create", + adapter: ADAPTER_ID, + managerHost: new URL(managerUrl).hostname, + valuesPrinted: false + }, agentRunTraceMeta(env, params)); + + const runInput = buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }); + const run = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs", { + method: "POST", + body: runInput, + timeoutMs + }); + const runId = requiredString(run?.id, "run.id"); + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:run:created", + message: `AgentRun run ${runId} created through internal k3s Service DNS.`, + runId, + backendProfile, + waitingFor: "agentrun-command-create", + valuesPrinted: false + }); + + const commandInput = buildAgentRunCommandInput({ params, traceId, backendProfile }); + const command = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(runId)}/commands`, { + method: "POST", + body: commandInput, + timeoutMs + }); + const commandId = requiredString(command?.id, "command.id"); + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:command:created", + message: `AgentRun command ${commandId} created; hwlab-cloud-api will start a runner Job explicitly without relying on scheduler automation.`, + runId, + commandId, + backendProfile, + waitingFor: "agentrun-runner-job-create", + valuesPrinted: false + }); + + const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId }); + const runnerJob = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, { + method: "POST", + body: runnerJobInput, + timeoutMs + }); + const mapping = agentRunMapping({ env, managerUrl, backendProfile, run, command, runnerJob, traceId, startedAt }); + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:runner-job:created", + message: `AgentRun runner Job ${mapping.jobName ?? "unknown"} created in namespace ${mapping.namespace ?? DEFAULT_RUNNER_NAMESPACE}.`, + runId: mapping.runId, + commandId: mapping.commandId, + attemptId: mapping.attemptId, + runnerId: mapping.runnerId, + jobName: mapping.jobName, + namespace: mapping.namespace, + waitingFor: "agentrun-result", + valuesPrinted: false + }); + return decorateAgentRunRunningResult({ base: initialAgentRunChatResult({ params, options, traceId }), mapping, traceStore, traceId }); +} + +export async function syncAgentRunChatResult({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore }) { + const mapped = currentResult ?? await loadPersistedAgentRunResult(traceId, options); + if (!mapped?.agentRun?.runId || !mapped?.agentRun?.commandId) return { result: currentResult, runnerTrace: traceStore.snapshot(traceId), found: Boolean(currentResult) }; + const env = options.env ?? process.env; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl); + const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); + const eventsResponse = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/events?afterSeq=${encodeURIComponent(String(mapped.agentRun.lastSeq ?? 0))}&limit=500`, { + method: "GET", + timeoutMs + }); + const events = Array.isArray(eventsResponse?.items) ? eventsResponse.items : []; + appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun); + const result = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/commands/${encodeURIComponent(mapped.agentRun.commandId)}/result`, { + method: "GET", + timeoutMs + }); + const nextMapping = { + ...mapped.agentRun, + ...agentRunResultRefs(result), + lastSeq: Math.max(Number(mapped.agentRun.lastSeq ?? 0), Number(result?.lastSeq ?? 0), ...events.map((event) => Number(event?.seq ?? 0))), + status: result?.status ?? mapped.agentRun.status ?? "running", + runStatus: result?.runStatus ?? mapped.agentRun.runStatus ?? null, + commandState: result?.commandState ?? mapped.agentRun.commandState ?? null, + terminalStatus: result?.terminalStatus ?? mapped.agentRun.terminalStatus ?? null, + updatedAt: nowIso(options.now), + valuesPrinted: false + }; + const base = { ...mapped, agentRun: nextMapping, updatedAt: nowIso(options.now) }; + const payload = agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }); + options.codeAgentChatResults?.set?.(traceId, payload); + return { result: payload, runnerTrace: payload.runnerTrace ?? traceStore.snapshot(traceId), found: true }; +} + +export async function refreshAgentRunTrace({ traceId, result = null, options = {}, traceStore = defaultCodeAgentTraceStore }) { + const mapped = result ?? await loadPersistedAgentRunResult(traceId, options); + if (!mapped?.agentRun?.runId) return traceStore.snapshot(traceId); + const env = options.env ?? process.env; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl); + const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); + const eventsResponse = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/events?afterSeq=${encodeURIComponent(String(mapped.agentRun.lastSeq ?? 0))}&limit=500`, { + method: "GET", + timeoutMs + }); + const events = Array.isArray(eventsResponse?.items) ? eventsResponse.items : []; + appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun); + const lastSeq = Math.max(Number(mapped.agentRun.lastSeq ?? 0), ...events.map((event) => Number(event?.seq ?? 0))); + if (lastSeq !== Number(mapped.agentRun.lastSeq ?? 0)) { + options.codeAgentChatResults?.set?.(traceId, { ...mapped, agentRun: { ...mapped.agentRun, lastSeq, updatedAt: nowIso(options.now) } }); + } + return traceStore.snapshot(traceId); +} + +export async function cancelAgentRunChatTurn({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore }) { + const mapped = currentResult ?? await loadPersistedAgentRunResult(traceId, options); + if (!mapped?.agentRun?.commandId) return null; + const env = options.env ?? process.env; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl); + const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); + await agentRunJson(fetchImpl, managerUrl, `/api/v1/commands/${encodeURIComponent(mapped.agentRun.commandId)}/cancel`, { + method: "POST", + body: { reason: "hwlab-user-cancel", traceId }, + timeoutMs + }); + traceStore.append(traceId, { + type: "cancel", + status: "canceled", + label: "agentrun:cancel:canceled", + message: "HWLAB forwarded cancel to AgentRun command cancel API.", + runId: mapped.agentRun.runId, + commandId: mapped.agentRun.commandId, + terminal: true, + valuesPrinted: false + }); + const now = nowIso(options.now); + const agentRun = { ...mapped.agentRun, status: "cancelled", commandState: "cancelled", terminalStatus: "cancelled", updatedAt: now }; + const payload = { + ...mapped, + status: "canceled", + canceled: true, + updatedAt: now, + agentRun, + runnerTrace: traceStore.snapshot(traceId), + error: { + code: "agentrun_canceled", + layer: "agentrun", + category: "canceled", + retryable: true, + message: "AgentRun command was canceled by user request", + userMessage: "当前 AgentRun 请求已取消;traceId/runId/commandId 已保留,可重试上一条消息。", + traceId, + route: "/v1/agent/chat/cancel", + toolName: "agentrun.command.cancel" + }, + userMessage: "当前 AgentRun 请求已取消;traceId/runId/commandId 已保留,可重试上一条消息。" + }; + options.codeAgentChatResults?.set?.(traceId, payload); + return payload; +} + +export async function loadPersistedAgentRunResult(traceId, options = {}) { + if (!safeTraceId(traceId) || typeof options.accessController?.getAgentSessionByTraceId !== "function") return null; + const session = await options.accessController.getAgentSessionByTraceId(traceId); + const agentRun = session?.session?.agentRun; + if (!agentRun || typeof agentRun !== "object" || !agentRun.runId || !agentRun.commandId) return null; + return { + accepted: true, + status: session.status === "canceled" ? "canceled" : "running", + shortConnection: true, + traceId, + conversationId: safeConversationId(session.conversationId) || agentRun.conversationId || null, + sessionId: safeSessionId(session.id) || agentRun.sessionId || null, + threadId: safeOpaqueId(session.threadId) || agentRun.threadId || null, + messageId: session.session?.messageId ?? `msg_${traceId.slice(4)}`, + createdAt: session.startedAt ?? session.updatedAt ?? nowIso(options.now), + updatedAt: session.updatedAt ?? nowIso(options.now), + ownerUserId: session.ownerUserId, + provider: ADAPTER_ID, + model: modelForBackendProfile(agentRun.backendProfile, options.env ?? process.env), + backend: `agentrun-v01/${agentRun.backendProfile ?? "deepseek"}`, + capabilityLevel: "agentrun-v01-manual-dispatch", + sessionMode: "agentrun-v01-session-ref", + implementationType: "hwlab-cloud-api-agentrun-v01-adapter", + agentRun: { ...agentRun, adapter: ADAPTER_ID, valuesPrinted: false }, + valuesPrinted: false + }; +} + +export function agentRunSessionEvidence(payload = {}) { + if (!payload?.agentRun) return {}; + return { + agentRun: { + adapter: ADAPTER_ID, + runId: payload.agentRun.runId ?? null, + commandId: payload.agentRun.commandId ?? null, + attemptId: payload.agentRun.attemptId ?? null, + runnerId: payload.agentRun.runnerId ?? null, + jobName: payload.agentRun.jobName ?? null, + 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, + status: payload.agentRun.status ?? null, + runStatus: payload.agentRun.runStatus ?? null, + commandState: payload.agentRun.commandState ?? null, + terminalStatus: payload.agentRun.terminalStatus ?? null, + lastSeq: payload.agentRun.lastSeq ?? 0, + providerId: payload.agentRun.providerId ?? DEFAULT_PROVIDER_ID, + sessionId: payload.sessionId ?? payload.agentRun.sessionId ?? null, + conversationId: payload.conversationId ?? payload.agentRun.conversationId ?? null, + threadId: payload.threadId ?? payload.agentRun.threadId ?? null, + valuesPrinted: false + } + }; +} + +function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }) { + const commitId = fullSourceCommit(env); + const resourceBundleRef = commitId + ? { + kind: "git", + repoUrl: resolveAgentRunRepoUrl(env), + commitId, + submodules: false, + lfs: false + } + : null; + return { + tenantId: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TENANT_ID, DEFAULT_TENANT_ID), + projectId: firstNonEmpty(params.projectId, env.HWLAB_CODE_AGENT_AGENTRUN_PROJECT_ID, DEFAULT_PROJECT_ID), + workspaceRef: { + kind: "opaque", + repo: DEFAULT_PROJECT_ID, + branch: firstNonEmpty(env.HWLAB_BOOT_REF, env.HWLAB_CODE_AGENT_AGENTRUN_BRANCH, "v0.2"), + runtimeNamespace: firstNonEmpty(env.HWLAB_RUNTIME_NAMESPACE, env.POD_NAMESPACE, env.HWLAB_NAMESPACE, "hwlab-v02"), + hwlabTraceId: traceId, + valuesPrinted: false + }, + sessionRef: { + sessionId: agentRunSessionId(params, traceId), + ...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}), + ...(safeOpaqueId(params.threadId) ? { threadId: safeOpaqueId(params.threadId) } : {}), + metadata: { + adapter: ADAPTER_ID, + hwlabTraceId: traceId, + hwlabApi: "/v1/agent/chat", + valuesPrinted: false + } + }, + resourceBundleRef, + providerId: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID, env.AGENTRUN_PROVIDER_ID, DEFAULT_PROVIDER_ID), + backendProfile, + executionPolicy: { + sandbox: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_SANDBOX, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "danger-full-access"), + approval: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_APPROVAL, "never"), + timeoutMs: parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_TIMEOUT_MS, parsePositiveInteger(env.HWLAB_CODE_AGENT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)), + network: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_NETWORK, "enabled"), + secretScope: { + allowCredentialEcho: false, + providerCredentials: [providerCredentialSecretRef(backendProfile, env)] + } + }, + traceSink: { + kind: "hwlab-cloud-api", + traceId, + resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, + traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, + valuesPrinted: false + } + }; +} + +function buildAgentRunCommandInput({ params, traceId, backendProfile }) { + const prompt = String(params.message ?? params.prompt ?? "").trim(); + return { + type: "turn", + payload: { + prompt, + message: prompt, + traceId, + conversationId: safeConversationId(params.conversationId) || null, + sessionId: agentRunSessionId(params, traceId), + threadId: safeOpaqueId(params.threadId) || null, + providerProfile: backendProfile, + source: "hwlab-cloud-api", + valuesPrinted: false + }, + idempotencyKey: traceId + }; +} + +function buildAgentRunRunnerJobInput({ env, traceId, commandId }) { + return { + commandId, + idempotencyKey: traceId, + namespace: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE, env.AGENTRUN_RUNTIME_NAMESPACE, DEFAULT_RUNNER_NAMESPACE), + ...(firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_IMAGE, env.AGENTRUN_RUNNER_IMAGE) + ? { image: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_IMAGE, env.AGENTRUN_RUNNER_IMAGE) } + : {}), + ...(firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_SERVICE_ACCOUNT, env.AGENTRUN_RUNNER_SERVICE_ACCOUNT) + ? { serviceAccountName: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_SERVICE_ACCOUNT, env.AGENTRUN_RUNNER_SERVICE_ACCOUNT) } + : {}) + }; +} + +function providerCredentialSecretRef(profile, env) { + return { + profile, + secretRef: { + namespace: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE, DEFAULT_RUNNER_NAMESPACE), + name: firstNonEmpty(env[`HWLAB_CODE_AGENT_AGENTRUN_${profile.toUpperCase()}_SECRET_NAME`], env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_SECRET_NAME, `agentrun-v01-provider-${profile}`), + keys: ["auth.json", "config.toml"], + mountPath: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_CODEX_HOME_MOUNT, "~/.codex") + } + }; +} + +function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) { + const terminalStatus = String(result?.terminalStatus ?? ""); + const terminal = terminalStatus === "completed" || terminalStatus === "failed" || terminalStatus === "blocked" || terminalStatus === "cancelled"; + if (!terminal) { + return decorateAgentRunRunningResult({ base, mapping: base.agentRun, traceStore, traceId }); + } + const now = nowIso(); + const runnerTrace = traceStore.snapshot(traceId, agentRunTraceMeta({}, {})); + if (terminalStatus === "completed" && String(result?.reply ?? "").trim()) { + traceStore.append(traceId, { + type: "result", + status: "completed", + label: "agentrun:result:completed", + message: "AgentRun result is ready for HWLAB short-connection polling.", + runId: base.agentRun.runId, + commandId: base.agentRun.commandId, + terminal: true, + valuesPrinted: false + }); + return { + ...base, + status: "completed", + updatedAt: now, + session: agentRunSessionSummary(base, "idle"), + runner: agentRunRunnerSummary(base.agentRun), + runnerTrace: traceStore.snapshot(traceId, agentRunTraceMeta({}, {})), + toolCalls: [], + skills: { status: "delegated", provider: ADAPTER_ID, items: [], valuesPrinted: false }, + reply: { + messageId: base.messageId ?? `msg_${traceId.slice(4)}`, + role: "assistant", + content: String(result.reply), + createdAt: now + }, + usage: null, + agentRun: { ...base.agentRun, terminalStatus, completed: true, valuesPrinted: false }, + valuesPrinted: false + }; + } + const canceled = terminalStatus === "cancelled"; + traceStore.append(traceId, { + type: "result", + status: canceled ? "canceled" : "failed", + label: `agentrun:result:${canceled ? "canceled" : terminalStatus || "failed"}`, + errorCode: canceled ? "agentrun_canceled" : result?.failureKind ?? "agentrun_failed", + message: result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled." : "AgentRun command failed without a completed reply."), + runId: base.agentRun.runId, + commandId: base.agentRun.commandId, + terminal: true, + valuesPrinted: false + }); + const code = canceled ? "agentrun_canceled" : result?.failureKind ?? (terminalStatus === "blocked" ? "agentrun_blocked" : "agentrun_failed"); + const message = result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled" : "AgentRun command failed"); + return { + ...base, + status: canceled ? "canceled" : "failed", + canceled, + updatedAt: now, + session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"), + runner: agentRunRunnerSummary(base.agentRun), + runnerTrace, + toolCalls: [], + skills: { status: "delegated", provider: ADAPTER_ID, items: [], valuesPrinted: false }, + error: { + code, + layer: "agentrun", + category: canceled ? "canceled" : "agentrun_failed", + retryable: true, + message, + userMessage: canceled ? "AgentRun 请求已取消。" : "AgentRun 请求失败;请查看 runnerTrace 和 agentRun 字段定位 runId/commandId/jobName。", + traceId, + route: "/v1/agent/chat", + toolName: "agentrun.manual-dispatch" + }, + blocker: canceled ? null : { + code, + layer: "agentrun", + category: "agentrun_failed", + retryable: true, + summary: message, + traceId, + route: "/v1/agent/chat", + toolName: "agentrun.manual-dispatch" + }, + agentRun: { ...base.agentRun, terminalStatus, completed: false, valuesPrinted: false }, + valuesPrinted: false + }; +} + +function decorateAgentRunRunningResult({ base, mapping, traceStore, traceId }) { + return { + ...base, + status: "running", + accepted: true, + shortConnection: true, + updatedAt: nowIso(), + session: agentRunSessionSummary({ ...base, agentRun: mapping }, "running"), + runner: agentRunRunnerSummary(mapping), + runnerTrace: traceStore.snapshot(traceId, agentRunTraceMeta({}, {})), + agentRun: { ...mapping, adapter: ADAPTER_ID, valuesPrinted: false }, + valuesPrinted: false + }; +} + +function appendAgentRunEventsToTrace(traceStore, traceId, events, mapping = {}) { + for (const event of events) { + const normalized = mapAgentRunEvent(event, mapping); + if (normalized) traceStore.append(traceId, normalized, agentRunTraceMeta({}, {})); + } +} + +function mapAgentRunEvent(event, mapping = {}) { + if (!event || typeof event !== "object") return null; + const payload = event.payload && typeof event.payload === "object" ? event.payload : {}; + const base = { + source: "agentrun", + sourceSeq: event.seq ?? null, + runId: event.runId ?? mapping.runId ?? null, + commandId: payload.commandId ?? mapping.commandId ?? null, + attemptId: payload.attemptId ?? mapping.attemptId ?? null, + runnerId: payload.runnerId ?? mapping.runnerId ?? null, + jobName: payload.jobName ?? mapping.jobName ?? null, + namespace: payload.namespace ?? mapping.namespace ?? null, + valuesPrinted: false + }; + if (event.type === "backend_status") { + const phase = String(payload.phase ?? "status"); + return { ...base, type: "backend", status: "running", label: `agentrun:backend:${phase}`, message: textPayload(payload, phase), waitingFor: waitingForForPhase(phase) }; + } + if (event.type === "assistant_message") { + return { ...base, type: "assistant", status: "running", label: "agentrun:assistant:message", message: textPayload(payload, "assistant message"), text: textPayload(payload, "") }; + } + if (event.type === "tool_call") { + return { ...base, type: "tool", status: String(payload.status ?? "running"), label: `agentrun:tool:${String(payload.name ?? payload.method ?? "call")}`, message: textPayload(payload, "tool call") }; + } + if (event.type === "command_output") { + const stream = payload.stream === "stderr" ? "stderr" : "stdout"; + return { ...base, type: "output", status: "running", label: `agentrun:output:${stream}`, stream, message: textPayload(payload, ""), outputTruncated: payload.outputTruncated === true }; + } + if (event.type === "diff") { + return { ...base, type: "diff", status: "running", label: "agentrun:diff", message: textPayload(payload, "diff") }; + } + if (event.type === "error") { + return { ...base, type: "error", status: "failed", label: `agentrun:error:${String(payload.failureKind ?? "backend")}`, errorCode: payload.failureKind ?? "agentrun_error", message: textPayload(payload, "AgentRun error") }; + } + if (event.type === "terminal_status") { + const terminalStatus = String(payload.terminalStatus ?? "failed"); + return { ...base, type: "result", status: terminalStatus === "cancelled" ? "canceled" : terminalStatus, label: `agentrun:terminal:${terminalStatus}`, errorCode: payload.failureKind ?? null, message: textPayload(payload, `AgentRun terminal status ${terminalStatus}`), terminal: true }; + } + return { ...base, type: "backend", status: "running", label: `agentrun:event:${String(event.type ?? "unknown")}`, message: textPayload(payload, "AgentRun event") }; +} + +async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body, timeoutMs = 20_000 } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${managerUrl}${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + const text = await response.text(); + const parsed = text ? JSON.parse(text) : {}; + if (!response.ok || parsed?.ok === false) { + 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") { + throw Object.assign(new Error(`AgentRun ${method} ${path} timed out after ${timeoutMs}ms`), { code: "agentrun_timeout", statusCode: 504 }); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +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 allowedHost = "agentrun-mgr.agentrun-v01.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(`AGENTRUN_MGR_URL must use internal k3s Service DNS ${DEFAULT_AGENTRUN_MGR_URL}; 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 resolveAgentRunBackendProfile(env = process.env, params = {}) { + const value = String(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_AGENTRUN_BACKEND_PROFILE ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE ?? "deepseek").trim().toLowerCase(); + const mapped = value === "codex-api" || value === "codex" ? "codex" : value === "runtime-default" ? String(env.HWLAB_CODE_AGENT_AGENTRUN_DEFAULT_BACKEND_PROFILE ?? "deepseek").trim().toLowerCase() : value; + return AGENTRUN_BACKENDS.includes(mapped) ? mapped : "deepseek"; +} + +function agentRunMapping({ env, managerUrl, backendProfile, run, command, runnerJob, traceId, startedAt }) { + return { + adapter: ADAPTER_ID, + managerUrl, + backendProfile, + providerId: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID, env.AGENTRUN_PROVIDER_ID, DEFAULT_PROVIDER_ID), + runId: run.id, + commandId: command.id, + attemptId: runnerJob?.attemptId ?? runnerJob?.runner?.attemptId ?? null, + runnerId: runnerJob?.runnerId ?? runnerJob?.runner?.runnerId ?? null, + runnerJobId: runnerJob?.id ?? null, + jobName: runnerJob?.jobName ?? runnerJob?.jobIdentity?.name ?? null, + namespace: runnerJob?.namespace ?? runnerJob?.jobIdentity?.namespace ?? DEFAULT_RUNNER_NAMESPACE, + status: "runner-job-created", + runStatus: run.status ?? null, + commandState: command.state ?? null, + terminalStatus: null, + lastSeq: 0, + traceId, + createdAt: startedAt, + updatedAt: nowIso(), + valuesPrinted: false + }; +} + +function agentRunResultRefs(result = {}) { + const refs = {}; + for (const key of ["attemptId", "runnerId", "jobName", "namespace", "runnerJobCount"]) { + if (result?.[key] !== undefined && result?.[key] !== null) refs[key] = result[key]; + } + return refs; +} + +function agentRunSessionId(params, traceId) { + return safeSessionId(params.sessionId) || `ses_agentrun_${String(safeTraceId(traceId) || `trc_${randomUUID()}`).slice(4)}`; +} + +function agentRunSessionSummary(base, status) { + return { + sessionId: base.sessionId ?? base.agentRun?.sessionId ?? null, + conversationId: base.conversationId ?? base.agentRun?.conversationId ?? null, + threadId: base.threadId ?? base.agentRun?.threadId ?? null, + status, + sessionMode: "agentrun-v01-session-ref", + runnerKind: "agentrun-v01-runner-job", + secretMaterialStored: false, + valuesRedacted: true + }; +} + +function agentRunRunnerSummary(mapping = {}) { + return { + kind: "agentrun-v01-runner-job", + adapter: ADAPTER_ID, + runId: mapping.runId ?? null, + commandId: mapping.commandId ?? null, + attemptId: mapping.attemptId ?? null, + runnerId: mapping.runnerId ?? null, + jobName: mapping.jobName ?? null, + namespace: mapping.namespace ?? null, + backendProfile: mapping.backendProfile ?? null, + valuesPrinted: false + }; +} + +function agentRunTraceMeta(env = process.env) { + return { + runnerKind: "agentrun-v01-runner-job", + workspace: resolveAgentRunRepoUrl(env), + sandbox: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_SANDBOX, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "danger-full-access"), + sessionMode: "agentrun-v01-session-ref", + implementationType: "hwlab-cloud-api-agentrun-v01-adapter" + }; +} + +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 fullSourceCommit(env) { + for (const value of [ + env.HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT, + env.HWLAB_BOOT_COMMIT, + env.HWLAB_GITOPS_SOURCE_COMMIT, + env.HWLAB_COMMIT_ID, + env.HWLAB_GIT_SHA, + env.GIT_COMMIT, + env.HWLAB_REVISION + ]) { + const text = String(value ?? "").trim().toLowerCase(); + if (/^[0-9a-f]{40}$/u.test(text)) return text; + } + return null; +} + +function modelForBackendProfile(profile, env = process.env) { + if (profile === "codex") return firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_API_MODEL, env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5"); + return firstNonEmpty(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, "deepseek-chat"); +} + +function waitingForForPhase(phase) { + if (phase.includes("runner-job")) return "agentrun-runner"; + if (phase.includes("resource-bundle")) return "agentrun-resource-bundle"; + if (phase.includes("backend")) return "agentrun-backend"; + return "agentrun-result"; +} + +function textPayload(payload, fallback) { + for (const key of ["message", "text", "content", "delta", "summary", "phase"]) { + if (typeof payload?.[key] === "string" && payload[key].length > 0) return payload[key]; + } + return fallback; +} + +function requiredString(value, fieldName) { + if (typeof value === "string" && value.trim()) return value.trim(); + throw Object.assign(new Error(`AgentRun response missing ${fieldName}`), { code: "agentrun_response_invalid", statusCode: 502 }); +} + +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(); +} + +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-trace-store.ts b/internal/cloud/code-agent-trace-store.ts index 3451b2fc..88c2f0c7 100644 --- a/internal/cloud/code-agent-trace-store.ts +++ b/internal/cloud/code-agent-trace-store.ts @@ -316,6 +316,14 @@ function normalizeTraceEvent(event, { traceId, seq, now, fallbackRunnerKind } = createdAt: timestampFor(now), elapsedMs: typeof event.elapsedMs === "number" ? Math.max(0, Math.trunc(event.elapsedMs)) : null, runnerKind: safeText(event.runnerKind ?? fallbackRunnerKind, 140), + source: safeText(event.source, 120), + sourceSeq: Number.isInteger(event.sourceSeq) ? event.sourceSeq : undefined, + runId: safeText(event.runId, 180), + commandId: safeText(event.commandId, 180), + attemptId: safeText(event.attemptId, 180), + runnerId: safeText(event.runnerId, 180), + jobName: safeText(event.jobName, 180), + namespace: safeText(event.namespace, 180), sessionId: safeText(event.sessionId, 180), sessionStatus: safeText(event.sessionStatus, 80), sessionLifecycleStatus: safeText(event.sessionLifecycleStatus, 80), diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index d353e71e..8816fcaa 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -137,6 +137,149 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async } }); +test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapter", async () => { + const calls = []; + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null; + calls.push({ method: request.method, path: url.pathname, search: url.search, body }); + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_agentrun" })}\n`); + }; + if (request.method === "POST" && url.pathname === "/api/v1/runs") { + assert.equal(body.tenantId, "hwlab"); + assert.equal(body.backendProfile, "deepseek"); + assert.equal(body.resourceBundleRef.repoUrl, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"); + assert.equal(body.resourceBundleRef.commitId, "0123456789abcdef0123456789abcdef01234567"); + return send({ id: "run_hwlab_adapter", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands") { + assert.equal(body.type, "turn"); + assert.match(body.payload.prompt, /AgentRun adapter/u); + return send({ id: "cmd_hwlab_adapter", runId: "run_hwlab_adapter", state: "pending", type: "turn", seq: 1 }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") { + assert.equal(body.commandId, "cmd_hwlab_adapter"); + return send({ + action: "create-kubernetes-job", + runId: "run_hwlab_adapter", + commandId: "cmd_hwlab_adapter", + attemptId: "attempt_hwlab_adapter", + runnerId: "runner_hwlab_adapter", + namespace: "agentrun-v01", + jobName: "agentrun-v01-runner-hwlab-adapter", + jobIdentity: { namespace: "agentrun-v01", name: "agentrun-v01-runner-hwlab-adapter" }, + runner: { attemptId: "attempt_hwlab_adapter", runnerId: "runner_hwlab_adapter" } + }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/events") { + return send({ items: [ + { id: "evt_1", runId: "run_hwlab_adapter", seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" }, + { id: "evt_2", runId: "run_hwlab_adapter", seq: 2, type: "assistant_message", payload: { text: "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" }, + { id: "evt_3", runId: "run_hwlab_adapter", seq: 3, type: "terminal_status", payload: { terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" } + ] }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter/result") { + return send({ + runId: "run_hwlab_adapter", + commandId: "cmd_hwlab_adapter", + attemptId: "attempt_hwlab_adapter", + runnerId: "runner_hwlab_adapter", + jobName: "agentrun-v01-runner-hwlab-adapter", + namespace: "agentrun-v01", + status: "completed", + runStatus: "completed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + reply: "AgentRun adapter 已接管 HWLAB Code Agent。", + lastSeq: 3, + eventCount: 3 + }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, failureKind: "schema-invalid", message: `unexpected ${request.method} ${url.pathname}`, traceId: "trc_fake_agentrun" })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + const traceStore = createCodeAgentTraceStore(); + const server = createCloudApiServer({ + traceStore, + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_DEEPSEEK_MODEL: "deepseek-chat" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const traceId = "trc_server-test-agentrun-adapter"; + const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json", "x-trace-id": traceId }, + body: JSON.stringify({ conversationId: "cnv_server-test-agentrun", message: "AgentRun adapter smoke" }) + }); + assert.equal(submit.status, 202); + const accepted = await submit.json(); + assert.equal(accepted.shortConnection, true); + + const payload = await pollAgentResult(port, traceId); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "agentrun-v01"); + assert.equal(payload.backend, "agentrun-v01/deepseek"); + assert.equal(payload.agentRun.runId, "run_hwlab_adapter"); + assert.equal(payload.agentRun.commandId, "cmd_hwlab_adapter"); + assert.equal(payload.agentRun.jobName, "agentrun-v01-runner-hwlab-adapter"); + assert.match(payload.reply.content, /AgentRun adapter/u); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "agentrun:backend:runner-job-created")); + assert.ok(calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/runner-jobs")); + + const trace = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${traceId}`); + assert.equal(trace.status, 200); + const traceBody = await trace.json(); + assert.ok(traceBody.events.some((event) => event.runId === "run_hwlab_adapter")); + assert.ok(traceBody.events.some((event) => event.label === "agentrun:assistant:message")); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); + } +}); + +test("cloud api AgentRun adapter rejects non-internal manager URLs by default", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: "http://74.48.78.17:8080" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json", "x-trace-id": "trc_server-test-agentrun-public-url" }, + body: JSON.stringify({ conversationId: "cnv_server-test-agentrun-public-url", message: "must reject public manager url" }) + }); + assert.equal(response.status, 500); + const body = await response.json(); + assert.equal(body.error.code, "internal_error"); + assert.match(body.error.reason, /internal k3s Service DNS/u); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api /v1/agent/chat records authenticated owner on agent session", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-codex-home-")); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index ef3feb91..f84d176f 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -2,6 +2,16 @@ import { randomUUID } from "node:crypto"; import { M3_IO_CONTROL_ROUTE, M3_STATUS_ROUTE, describeM3StatusLive, handleM3IoControl } from "./m3-io-control.ts"; +import { + agentRunSessionEvidence, + cancelAgentRunChatTurn, + codeAgentAgentRunAdapterEnabled, + initialAgentRunChatResult, + loadPersistedAgentRunResult, + refreshAgentRunTrace, + submitAgentRunChatTurn, + syncAgentRunChatResult +} from "./code-agent-agentrun-adapter.ts"; import { createCodeAgentErrorPayload, handleCodeAgentChat } from "./code-agent-chat.ts"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts"; @@ -221,6 +231,52 @@ function firstNonEmptyValue(...values) { function submitCodeAgentChatTurn({ params, options, traceId }) { const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; const results = options.codeAgentChatResults ?? createCodeAgentChatResultStore(); + if (codeAgentAgentRunAdapterEnabled(options.env ?? process.env)) { + const initial = initialAgentRunChatResult({ params, options, traceId }); + results.set(traceId, annotateOwner(initial, params)); + const run = async () => { + try { + const payload = await submitAgentRunChatTurn({ params, options, traceId, traceStore, results }); + if (isCodeAgentResultCanceled(results.get(traceId))) return; + const owned = annotateOwner(payload, params); + await recordCodeAgentSessionOwner({ payload: owned, params, options, status: "running" }); + results.set(traceId, owned); + } catch (error) { + if (isCodeAgentResultCanceled(results.get(traceId))) return; + const payload = annotateOwner({ + ...initial, + status: "failed", + error: { + code: error?.code ?? "agentrun_dispatch_failed", + layer: "agentrun", + retryable: true, + message: error?.message ?? "AgentRun dispatch failed", + userMessage: "AgentRun 调度失败;trace/result 轮询已保留错误。" + }, + blocker: { + code: error?.code ?? "agentrun_dispatch_failed", + layer: "agentrun", + retryable: true, + summary: error?.message ?? "AgentRun dispatch failed" + }, + runnerTrace: traceStore.snapshot(traceId), + updatedAt: new Date().toISOString() + }, params); + results.set(traceId, payload); + traceStore.append(traceId, { + type: "result", + status: "failed", + label: "agentrun:dispatch:failed", + errorCode: payload.error.code, + message: payload.error.message, + terminal: true + }); + await recordCodeAgentSessionOwner({ payload, params, options, status: "failed" }); + } + }; + setImmediate(() => { run(); }); + return; + } results.set(traceId, { accepted: true, status: "running", @@ -297,7 +353,8 @@ function codeAgentChatShortConnectionRequested(request, params, options = {}) { const header = firstHeaderValue(request, "prefer"); const explicit = firstHeaderValue(request, "x-hwlab-short-connection"); const envValue = options.env?.HWLAB_CODE_AGENT_CHAT_SHORT_CONNECTION; - return /(?:^|,)\s*respond-async\s*(?:,|$)/iu.test(String(header ?? "")) || + return codeAgentAgentRunAdapterEnabled(options.env ?? process.env) || + /(?:^|,)\s*respond-async\s*(?:,|$)/iu.test(String(header ?? "")) || truthyFlag(explicit) || params?.shortConnection === true || truthyFlag(envValue); @@ -326,6 +383,61 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti }); return; } + if ((result?.agentRun?.runId && result.status === "running") || (!result && codeAgentAgentRunAdapterEnabled(options.env ?? process.env))) { + try { + const synced = await syncAgentRunChatResult({ traceId, currentResult: result, options, traceStore: options.traceStore ?? defaultCodeAgentTraceStore }); + if (synced.result && !canAccessOwnedResult(synced.result, options.actor)) { + sendJson(response, 403, { + error: { + code: "agent_session_owner_required", + message: "Only the session owner or admin can read this Code Agent result" + } + }); + return; + } + if (synced.result && synced.result.status !== "running") { + await recordCodeAgentSessionOwner({ payload: synced.result, params: synced.result, options, status: synced.result.status === "completed" ? "active" : synced.result.status }); + sendJson(response, 200, compactCodeAgentChatResultPayload(synced.result, options)); + return; + } + if (synced.result) { + sendJson(response, 202, { + accepted: true, + status: "running", + shortConnection: true, + traceId, + agentRun: synced.result.agentRun, + runnerTrace: compactRunnerTraceForResult(synced.runnerTrace, resultTraceEventLimit(options)), + waitingFor: synced.runnerTrace?.waitingFor ?? "agentrun-result" + }); + return; + } + } catch (error) { + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; + traceStore.append(traceId, { + type: "result", + status: "degraded", + label: "agentrun:result:poll-failed", + errorCode: error?.code ?? "agentrun_result_poll_failed", + message: error?.message ?? "AgentRun result polling failed", + waitingFor: "agentrun-result" + }); + if (result?.agentRun) { + sendJson(response, 202, { + accepted: true, + status: "running", + shortConnection: true, + traceId, + agentRun: result.agentRun, + runnerTrace: compactRunnerTraceForResult(traceStore.snapshot(traceId), resultTraceEventLimit(options)), + waitingFor: "agentrun-result", + degraded: true, + error: { code: error?.code ?? "agentrun_result_poll_failed", message: error?.message ?? "AgentRun result polling failed" } + }); + return; + } + } + } if (result && result.status !== "running") { sendJson(response, 200, compactCodeAgentChatResultPayload(result, options)); return; @@ -451,6 +563,15 @@ export async function handleCodeAgentCancelHttp(request, response, options) { const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId); const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; const snapshot = traceId ? traceStore.snapshot(traceId) : null; + const currentResult = traceId ? options.codeAgentChatResults?.get(traceId) ?? await loadPersistedAgentRunResult(traceId, options) : null; + if (currentResult?.agentRun?.commandId) { + const payload = await cancelAgentRunChatTurn({ traceId, currentResult, options, traceStore }); + if (payload) { + await recordCodeAgentSessionOwner({ payload, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "canceled" }); + sendJson(response, 200, payload); + return; + } + } const sessionId = safeSessionId(params.sessionId) || safeSessionId(snapshot?.sessionId); const conversationId = safeConversationId(params.conversationId); const manager = options.codexStdioManager; @@ -645,6 +766,7 @@ function codeAgentSessionOwnerEvidence(payload = {}, params = {}) { threadId: payload.session?.threadId ?? payload.sessionReuse?.threadId ?? payload.threadId ?? params.threadId ?? null, traceId: payload.traceId ?? params.traceId ?? null, projectId: payload.projectId ?? params.projectId ?? null, + ...agentRunSessionEvidence(payload), secretMaterialStored: false, valuesRedacted: true }; @@ -752,6 +874,11 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) return; } + const agentRunResult = result?.agentRun ? result : await loadPersistedAgentRunResult(traceId, options); + if (agentRunResult?.agentRun) { + await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore }); + } + if (parts[5] === "stream") { sendTraceSse(response, traceStore, traceId, options); return; diff --git a/protocol/schemas/common.json b/protocol/schemas/common.json index dff0a3b1..68fac14e 100644 --- a/protocol/schemas/common.json +++ b/protocol/schemas/common.json @@ -28,7 +28,6 @@ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ] }, diff --git a/scripts/cloud-api-runtime-smoke.mjs b/scripts/cloud-api-runtime-smoke.mjs index 4654431a..b4e0e333 100644 --- a/scripts/cloud-api-runtime-smoke.mjs +++ b/scripts/cloud-api-runtime-smoke.mjs @@ -115,8 +115,7 @@ async function run() { "box.capability.report", "hardware.operation.request", "hardware.invoke.shell", - "audit.event.query", - "evidence.record.query" + "audit.event.query" ]) { assert.ok(v1.body.methods.includes(method), `missing ${method}`); } @@ -131,7 +130,7 @@ async function run() { projectId, gatewaySessionId, gatewayId: "gtw_cloud-api-runtime-smoke", - serviceId: "hwlab-gateway-simu", + serviceId: "hwlab-gateway", endpoint: "http://127.0.0.1:7101" }); assert.equal(gateway.registered, true); @@ -178,10 +177,7 @@ async function run() { assert.equal(invoke.operation.status, "accepted"); assert.equal(invoke.dispatch.shellExecuted, false); assert.equal(invoke.dispatch.dispatchStatus, "not_connected"); - assert.equal(invoke.evidenceRecord.kind, "trace"); - assert.equal(invoke.evidenceRecord.serviceId, "hwlab-cloud-api"); - assert.match(invoke.evidenceRecord.sha256, /^[a-f0-9]{64}$/); - logOk("hardware invoke shell degraded dispatch with evidence"); + logOk("hardware invoke shell degraded dispatch"); const audit = await rpc(baseUrl, "req_cloud_api_runtime_audit_query", "audit.event.query", { projectId @@ -191,14 +187,7 @@ async function run() { assert.ok(audit.events.every((event) => event.environment === ENVIRONMENT_DEV)); assert.ok(audit.events.some((event) => event.action === "hardware.operation.request")); assert.ok(audit.events.some((event) => event.action === "hardware.invoke.shell")); - - const evidence = await rpc(baseUrl, "req_cloud_api_runtime_evidence_query", "evidence.record.query", { - projectId - }); - assert.equal(evidence.count, 1); - assert.equal(evidence.records[0].operationId, invoke.operationId); - assert.equal(evidence.records[0].metadata.dispatchStatus, "not_connected"); - logOk("audit/evidence query shape"); + logOk("audit query shape"); process.stdout.write("[cloud-api-runtime-smoke] passed\n"); } finally { diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index e6159b27..c066354f 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -870,6 +870,12 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_USERNAME", "admin"); upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME", "HWLAB v0.2 Admin"); upsertEnvEntry(container.env, deployEnvEntry("HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH", "secretRef:hwlab-v02-bootstrap-admin/password-hash", namespace, profile)); + upsertEnv(container.env, "HWLAB_CODE_AGENT_ADAPTER", "agentrun-v01"); + upsertEnv(container.env, "AGENTRUN_MGR_URL", "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080"); + upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID", "G14"); + upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE", "agentrun-v01"); + upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE", "agentrun-v01"); + upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL", "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"); } upsertEnv(container.env, "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "deepseek"); upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", deepSeekProfileModel); diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index 217ecca4..f620b53a 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -17,7 +17,7 @@ export const checkProfiles = Object.freeze({ { id: "validate-011-artifact-artifact-runtime-readiness-guard", group: "artifact", command: ["node","--check","scripts/src/artifact-runtime-readiness-guard.mjs"] }, { id: "validate-012-dev-runtime-dev-runtime-hotfix-audit", group: "dev-runtime", command: ["node","--check","scripts/dev-runtime-hotfix-audit.mjs"] }, { id: "validate-013-dev-runtime-dev-runtime-hotfix-audit", group: "dev-runtime", command: ["node","--check","scripts/src/dev-runtime-hotfix-audit.mjs"] }, - { id: "validate-014-artifact-artifact-runtime-readiness-guard-test", group: "artifact", command: ["node","--test","scripts/artifact-runtime-readiness-guard.test.mjs","scripts/src/dev-runtime-hotfix-audit.test.mjs"] } + { id: "validate-014-artifact-artifact-runtime-readiness-guard-test", group: "artifact", command: ["node","scripts/run-bun.mjs","test","scripts/artifact-runtime-readiness-guard.test.mjs","scripts/src/dev-runtime-hotfix-audit.test.mjs"] } ]), check: Object.freeze([ { id: "check-001-check-runner-check-runner", group: "check-runner", command: ["node","--check","scripts/check-runner.mjs"] },