Files
pikasTech-HWLAB/internal/cloud/server-code-agent-trace-http.ts
T

521 lines
19 KiB
TypeScript

// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010403 API契约 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010205 HWLAB接入 draft-2026-06-25-p0-session-warm-runner-contract; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Code Agent HTTP steer and trace resources.
import { createHash, 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,
steerAgentRunChatTurn,
submitAgentRunChatTurn
} 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 { messageAuthorityTextValue } from "./code-agent-agentrun-prompt.ts";
import { createWorkbenchReadModel } from "./workbench-read-model.ts";
import { codeAgentOtelTraceFields, emitCodeAgentOtelSpan } from "./otel-trace.ts";
import {
firstHeaderValue,
getHeader,
parsePositiveInteger,
positiveInteger,
readBody,
safeConversationId,
safeOpaqueId,
safeSessionId,
safeTraceId,
sendJson,
truthyFlag
} from "./server-http-utils.ts";
import {
normalizeCodeAgentProviderProfile,
firstNonEmptyValue,
recordCodeAgentSessionInputFact,
stableCodeAgentInputId,
codeAgentInputDelivery,
codeAgentInputStatus,
codeAgentResultTimingFields,
firstTimestampIso,
firstLatestTimestampIso,
codeAgentChatShortConnectionRequested,
preflightCodeAgentBilling,
recordCodeAgentBillingUsage,
finalizeCodeAgentBillingUsage,
releaseCodeAgentBillingReservation,
withCodeAgentBillingReservation,
codeAgentBillingEnabled,
codeAgentBillingMetadata,
sanitizeBillingReservation,
sanitizeBillingRecord,
sanitizeBillingRelease,
codeAgentUsedTokens,
normalizeTurnStatus,
recordCodeAgentConversationFact,
recordCodeAgentSessionOwner,
codeAgentOwnerStatusForResult,
textValue,
timestampIsoOrNow,
timestampIsoOrNull,
codeAgentSessionOwnerEvidence,
codeAgentContinuationEvidence,
codeAgentFreshContinuationFailure,
normalizeCodeAgentFailureKind,
codeAgentFailureRequiresFreshContinuation,
codeAgentProviderProfileEvidence,
codeAgentTraceResultEvidence,
codeAgentPromptMetadata,
codeAgentPromptMetadataFromText,
codeAgentPromptMetadataFromParts,
codeAgentPromptFields,
codeAgentPromptId,
clippedPromptText,
sha256Text,
compactCodeAgentObject,
codeAgentBillingReservationEvidence,
codeAgentConversationMessagesEvidence,
boundedConversationMessageText,
conversationText,
conversationTextRaw,
codeAgentMessageTraceSuffix,
codeAgentTurnLifecycleFields,
codeAgentLifecycleMessageId,
safeTurnId,
safeMessageId,
codeAgentConversationAgentMessageStatus,
codeAgentPayloadHasSealedFinalResponse,
codeAgentConversationRunnerTraceEvidence,
codeAgentFinalResponseEvidence,
codeAgentFinalResponseText,
codeAgentTraceSummaryEvidence,
annotateOwner,
canAccessOwnedResult,
isCodeAgentResultCanceled,
codeAgentCancelScopeMismatch,
cancelExpectedValues,
cancelScopeFieldMismatch,
cancelBlockedPayload,
isTraceCommandTerminalStatus,
traceSnapshotWithTerminalEvidence,
traceSnapshotWithAttachedTerminalEvidence,
agentRunTerminalTraceEvidence,
agentRunTerminalFinalResponse,
traceRetentionSummary,
numberOrNull,
createCodeAgentChatResultStore,
compactCodeAgentChatResultPayload,
terminalEvidencePayload,
compactRunnerTraceForResult,
traceEventLabel,
resultTraceEventLimit,
createCodeAgentM3HwlabApiRequestJson,
recordCodeAgentProjectionMetrics,
recordCodeAgentTurnReadMetric,
turnReadDegradedReason,
responseBodyBytes,
statusClassLabel,
sourceLatestSeqFromResult,
sourceLatestAtFromResult,
terminalSourceAtFromResult,
projectionReason,
nowMs,
uniqueStrings
} from "./server-code-agent-http-support.ts";
import {
codeAgentCompatProjectionPayload,
codeAgentRequestScopedOptions,
measureCodeAgentHttpPhase,
readCodeAgentCompatProjection,
traceProjectMismatchSnapshot
} from "./server-code-agent-turn-http.ts";
const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120;
const DEFAULT_CODE_AGENT_TRACE_PAGE_LIMIT = 100;
const MAX_CODE_AGENT_TRACE_PAGE_LIMIT = 100;
const DEFAULT_CODE_AGENT_TURN_STATUS_REFRESH_TIMEOUT_MS = 2500;
const DEFAULT_CODE_AGENT_PROJECT_ID = "prj_hwpod_workbench";
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
const CODE_AGENT_TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled"]);
const CODE_AGENT_PROVIDER_PROFILE_ALIASES = Object.freeze({ codex: "codex-api" });
const CODE_AGENT_PROVIDER_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{0,63}$/u;
const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({
"deepseek": "DeepSeek",
"codex-api": "Codex API",
"gpt.pika": "gpt.pika",
"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 handleCodeAgentSteerHttp(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,
route: "/v1/agent/chat/steer"
})
});
return;
}
if (!params || typeof params !== "object" || Array.isArray(params)) {
sendJson(response, 400, {
...createCodeAgentErrorPayload({
code: "invalid_params",
message: "Code Agent steer body must be a JSON object",
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
layer: "api",
retryable: true,
route: "/v1/agent/chat/steer"
})
});
return;
}
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId || params.targetTraceId);
const message = firstNonEmptyValue(params.message, params.prompt, params.text);
if (!traceId || !message) {
sendJson(response, 400, {
...createCodeAgentErrorPayload({
code: !traceId ? "steer_trace_missing" : "steer_message_missing",
message: !traceId ? "traceId is required to steer the current Code Agent turn." : "message is required to steer the current Code Agent turn.",
traceId: traceId || "trc_unassigned",
layer: "api",
retryable: true,
route: "/v1/agent/chat/steer"
})
});
return;
}
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const durableResult = await loadPersistedAgentRunResult(traceId, options);
const currentResult = durableResult ?? options.codeAgentChatResults?.get?.(traceId);
const projectionContext = await readCodeAgentCompatProjection(traceId, options);
if (!canAccessOwnedResult(currentResult, options.actor)) {
sendJson(response, 403, {
ok: false,
status: "forbidden",
traceId,
error: { code: "code_agent_trace_forbidden", message: "Code Agent trace is not visible to the current actor" }
});
return;
}
const projectionStoreBlocker = codeAgentProjectionStoreBlocker(projectionContext);
if (projectionStoreBlocker) {
sendJson(response, 503, {
ok: false,
accepted: false,
status: "blocked",
traceId,
route: "/v1/agent/chat/steer",
error: {
...projectionStoreBlocker,
code: "projection_store_unavailable",
layer: projectionStoreBlocker.layer ?? "runtime-store",
category: projectionStoreBlocker.category ?? "projection-store-unavailable",
retryable: true,
message: projectionStoreBlocker.message ?? "Workbench projection store is unavailable; steer is blocked until terminal authority can be checked.",
traceId,
route: "/v1/agent/chat/steer"
},
valuesPrinted: false
});
return;
}
if (!currentResult?.agentRun?.runId || !currentResult?.agentRun?.commandId) {
sendJson(response, 404, {
ok: false,
status: "not_found",
traceId,
error: { code: "steer_trace_not_found", message: "No AgentRun-backed Code Agent turn was found for the requested traceId." },
route: "/v1/agent/chat/steer",
runnerTrace: traceStore.snapshot(traceId)
});
return;
}
const projectedTerminalStatus = normalizeTurnStatus(
projectionContext.trace?.terminalEvidence?.status,
projectionContext.trace?.status
);
if (isTraceCommandTerminalStatus(projectedTerminalStatus)) {
sendJson(response, 409, {
ok: false,
accepted: false,
status: "blocked",
traceId,
route: "/v1/agent/chat/steer",
sessionId: safeSessionId(currentResult.sessionId ?? params.sessionId) || null,
threadId: safeOpaqueId(currentResult.threadId ?? params.threadId) || null,
agentRun: currentResult.agentRun,
runnerTrace: traceStore.snapshot(traceId),
error: {
code: "steer_trace_terminal",
layer: "api",
category: "not_in_flight",
retryable: false,
message: `Trace ${traceId} is already terminal and cannot accept a steer command.`,
traceId,
route: "/v1/agent/chat/steer",
toolName: "agentrun.command.steer"
},
valuesPrinted: false
});
return;
}
try {
await recordCodeAgentSessionInputFact({
params: {
...params,
sessionId: currentResult.sessionId ?? currentResult.session?.sessionId ?? params.sessionId,
ownerUserId: options.actor?.id,
ownerRole: options.actor?.role
},
options,
traceId,
delivery: "steer",
status: "promoted",
commandId: currentResult.agentRun.commandId
});
const payload = await steerAgentRunChatTurn({
traceId,
currentResult,
params: {
...params,
message,
ownerUserId: options.actor?.id ?? params.ownerUserId,
ownerRole: options.actor?.role ?? params.ownerRole
},
options,
traceStore
});
sendJson(response, 202, payload);
} catch (error) {
traceStore.append(traceId, {
type: "backend",
status: "failed",
label: "agentrun:steer:failed",
errorCode: error?.code ?? "agentrun_steer_failed",
message: error?.message ?? "AgentRun steer command failed.",
terminal: false,
valuesPrinted: false
});
sendJson(response, error?.statusCode === 404 ? 404 : 409, {
ok: false,
accepted: false,
status: "failed",
traceId,
route: "/v1/agent/chat/steer",
sessionId: safeSessionId(currentResult.sessionId ?? params.sessionId) || null,
threadId: safeOpaqueId(currentResult.threadId ?? params.threadId) || null,
agentRun: currentResult.agentRun,
runnerTrace: traceStore.snapshot(traceId),
error: {
code: error?.code ?? "agentrun_steer_failed",
layer: "agentrun",
category: "steer_failed",
retryable: true,
message: error?.message ?? "AgentRun steer command failed.",
traceId,
route: "/v1/agent/chat/steer",
toolName: "agentrun.command.steer"
}
});
}
}
export async function handleCodeAgentTraceHttp(request, response, url, options) {
const startedAt = nowMs();
const parts = url.pathname.split("/").filter(Boolean);
const traceId = decodeURIComponent(parts[3] ?? "");
const streamSegment = parts[4];
const pageOptions = codeAgentTracePageOptions(url);
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 requestOptions = codeAgentRequestScopedOptions(options);
const projectMismatch = await measureCodeAgentHttpPhase(requestOptions, "trace_project_check", () => traceProjectMismatchSnapshot(traceId, url, requestOptions, "trace"));
if (projectMismatch) {
sendJson(response, projectMismatch.statusCode, projectMismatch.body);
return;
}
const context = await measureCodeAgentHttpPhase(requestOptions, "trace_projection_read", () => readCodeAgentCompatProjection(traceId, requestOptions));
if (context.statusCode) {
sendJson(response, context.statusCode, context.body);
return;
}
if (streamSegment === "stream") {
sendJson(response, 410, {
ok: false,
status: "removed",
traceId,
error: {
code: "trace_store_sse_removed",
message: "Trace-store SSE was removed; use the committed Workbench projection event stream.",
workbenchEventsUrl: `/v1/workbench/events?traceId=${encodeURIComponent(traceId)}`
},
valuesRedacted: true
});
return;
}
const diagnosticTrace = (requestOptions.traceStore ?? defaultCodeAgentTraceStore).snapshot(traceId);
const trace = mergeCodeAgentDiagnosticTrace(context.trace, diagnosticTrace, traceId);
const diagnosticFound = trace.events.length > 0;
const body = await measureCodeAgentHttpPhase(requestOptions, "trace_paginate", () => {
return paginateTraceSnapshot(trace, pageOptions);
});
const statusCode = context.found || diagnosticFound ? 200 : 404;
void emitCodeAgentOtelSpan("trace_events_read", traceId, requestOptions.env ?? process.env, {
startTimeMs: startedAt,
attributes: {
"http.method": "GET",
"http.route": "/v1/agent/traces/:traceId",
"http.status_code": statusCode,
found: Boolean(context.found),
returnedEvents: Array.isArray(body?.events) ? body.events.length : 0,
sinceSeq: pageOptions.sinceSeq,
limit: pageOptions.limit,
fromSeq: body?.range?.fromSeq ?? null,
toSeq: body?.range?.toSeq ?? null,
totalEvents: body?.range?.total ?? body?.eventCount ?? null,
hasMore: Boolean(body?.hasMore),
fullTraceLoaded: Boolean(body?.fullTraceLoaded)
}
});
sendJson(response, statusCode, codeAgentCompatProjectionPayload(body, context));
}
function mergeCodeAgentDiagnosticTrace(projectionTrace, diagnosticTrace, traceId) {
const projected = projectionTrace && typeof projectionTrace === "object" ? projectionTrace : {};
const diagnostics = diagnosticTrace && typeof diagnosticTrace === "object" ? diagnosticTrace : {};
const merged = new Map();
for (const [source, events] of [["diagnostic", diagnostics.events], ["projection", projected.events]]) {
for (const [index, event] of (Array.isArray(events) ? events : []).entries()) {
const originalSeq = traceEventSequence(event, index);
const key = textValue(event?.id ?? event?.sourceEventId) || `${originalSeq}:${textValue(event?.label ?? event?.type)}:${textValue(event?.createdAt ?? event?.occurredAt)}`;
const normalized = source === "projection"
? {
...event,
projectedSeq: Number.isFinite(Number(event?.projectedSeq)) ? Number(event.projectedSeq) : originalSeq,
authority: "workbench-projection"
}
: {
...event,
sourceSeq: Number.isFinite(Number(event?.sourceSeq)) ? Number(event.sourceSeq) : originalSeq,
terminal: false,
sealed: false,
diagnosticOnly: true,
authority: "trace-store-diagnostics"
};
merged.set(key, { event: normalized, source, originalSeq, sourceIndex: index, key });
}
}
const events = [...merged.values()]
.sort((left, right) => left.originalSeq - right.originalSeq
|| (left.source === right.source ? 0 : left.source === "projection" ? -1 : 1)
|| left.sourceIndex - right.sourceIndex
|| left.key.localeCompare(right.key))
.map(({ event }, index) => ({ ...event, seq: index + 1, paginationSeq: index + 1 }));
const projectionFound = projected.status && projected.status !== "missing";
return {
...projected,
traceId,
status: projectionFound ? projected.status : events.length > 0 ? "running" : "missing",
terminal: projected.terminal === true,
sealed: projected.sealed === true,
events,
eventCount: events.length,
diagnosticEventCount: events.filter((event) => event.diagnosticOnly === true).length,
terminalAuthority: "workbench-projection",
valuesRedacted: true
};
}
function codeAgentProjectionStoreBlocker(context = {}) {
const candidates = [
context?.projection?.blocker,
context?.trace?.projection?.blocker,
context?.trace?.blocker,
context?.result?.projection?.blocker,
context?.result?.blocker
];
return candidates.find((blocker) => blocker?.code === "projection_store_unavailable") ?? null;
}
function codeAgentTracePageOptions(url) {
const sinceSeq = nonNegativeInteger(url.searchParams.get("sinceSeq") ?? url.searchParams.get("since"), 0);
const requestedLimit = parsePositiveInteger(url.searchParams.get("limit"), DEFAULT_CODE_AGENT_TRACE_PAGE_LIMIT);
return {
sinceSeq,
limit: Math.min(requestedLimit, MAX_CODE_AGENT_TRACE_PAGE_LIMIT)
};
}
function paginateTraceSnapshot(snapshot, { sinceSeq, limit }) {
const sourceEvents = Array.isArray(snapshot?.events) ? snapshot.events : [];
const indexedEvents = sourceEvents.map((event, index) => ({ event, seq: traceEventSequence(event, index) }));
const firstIndex = indexedEvents.findIndex((item) => item.seq > sinceSeq);
const startIndex = firstIndex >= 0 ? firstIndex : indexedEvents.length;
const pageItems = indexedEvents.slice(startIndex, startIndex + limit);
const events = pageItems.map((item) => item.event);
const fromSeq = pageItems.length > 0 ? pageItems[0].seq : null;
const toSeq = pageItems.length > 0 ? pageItems[pageItems.length - 1].seq : null;
const hasMore = startIndex + pageItems.length < indexedEvents.length;
const totalEvents = snapshot?.eventCount ?? indexedEvents.length;
return {
...snapshot,
events,
eventCount: totalEvents,
range: {
sinceSeq,
fromSeq,
toSeq,
limit,
returned: events.length,
total: totalEvents
},
truncated: hasMore,
hasMore,
nextSinceSeq: pageItems.length > 0 ? toSeq : sinceSeq,
fullTraceLoaded: !hasMore
};
}
function traceEventSequence(event, index) {
const seq = Number(event?.seq);
return Number.isFinite(seq) && seq > 0 ? Math.trunc(seq) : index + 1;
}
function nonNegativeInteger(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
}