fix: 移除 Workbench HTTP 自动补链

This commit is contained in:
root
2026-07-14 07:18:51 +02:00
parent fc87a7e5f7
commit bef232804f
21 changed files with 116 additions and 500 deletions
-8
View File
@@ -621,9 +621,6 @@ lanes:
HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: Asia/Shanghai
HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: zh-CN
HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: 北京时间
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true"
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "true"
HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false"
HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED: "true"
HWLAB_CLOUD_WEB_OPENCODE_UPSTREAM_URL: http://opencode-server.hwlab-v03.svc.cluster.local:4096
HWLAB_CLOUD_WEB_OPENCODE_USERNAME: secretRef:hwlab-opencode-server-auth/username
@@ -850,11 +847,6 @@ lanes:
HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git
HWLAB_KAFKA_ENABLED: "true"
HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED: "true"
HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true"
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true"
HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false"
HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false"
HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false"
HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED: "true"
HWLAB_KAFKA_BOOTSTRAP_SERVERS: platform-infra-kafka-kafka-bootstrap.platform-infra.svc.cluster.local:9092
HWLAB_KAFKA_STDIO_TOPIC: codex-stdio.raw.v1
@@ -1042,7 +1042,6 @@ async function submitCodeAgentChatTurn({ params, options, traceId }) {
}
});
if (adapterEnabled) {
const transactionalAdmission = true;
const pendingPromotion = results.get(traceId);
if (pendingPromotion?.admissionState === "promotion-pending" && pendingPromotion?.agentRun?.durableDispatch === true) {
const retryParams = { ...params, userBillingReservation: pendingPromotion.userBillingReservation ?? null };
@@ -1057,15 +1056,13 @@ async function submitCodeAgentChatTurn({ params, options, traceId }) {
}
const lifecycle = codeAgentTurnLifecycleFields(traceId, params);
if (transactionalAdmission) {
await persistCodeAgentAdmissionInputState({
params,
options,
traceId,
status: "admitting",
messageId: lifecycle.userMessageId
});
}
await persistCodeAgentAdmissionInputState({
params,
options,
traceId,
status: "admitting",
messageId: lifecycle.userMessageId
});
traceStore.append(traceId, {
type: "request",
@@ -1086,16 +1083,14 @@ async function submitCodeAgentChatTurn({ params, options, traceId }) {
error: billingPreflight?.blocked ? billingPreflight?.payload?.error?.message ?? billingPreflight?.blocker?.summary ?? "billing preflight blocked" : null
});
if (billingPreflight?.blocked) {
if (transactionalAdmission) {
await persistCodeAgentAdmissionInputState({
params,
options,
traceId,
status: "blocked",
messageId: lifecycle.userMessageId,
error: billingPreflight.payload?.error ?? billingPreflight.blocker ?? { code: "billing_preflight_blocked" }
});
}
await persistCodeAgentAdmissionInputState({
params,
options,
traceId,
status: "blocked",
messageId: lifecycle.userMessageId,
error: billingPreflight.payload?.error ?? billingPreflight.blocker ?? { code: "billing_preflight_blocked" }
});
traceStore.append(traceId, {
type: "billing",
status: "blocked",
@@ -1121,16 +1116,14 @@ async function submitCodeAgentChatTurn({ params, options, traceId }) {
const failure = codeAgentDispatchFailure(error);
const rejected = codeAgentSubmissionUnavailablePayload(failure, { params: dispatchParams, options: executionOptions, traceId });
await finalizeCodeAgentBillingUsage({ payload: rejected, params: dispatchParams, options: executionOptions });
if (transactionalAdmission) {
await persistCodeAgentAdmissionInputState({
params: dispatchParams,
options: executionOptions,
traceId,
status: "failed",
messageId: lifecycle.userMessageId,
error: failure.payload?.error ?? error
});
}
await persistCodeAgentAdmissionInputState({
params: dispatchParams,
options: executionOptions,
traceId,
status: "failed",
messageId: lifecycle.userMessageId,
error: failure.payload?.error ?? error
});
void emitCodeAgentOtelSpan("agentrun_dispatch", traceId, executionOptions.env ?? process.env, {
startTimeMs: dispatchStartedAt,
status: "error",
@@ -1151,17 +1144,15 @@ async function submitCodeAgentChatTurn({ params, options, traceId }) {
}, dispatchParams);
results.set(traceId, pending);
try {
if (transactionalAdmission) {
await persistCodeAgentAdmissionInputState({
params: dispatchParams,
options: executionOptions,
traceId,
status: "admitting",
messageId: lifecycle.userMessageId,
commandId: payload.agentRun?.commandId ?? null,
agentRun: payload.agentRun ?? null
});
}
await persistCodeAgentAdmissionInputState({
params: dispatchParams,
options: executionOptions,
traceId,
status: "admitting",
messageId: lifecycle.userMessageId,
commandId: payload.agentRun?.commandId ?? null,
agentRun: payload.agentRun ?? null
});
await promoteCodeAgentTurnAdmission({ payload: pending, params: dispatchParams, options: executionOptions, traceId });
} catch (error) {
throw codeAgentPromotionUnavailableError(error, { payload: pending, params: dispatchParams, options: executionOptions, traceId });
-1
View File
@@ -16,7 +16,6 @@ import {
import { createWorkbenchReadModel } from "./workbench-read-model.ts";
import { createWorkbenchRuntimeClient } from "./workbench-runtime-client.ts";
import { buildWorkbenchSessionDetail, compactLaunchContext, includeMessagesForSessionDetail } from "./workbench-session-detail-response.ts";
import { handleWorkbenchSyncHttp } from "./workbench-realtime-authority.ts";
import { durableTraceStatus, RUNNING_STATUSES, terminalFinalResponse, TERMINAL_STATUSES } from "./workbench-turn-projection.ts";
import { emitCodeAgentOtelSpan, emitHttpServerRequestSpan } from "./otel-trace.ts";
@@ -71,22 +71,6 @@ test("projection outbox emits immutable assistant versions in row order", () =>
assert.deepEqual(events.map((item) => item.payload.cursor.outboxSeq), [10, 11]);
});
test("workbench sync repair endpoint is removed from the product API", async () => {
const server = createCloudApiServer({
accessController: realtimeAccessController(),
workbenchRuntime: {},
kafkaEventBridge: projectionRealtimeBridge(),
env: { ...TRANSACTIONAL_REALTIME_ENV }
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const response = await getJson(server.address().port, "/v1/workbench/sync?sessionId=ses_removed_sync_repair");
assert.equal(response.status, 404);
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
test("workbench realtime initial connection emits current snapshot without replaying historical outbox", async () => {
const sessionId = "ses_realtime_snapshot_only";
const traceId = "trc_realtime_snapshot_only";
@@ -15,7 +15,6 @@ import {
import { createWorkbenchReadModel } from "./workbench-read-model.ts";
import { createWorkbenchRuntimeClient } from "./workbench-runtime-client.ts";
import { buildWorkbenchSessionDetail, compactLaunchContext, includeMessagesForSessionDetail } from "./workbench-session-detail-response.ts";
import { handleWorkbenchSyncHttp } from "./workbench-realtime-authority.ts";
import { projectionOutboxRealtimeEvents } from "./workbench-projection-outbox-events.ts";
import { durableTraceStatus, RUNNING_STATUSES, TERMINAL_STATUSES } from "./workbench-turn-projection.ts";
import { emitCodeAgentOtelSpan, emitHttpServerRequestSpan } from "./otel-trace.ts";
+16 -7
View File
@@ -687,11 +687,14 @@ test("v03 render keeps node identity as data instead of generated structure", as
assert.equal(cloudApiEnv.get("HWLAB_CLOUD_DB_SSL_MODE"), "require");
assert.equal(cloudApiEnv.get("HWLAB_KAFKA_ENABLED"), "true");
assert.equal(cloudApiEnv.get("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED"), "true");
assert.equal(cloudApiEnv.get("HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED"), "true");
assert.equal(cloudApiEnv.get("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED"), "true");
assert.equal(cloudApiEnv.get("HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED"), "false");
assert.equal(cloudApiEnv.get("HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED"), "false");
assert.equal(cloudApiEnv.get("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED"), "false");
for (const name of [
"HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED",
"HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED",
"HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED",
"HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED",
"HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED"
]) assert.equal(cloudApiEnv.has(name), false);
assert.equal(cloudApiEnv.get("HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED"), "true");
assert.equal(cloudApiEnv.get("HWLAB_KAFKA_BOOTSTRAP_SERVERS"), "platform-infra-kafka-kafka-bootstrap.platform-infra.svc.cluster.local:9092");
assert.equal(cloudApiEnv.get("HWLAB_KAFKA_STDIO_TOPIC"), "codex-stdio.raw.v1");
@@ -709,8 +712,14 @@ test("v03 render keeps node identity as data instead of generated structure", as
const cloudWeb = (workloadsJson.items ?? []).find((item) => item.kind === "Deployment" && item.metadata?.name === "hwlab-cloud-web");
const cloudWebContainer = collectContainersFromItem(cloudWeb).find((container) => container.name === "hwlab-cloud-web");
const cloudWebEnv = new Map((cloudWebContainer?.env ?? []).map((entry) => [entry.name, entry.value]));
assert.equal(cloudWebEnv.get("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED"), "true");
assert.equal(cloudWebEnv.get("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED"), "false");
for (const name of [
"HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED",
"HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED",
"HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED",
"HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED",
"HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED"
]) assert.equal(cloudWebEnv.has(name), false);
assert.equal(cloudWebEnv.get("HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED"), "true");
assert.equal(cloudWebEnv.get("HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_ENABLED"), "1");
assert.equal(cloudWebEnv.get("HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_ENTRIES"), "200");
@@ -3,13 +3,14 @@ import test from "node:test";
import { readStructuredFile } from "./src/structured-config.mjs";
const PRODUCT_CAPABILITIES = Object.freeze({
HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true",
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true",
HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false",
HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false",
HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false"
});
const OBSOLETE_ARCHITECTURE_ENV = Object.freeze([
"HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED",
"HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED",
"HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED",
"HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED",
"HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED"
]);
const LIVE_KAFKA_CONTRACT = Object.freeze({
HWLAB_KAFKA_STDIO_TOPIC: "codex-stdio.raw.v1",
@@ -21,7 +22,6 @@ const LIVE_KAFKA_CONTRACT = Object.freeze({
});
const UNIDESK_OWNED_REFRESH_REPLAY_ENV = Object.freeze([
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT",
@@ -37,15 +37,16 @@ const ISOLATED_DEBUG_CONTRACT = Object.freeze({
HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_TIMEOUT_MS: "15000"
});
test("v03 YAML keeps pure Kafka realtime capabilities independently composable", async () => {
test("v03 YAML fixes one transactional projection architecture without architecture env", async () => {
const deploy = await readStructuredFile(process.cwd(), "deploy/deploy.yaml");
const cloudApi = deploy.lanes?.v03?.services?.find((service) => service.serviceId === "hwlab-cloud-api");
const cloudWeb = deploy.lanes?.v03?.serviceDeclarations?.["hwlab-cloud-web"];
assert.ok(cloudApi, "deploy.lanes.v03.services must declare hwlab-cloud-api");
assert.ok(cloudWeb, "deploy.lanes.v03.serviceDeclarations must declare hwlab-cloud-web");
for (const [name, expected] of Object.entries(PRODUCT_CAPABILITIES)) {
assert.equal(cloudApi.env?.[name], expected, `${name} must be explicitly YAML-owned`);
for (const name of OBSOLETE_ARCHITECTURE_ENV) {
assert.equal(cloudApi.env?.[name], undefined, `${name} must not be rendered into cloud-api`);
assert.equal(cloudWeb.env?.[name], undefined, `${name} must not be rendered into cloud-web`);
}
for (const [name, expected] of Object.entries(LIVE_KAFKA_CONTRACT)) {
assert.equal(cloudApi.env?.[name], expected, `${name} must preserve the pure Kafka live contract`);
@@ -59,8 +60,5 @@ test("v03 YAML keeps pure Kafka realtime capabilities independently composable",
assert.equal(cloudApi.env?.HWLAB_SESSION_COOKIE_DOMAIN, undefined, "internal and public origins require host-scoped session cookies");
assert.notEqual(cloudApi.env?.HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID, "hwlab-v03-agentrun-event-bridge");
assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED, "true");
assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED, "true");
assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED, "false");
assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED, "true");
});
-1
View File
@@ -199,7 +199,6 @@ assertIncludes(workbenchRealtimePlanSource, "afterOutboxSeq: finiteNumber(recove
assert.doesNotMatch(workbenchRealtimePlanSource, /force:\s*true/u, "Realtime recovery planner must not turn transport recovery into force-refresh work");
assertIncludes(workbenchColadaSource, "const state = await queryCache.refresh(entry);", "Workbench reads must preserve Colada staleTime/min-interval governance");
assert.doesNotMatch(workbenchColadaSource, /queryCache\.fetch\(entry/u, "Workbench reads must not call queryCache.fetch(entry), which bypasses Colada freshness governance");
assertIncludes(workbenchStoreSource, "runtimePolicy.sessionListRealtimeRefreshDelayMs", "Realtime recovery delay must come from runtime policy instead of store constants");
assertIncludes(workbenchStoreSource, "workbenchColadaQueries.fetchSession", "Realtime session detail recovery must enter Colada query facade");
assertIncludes(workbenchStoreSource, "runtimePolicy.workbenchSessionDetailMinRefreshMs", "Realtime session detail recovery budget must come from runtime policy");
assert.doesNotMatch(workbenchStoreSource, /refreshRealtimeSessionMessages[\s\S]{0,900}refreshSessionMessageProjectionPage\(id, \{ force: true \}\)/u, "Realtime session message recovery must not force-bypass the message projection refresh budget");
@@ -7,6 +7,7 @@ const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url));
const productionFiles = [
"internal/cloud/kafka-event-bridge.ts",
"internal/cloud/server-workbench-realtime-http.ts",
"internal/cloud/server-workbench-facts.ts",
"internal/cloud/server-code-agent-admission-http.ts",
"internal/cloud/server.ts",
"web/hwlab-cloud-web/src/config/runtime.ts",
@@ -14,8 +15,11 @@ const productionFiles = [
"web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts",
"web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts",
"web/hwlab-cloud-web/src/stores/workbench.ts",
"web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts",
"web/hwlab-cloud-web/src/stores/workbench-event-reducer.ts",
"web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts"
"web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts",
"web/hwlab-cloud-web/src/stores/workbench-session.ts",
"web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.ts"
];
const forbiddenArchitectureIdentifiers = [
"directPublish",
@@ -34,7 +38,26 @@ const forbiddenArchitectureIdentifiers = [
"workbenchHistoryAuthorityPolicy",
"workbenchRealtimeTransportEnabled",
"workbenchRealtimeTraceIdForCapabilities",
"workbenchProjectionEventStreamPath"
"workbenchProjectionEventStreamPath",
"scheduleSessionListRefresh",
"invalidateSessionList",
"fetchWorkbenchTurnStatus",
"refreshTurnStatusByTraceId",
"refreshMessageProjectionForTrace",
"selectActiveTurnStatusRefreshTraceIds",
"sessionDetailAutoReadDecision",
"projectionMergeCommitSummary",
"forceRead",
"handleWorkbenchSyncHttp",
"/v1/workbench/sync"
];
const obsoleteArchitectureEnv = [
"HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED",
"HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED",
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED",
"HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED",
"HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED",
"HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED"
];
test("Workbench production source has one fixed transactional projection architecture", async () => {
@@ -48,6 +71,12 @@ test("Workbench production source has one fixed transactional projection archite
assert.deepEqual(violations, []);
});
test("production deploy does not render architecture selection env", async () => {
const deploySource = await readFile(`${repositoryRoot}/deploy/deploy.yaml`, "utf8");
const violations = obsoleteArchitectureEnv.filter((name) => deploySource.includes(name));
assert.deepEqual(violations, []);
});
test("raw hwlab Kafka fixtures remain isolated to the explicit debug path", async () => {
const fixtureSource = await readFile(`${repositoryRoot}/web/hwlab-cloud-web/src/stores/workbench-live-kafka-event.ts`, "utf8");
const debugSource = await readFile(`${repositoryRoot}/web/hwlab-cloud-web/src/stores/workbench-isolated-kafka-debug.ts`, "utf8");
@@ -25,7 +25,6 @@ import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery } from "../sr
import { WORKBENCH_REALTIME_AUTHORITY_VERSION, workbenchRealtimePrimaryAuthorityDecision } from "../src/stores/workbench-realtime-authority.ts";
import { cleanupWorkbenchServerStateDroppedSessions, cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState } from "../src/stores/workbench-server-state.ts";
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "../src/stores/workbench-session-cache.ts";
import { selectActiveTurnStatusRefreshTraceIds } from "../src/stores/workbench-session.ts";
test("Workbench scoped keys encode delimiter characters", () => {
assert.deepEqual(splitWorkbenchScopedKey(workbenchRealtimeScopeKey("ses|one", "trc/two")), ["workbench.realtime", "ses|one", "trc/two"]);
@@ -471,42 +470,6 @@ test("server-state cleanup removes dropped session trace and turn authority", ()
assert.deepEqual(Object.keys(cleanedById.traceById), []);
});
test("passive message projection selects active turn status refresh without local request", () => {
const traceIds = selectActiveTurnStatusRefreshTraceIds({
messages: [
agentMessage({ id: "m_old", traceId: "trc_old", status: "completed", text: "done" }),
agentMessage({ id: "m_new", traceId: "trc_new", status: "running", text: "thinking" })
],
currentRequestTraceId: null,
turnStatusAuthority: {}
});
assert.deepEqual(traceIds, ["trc_new"]);
});
test("passive message projection skips sealed terminal turn status refresh", () => {
const traceIds = selectActiveTurnStatusRefreshTraceIds({
messages: [agentMessage({ id: "m_done", traceId: "trc_done", status: "completed", text: "done" })],
turnStatusAuthority: { trc_done: { traceId: "trc_done", status: "completed", running: false, terminal: true, sessionId: "ses_1" } }
});
assert.deepEqual(traceIds, []);
});
test("turn status refresh keeps local request trace priority", () => {
const traceIds = selectActiveTurnStatusRefreshTraceIds({
messages: [
agentMessage({ id: "m_request", traceId: "trc_request", status: "pending", text: "" }),
agentMessage({ id: "m_observed", traceId: "trc_observed", status: "running", text: "thinking" })
],
currentRequestTraceId: "trc_request",
turnStatusAuthority: {},
limit: 2
});
assert.deepEqual(traceIds, ["trc_request", "trc_observed"]);
});
test("realtime event reducer classifies SSE payloads before store side effects", () => {
const trace = reduceWorkbenchRealtimeEvent(realtimeEvent({ type: "trace.event", traceId: "trc_1", event: { traceId: "trc_1", label: "delta" }, snapshot: { traceId: "trc_1", status: "running" }, entity: { family: "traceEvents", id: "trc_1:1", version: 1, projectionRevision: "prj_1" } }), "workbench.trace.event");
assert.equal(trace.activityLabel, "realtime:trace.event");
-44
View File
@@ -129,7 +129,6 @@ export const workbenchAPI = {
return fetchJson(sessionDetailPath(sessionId, detailOptions), { timeoutMs: requestTimeoutMs(detailOptions.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" });
},
sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise<ApiResult<WorkbenchMessagePageResponse>> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }),
turn: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => normalizeWorkbenchTurnResult(await fetchJson<Record<string, unknown>>(`/v1/workbench/turns/${encodeURIComponent(traceId)}`, { timeoutMs, timeoutName: "workbench turn", activityRef }), traceId),
traceEvents: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: WorkbenchTraceRequestOptions): Promise<ApiResult<AgentChatResultResponse>> => normalizeWorkbenchTraceResult(await fetchJson<Record<string, unknown>>(workbenchTracePath(traceId, options), { timeoutMs, timeoutName: "workbench trace", activityRef }), traceId)
};
@@ -186,42 +185,6 @@ function workbenchTracePath(traceId: string, options: WorkbenchTraceRequestOptio
return `/v1/workbench/traces/${encodeURIComponent(traceId)}/events${query ? `?${query}` : ""}`;
}
function normalizeWorkbenchTurnResult(result: ApiResult<Record<string, unknown>>, traceId: string): ApiResult<AgentChatResultResponse> {
if (!result.ok || !result.data) return result as ApiResult<AgentChatResultResponse>;
const turn = recordValue(result.data.turn) ?? result.data;
const trace = recordValue(turn.trace);
const status = normalizeStatus(textValue(turn.status)) ?? "unknown";
const projection = normalizeProjectionDiagnostic(result.data.projection, turn.projection, result.data);
const timing = normalizeTimingProjection(turn);
return {
...result,
data: {
...turn,
projection,
timing,
startedAt: timing?.startedAt ?? undefined,
lastEventAt: timing?.lastEventAt ?? undefined,
finishedAt: timing?.finishedAt ?? undefined,
durationMs: timing?.durationMs ?? undefined,
projectionStatus: projection?.projectionStatus ?? textValue(result.data.projectionStatus) ?? undefined,
projectionHealth: projection?.projectionHealth ?? textValue(result.data.projectionHealth) ?? undefined,
lastProjectedSeq: Number.isFinite(Number(result.data.lastProjectedSeq)) ? Number(result.data.lastProjectedSeq) : undefined,
sourceRunId: projection?.sourceRunId ?? textValue(result.data.sourceRunId) ?? undefined,
sourceCommandId: projection?.sourceCommandId ?? textValue(result.data.sourceCommandId) ?? undefined,
staleMs: projection?.staleMs ?? numberValue(result.data.staleMs) ?? undefined,
blocker: projection?.blocker ?? recordValue(result.data.blocker) ?? undefined,
traceId: textValue(turn.traceId) ?? traceId,
status,
running: typeof turn.running === "boolean" ? turn.running : undefined,
terminal: typeof turn.terminal === "boolean" ? turn.terminal : undefined,
sessionId: textValue(turn.sessionId) ?? undefined,
threadId: textValue(turn.threadId) ?? undefined,
agentRun: recordValue(turn.agentRun) ?? undefined,
updatedAt: textValue(turn.updatedAt) ?? textValue(result.data.observedAt) ?? undefined
} as AgentChatResultResponse
};
}
function normalizeWorkbenchTraceResult(result: ApiResult<Record<string, unknown>>, traceId: string): ApiResult<AgentChatResultResponse> {
if (!result.ok || !result.data) return result as ApiResult<AgentChatResultResponse>;
const events = Array.isArray(result.data.events) ? result.data.events : [];
@@ -308,13 +271,6 @@ function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjectio
} as WorkbenchTurnTimingProjection;
}
function normalizeStatus(value: unknown): string | null {
const text = textValue(value);
if (!text) return null;
const normalized = text.toLowerCase().replace(/_/gu, "-");
return normalized === "cancelled" ? "canceled" : normalized;
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
@@ -14,7 +14,6 @@ export interface WorkbenchRuntimePolicy {
sessionListPageLimit: number;
workbenchDetailReadConcurrency: number;
workbenchReadFailureCooldownMs: number;
workbenchTurnStatusMinRefreshMs: number;
workbenchTraceEventsMinRefreshMs: number;
workbenchSessionDetailMinRefreshMs: number;
workbenchSessionMessagesMinRefreshMs: number;
@@ -22,8 +21,6 @@ export interface WorkbenchRuntimePolicy {
workbenchTraceMessagesWindowLimit: number;
workbenchRealtimeSessionMessagesMinRefreshMs: number;
workbenchTraceEventsTimeoutMs: number;
sessionListRealtimeRefreshDelayMs: number;
sessionListTerminalRefreshDelayMs: number;
sessionListMinRefreshIntervalMs: number;
workbenchRealtimeErrorSyncReplayMinMs: number;
workbenchRealtimeFlushMaxItemsPerChunk: number;
@@ -44,7 +41,6 @@ const DEFAULT_WORKBENCH_RUNTIME_POLICY: WorkbenchRuntimePolicy = Object.freeze({
sessionListPageLimit: 20,
workbenchDetailReadConcurrency: 3,
workbenchReadFailureCooldownMs: 5_000,
workbenchTurnStatusMinRefreshMs: 2_000,
workbenchTraceEventsMinRefreshMs: 4_000,
workbenchSessionDetailMinRefreshMs: 5_000,
workbenchSessionMessagesMinRefreshMs: 5_000,
@@ -52,8 +48,6 @@ const DEFAULT_WORKBENCH_RUNTIME_POLICY: WorkbenchRuntimePolicy = Object.freeze({
workbenchTraceMessagesWindowLimit: 8,
workbenchRealtimeSessionMessagesMinRefreshMs: 1_000,
workbenchTraceEventsTimeoutMs: 5_000,
sessionListRealtimeRefreshDelayMs: 5_000,
sessionListTerminalRefreshDelayMs: 1_500,
sessionListMinRefreshIntervalMs: 15_000,
workbenchRealtimeErrorSyncReplayMinMs: 2_000,
workbenchRealtimeFlushMaxItemsPerChunk: 4,
@@ -76,7 +70,6 @@ export function workbenchRuntimePolicy(input: unknown = runtimePolicyConfig()):
sessionListPageLimit: positiveInteger(source.sessionListPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListPageLimit),
workbenchDetailReadConcurrency: positiveInteger(source.workbenchDetailReadConcurrency ?? source.workbenchReadHydrationConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchDetailReadConcurrency),
workbenchReadFailureCooldownMs: positiveNumber(source.workbenchReadFailureCooldownMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchReadFailureCooldownMs),
workbenchTurnStatusMinRefreshMs: nonNegativeNumber(source.workbenchTurnStatusMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTurnStatusMinRefreshMs),
workbenchTraceEventsMinRefreshMs: nonNegativeNumber(source.workbenchTraceEventsMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTraceEventsMinRefreshMs),
workbenchSessionDetailMinRefreshMs: nonNegativeNumber(source.workbenchSessionDetailMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchSessionDetailMinRefreshMs),
workbenchSessionMessagesMinRefreshMs: nonNegativeNumber(source.workbenchSessionMessagesMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchSessionMessagesMinRefreshMs),
@@ -84,8 +77,6 @@ export function workbenchRuntimePolicy(input: unknown = runtimePolicyConfig()):
workbenchTraceMessagesWindowLimit: positiveInteger(source.workbenchTraceMessagesWindowLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTraceMessagesWindowLimit),
workbenchRealtimeSessionMessagesMinRefreshMs: nonNegativeNumber(source.workbenchRealtimeSessionMessagesMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchRealtimeSessionMessagesMinRefreshMs),
workbenchTraceEventsTimeoutMs: positiveNumber(source.workbenchTraceEventsTimeoutMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTraceEventsTimeoutMs),
sessionListRealtimeRefreshDelayMs: nonNegativeNumber(source.sessionListRealtimeRefreshDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListRealtimeRefreshDelayMs),
sessionListTerminalRefreshDelayMs: nonNegativeNumber(source.sessionListTerminalRefreshDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListTerminalRefreshDelayMs),
sessionListMinRefreshIntervalMs: nonNegativeNumber(source.sessionListMinRefreshIntervalMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListMinRefreshIntervalMs),
workbenchRealtimeErrorSyncReplayMinMs: nonNegativeNumber(source.workbenchRealtimeErrorSyncReplayMinMs ?? source.workbenchRealtimeErrorGapFillMinMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchRealtimeErrorSyncReplayMinMs),
workbenchRealtimeFlushMaxItemsPerChunk: positiveInteger(source.workbenchRealtimeFlushMaxItemsPerChunk, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchRealtimeFlushMaxItemsPerChunk),
@@ -31,8 +31,6 @@ export const workbenchColadaKeys = {
historySessionMessagesRoot: (): EntryKey => ["workbench", "history", "session-messages"],
historySessionMessages: (sessionId: string, input: WorkbenchSessionMessagesKeyInput = {}): EntryKey => ["workbench", "history", "session-messages", sessionId, normalizeKeyObject({ cursor: input.cursor ?? null, limit: finiteNumber(input.limit) })],
detailRoot: (): EntryKey => ["workbench", "detail"],
detailTurnRoot: (): EntryKey => ["workbench", "detail", "turn"],
detailTurn: (traceId: string): EntryKey => ["workbench", "detail", "turn", traceId],
detailTraceEventsRoot: (): EntryKey => ["workbench", "detail", "trace-events"],
detailTraceEvents: (traceId: string, input: WorkbenchTraceEventsKeyInput = {}): EntryKey => ["workbench", "detail", "trace-events", traceId, normalizeKeyObject({ afterProjectedSeq: finiteNumber(input.afterProjectedSeq), limit: finiteNumber(input.limit) })],
sessionsRoot: (): EntryKey => workbenchColadaKeys.snapshotSessionsRoot(),
@@ -41,8 +39,6 @@ export const workbenchColadaKeys = {
session: (sessionId: string): EntryKey => workbenchColadaKeys.snapshotSession(sessionId),
sessionMessagesRoot: (): EntryKey => workbenchColadaKeys.historySessionMessagesRoot(),
sessionMessages: (sessionId: string, input: WorkbenchSessionMessagesKeyInput = {}): EntryKey => workbenchColadaKeys.historySessionMessages(sessionId, input),
turnRoot: (): EntryKey => workbenchColadaKeys.detailTurnRoot(),
turn: (traceId: string): EntryKey => workbenchColadaKeys.detailTurn(traceId),
traceEventsRoot: (): EntryKey => workbenchColadaKeys.detailTraceEventsRoot(),
traceEvents: (traceId: string, input: WorkbenchTraceEventsKeyInput = {}): EntryKey => workbenchColadaKeys.detailTraceEvents(traceId, input),
providerProfiles: (): EntryKey => ["workbench", "provider-profiles"],
@@ -3,7 +3,6 @@
import { useQueryCache, type EntryKey, type QueryCache } from "@pinia/colada";
import { api } from "@/api";
import type { ApiRequestOptions } from "@/api/client";
import type { AgentChatResultResponse, ApiResult } from "@/types";
import type { SessionListOptions, SessionMessageOptions, WorkbenchMessagePageResponse, WorkbenchSessionDetailResponse, WorkbenchSessionListResponse, WorkbenchTraceRequestOptions } from "@/api/workbench";
import { workbenchColadaKeys } from "./workbench-colada-keys";
@@ -13,14 +12,9 @@ interface QueryRunOptions {
minIntervalMs?: number | null;
}
export interface WorkbenchTurnQueryOptions extends QueryRunOptions {
timeoutMs?: number;
activityRef?: ApiRequestOptions["activityRef"];
}
export interface WorkbenchTraceEventsQueryOptions extends QueryRunOptions, WorkbenchTraceRequestOptions {
timeoutMs?: number;
activityRef?: ApiRequestOptions["activityRef"];
activityRef?: import("@/api/client").ApiRequestOptions["activityRef"];
}
export interface WorkbenchSessionDetailQueryOptions extends QueryRunOptions {
@@ -33,13 +27,10 @@ export interface WorkbenchColadaQueries {
fetchSessions: (options?: SessionListOptions & QueryRunOptions) => Promise<ApiResult<WorkbenchSessionListResponse>>;
fetchSession: (sessionId: string, options?: WorkbenchSessionDetailQueryOptions) => Promise<ApiResult<WorkbenchSessionDetailResponse>>;
fetchSessionMessages: (sessionId: string, options?: SessionMessageOptions & QueryRunOptions) => Promise<ApiResult<WorkbenchMessagePageResponse>>;
fetchTurn: (traceId: string, options?: WorkbenchTurnQueryOptions) => Promise<ApiResult<AgentChatResultResponse>>;
fetchTraceEvents: (traceId: string, options?: WorkbenchTraceEventsQueryOptions) => Promise<ApiResult<AgentChatResultResponse>>;
fetchProviderProfiles: (options?: QueryRunOptions) => Promise<ApiResult<unknown>>;
invalidateSessionList: () => Promise<unknown>;
invalidateSession: (sessionId: string | null | undefined) => Promise<unknown>;
invalidateSessionMessages: (sessionId: string | null | undefined) => Promise<unknown>;
invalidateTurn: (traceId: string | null | undefined) => Promise<unknown>;
invalidateTraceEvents: (traceId: string | null | undefined) => Promise<unknown>;
invalidateTraceScope: (input: { sessionId?: string | null; traceId?: string | null }) => Promise<unknown[]>;
}
@@ -52,13 +43,10 @@ export function useWorkbenchColadaQueries(): WorkbenchColadaQueries {
fetchSessions: (options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.sessions(options), () => api.workbench.sessions(options), options),
fetchSession: (sessionId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.session(sessionId), () => api.workbench.session(sessionId, { timeoutMs: options.timeoutMs ?? null, includeMessages: false }), options),
fetchSessionMessages: (sessionId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.sessionMessages(sessionId, options), () => api.workbench.sessionMessages(sessionId, options), options),
fetchTurn: (traceId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.turn(traceId), () => api.workbench.turn(traceId, options.timeoutMs ?? 8000, options.activityRef), options),
fetchTraceEvents: (traceId, options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.traceEvents(traceId, options), () => api.workbench.traceEvents(traceId, options.timeoutMs ?? 8000, options.activityRef, { afterProjectedSeq: options.afterProjectedSeq, limit: options.limit }), options),
fetchProviderProfiles: (options = {}) => runWorkbenchQuery(queryCache, workbenchColadaKeys.providerProfiles(), () => api.providerProfiles.catalog(), options),
invalidateSessionList: () => queryCache.invalidateQueries({ key: workbenchColadaKeys.sessionsRoot() }),
invalidateSession: (sessionId) => sessionId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.session(sessionId) }) : Promise.resolve(),
invalidateSessionMessages: (sessionId) => sessionId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.sessionMessagesRoot().concat(sessionId) }) : Promise.resolve(),
invalidateTurn: (traceId) => traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.turn(traceId) }) : Promise.resolve(),
invalidateTraceEvents: (traceId) => traceId ? queryCache.invalidateQueries({ key: workbenchColadaKeys.traceEventsRoot().concat(traceId) }) : Promise.resolve(),
invalidateTraceScope: async (input) => Promise.all([
queryCache.invalidateQueries({ key: workbenchColadaKeys.snapshotRoot() }),
@@ -137,7 +137,8 @@ test("workbench active terminal paths seal final response from turn authority",
const projectBlock = source.slice(source.indexOf("function projectTurnAuthorityToMessages"), source.indexOf("async function submitMessage"));
const realtimeTurnBlock = source.slice(source.indexOf("function applyRealtimeTurnSnapshot"), source.indexOf("function scheduleRealtimeTurnProjection"));
const realtimeTurnProjectionBlock = source.slice(source.indexOf("function flushRealtimeTurnProjection"), source.indexOf("function installRealtimeVisibilityHandler"));
const completeBlock = source.slice(source.indexOf("function completeTrace"), source.indexOf("async function hydrateTerminalMessageDiagnostics"));
const completeBlock = source.slice(source.indexOf("function completeTrace"), source.indexOf("function applyTerminalResultDiagnostics"));
const terminalDiagnosticsBlock = source.slice(source.indexOf("function applyTerminalResultDiagnostics"), source.indexOf("function applyRealtimeProjectionError"));
const crossTabSyncBlock = source.slice(source.indexOf("async function refreshCrossTabSessionFromSyncReplay"), source.indexOf("function completeTrace"));
const sessionDetailReadBlock = source.slice(source.indexOf("function fetchSessionDetailPage"), source.indexOf("function sessionMessageProjectionWindowLimit"));
const traceDetailReadBlock = source.slice(source.indexOf("async function readTraceEventsForExplicitDetailPages"), source.indexOf("async function fetchTraceDetailEventsPage"));
@@ -147,7 +148,7 @@ test("workbench active terminal paths seal final response from turn authority",
assert.match(projectBlock, /terminalAuthorityMessageFromTurnResult\(result\)/u);
assert.match(projectBlock, /type:\s*"message\.upsert"/u);
assert.match(projectBlock, /\.\.\.terminalPatch/u);
assert.match(source, /createKeyedSingleflight<ApiResult<AgentChatResultResponse>>/u);
assert.match(source, /const traceDetailReadSingleflight = createKeyedSingleflight<void>\(\)/u);
assert.match(source, /traceDetailReadSingleflight\.run/u);
assert.doesNotMatch(source, new RegExp(["refresh", "RealtimeSessionMessages"].join(""), "u"));
assert.match(realtimeTurnBlock, /rememberTurnStatus\(traceId, result\)[\s\S]*scheduleRealtimeTurnProjection\(\{ traceId, result, terminalTurn \}\)/u);
@@ -156,7 +157,8 @@ test("workbench active terminal paths seal final response from turn authority",
assert.match(realtimeTurnProjectionBlock, /workbench_realtime_turn_projection_budget/u);
assert.doesNotMatch(realtimeTurnProjectionBlock, /refreshWorkbenchSyncReplay|refreshTerminalTraceFromSyncReplay/u);
assert.match(completeBlock, /projectTurnAuthorityToMessages\(traceId, result, "complete-trace"\)/u);
assert.match(completeBlock, /options\.forceRead[\s\S]*refreshMessageProjectionForTrace\(ownerSessionId, traceId, \{ force: true \}\)/u);
assert.doesNotMatch(completeBlock, /forceRead|refreshMessageProjectionForTrace|fetchWorkbenchTurnStatus|refreshTurnStatusByTraceId/u);
assert.doesNotMatch(terminalDiagnosticsBlock, /readTraceEventsForMessages|fetchSessionMessagesPage|fetchWorkbenchTurnStatus/u);
assert.doesNotMatch(completeBlock, /scheduleActiveTraceSyncReplay|refreshWorkbenchSyncReplay/u);
assert.match(source.slice(source.indexOf("function handleWorkbenchProjectionSignal"), source.indexOf("function applyTraceSnapshot")), /restartRealtime\("cross-tab-session-projection", true\)/u);
assert.doesNotMatch(source, /scheduleActiveTraceSyncReplay|refreshActiveTraceFromSyncReplay/u);
@@ -169,6 +171,8 @@ test("workbench active terminal paths seal final response from turn authority",
assert.match(loadBlock, /fetchSessionDetailPage\(requestId, \{ reason: "load-session:detail", force: true \}\)/u);
assert.match(loadBlock, /sessionFromWorkbenchSession\(detail\.data\?\.session, \{ includeMessages: false \}\)/u);
assert.match(loadBlock, /const fallbackMessages = seed\?\.sessionId === id \? seed\.messages \?\? \[\] : \[\];/u);
assert.match(loadBlock, /fetchSessionMessagesPage\(normalizedRequestId, \{ limit: messageLimit, reason: "load-session:messages", force: true \}\)/u);
assert.doesNotMatch(loadBlock, /messages:\s*\[\]/u);
assert.doesNotMatch(loadBlock, /limit:\s*100/u);
assert.match(restoreSealBlock, /workbench_restored_turn_auto_read_disabled/u);
assert.doesNotMatch(restoreSealBlock, /fetchWorkbenchTurnStatus|Promise\.all/u);
@@ -181,7 +185,7 @@ test("workbench active SSE path only recovers by reconnecting the events cursor"
source.slice(source.indexOf("async function submitMessage"), source.indexOf("async function cancelAgentMessage")),
source.slice(source.indexOf("function reattachTrace"), source.indexOf("function restartRealtime")),
source.slice(source.indexOf("function flushRealtimeTurnProjection"), source.indexOf("function installRealtimeVisibilityHandler")),
source.slice(source.indexOf("function completeTrace"), source.indexOf("async function hydrateTerminalMessageDiagnostics")),
source.slice(source.indexOf("function completeTrace"), source.indexOf("function applyTerminalResultDiagnostics")),
source.slice(source.indexOf("async function sealRestoredActiveTurnMessages"), source.indexOf("function reattachRestoredActiveTrace"))
].join("\n");
@@ -1,13 +1,7 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import type { ChatMessage } from "@/types";
import { projectionMergeCommitSummary, sessionDetailAutoReadDecision, traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey } from "./workbench-session-messages-read-budget";
import { selectActiveTurnStatusRefreshTraceIds } from "./workbench-session";
function message(id: string): ChatMessage {
return { id, role: "agent", text: "", status: "running", createdAt: "2026-07-02T00:00:00.000Z" } as ChatMessage;
}
import { traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey } from "./workbench-session-messages-read-budget";
test("session messages singleflight key separates limit and force class", () => {
const limit8 = workbenchSessionMessagesReadKey({ sessionId: "ses_key", limit: 8, force: false });
@@ -18,7 +12,6 @@ test("session messages singleflight key separates limit and force class", () =>
assert.notEqual(limit20, force20);
assert.equal(limit20, workbenchSessionMessagesReadKey({ sessionId: "ses_key", limit: 20, force: false }));
});
test("session detail singleflight key separates force freshness class", () => {
const fresh = workbenchSessionDetailReadKey({ sessionId: "ses_key", force: false });
const forced = workbenchSessionDetailReadKey({ sessionId: "ses_key", force: true });
@@ -26,7 +19,6 @@ test("session detail singleflight key separates force freshness class", () => {
assert.notEqual(fresh, forced);
assert.equal(fresh, workbenchSessionDetailReadKey({ sessionId: "ses_key" }));
});
test("trace events auto read skips initial and completed equivalent ranges", () => {
const key = workbenchTraceEventsReadKey({ traceId: "trc_range", afterProjectedSeq: 12, limit: 50 });
@@ -35,25 +27,3 @@ test("trace events auto read skips initial and completed equivalent ranges", ()
assert.equal(traceEventsAutoReadDecision({ traceId: "trc_range", afterProjectedSeq: 12, limit: 50, cachedRange: { traceId: "trc_range", afterProjectedSeq: 12, limit: 50, nextProjectedSeq: 40, hasMore: true } }).read, true);
assert.equal(key, workbenchTraceEventsReadKey({ traceId: "trc_range", afterProjectedSeq: 12, limit: 50 }));
});
test("session detail auto read defers trace scoped realtime refresh to turn authority", () => {
assert.deepEqual(sessionDetailAutoReadDecision({ traceId: "trc_turn", nowMs: 10_000, minIntervalMs: 5_000 }), { read: false, reason: "turn-authority-first" });
assert.deepEqual(sessionDetailAutoReadDecision({ nowMs: 10_000, lastReadAtMs: 9_000, minIntervalMs: 5_000 }), { read: false, reason: "min-interval" });
assert.deepEqual(sessionDetailAutoReadDecision({ nowMs: 10_000, lastReadAtMs: 1_000, minIntervalMs: 5_000 }), { read: true, reason: "metadata-only" });
});
test("projection merge summary reports no write without blocking follow-up hydrate", () => {
const existing = [{ ...message("msg_same"), traceId: "trc_current", sessionId: "ses_same" }];
const summary = projectionMergeCommitSummary(existing, existing);
const hydrateTraceIds = selectActiveTurnStatusRefreshTraceIds({ messages: existing, currentRequestTraceId: "trc_current", limit: 1 });
assert.deepEqual(summary, { changed: false, changedCount: 0 });
assert.deepEqual(hydrateTraceIds, ["trc_current"]);
});
test("projection merge summary reports changed message references", () => {
const existing = [message("msg_old")];
const merged = [message("msg_new")];
assert.deepEqual(projectionMergeCommitSummary(existing, merged), { changed: true, changedCount: 1 });
});
@@ -1,7 +1,6 @@
// SPEC: pikasTech/HWLAB#2356 Workbench bounded request storm follow-up.
// Responsibility: Pure budgeting helpers for Workbench detail read de-duplication and projection merge writes.
import type { ChatMessage } from "@/types";
import { firstNonEmptyString } from "@/utils";
import { composeWorkbenchScopedKey } from "@/utils/workbench-key";
@@ -16,11 +15,6 @@ export interface SessionDetailReadKeyInput {
force?: boolean | null;
}
export interface ProjectionMergeCommitSummary {
changed: boolean;
changedCount: number;
}
export interface TraceEventsReadRangeInput {
traceId: string | null | undefined;
afterProjectedSeq: number | null | undefined;
@@ -38,15 +32,6 @@ export interface TraceEventsAutoReadDecisionInput extends TraceEventsReadRangeIn
cachedRange?: TraceEventsReadRangeRecord | null;
}
export interface SessionDetailAutoReadDecisionInput {
force?: boolean | null;
traceId?: string | null;
terminalBodyVisible?: boolean | null;
lastReadAtMs?: number | null;
nowMs: number;
minIntervalMs: number;
}
export interface ReadDetailDecision {
read: boolean;
reason: string;
@@ -89,25 +74,6 @@ export function traceEventsAutoReadDecision(input: TraceEventsAutoReadDecisionIn
return { read: true, reason: "delta-range" };
}
export function sessionDetailAutoReadDecision(input: SessionDetailAutoReadDecisionInput): ReadDetailDecision {
if (input.force === true) return { read: true, reason: "force" };
if (input.terminalBodyVisible === true) return { read: false, reason: "terminal-visible" };
if (firstNonEmptyString(input.traceId)) return { read: false, reason: "turn-authority-first" };
const lastReadAtMs = Number(input.lastReadAtMs);
const minIntervalMs = Math.max(0, Math.trunc(Number(input.minIntervalMs)));
if (Number.isFinite(lastReadAtMs) && input.nowMs - lastReadAtMs < minIntervalMs) return { read: false, reason: "min-interval" };
return { read: true, reason: "metadata-only" };
}
export function projectionMergeCommitSummary(existing: ChatMessage[], merged: ChatMessage[]): ProjectionMergeCommitSummary {
let changedCount = Math.max(0, merged.length - existing.length);
const sharedLength = Math.min(existing.length, merged.length);
for (let index = 0; index < sharedLength; index += 1) {
if (existing[index] !== merged[index]) changedCount += 1;
}
return { changed: changedCount > 0, changedCount };
}
function sessionMessagesReadLimitPart(value: number | null | undefined): number {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 1;
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "bun:test";
import type { ChatMessage, WorkbenchSessionRecord } from "../types";
import { resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, sessionToSessionTab, shouldReadTerminalTraceDetail } from "./workbench-session";
import { resolveCancelableAgentMessage, resolveComposerState, sessionToSessionTab, shouldReadTerminalTraceDetail } from "./workbench-session";
import type { TurnStatusAuthority } from "./workbench-session";
function agentMessage(input: Partial<ChatMessage> & Pick<ChatMessage, "id" | "traceId" | "sessionId">): ChatMessage {
@@ -96,22 +96,6 @@ test("cancel target remains available for failed message until terminal body exi
assert.equal(resolveCancelableAgentMessage({ messages: [sealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "running", running: true, terminal: false }) } }), null);
});
test("turn status refresh selector keeps automatic hydrate bounded to the active trace", () => {
const sessionId = "ses_turn_hydrate_budget";
const older = agentMessage({ id: "msg_older", traceId: "trc_older", sessionId, status: "running" });
const current = agentMessage({ id: "msg_current", traceId: "trc_current", sessionId, status: "running" });
assert.deepEqual(selectActiveTurnStatusRefreshTraceIds({ messages: [older, current], currentRequestTraceId: current.traceId, limit: 1 }), [current.traceId]);
});
test("turn status refresh selector skips sealed terminal traces", () => {
const sessionId = "ses_turn_detail_sealed";
const traceId = "trc_sealed";
const sealed = agentMessage({ id: "msg_sealed", traceId, sessionId, status: "completed", text: "final" });
assert.deepEqual(selectActiveTurnStatusRefreshTraceIds({ messages: [sealed], currentRequestTraceId: traceId, turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "completed", running: false, terminal: true }) }, limit: 1 }), []);
});
test("terminal detail read skips sealed terminal trace authority", () => {
const sessionId = "ses_terminal_detail_sealed";
const traceId = "trc_terminal_detail_sealed";
@@ -117,22 +117,6 @@ export function resolveCancelableAgentMessage(input: { messages: ChatMessage[];
return null;
}
export function selectActiveTurnStatusRefreshTraceIds(input: { messages: ChatMessage[]; currentRequestTraceId?: string | null; turnStatusAuthority?: TurnStatusAuthorityMap; limit?: number }): string[] {
const traceIds = new Set<string>();
const limit = Math.max(1, Math.trunc(input.limit ?? 3));
const currentRequestTraceId = firstNonEmptyString(input.currentRequestTraceId);
if (currentRequestTraceId && traceNeedsTurnStatusRefresh(currentRequestTraceId, input.messages, input.turnStatusAuthority)) traceIds.add(currentRequestTraceId);
if (traceIds.size >= limit) return [...traceIds];
for (const message of [...input.messages].reverse()) {
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId || traceIds.has(traceId)) continue;
if (!messageHasActiveTurnStatusEvidence(message, traceId, currentRequestTraceId)) continue;
if (traceNeedsTurnStatusRefresh(traceId, input.messages, input.turnStatusAuthority)) traceIds.add(traceId);
if (traceIds.size >= limit) break;
}
return [...traceIds];
}
export function shouldReadTerminalTraceDetail(input: { traceId?: string | null; messages: ChatMessage[]; turnStatusAuthority?: TurnStatusAuthorityMap }): boolean {
const traceId = firstNonEmptyString(input.traceId);
if (!traceId) return false;
@@ -317,21 +301,6 @@ function latestMessageForTrace(messages: ChatMessage[], traceId: string): ChatMe
return [...messages].reverse().find((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) === traceId) ?? null;
}
function messageHasActiveTurnStatusEvidence(message: ChatMessage, traceId: string, currentRequestTraceId: string | null): boolean {
if (currentRequestTraceId === traceId) return true;
return isActiveStatus(message.status) || message.traceAutoLifecycle === "running";
}
function traceNeedsTurnStatusRefresh(traceId: string | null | undefined, messages: ChatMessage[], turnStatusAuthority: TurnStatusAuthorityMap | undefined): boolean {
const id = firstNonEmptyString(traceId);
if (!id) return false;
const turn = turnStatusAuthority?.[id];
const message = latestMessageForTrace(messages, id);
if (turnStatusAuthorityIsSealed(turn, message)) return false;
if (messageIsSealedTerminal(message)) return false;
return true;
}
function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined, latestAgent: ChatMessage | null = latestAgentMessage(session.messages)): string {
const latestTraceId = firstNonEmptyString(latestAgent?.traceId, latestAgent?.runnerTrace?.traceId);
const authorityTraceId = firstNonEmptyString(authority?.lastTraceId);
+14 -182
View File
@@ -17,14 +17,14 @@ import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, Ap
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { composeWorkbenchScopedKey, workbenchRealtimeScopeKey } from "@/utils/workbench-key";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, recordWorkbenchRuntimeDiagnostic, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache";
import { reduceWorkbenchRealtimeEvent, type WorkbenchRealtimeAction } from "./workbench-event-reducer";
import { projectRejectedWorkbenchAdmission } from "./workbench-admission-failure";
import { messageHasSealedTerminalResult, messageIsSealedTerminal, traceAuthorityIsSealed } from "./workbench-terminal-authority";
import { boundedProjectionMessageLimit, mergeBoundedProjectionMessages, selectProjectionMessageWindow, traceProjectionIsTerminalSealed } from "./workbench-message-projection-budget";
import { boundedProjectionMessageLimit, traceProjectionIsTerminalSealed } from "./workbench-message-projection-budget";
import {
agentErrorDisplayText,
agentErrorFromApiFailure,
@@ -37,7 +37,6 @@ import {
isTerminalMessageStatus,
isTraceActiveStatus,
messageHasTerminalResponse,
messageNeedsTerminalDiagnostics,
messageNeedsTraceDetailRead,
messageStatusPatchForTerminalMerge,
terminalAuthorityMessageFromTurnResult,
@@ -46,7 +45,6 @@ import {
messageTimingPatch,
messageTimingPatchForMerge,
messageTimingPatchFromProjection,
messageTimingSealPatchForProjectionMerge,
mergeTerminalResultTrace,
nonBlockingProjection,
normalizeAgentError,
@@ -63,7 +61,6 @@ import {
terminalMessageTimingPatchForNormalize,
turnResultIsTerminalForMerge,
turnResultStatusForMerge,
traceHasTerminalResponse,
traceHasEvents,
traceResultHasTerminalEvidence,
} from "./workbench-message-projection-runtime";
@@ -71,7 +68,7 @@ import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type Workben
import { useWorkbenchColadaMutations } from "./workbench-colada-mutations";
import { useWorkbenchColadaQueries } from "./workbench-colada-queries";
import { useWorkbenchColadaReducer } from "./workbench-colada-reducer";
import { projectionMergeCommitSummary, traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey, type TraceEventsReadRangeRecord } from "./workbench-session-messages-read-budget";
import { traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey, type TraceEventsReadRangeRecord } from "./workbench-session-messages-read-budget";
import { realtimeSnapshotToTraceSnapshot, terminalSealResultWithoutTraceEvents, traceDetailProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-detail";
import { appendRawHwlabIngressFrame, createRawHwlabIngressState } from "./workbench-raw-hwlab-ingress";
@@ -121,7 +118,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const workbenchColadaReducer = useWorkbenchColadaReducer();
const workbenchColadaQueries = useWorkbenchColadaQueries();
const workbenchColadaMutations = useWorkbenchColadaMutations();
const turnStatusReadSingleflight = createKeyedSingleflight<ApiResult<AgentChatResultResponse>>();
const traceDetailReadSingleflight = createKeyedSingleflight<void>();
const sessionMessagesReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchMessagePageResponse>>();
const sessionDetailReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchSessionDetailResponse>>();
@@ -386,15 +382,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (sessions.value.length === 0) error.value = response.error ?? "session list unavailable";
}
function scheduleSessionListRefresh(includeSessionId: string | null | undefined = activeSessionId.value, delayMs = runtimePolicy.sessionListRealtimeRefreshDelayMs): void {
void includeSessionId;
if (typeof window === "undefined") {
void workbenchColadaQueries.invalidateSessionList();
return;
}
window.setTimeout(() => { void workbenchColadaQueries.invalidateSessionList(); }, delayMs);
}
async function loadMoreSessions(): Promise<void> {
if (sessionListLoadingMore.value || !sessionListHasMore.value) return;
const cursor = sessionListNextCursor.value;
@@ -516,11 +503,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void source;
}
function fetchWorkbenchTurnStatus(traceId: string, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
const activitySource = useActivityTimeout ? () => activityRef.value : null;
return turnStatusReadSingleflight.run(traceId, () => workbenchColadaQueries.fetchTurn(traceId, { timeoutMs: 8000, activityRef: activitySource, minIntervalMs: runtimePolicy.workbenchTurnStatusMinRefreshMs, force: options.force }), { reason: options.force ? "force-turn-status" : "turn-status" });
}
function fetchWorkbenchTraceEvents(traceId: string, afterProjectedSeq: number, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
const activitySource = useActivityTimeout ? () => activityRef.value : null;
return workbenchColadaQueries.fetchTraceEvents(traceId, { timeoutMs: runtimePolicy.workbenchTraceEventsTimeoutMs, activityRef: activitySource, afterProjectedSeq, limit: runtimePolicy.traceDetailPageLimit, minIntervalMs: runtimePolicy.workbenchTraceEventsMinRefreshMs, force: options.force });
@@ -537,25 +519,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return isTraceActiveStatus(turn?.status) || isTraceActiveStatus(message?.status);
}
async function refreshMessageProjectionForTrace(sessionId: string | null | undefined, traceId: string, options: { force?: boolean } = {}): Promise<void> {
const id = normalizeWorkbenchSessionId(sessionId);
if (!id) return;
const existing = serverState.value.messagesBySessionId[id] ?? [];
if (traceProjectionIsTerminalSealed(traceId, existing)) return;
const response = await fetchSessionMessagesPage(id, { limit: traceMessageProjectionWindowLimit(), reason: `trace-message-page:${traceId}`, force: options.force });
if (!response.ok || !response.data) {
if (shouldSuppressTransientWorkbenchReadFailure(response)) return;
if (traceHasTerminalResponse(traceId, messages.value)) return;
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(response, { code: "message_projection_refresh_failed", message: response.error ?? "消息投影刷新失败,主消息正文保持上一份 canonical projection。", health: response.status === 0 ? "unavailable" : "degraded" }));
return;
}
const pageMessages = Array.isArray(response.data.messages) ? response.data.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : [];
const merged = mergeMessageProjectionPage(id, pageMessages, { traceId, limit: traceMessageProjectionWindowLimit() });
commitMergedSessionMessages(id, pageMessages, merged, { traceId, limit: traceMessageProjectionWindowLimit(), reason: "trace-message-page" });
await hydrateTurnStatusAuthority(merged, { traceId, limit: 1, reason: "trace-message-page" });
if (!traceProjectionIsTerminalSealed(traceId, merged)) readTerminalTraceDetailGaps(merged, `trace-message-page:${traceId}`);
}
function fetchSessionMessagesPage(sessionId: string, options: SessionMessagesReadOptions): Promise<ApiResult<WorkbenchMessagePageResponse>> {
const key = workbenchSessionMessagesReadKey({ sessionId, limit: options.limit, force: options.force });
return sessionMessagesReadSingleflight.run(key, () => workbenchColadaQueries.fetchSessionMessages(sessionId, { limit: options.limit, minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs, force: options.force }), { reason: options.reason });
@@ -570,121 +533,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return boundedProjectionMessageLimit(runtimePolicy.workbenchSessionMessagesWindowLimit, runtimePolicy.sessionListPageLimit);
}
function traceMessageProjectionWindowLimit(): number {
return boundedProjectionMessageLimit(runtimePolicy.workbenchTraceMessagesWindowLimit, runtimePolicy.traceDetailAutoQueueLimit);
}
function mergeMessageProjectionPage(sessionId: string, pageMessages: ChatMessage[], options: { traceId?: string | null; limit: number }): ChatMessage[] {
const existing = serverState.value.messagesBySessionId[sessionId] ?? [];
const windowMessages = selectProjectionMessageWindow(pageMessages, options);
const incoming = windowMessages.map((message) => mergeMessageProjectionMessage(message, existing));
return mergeBoundedProjectionMessages(existing, incoming);
}
function commitMergedSessionMessages(sessionId: string, incoming: ChatMessage[], merged: ChatMessage[], options: { traceId?: string | null; limit: number; reason: string }): void {
const existing = serverState.value.messagesBySessionId[sessionId] ?? [];
const summary = projectionMergeCommitSummary(existing, merged);
recordWorkbenchRuntimeDiagnostic({
module: "workbench-message-projection",
sessionId,
traceId: firstNonEmptyString(options.traceId) ?? null,
outcome: "ok",
diagnostic: {
code: "workbench_message_projection_merge",
reason: options.reason,
source: "session-messages-page",
inputCount: incoming.length,
existingCount: existing.length,
mergedCount: merged.length,
changedCount: summary.changedCount,
limit: options.limit,
valuesRedacted: true
}
});
if (summary.changed) rememberSessionMessages(sessionId, merged);
}
function mergeMessageProjectionMessage(message: ChatMessage, existing: ChatMessage[]): ChatMessage {
const previous = findExistingProjectionMessage(message, existing);
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId, previous?.traceId, previous?.runnerTrace?.traceId);
const authority = traceId ? traceAuthorityById.value[traceId] ?? null : null;
const preservedTrace = authority ?? previous?.runnerTrace ?? null;
const timingSealPatch = messageTimingSealPatchForProjectionMerge(message, previous);
if (!preservedTrace) return { ...message, ...timingSealPatch };
const runnerTrace = message.runnerTrace ? mergeRunnerTrace(message.runnerTrace, preservedTrace) : preservedTrace;
return { ...message, ...timingSealPatch, runnerTrace };
}
function findExistingProjectionMessage(message: ChatMessage, existing: ChatMessage[]): ChatMessage | null {
const messageId = firstNonEmptyString(message.messageId, message.id);
if (messageId) {
const byId = existing.find((item) => firstNonEmptyString(item.messageId, item.id) === messageId);
if (byId) return byId;
}
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return null;
return existing.find((item) => item.role === message.role && firstNonEmptyString(item.traceId, item.runnerTrace?.traceId) === traceId) ?? null;
}
async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value, options: { traceId?: string | null; limit?: number; reason?: string; force?: boolean } = {}): Promise<void> {
const traceIds = activeTurnStatusRefreshTraceIds(source, options);
if (options.force !== true) {
if (traceIds.length > 0) {
recordWorkbenchRuntimeDiagnostic({
module: "workbench-turn-status",
traceId: options.traceId ?? traceIds[0] ?? null,
outcome: "ok",
diagnostic: { code: "workbench_turn_status_auto_read_disabled", reason: options.reason ?? "auto-turn-status-hydrate", source: "projection-sse-authority", requestedCount: traceIds.length, valuesRedacted: true }
});
}
return;
}
for (const traceId of traceIds) {
await refreshTurnStatusByTraceId(traceId);
}
}
function activeTurnStatusRefreshTraceIds(source: ChatMessage[] = messages.value, options: { traceId?: string | null; limit?: number; reason?: string; force?: boolean } = {}): string[] {
const requestedTraceId = firstNonEmptyString(options.traceId, currentRequest.value?.traceId);
const traceIds = selectActiveTurnStatusRefreshTraceIds({ messages: source, currentRequestTraceId: requestedTraceId, turnStatusAuthority: turnStatusAuthority.value, limit: options.limit ?? 1 });
recordWorkbenchRuntimeDiagnostic({
module: "workbench-turn-status",
traceId: requestedTraceId ?? traceIds[0] ?? null,
outcome: "ok",
diagnostic: {
code: "workbench_turn_status_hydrate_budget",
reason: options.reason ?? "auto-turn-status-hydrate",
source: "session-message-projection",
requestedCount: traceIds.length,
limit: options.limit ?? 1,
valuesRedacted: true
}
});
return traceIds;
}
async function refreshTurnStatusByTraceId(traceId: string | null | undefined, options: { force?: boolean } = {}): Promise<void> {
const id = firstNonEmptyString(traceId);
if (!id) return;
if (options.force !== true) {
recordWorkbenchRuntimeDiagnostic({ module: "workbench-turn-status", traceId: id, outcome: "ok", diagnostic: { code: "workbench_turn_status_auto_read_disabled", source: "projection-sse-authority", valuesRedacted: true } });
return;
}
if (traceTerminalBodyIsVisible(id)) {
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", traceId: id, outcome: "ok", diagnostic: { code: "terminal_low_priority_turn_skip", source: "turn-status", valuesRedacted: true } });
return;
}
const response = await fetchWorkbenchTurnStatus(id, shouldUseActivityTimeoutForTrace(id), { force: options.force });
if (response.ok && response.data) {
applyTurnStatusSnapshot(id, response.data);
if (turnResultIsTerminalForMerge(response.data as AgentChatResultResponse)) completeTrace(id, response.data, { forceRead: options.force });
return;
}
if (shouldSuppressTransientWorkbenchReadFailure(response)) return;
if (traceHasTerminalResponse(id, messages.value)) return;
applyProjectionDiagnostic(id, projectionDiagnosticFromApiFailure(response, { code: "turn_status_poll_failed", message: response.error ?? "状态更新失败,当前 turn 状态暂不可见。", health: response.status === 0 ? "unavailable" : "degraded" }));
}
function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
const ownerSessionId = traceOwnerSessionId(traceId, traceResultSessionId(result));
if (!ownerSessionId) return;
@@ -816,7 +664,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
status: "running"
};
void readTraceEventsForMessages(messages.value);
scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
restartRealtime("steer");
return true;
}
@@ -838,7 +685,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
};
applyTurnStatusSnapshot(canonicalTraceId, response.data);
publishWorkbenchProjectionSignal(sessionId, canonicalTraceId, "submit-admitted");
scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
if (turnResultIsTerminalForMerge(response.data as AgentChatResultResponse)) {
completeTrace(canonicalTraceId, response.data as AgentChatResultResponse);
return true;
@@ -860,7 +706,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (message.status === "running") chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "cancel-agent-message");
scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
}
async function cancelRunningTrace(): Promise<void> {
@@ -1465,10 +1310,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return { ...message, ...messageTimingPatchForMerge(message, trace), ...messageStatusPatchForTerminalMerge(message, traceStatus, terminal), runnerTrace, error: clearCompletedDiagnostics ? null : error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, updatedAt: new Date().toISOString() };
}));
markWorkbenchTraceProjected(traceId);
if (!traceProjectionIsTerminalSealed(traceId, serverState.value.messagesBySessionId[ownerSessionId] ?? [])) scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListRealtimeRefreshDelayMs);
}
function completeTrace(traceId: string, result: AgentChatResultResponse, options: { forceRead?: boolean } = {}): void {
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
if (!ownerSessionId) return;
@@ -1476,25 +1320,12 @@ export const useWorkbenchStore = defineStore("workbench", () => {
projectTurnAuthorityToMessages(traceId, result, "complete-trace");
rememberTurnStatus(traceId, result);
markWorkbenchTraceProjected(traceId);
const ownerMessages = serverState.value.messagesBySessionId[ownerSessionId] ?? [];
const terminalMessage = latestMessageForTrace(traceId, ownerMessages);
if (!traceProjectionIsTerminalSealed(traceId, ownerMessages)) {
if (options.forceRead) void refreshMessageProjectionForTrace(ownerSessionId, traceId, { force: true });
}
if (options.forceRead && terminalMessage && !traceProjectionIsTerminalSealed(traceId, ownerMessages)) void readTraceEventsForMessage(terminalMessage, { force: true });
if (ownerSessionId === activeSessionId.value) {
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-terminal");
restartRealtime("trace-terminal");
}
if (!traceProjectionIsTerminalSealed(traceId, serverState.value.messagesBySessionId[ownerSessionId] ?? [])) scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
}
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
const targets = messages.value.filter(messageNeedsTerminalDiagnostics).slice(-6);
void targets;
void readTraceEventsForMessages(messages.value);
}
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
@@ -1516,8 +1347,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null);
return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
void readTraceEventsForMessages(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
}
function applyRealtimeProjectionError(event: WorkbenchRealtimeEvent): void {
@@ -1527,7 +1356,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const code = firstNonEmptyString(errorRecord.code, "workbench_realtime_error") ?? "workbench_realtime_error";
const phase = firstNonEmptyString(event.phase, errorRecord.phase, "realtime") ?? "realtime";
const message = firstNonEmptyString(errorRecord.message, errorRecord.summary, "Workbench 实时状态更新异常,最新运行记录暂不可见。") ?? "Workbench 实时状态更新异常,最新运行记录暂不可见。";
error.value = `${phase} · ${code} · ${message}`;
error.value = `${phase} · ${code} · ${message}`;
return;
}
const projection = normalizeProjectionDiagnostic(event) ?? projectionDiagnosticFromFailure({ code: firstNonEmptyString(errorRecord.code, "workbench_realtime_error") ?? "workbench_realtime_error", message: firstNonEmptyString(errorRecord.message, errorRecord.summary, "Workbench 实时状态更新异常,最新运行记录暂不可见。") ?? "Workbench 实时状态更新异常,最新运行记录暂不可见。", health: "degraded", diagnostic: normalizeErrorDiagnostic(errorRecord.diagnostic, recordValue(event.projection)?.diagnostic, recordValue(event)?.diagnostic), apiError: normalizeApiErrorRecord(errorRecord, "Workbench 实时状态更新异常,最新运行记录暂不可见。") });
@@ -1558,7 +1387,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-infrastructure-error");
scheduleSessionListRefresh(selectedSessionId.value, runtimePolicy.sessionListTerminalRefreshDelayMs);
}
function projectRejectedAdmissionFailure(input: { traceId: string; message: string; error: ChatMessage["error"]; projection: ProjectionDiagnostic; submittedAt: string }): void {
@@ -1700,8 +1528,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
rememberSessionDetail({ ...session, messages: projectedMessages, messageCount: session.messageCount ?? projectedMessages.length });
sessionsReady.value = true;
currentRequest.value = null;
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
void readTraceEventsForMessages(messages.value);
reattachRestoredActiveTrace();
restartRealtime("apply-selected-session");
@@ -1727,7 +1553,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const requestId = normalizeWorkbenchSessionRouteId(sessionId);
if (!requestId) return null;
const normalizedRequestId = normalizeWorkbenchSessionId(requestId);
const detail = await fetchSessionDetailPage(requestId, { reason: "load-session:detail", force: true });
const messageLimit = sessionMessageProjectionWindowLimit();
const [detail, messagePage] = await Promise.all([
fetchSessionDetailPage(requestId, { reason: "load-session:detail", force: true }),
normalizedRequestId ? fetchSessionMessagesPage(normalizedRequestId, { limit: messageLimit, reason: "load-session:messages", force: true }) : Promise.resolve(null)
]);
if (!detail.ok) return null;
const detailSession = sessionFromWorkbenchSession(detail.data?.session, { includeMessages: false });
const id = detailSession?.sessionId ?? normalizedRequestId ?? seed?.sessionId;
@@ -1735,7 +1565,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const fallbackMessages = seed?.sessionId === id ? seed.messages ?? [] : [];
const base = detailSession ? { ...detailSession, messages: fallbackMessages } : seed;
if (!base) return null;
return { ...base, sessionId: id, messages: [], messageCount: base.messageCount ?? 0 };
const hydratedMessages = messagePage?.ok && Array.isArray(messagePage.data?.messages)
? messagePage.data.messages.map((message) => normalizeChatMessage(message as ChatMessage))
: fallbackMessages;
return { ...base, sessionId: id, messages: hydratedMessages, messageCount: base.messageCount ?? hydratedMessages.length };
}
async function sealRestoredActiveTurnMessages(source: ChatMessage[]): Promise<ChatMessage[]> {
@@ -1756,7 +1589,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function reattachRestoredActiveTrace(): void {
void readTraceEventsForMessages(messages.value);
const traceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
if (traceId) reattachTrace(traceId);
}
-3
View File
@@ -50,7 +50,6 @@ declare global {
/** @deprecated Use workbenchDetailReadConcurrency. */
workbenchReadHydrationConcurrency?: number;
workbenchReadFailureCooldownMs?: number;
workbenchTurnStatusMinRefreshMs?: number;
workbenchTraceEventsMinRefreshMs?: number;
workbenchSessionDetailMinRefreshMs?: number;
workbenchSessionMessagesMinRefreshMs?: number;
@@ -58,8 +57,6 @@ declare global {
workbenchTraceMessagesWindowLimit?: number;
workbenchRealtimeSessionMessagesMinRefreshMs?: number;
workbenchTraceEventsTimeoutMs?: number;
sessionListRealtimeRefreshDelayMs?: number;
sessionListTerminalRefreshDelayMs?: number;
sessionListMinRefreshIntervalMs?: number;
workbenchRealtimeErrorSyncReplayMinMs?: number;
/** @deprecated Use workbenchRealtimeErrorSyncReplayMinMs. */