1326 lines
52 KiB
TypeScript
1326 lines
52 KiB
TypeScript
|
|
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";
|
|
import {
|
|
firstHeaderValue,
|
|
getHeader,
|
|
parsePositiveInteger,
|
|
positiveInteger,
|
|
readBody,
|
|
safeConversationId,
|
|
safeOpaqueId,
|
|
safeSessionId,
|
|
safeTraceId,
|
|
sendJson,
|
|
truthyFlag
|
|
} from "./server-http-utils.ts";
|
|
|
|
const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120;
|
|
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
|
|
const CODE_AGENT_PROVIDER_PROFILE_IDS = Object.freeze(["deepseek", "codex-api", "minimax-m3", "runtime-default"]);
|
|
const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({
|
|
"deepseek": "DeepSeek",
|
|
"codex-api": "Codex API",
|
|
"minimax-m3": "MiniMax-M3 via AgentRun",
|
|
"runtime-default": "运行默认"
|
|
});
|
|
const DEFAULT_CODE_AGENT_CODEX_API_MODEL = "gpt-5.5";
|
|
const DEFAULT_CODE_AGENT_CODEX_API_BASE_URL = "http://127.0.0.1:49280/responses";
|
|
const DEFAULT_CODE_AGENT_DEEPSEEK_MODEL = "deepseek-chat";
|
|
const DEFAULT_CODE_AGENT_MINIMAX_M3_MODEL = "MiniMax-M3";
|
|
|
|
export async function handleCodeAgentChatHttp(request, response, options) {
|
|
const body = await readBody(request, options.bodyLimitBytes);
|
|
let params = {};
|
|
|
|
try {
|
|
params = body ? JSON.parse(body) : {};
|
|
} catch (error) {
|
|
sendJson(response, 400, {
|
|
...createCodeAgentErrorPayload({
|
|
code: "parse_error",
|
|
message: "Invalid JSON body",
|
|
reason: error.message,
|
|
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
|
model: options.env?.HWLAB_CODE_AGENT_MODEL || options.env?.OPENAI_MODEL || process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown",
|
|
layer: "api",
|
|
retryable: true
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
sendJson(response, 400, {
|
|
...createCodeAgentErrorPayload({
|
|
code: "invalid_params",
|
|
message: "Code Agent chat body must be a JSON object",
|
|
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
|
model: options.env?.HWLAB_CODE_AGENT_MODEL || options.env?.OPENAI_MODEL || process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown",
|
|
layer: "api",
|
|
retryable: true
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId) || `trc_${randomUUID()}`;
|
|
const chatParams = {
|
|
...params,
|
|
traceId,
|
|
ownerUserId: options.actor?.id ?? params.ownerUserId,
|
|
ownerRole: options.actor?.role ?? params.ownerRole
|
|
};
|
|
const workspaceClaim = await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response });
|
|
if (workspaceClaim?.blocked) return;
|
|
|
|
if (codeAgentChatShortConnectionRequested(request, params, options)) {
|
|
submitCodeAgentChatTurn({
|
|
params: chatParams,
|
|
options,
|
|
traceId
|
|
});
|
|
const traceUrl = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
|
|
sendJson(response, 202, {
|
|
accepted: true,
|
|
status: "running",
|
|
shortConnection: true,
|
|
controlSemantics: "submit-and-poll",
|
|
traceId,
|
|
workspaceId: workspaceClaim?.workspace?.id ?? null,
|
|
workspaceRevision: workspaceClaim?.workspace?.revision ?? null,
|
|
conversationId: safeConversationId(params.conversationId) || null,
|
|
sessionId: safeSessionId(params.sessionId) || null,
|
|
traceUrl,
|
|
resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
|
|
streamUrl: `${traceUrl}/stream`,
|
|
cancelUrl: "/v1/agent/chat/cancel",
|
|
polling: {
|
|
resultIntervalMs: parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_RESULT_POLL_INTERVAL_MS, 1000),
|
|
traceIntervalMs: parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_TRACE_POLL_INTERVAL_MS, 1000)
|
|
},
|
|
runnerTrace: (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(traceId)
|
|
});
|
|
return;
|
|
}
|
|
|
|
const payload = await runCodeAgentChat(chatParams, options);
|
|
await recordCodeAgentSessionOwner({ payload, params: chatParams, options, status: payload.status === "completed" ? "active" : payload.status });
|
|
const responsePayload = annotateOwner(payload, chatParams);
|
|
|
|
sendJson(response, responsePayload.status === "failed" && responsePayload.error?.code === "invalid_params" ? 400 : 200, responsePayload);
|
|
}
|
|
|
|
async function runCodeAgentChat(params, options) {
|
|
return handleCodeAgentChat(params, await codeAgentChatExecutionOptions(options, params));
|
|
}
|
|
|
|
async function codeAgentChatExecutionOptions(options = {}, params = {}) {
|
|
const env = codeAgentProviderProfileEnv(options.env ?? process.env, params);
|
|
Object.assign(env, await codeAgentDevicePodAuthEnv(options, env));
|
|
return {
|
|
callProvider: options.callCodeAgentProvider,
|
|
timeoutMs: options.codeAgentTimeoutMs,
|
|
hardTimeoutMs: options.codeAgentHardTimeoutMs,
|
|
env,
|
|
workspace: options.workspace,
|
|
skillsDirs: options.skillsDirs,
|
|
skillsDirsExact: options.skillsDirsExact,
|
|
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
|
|
sessionRegistry: options.sessionRegistry,
|
|
m3IoSkillRequestJson: options.m3IoSkillRequestJson ?? createCodeAgentM3HwlabApiRequestJson(options),
|
|
codexStdioManager: options.codexStdioManager,
|
|
traceStore: options.traceStore,
|
|
gatewayRegistry: options.gatewayRegistry
|
|
};
|
|
}
|
|
|
|
async function codeAgentDevicePodAuthEnv(options = {}, env = process.env) {
|
|
if (typeof options.accessController?.createCodeAgentDevicePodSession !== "function") return {};
|
|
const auth = await options.accessController.createCodeAgentDevicePodSession();
|
|
if (!auth?.token) return {};
|
|
const runtimeNamespace = runtimeNamespaceForEnv(env);
|
|
const runtimeLane = runtimeLaneForNamespace(runtimeNamespace) ?? runtimeLaneForEnv(env);
|
|
const inClusterApiUrl = internalCodeAgentApiUrl(env, runtimeNamespace);
|
|
const apiUrl = firstNonEmptyValue(
|
|
codeAgentAgentRunAdapterEnabled(env) ? inClusterApiUrl : null,
|
|
env.HWLAB_RUNTIME_API_URL,
|
|
env.HWLAB_CODE_AGENT_DEVICE_POD_API_URL,
|
|
sameRuntimeEndpoint(env.HWLAB_DEVICE_POD_API_URL, runtimeNamespace, runtimeLane),
|
|
sameRuntimeEndpoint(env.HWLAB_CLOUD_API_URL, runtimeNamespace, runtimeLane),
|
|
env.HWLAB_PUBLIC_ENDPOINT,
|
|
localCloudApiUrl(env)
|
|
);
|
|
return {
|
|
HWLAB_DEVICE_POD_API_URL: apiUrl,
|
|
HWLAB_CLOUD_API_URL: apiUrl,
|
|
HWLAB_RUNTIME_API_URL: apiUrl,
|
|
HWLAB_RUNTIME_WEB_URL: apiUrl,
|
|
HWLAB_RUNTIME_NAMESPACE: runtimeNamespace,
|
|
HWLAB_RUNTIME_LANE: runtimeLane,
|
|
HWLAB_RUNTIME_ENDPOINT_SOURCE: codeAgentAgentRunAdapterEnabled(env) ? "runtime-namespace" : "runtime-env",
|
|
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
|
|
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1",
|
|
HWLAB_DEVICE_POD_SESSION_TOKEN: auth.token
|
|
};
|
|
}
|
|
|
|
function internalCodeAgentApiUrl(env = process.env, namespace = runtimeNamespaceForEnv(env)) {
|
|
// Fetch/undici blocks cloud-api's 6667 as a bad port, so AgentRun runners use the internal web proxy.
|
|
const serviceName = firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_API_SERVICE_NAME, env.HWLAB_CLOUD_WEB_SERVICE_NAME, "hwlab-cloud-web");
|
|
const port = parsePositiveInteger(firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_API_PORT, env.HWLAB_CLOUD_WEB_PORT), 8080);
|
|
return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
|
|
}
|
|
|
|
function runtimeNamespaceForEnv(env = process.env) {
|
|
return firstNonEmptyValue(
|
|
env.HWLAB_RUNTIME_NAMESPACE,
|
|
env.POD_NAMESPACE,
|
|
env.HWLAB_NAMESPACE,
|
|
defaultRuntimeNamespace(env)
|
|
);
|
|
}
|
|
|
|
function runtimeLaneForEnv(env = process.env) {
|
|
const profile = String(firstNonEmptyValue(env.HWLAB_RUNTIME_LANE, env.HWLAB_GITOPS_LANE, env.HWLAB_GITOPS_PROFILE, env.HWLAB_ENVIRONMENT, env.HWLAB_BOOT_REF, "dev") ?? "dev").trim().toLowerCase();
|
|
if (profile === "prod" || profile === "production") return "prod";
|
|
if (profile === "v02" || profile === "v0.2" || profile === "0.2") return "v02";
|
|
return "dev";
|
|
}
|
|
|
|
function runtimeLaneForNamespace(namespace) {
|
|
if (namespace === "hwlab-v02") return "v02";
|
|
if (namespace === "hwlab-prod") return "prod";
|
|
if (namespace === "hwlab-dev") return "dev";
|
|
return null;
|
|
}
|
|
|
|
function sameRuntimeEndpoint(value, namespace, lane) {
|
|
const text = firstNonEmptyValue(value);
|
|
if (!text) return null;
|
|
const endpointLane = runtimeLaneForEndpoint(text);
|
|
const endpointNamespace = runtimeNamespaceForEndpoint(text);
|
|
if (namespace && endpointNamespace && endpointNamespace !== namespace) return null;
|
|
if (lane && endpointLane && endpointLane !== lane) return null;
|
|
return text;
|
|
}
|
|
|
|
function runtimeNamespaceForEndpoint(value) {
|
|
const match = String(value ?? "").match(/\.((?:hwlab-[a-z0-9-]+))\.svc\.cluster\.local(?::|\/|$)/iu);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function runtimeLaneForEndpoint(value) {
|
|
const text = String(value ?? "").toLowerCase();
|
|
if (/hwlab-v02|:19666(?:\/|$)|:19667(?:\/|$)/u.test(text)) return "v02";
|
|
if (/hwlab-prod|:18666(?:\/|$)|:18667(?:\/|$)/u.test(text)) return "prod";
|
|
if (/hwlab-dev|:17666(?:\/|$)|:17667(?:\/|$)|:16666(?:\/|$)|:16667(?:\/|$)/u.test(text)) return "dev";
|
|
return null;
|
|
}
|
|
|
|
function defaultRuntimeNamespace(env = process.env) {
|
|
const profile = runtimeLaneForEnv(env);
|
|
if (profile === "prod") return "hwlab-prod";
|
|
if (profile === "v02") return "hwlab-v02";
|
|
return "hwlab-dev";
|
|
}
|
|
|
|
function localCloudApiUrl(env = process.env) {
|
|
const port = parsePositiveInteger(env.HWLAB_CLOUD_API_PORT, 6667);
|
|
return `http://127.0.0.1:${port}`;
|
|
}
|
|
|
|
function codeAgentProviderProfileEnv(env = process.env, params = {}) {
|
|
const profile = normalizeCodeAgentProviderProfile(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE);
|
|
if (profile === "runtime-default") return env;
|
|
const overlay = { ...env };
|
|
overlay.HWLAB_CODE_AGENT_SELECTED_PROVIDER_PROFILE = profile;
|
|
overlay.HWLAB_CODE_AGENT_PROVIDER_PROFILE_LABEL = CODE_AGENT_PROVIDER_PROFILE_LABELS[profile] ?? profile;
|
|
overlay.HWLAB_CODE_AGENT_PROVIDER = "codex-stdio";
|
|
if (profile === "codex-api") {
|
|
overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_CODEX_API_MODEL, env.HWLAB_CODE_AGENT_LEGACY_CODEX_MODEL, DEFAULT_CODE_AGENT_CODEX_API_MODEL);
|
|
overlay.HWLAB_CODE_AGENT_OPENAI_BASE_URL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_CODEX_API_BASE_URL, env.HWLAB_CODE_AGENT_LEGACY_OPENAI_BASE_URL, DEFAULT_CODE_AGENT_CODEX_API_BASE_URL);
|
|
} else if (profile === "minimax-m3") {
|
|
overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_MINIMAX_M3_MODEL, DEFAULT_CODE_AGENT_MINIMAX_M3_MODEL);
|
|
} else {
|
|
overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, DEFAULT_CODE_AGENT_DEEPSEEK_MODEL);
|
|
overlay.HWLAB_CODE_AGENT_OPENAI_BASE_URL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL, defaultDeepSeekBaseUrlForEnv(env));
|
|
}
|
|
overlay.NO_PROXY = mergeNoProxyEntries(env.NO_PROXY, codeAgentClusterNoProxyEntries(env));
|
|
overlay.no_proxy = mergeNoProxyEntries(env.no_proxy ?? env.NO_PROXY, codeAgentClusterNoProxyEntries(env));
|
|
return overlay;
|
|
}
|
|
|
|
function normalizeCodeAgentProviderProfile(value) {
|
|
const text = String(value ?? DEFAULT_CODE_AGENT_PROVIDER_PROFILE).trim().toLowerCase();
|
|
return CODE_AGENT_PROVIDER_PROFILE_IDS.includes(text) ? text : DEFAULT_CODE_AGENT_PROVIDER_PROFILE;
|
|
}
|
|
|
|
function defaultDeepSeekBaseUrlForEnv(env = process.env) {
|
|
const namespace = env.HWLAB_GITOPS_PROFILE === "prod" || env.HWLAB_ENVIRONMENT === "prod" ? "hwlab-prod" : "hwlab-dev";
|
|
return `http://hwlab-deepseek-proxy.${namespace}.svc.cluster.local:4000/v1/responses`;
|
|
}
|
|
|
|
function codeAgentClusterNoProxyEntries(env = process.env) {
|
|
const namespace = env.HWLAB_GITOPS_PROFILE === "prod" || env.HWLAB_ENVIRONMENT === "prod" ? "hwlab-prod" : "hwlab-dev";
|
|
return [
|
|
`${namespace}.svc.cluster.local`,
|
|
".svc",
|
|
".cluster.local",
|
|
"127.0.0.1",
|
|
"localhost",
|
|
"::1",
|
|
"10.0.0.0/8",
|
|
"10.42.0.0/16",
|
|
"10.43.0.0/16",
|
|
"hyueapi.com",
|
|
".hyueapi.com",
|
|
"hyui.com",
|
|
".hyui.com",
|
|
"api.minimaxi.com",
|
|
".minimaxi.com"
|
|
];
|
|
}
|
|
|
|
function mergeNoProxyEntries(current, additions = []) {
|
|
return uniqueStrings([...String(current ?? "").split(","), ...additions]).join(",");
|
|
}
|
|
|
|
function firstNonEmptyValue(...values) {
|
|
for (const value of values) {
|
|
const text = String(value ?? "").trim();
|
|
if (text) return text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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 () => {
|
|
let executionOptions = options;
|
|
try {
|
|
executionOptions = { ...options, ...(await codeAgentChatExecutionOptions(options, params)) };
|
|
const payload = await submitAgentRunChatTurn({ params, options: executionOptions, traceId, traceStore, results });
|
|
if (isCodeAgentResultCanceled(results.get(traceId))) return;
|
|
const owned = annotateOwner(payload, params);
|
|
await recordCodeAgentSessionOwner({ payload: owned, params, options: executionOptions, 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);
|
|
recordCodeAgentConversationFact(payload, executionOptions);
|
|
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: executionOptions, status: "failed" });
|
|
}
|
|
};
|
|
setImmediate(() => { run(); });
|
|
return;
|
|
}
|
|
results.set(traceId, {
|
|
accepted: true,
|
|
status: "running",
|
|
traceId,
|
|
conversationId: safeConversationId(params.conversationId) || null,
|
|
sessionId: safeSessionId(params.sessionId) || null,
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
traceStore.ensure(traceId, {
|
|
runnerKind: "codex-app-server-stdio-runner",
|
|
workspace: options.workspace ?? options.env?.HWLAB_CODE_AGENT_CODEX_WORKSPACE ?? options.env?.HWLAB_CODE_AGENT_WORKSPACE ?? null,
|
|
sandbox: options.env?.HWLAB_CODE_AGENT_CODEX_SANDBOX ?? null,
|
|
sessionMode: "codex-app-server-stdio-long-lived",
|
|
implementationType: "repo-owned-codex-app-server-stdio-session"
|
|
});
|
|
traceStore.append(traceId, {
|
|
type: "request",
|
|
status: "accepted",
|
|
label: "request:accepted-short-connection",
|
|
message: "Code Agent request accepted; client must poll trace/result with short HTTP requests.",
|
|
waitingFor: "codex-stdio"
|
|
});
|
|
|
|
const run = async () => {
|
|
try {
|
|
const payload = await runCodeAgentChat(params, options);
|
|
if (isCodeAgentResultCanceled(results.get(traceId))) return;
|
|
await recordCodeAgentSessionOwner({ payload, params, options, status: payload.status === "completed" ? "active" : payload.status });
|
|
results.set(traceId, annotateOwner(payload, params));
|
|
traceStore.append(traceId, {
|
|
type: "result",
|
|
status: payload.status === "completed" ? "completed" : "failed",
|
|
label: `result:${payload.status}`,
|
|
message: payload.status === "completed"
|
|
? "Code Agent result is ready for short-connection polling."
|
|
: payload.error?.message ?? "Code Agent completed without a successful reply.",
|
|
sessionId: payload.sessionId ?? payload.session?.sessionId,
|
|
sessionStatus: payload.session?.status,
|
|
terminal: true
|
|
});
|
|
} catch (error) {
|
|
if (isCodeAgentResultCanceled(results.get(traceId))) return;
|
|
const payload = {
|
|
status: "failed",
|
|
traceId,
|
|
conversationId: safeConversationId(params.conversationId) || null,
|
|
sessionId: safeSessionId(params.sessionId) || null,
|
|
error: {
|
|
code: "code_agent_background_failed",
|
|
layer: "api",
|
|
retryable: true,
|
|
message: error?.message ?? "Code Agent background turn failed",
|
|
userMessage: "Code Agent 后台任务失败;trace/result 轮询已保留错误。"
|
|
},
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
results.set(traceId, payload);
|
|
traceStore.append(traceId, {
|
|
type: "result",
|
|
status: "failed",
|
|
label: "result:failed",
|
|
errorCode: payload.error.code,
|
|
message: payload.error.message,
|
|
terminal: true
|
|
});
|
|
}
|
|
};
|
|
setImmediate(() => {
|
|
run();
|
|
});
|
|
}
|
|
|
|
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 codeAgentAgentRunAdapterEnabled(options.env ?? process.env) ||
|
|
/(?:^|,)\s*respond-async\s*(?:,|$)/iu.test(String(header ?? "")) ||
|
|
truthyFlag(explicit) ||
|
|
params?.shortConnection === true ||
|
|
truthyFlag(envValue);
|
|
}
|
|
|
|
export async function handleCodeAgentChatResultHttp(request, response, url, options) {
|
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
const traceId = decodeURIComponent(parts[4] ?? "");
|
|
if (!safeTraceId(traceId)) {
|
|
sendJson(response, 400, {
|
|
error: {
|
|
code: "invalid_trace_id",
|
|
message: "traceId must start with trc_ and contain only safe identifier characters"
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const results = options.codeAgentChatResults;
|
|
const result = results?.get(traceId) ?? null;
|
|
if (result && !canAccessOwnedResult(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 ((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") {
|
|
recordCodeAgentConversationFact(synced.result, options);
|
|
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;
|
|
}
|
|
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
|
const runnerTrace = traceStore.snapshot(traceId);
|
|
if (result || runnerTrace.status !== "missing") {
|
|
sendJson(response, 202, {
|
|
accepted: true,
|
|
status: "running",
|
|
shortConnection: true,
|
|
traceId,
|
|
runnerTrace: compactRunnerTraceForResult(runnerTrace, resultTraceEventLimit(options)),
|
|
waitingFor: runnerTrace.waitingFor ?? "codex-stdio"
|
|
});
|
|
return;
|
|
}
|
|
sendJson(response, 404, {
|
|
error: {
|
|
code: "code_agent_result_not_found",
|
|
message: `No Code Agent result is registered for ${traceId}`
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function handleCodeAgentInspectHttp(request, response, url, options) {
|
|
const query = {
|
|
conversationId: safeConversationId(url.searchParams.get("conversationId")),
|
|
sessionId: safeSessionId(url.searchParams.get("sessionId")),
|
|
threadId: safeOpaqueId(url.searchParams.get("threadId")),
|
|
traceId: safeTraceId(url.searchParams.get("traceId"))
|
|
};
|
|
const sessionRegistry = options.sessionRegistry;
|
|
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
|
const manager = options.codexStdioManager;
|
|
const managerDescription = manager && typeof manager.describe === "function" ? manager.describe() : null;
|
|
const managerRecentSessions = Array.isArray(managerDescription?.recentSessions) ? managerDescription.recentSessions : [];
|
|
const directManagerSession = query.sessionId && manager && typeof manager.get === "function"
|
|
? manager.get(query.sessionId, { conversationId: query.conversationId })
|
|
: null;
|
|
const matchedManagerSession = directManagerSession ?? managerRecentSessions.find((session) => sessionMatchesInspectQuery(session, query)) ?? null;
|
|
const registryInspect = sessionRegistry && typeof sessionRegistry.inspect === "function"
|
|
? sessionRegistry.inspect(query)
|
|
: { ok: false, status: "unavailable", traceIds: [], session: null, conversationFacts: null };
|
|
const resultEvidence = await codeAgentInspectResultEvidence(query.traceId, options);
|
|
const session = matchedManagerSession ?? registryInspect.session ?? resultEvidence.session ?? null;
|
|
const conversationFacts = registryInspect.conversationFacts ?? resultEvidence.conversationFacts ?? null;
|
|
const requestedRunnerTrace = query.traceId ? traceStore.snapshot(query.traceId) : null;
|
|
const requestedTraceFound = Boolean(requestedRunnerTrace && requestedRunnerTrace.status !== "missing");
|
|
const traceIds = uniqueStrings([
|
|
requestedTraceFound ? query.traceId : null,
|
|
session?.currentTraceId,
|
|
session?.lastTraceId,
|
|
conversationFacts?.latestTraceId,
|
|
resultEvidence.latestTraceId,
|
|
...(Array.isArray(conversationFacts?.traceIds) ? conversationFacts.traceIds : []),
|
|
...(Array.isArray(registryInspect.traceIds) ? registryInspect.traceIds : [])
|
|
]);
|
|
const latestTraceId = traceIds[0] ?? null;
|
|
const runnerTrace = latestTraceId
|
|
? latestTraceId === query.traceId && requestedRunnerTrace
|
|
? requestedRunnerTrace
|
|
: traceStore.snapshot(latestTraceId)
|
|
: null;
|
|
const found = Boolean(registryInspect.ok || session || latestTraceId);
|
|
sendJson(response, found ? 200 : 404, {
|
|
ok: found,
|
|
action: "code-agent.chat.inspect",
|
|
status: found ? "found" : "not_found",
|
|
query,
|
|
session,
|
|
conversationFacts,
|
|
traceIds,
|
|
latestTraceId,
|
|
traceUrl: latestTraceId ? `/v1/agent/chat/trace/${encodeURIComponent(latestTraceId)}` : null,
|
|
resultUrl: latestTraceId ? `/v1/agent/chat/result/${encodeURIComponent(latestTraceId)}` : null,
|
|
runnerTrace,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
});
|
|
}
|
|
|
|
async function codeAgentInspectResultEvidence(traceId, options = {}) {
|
|
if (!safeTraceId(traceId)) return { session: null, conversationFacts: null, latestTraceId: null };
|
|
const cached = options.codeAgentChatResults?.get?.(traceId) ?? null;
|
|
const persisted = cached ? null : await loadPersistedAgentRunResult(traceId, options);
|
|
const result = cached ?? persisted ?? null;
|
|
if (!result || typeof result !== "object") return { session: null, conversationFacts: null, latestTraceId: null };
|
|
const agentRun = result.agentRun && typeof result.agentRun === "object" ? result.agentRun : null;
|
|
const resultSession = result.session && typeof result.session === "object" ? result.session : null;
|
|
const sessionReuse = result.sessionReuse && typeof result.sessionReuse === "object" ? result.sessionReuse : null;
|
|
const providerTrace = result.providerTrace && typeof result.providerTrace === "object" ? result.providerTrace : null;
|
|
const sessionId = safeSessionId(result.sessionId ?? resultSession?.sessionId ?? sessionReuse?.sessionId ?? agentRun?.sessionId) || null;
|
|
const conversationId = safeConversationId(result.conversationId ?? resultSession?.conversationId ?? sessionReuse?.conversationId ?? agentRun?.conversationId) || null;
|
|
const threadId = safeOpaqueId(result.threadId ?? resultSession?.threadId ?? sessionReuse?.threadId ?? providerTrace?.threadId ?? agentRun?.threadId) || null;
|
|
const status = textValue(resultSession?.status ?? result.status ?? agentRun?.status) || null;
|
|
const updatedAt = textValue(result.updatedAt ?? agentRun?.updatedAt ?? resultSession?.updatedAt) || null;
|
|
const session = sessionId || conversationId || threadId ? {
|
|
sessionId,
|
|
conversationId,
|
|
threadId,
|
|
status,
|
|
lastTraceId: traceId,
|
|
source: "code-agent-result",
|
|
agentRun: agentRun ? agentRunSessionEvidence(result).agentRun : null,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
} : null;
|
|
const conversationFacts = conversationId ? {
|
|
conversationId,
|
|
sessionId,
|
|
threadId,
|
|
latestTraceId: traceId,
|
|
traceIds: [traceId],
|
|
turnCount: 1,
|
|
source: "code-agent-result",
|
|
latestStatus: status,
|
|
updatedAt,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
} : null;
|
|
return { session, conversationFacts, latestTraceId: traceId };
|
|
}
|
|
|
|
function sessionMatchesInspectQuery(session, query) {
|
|
if (!session || typeof session !== "object") return false;
|
|
if (query.sessionId && session.sessionId === query.sessionId) return true;
|
|
if (query.threadId && session.threadId === query.threadId) return true;
|
|
if (query.conversationId && session.conversationId === query.conversationId) return true;
|
|
if (query.traceId && (session.currentTraceId === query.traceId || session.lastTraceId === query.traceId)) return true;
|
|
return false;
|
|
}
|
|
|
|
export async function handleCodeAgentCancelHttp(request, response, options) {
|
|
const body = await readBody(request, options.bodyLimitBytes);
|
|
let params = {};
|
|
|
|
try {
|
|
params = body ? JSON.parse(body) : {};
|
|
} catch (error) {
|
|
sendJson(response, 400, {
|
|
...createCodeAgentErrorPayload({
|
|
code: "parse_error",
|
|
message: "Invalid JSON body",
|
|
reason: error.message,
|
|
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
|
layer: "api",
|
|
retryable: true
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
sendJson(response, 400, {
|
|
...createCodeAgentErrorPayload({
|
|
code: "invalid_params",
|
|
message: "Code Agent cancel body must be a JSON object",
|
|
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
|
layer: "api",
|
|
retryable: true
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
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;
|
|
|
|
if (!traceId) {
|
|
sendJson(response, 400, cancelBlockedPayload({
|
|
code: "cancel_trace_missing",
|
|
message: "traceId is required to cancel the current Code Agent request.",
|
|
traceId: "trc_unassigned",
|
|
conversationId,
|
|
sessionId
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (!manager || typeof manager.get !== "function" || typeof manager.cancel !== "function") {
|
|
traceStore.append(traceId, {
|
|
type: "cancel",
|
|
status: "unsupported",
|
|
label: "cancel:unsupported",
|
|
errorCode: "cancel_unsupported",
|
|
message: "Codex stdio cancel/interrupt backend is not available on this runtime.",
|
|
waitingFor: "session-control"
|
|
});
|
|
const runnerTrace = traceStore.snapshot(traceId);
|
|
sendJson(response, 501, cancelBlockedPayload({
|
|
code: "cancel_unsupported",
|
|
message: "当前后端不支持 Code Agent interrupt/cancel;本次按 unsupported/degraded 返回,不会静默假装已中断。",
|
|
traceId,
|
|
conversationId,
|
|
sessionId,
|
|
runnerTrace,
|
|
status: "degraded",
|
|
unsupported: true,
|
|
degraded: true
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (!sessionId) {
|
|
traceStore.append(traceId, {
|
|
type: "cancel",
|
|
status: "blocked",
|
|
label: "cancel:not_cancelable",
|
|
errorCode: "cancel_session_missing",
|
|
message: "Cancel request did not include a bound Codex stdio sessionId.",
|
|
waitingFor: "session-binding"
|
|
});
|
|
sendJson(response, 409, cancelBlockedPayload({
|
|
code: "cancel_session_missing",
|
|
message: "当前请求尚未暴露可取消的 Codex stdio sessionId;页面不能只隐藏 UI,已保留输入和 trace。",
|
|
traceId,
|
|
conversationId,
|
|
sessionId: null,
|
|
runnerTrace: traceStore.snapshot(traceId)
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const currentSession = manager.get(sessionId, { conversationId }) ?? null;
|
|
if (!currentSession || !["busy", "creating"].includes(currentSession.status)) {
|
|
traceStore.append(traceId, {
|
|
type: "cancel",
|
|
status: "blocked",
|
|
label: "cancel:not_in_flight",
|
|
errorCode: "cancel_not_in_flight",
|
|
message: `Session ${sessionId} is not an in-flight Codex stdio request.`,
|
|
sessionId,
|
|
sessionStatus: currentSession?.status ?? "missing"
|
|
});
|
|
sendJson(response, 409, cancelBlockedPayload({
|
|
code: currentSession ? "cancel_not_in_flight" : "cancel_session_not_found",
|
|
message: currentSession
|
|
? `当前 session 状态为 ${currentSession.status},没有可取消的 in-flight Codex stdio 请求。`
|
|
: `没有找到 sessionId=${sessionId} 的 Codex stdio session。`,
|
|
traceId,
|
|
conversationId,
|
|
sessionId,
|
|
session: currentSession,
|
|
runnerTrace: traceStore.snapshot(traceId)
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const canceledSession = manager.cancel(sessionId, {
|
|
traceId,
|
|
conversationId,
|
|
reason: "user_cancel"
|
|
});
|
|
traceStore.append(traceId, {
|
|
type: "cancel",
|
|
status: "canceled",
|
|
label: "cancel:canceled",
|
|
message: "User canceled the current Codex stdio request.",
|
|
sessionId,
|
|
sessionStatus: canceledSession?.status ?? "canceled",
|
|
sessionLifecycleStatus: codeAgentSessionLifecycleSummary({ session: canceledSession, status: "canceled" }).status,
|
|
waitingFor: "user-retry",
|
|
terminal: true
|
|
});
|
|
const runnerTraceSnapshot = traceStore.snapshot(traceId);
|
|
const sessionSummary = codeAgentSessionLifecycleSummary({
|
|
session: canceledSession,
|
|
runnerTrace: runnerTraceSnapshot,
|
|
status: "canceled"
|
|
});
|
|
const runnerTrace = {
|
|
...runnerTraceSnapshot,
|
|
sessionLifecycleStatus: sessionSummary.status
|
|
};
|
|
const payload = {
|
|
accepted: true,
|
|
canceled: true,
|
|
status: "canceled",
|
|
conversationId: conversationId ?? canceledSession?.conversationId ?? null,
|
|
sessionId,
|
|
traceId,
|
|
session: canceledSession,
|
|
sessionLifecycleStatus: sessionSummary.status,
|
|
sessionLifecycle: sessionSummary,
|
|
sessionSummary,
|
|
runnerTrace,
|
|
lastTraceEvent: runnerTrace.lastEvent,
|
|
retryable: true,
|
|
error: {
|
|
code: "codex_stdio_canceled",
|
|
layer: "session",
|
|
category: "canceled",
|
|
retryable: true,
|
|
message: "user canceled current Code Agent request",
|
|
userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
|
|
traceId,
|
|
route: "/v1/agent/chat/cancel",
|
|
toolName: "codex-stdio.cancel"
|
|
},
|
|
userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
await recordCodeAgentSessionOwner({ payload, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "canceled" });
|
|
recordCodeAgentConversationFact(payload, options);
|
|
options.codeAgentChatResults?.set(traceId, annotateOwner(payload, { ownerUserId: options.actor?.id, ownerRole: options.actor?.role }));
|
|
sendJson(response, 200, payload);
|
|
}
|
|
|
|
function recordCodeAgentConversationFact(payload = {}, options = {}) {
|
|
const registry = options.sessionRegistry;
|
|
const conversationId = safeConversationId(payload.conversationId);
|
|
if (!conversationId || typeof registry?.recordFact !== "function") return null;
|
|
try {
|
|
return registry.recordFact(conversationId, {
|
|
kind: payload.status === "completed" ? "runner_turn" : "runner_turn_terminal",
|
|
conversationId,
|
|
sessionId: payload.sessionId,
|
|
traceId: payload.traceId,
|
|
provider: payload.provider,
|
|
backend: payload.backend,
|
|
workspace: payload.workspace,
|
|
sandbox: payload.sandbox,
|
|
session: payload.session,
|
|
sessionMode: payload.sessionMode,
|
|
sessionReuse: payload.sessionReuse,
|
|
implementationType: payload.implementationType,
|
|
runner: payload.runner,
|
|
runnerTrace: payload.runnerTrace,
|
|
partialContext: payload.partialContext ?? payload.runnerTrace?.partialContext,
|
|
capabilityLevel: payload.capabilityLevel,
|
|
toolCalls: payload.toolCalls,
|
|
skills: payload.skills
|
|
}, { now: options.now });
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options = {}, status = "active" } = {}) {
|
|
const ownerUserId = options.actor?.id ?? params.ownerUserId;
|
|
if (!ownerUserId || !options.accessController?.recordAgentSessionOwner) return null;
|
|
const traceId = safeTraceId(payload.traceId ?? params.traceId);
|
|
const session = payload.session && typeof payload.session === "object" ? payload.session : null;
|
|
const sessionReuse = payload.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null;
|
|
const sessionId = safeSessionId(payload.sessionId ?? session?.sessionId ?? sessionReuse?.sessionId ?? params.sessionId) || null;
|
|
const conversationId = safeConversationId(payload.conversationId ?? session?.conversationId ?? sessionReuse?.conversationId ?? params.conversationId) || null;
|
|
const threadId = safeOpaqueId(session?.threadId ?? sessionReuse?.threadId ?? payload.threadId ?? params.threadId) || null;
|
|
try {
|
|
return await options.accessController.recordAgentSessionOwner({
|
|
ownerUserId,
|
|
ownerRole: options.actor?.role ?? params.ownerRole ?? null,
|
|
sessionId,
|
|
projectId: params.projectId ?? payload.projectId ?? null,
|
|
conversationId,
|
|
threadId,
|
|
traceId,
|
|
status,
|
|
session: codeAgentSessionOwnerEvidence(payload, params)
|
|
});
|
|
} catch (error) {
|
|
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
|
if (traceId) {
|
|
traceStore.append(traceId, {
|
|
type: "session-owner",
|
|
status: "degraded",
|
|
label: "session-owner:persist-failed",
|
|
errorCode: "agent_session_owner_persist_failed",
|
|
message: error?.message ?? "Code Agent session owner binding persistence failed.",
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, response } = {}) {
|
|
const workspaceId = safeWorkspaceId(params.workspaceId);
|
|
if (!workspaceId || !options.accessController?.store || !options.actor) return null;
|
|
const store = options.accessController.store;
|
|
const workspace = await store.getWorkspaceForUser?.({ workspaceId, ownerUserId: options.actor.id, actorRole: options.actor.role });
|
|
if (!workspace) {
|
|
sendJson(response, 404, {
|
|
ok: false,
|
|
status: "failed",
|
|
error: { code: "workbench_workspace_not_found", message: "Workbench workspace is not visible to the current actor" },
|
|
workspaceId
|
|
});
|
|
return { blocked: true };
|
|
}
|
|
const expectedRevision = Number.parseInt(String(params.expectedWorkspaceRevision ?? params.workspaceRevision ?? ""), 10);
|
|
if (Number.isInteger(expectedRevision) && expectedRevision > 0 && workspace.revision !== expectedRevision) {
|
|
sendJson(response, 409, {
|
|
ok: false,
|
|
status: "revision_conflict",
|
|
error: { code: "workspace_revision_conflict", message: "Workbench workspace revision changed; restore workspace before submitting a new turn." },
|
|
workspaceId,
|
|
expectedRevision,
|
|
workspaceRevision: workspace.revision
|
|
});
|
|
return { blocked: true };
|
|
}
|
|
const activeTraceId = safeTraceId(workspace.activeTraceId);
|
|
const activeResult = activeTraceId ? await resolveWorkbenchActiveTraceResult(activeTraceId, options) : null;
|
|
const selectedConversationId = safeConversationId(workspace.selectedConversationId);
|
|
const shouldInspectSelectedConversation = Boolean(
|
|
activeTraceId && !activeResult && selectedConversationId && options.accessController?.visibleConversationForActor
|
|
);
|
|
const selectedConversation = shouldInspectSelectedConversation
|
|
? await options.accessController.visibleConversationForActor(options.actor, selectedConversationId, workspace.projectId)
|
|
: null;
|
|
const activeTraceBlocksTurn = Boolean(
|
|
activeTraceId && (activeResult?.status === "running" || (!activeResult && isActiveWorkbenchConversation(selectedConversation)))
|
|
);
|
|
if (activeTraceBlocksTurn) {
|
|
sendJson(response, 409, {
|
|
ok: false,
|
|
status: "workspace_agent_busy",
|
|
error: { code: "workspace_agent_busy", message: "Workbench workspace already has an active Code Agent turn; follow the active trace instead of opening a new conversation." },
|
|
workspaceId,
|
|
workspaceRevision: workspace.revision,
|
|
activeTraceId,
|
|
resultUrl: `/v1/agent/chat/result/${encodeURIComponent(activeTraceId)}`,
|
|
traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(activeTraceId)}`
|
|
});
|
|
return { blocked: true };
|
|
}
|
|
const updated = await store.updateWorkspace?.({
|
|
workspaceId,
|
|
ownerUserId: options.actor.id,
|
|
actorRole: options.actor.role,
|
|
selectedConversationId: safeConversationId(params.conversationId) || workspace.selectedConversationId,
|
|
selectedAgentSessionId: safeSessionId(params.sessionId) || workspace.selectedAgentSessionId,
|
|
activeTraceId: traceId,
|
|
providerProfile: textValue(params.providerProfile) || workspace.providerProfile,
|
|
patch: {
|
|
selectedConversationId: safeConversationId(params.conversationId) || workspace.selectedConversationId,
|
|
selectedAgentSessionId: safeSessionId(params.sessionId) || workspace.selectedAgentSessionId,
|
|
activeTraceId: traceId,
|
|
providerProfile: textValue(params.providerProfile) || workspace.providerProfile,
|
|
sessionStatus: "running",
|
|
updatedAt: new Date().toISOString(),
|
|
source: "code-agent-submit",
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
},
|
|
updatedBySessionId: options.authSession?.id ?? null,
|
|
updatedByClient: textValue(params.updatedByClient ?? params.client) || "code-agent-chat",
|
|
now: new Date().toISOString()
|
|
});
|
|
return { workspace: updated ?? workspace };
|
|
}
|
|
|
|
function isActiveWorkbenchConversation(conversation = null) {
|
|
if (!conversation) return false;
|
|
const values = [conversation.status, conversation.session?.status, conversation.snapshot?.sessionStatus];
|
|
for (const message of Array.isArray(conversation.messages) ? conversation.messages.slice(-5) : []) {
|
|
values.push(message?.status, message?.runnerTrace?.sessionStatus, message?.sessionStatus);
|
|
}
|
|
return values.some((value) => ["running", "busy", "pending", "active", "queued"].includes(textValue(value).toLowerCase()));
|
|
}
|
|
|
|
async function resolveWorkbenchActiveTraceResult(traceId, options = {}) {
|
|
if (!safeTraceId(traceId)) return null;
|
|
const cached = options.codeAgentChatResults?.get?.(traceId) ?? null;
|
|
if (cached?.agentRun?.runId && cached.status === "running") {
|
|
try {
|
|
const synced = await syncAgentRunChatResult({ traceId, currentResult: cached, options, traceStore: options.traceStore ?? defaultCodeAgentTraceStore });
|
|
return synced.result ?? cached;
|
|
} catch {
|
|
return cached;
|
|
}
|
|
}
|
|
if (cached) return cached;
|
|
if (!codeAgentAgentRunAdapterEnabled(options.env ?? process.env)) return null;
|
|
try {
|
|
const persisted = await loadPersistedAgentRunResult(traceId, options);
|
|
if (!persisted?.agentRun?.runId) return persisted;
|
|
const synced = await syncAgentRunChatResult({ traceId, currentResult: persisted, options, traceStore: options.traceStore ?? defaultCodeAgentTraceStore });
|
|
return synced.result ?? persisted;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function safeWorkspaceId(value) {
|
|
const text = textValue(value);
|
|
return /^wsp_[A-Za-z0-9_.:-]+$/u.test(text) ? text : "";
|
|
}
|
|
|
|
function textValue(value) {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function codeAgentSessionOwnerEvidence(payload = {}, params = {}) {
|
|
return {
|
|
provider: payload.provider ?? null,
|
|
model: payload.model ?? null,
|
|
backend: payload.backend ?? null,
|
|
status: payload.status ?? null,
|
|
sessionMode: payload.sessionMode ?? payload.session?.sessionMode ?? null,
|
|
sessionLifecycleStatus: payload.sessionLifecycleStatus ?? null,
|
|
runnerKind: payload.runner?.kind ?? payload.runnerTrace?.runnerKind ?? null,
|
|
conversationId: payload.conversationId ?? params.conversationId ?? null,
|
|
sessionId: payload.sessionId ?? payload.session?.sessionId ?? payload.sessionReuse?.sessionId ?? params.sessionId ?? null,
|
|
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
|
|
};
|
|
}
|
|
|
|
function annotateOwner(payload, params = {}) {
|
|
if (!params.ownerUserId) return payload;
|
|
return {
|
|
...payload,
|
|
ownerUserId: params.ownerUserId,
|
|
ownerRole: params.ownerRole ?? null
|
|
};
|
|
}
|
|
|
|
function canAccessOwnedResult(result, actor) {
|
|
if (!result?.ownerUserId || !actor) return true;
|
|
return actor.role === "admin" || actor.id === result.ownerUserId;
|
|
}
|
|
|
|
function isCodeAgentResultCanceled(result) {
|
|
return result?.status === "canceled" || result?.canceled === true || result?.error?.code === "codex_stdio_canceled";
|
|
}
|
|
|
|
function cancelBlockedPayload({
|
|
code,
|
|
message,
|
|
traceId,
|
|
conversationId = null,
|
|
sessionId = null,
|
|
session = null,
|
|
runnerTrace = null,
|
|
status = "failed",
|
|
unsupported = false,
|
|
degraded = false
|
|
}) {
|
|
const sessionSummary = codeAgentSessionLifecycleSummary({
|
|
session,
|
|
runnerTrace,
|
|
status,
|
|
unsupported,
|
|
degraded,
|
|
error: { code, userMessage: message, message }
|
|
});
|
|
return {
|
|
accepted: false,
|
|
canceled: false,
|
|
status,
|
|
conversationId,
|
|
sessionId,
|
|
traceId,
|
|
session,
|
|
sessionLifecycleStatus: sessionSummary.status,
|
|
sessionLifecycle: sessionSummary,
|
|
sessionSummary,
|
|
unsupported,
|
|
degraded,
|
|
runnerTrace,
|
|
lastTraceEvent: runnerTrace?.lastEvent ?? null,
|
|
error: {
|
|
code,
|
|
layer: "session",
|
|
category: "cancel_blocked",
|
|
retryable: true,
|
|
userMessage: message,
|
|
message,
|
|
traceId,
|
|
route: "/v1/agent/chat/cancel",
|
|
toolName: "codex-stdio.cancel"
|
|
},
|
|
blocker: {
|
|
code,
|
|
layer: "session",
|
|
category: "cancel_blocked",
|
|
retryable: true,
|
|
summary: message,
|
|
userMessage: message,
|
|
traceId,
|
|
route: "/v1/agent/chat/cancel",
|
|
toolName: "codex-stdio.cancel"
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function handleCodeAgentTraceHttp(request, response, url, options) {
|
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
const traceId = decodeURIComponent(parts[4] ?? "");
|
|
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
|
if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(traceId)) {
|
|
sendJson(response, 400, {
|
|
error: {
|
|
code: "invalid_trace_id",
|
|
message: "traceId must start with trc_ and contain only safe identifier characters"
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const result = options.codeAgentChatResults?.get(traceId) ?? null;
|
|
if (result && !canAccessOwnedResult(result, options.actor)) {
|
|
sendJson(response, 403, {
|
|
error: {
|
|
code: "agent_session_owner_required",
|
|
message: "Only the session owner or admin can read this Code Agent trace"
|
|
}
|
|
});
|
|
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;
|
|
}
|
|
|
|
sendJson(response, 200, traceStore.snapshot(traceId));
|
|
}
|
|
|
|
function sendTraceSse(response, traceStore, traceId, options = {}) {
|
|
response.writeHead(200, {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-store",
|
|
connection: "keep-alive",
|
|
"x-accel-buffering": "no"
|
|
});
|
|
const writeEvent = (eventName, payload) => {
|
|
response.write(`event: ${eventName}\n`);
|
|
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
};
|
|
writeEvent("snapshot", traceStore.snapshot(traceId));
|
|
const unsubscribe = traceStore.subscribe(traceId, (event, snapshot) => {
|
|
writeEvent("runnerTrace", { event, snapshot });
|
|
});
|
|
const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_TRACE_HEARTBEAT_MS, 15000);
|
|
const heartbeat = setInterval(() => {
|
|
writeEvent("heartbeat", traceStore.snapshot(traceId));
|
|
}, heartbeatMs);
|
|
response.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
unsubscribe();
|
|
});
|
|
}
|
|
|
|
export function createCodeAgentChatResultStore({ maxResults = 256 } = {}) {
|
|
const limit = positiveInteger(maxResults, 256);
|
|
const results = new Map();
|
|
function set(traceId, payload) {
|
|
const id = safeTraceId(traceId);
|
|
if (!id) return;
|
|
results.set(id, {
|
|
...payload,
|
|
traceId: id,
|
|
cachedAt: new Date().toISOString()
|
|
});
|
|
while (results.size > limit) {
|
|
const oldest = results.keys().next().value;
|
|
if (!oldest) break;
|
|
results.delete(oldest);
|
|
}
|
|
}
|
|
return {
|
|
set,
|
|
get: (traceId) => results.get(safeTraceId(traceId)) ?? null,
|
|
clear: () => results.clear()
|
|
};
|
|
}
|
|
|
|
function compactCodeAgentChatResultPayload(payload, options = {}) {
|
|
if (!payload || typeof payload !== "object") return payload;
|
|
const limit = resultTraceEventLimit(options);
|
|
return {
|
|
...payload,
|
|
...(payload.runnerTrace && typeof payload.runnerTrace === "object"
|
|
? { runnerTrace: compactRunnerTraceForResult(payload.runnerTrace, limit) }
|
|
: {})
|
|
};
|
|
}
|
|
|
|
function compactRunnerTraceForResult(runnerTrace, limit = DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT) {
|
|
if (!runnerTrace || typeof runnerTrace !== "object") return runnerTrace;
|
|
const events = Array.isArray(runnerTrace.events) ? runnerTrace.events : [];
|
|
const total = Number.isInteger(runnerTrace.eventCount) ? runnerTrace.eventCount : events.length;
|
|
const effectiveLimit = Math.max(8, positiveInteger(limit, DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT));
|
|
if (events.length <= effectiveLimit) {
|
|
return {
|
|
...runnerTrace,
|
|
eventCount: total,
|
|
eventsCompacted: false
|
|
};
|
|
}
|
|
const headCount = Math.min(24, Math.max(1, Math.floor(effectiveLimit / 4)));
|
|
const tailCount = Math.max(1, effectiveLimit - headCount - 1);
|
|
const omitted = Math.max(0, events.length - headCount - tailCount);
|
|
const head = events.slice(0, headCount);
|
|
const tail = events.slice(events.length - tailCount);
|
|
const marker = {
|
|
traceId: runnerTrace.traceId ?? head[0]?.traceId ?? tail[0]?.traceId ?? null,
|
|
type: "trace",
|
|
stage: "trace",
|
|
status: "truncated",
|
|
label: "trace:compacted",
|
|
createdAt: tail[0]?.createdAt ?? runnerTrace.updatedAt ?? new Date().toISOString(),
|
|
message: `Result polling response compacted ${omitted} trace events; fetch /v1/agent/chat/trace/${encodeURIComponent(runnerTrace.traceId ?? "")} for the full trace.`,
|
|
omittedEventCount: omitted,
|
|
retainedEventCount: head.length + tail.length,
|
|
totalEventCount: total,
|
|
valuesPrinted: false
|
|
};
|
|
const compactEvents = [...head, marker, ...tail];
|
|
return {
|
|
...runnerTrace,
|
|
eventCount: total,
|
|
events: compactEvents,
|
|
eventLabels: compactEvents.map(traceEventLabel).filter(Boolean),
|
|
eventsCompacted: true,
|
|
eventWindow: {
|
|
mode: "head-tail",
|
|
retained: compactEvents.length,
|
|
omitted,
|
|
total
|
|
}
|
|
};
|
|
}
|
|
|
|
function traceEventLabel(event) {
|
|
return typeof event === "string" ? event : event?.label;
|
|
}
|
|
|
|
function resultTraceEventLimit(options = {}) {
|
|
return positiveInteger(
|
|
options.env?.HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT,
|
|
DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT
|
|
);
|
|
}
|
|
|
|
function createCodeAgentM3HwlabApiRequestJson(options = {}) {
|
|
return async (targetUrl, request = {}) => {
|
|
const url = new URL(targetUrl);
|
|
const route = url.pathname;
|
|
if (route === M3_IO_CONTROL_ROUTE && request.method === "POST") {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: await handleM3IoControl(request.body ?? {}, {
|
|
runtimeStore: options.runtimeStore,
|
|
now: options.now,
|
|
env: options.env,
|
|
requestJson: options.m3IoRequestJson
|
|
})
|
|
};
|
|
}
|
|
if (route === M3_STATUS_ROUTE && request.method === "GET") {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: await describeM3StatusLive(options)
|
|
};
|
|
}
|
|
return {
|
|
ok: false,
|
|
status: 404,
|
|
body: {
|
|
error: {
|
|
code: "not_found",
|
|
message: `Unsupported Code Agent M3 HWLAB API route ${route}`
|
|
}
|
|
},
|
|
error: `Unsupported Code Agent M3 HWLAB API route ${route}`
|
|
};
|
|
};
|
|
}
|
|
|
|
|
|
|
|
function uniqueStrings(values) {
|
|
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
|
|
}
|