From 3e03e94e3538649e5bf7671f2dd00f51a7620f08 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 03:55:47 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E5=9B=BA=E5=AE=9A=20Workbench=20tra?= =?UTF-8?q?nsactional=20=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/feature-config.schema.json | 44 ++++++ internal/cloud/kafka-event-bridge.test.ts | 77 +++++----- internal/cloud/kafka-event-bridge.ts | 138 ++++++------------ .../cloud/server-code-agent-admission-http.ts | 28 +--- .../cloud/server-workbench-realtime-http.ts | 10 +- .../cloud/workbench-projection-writer.test.ts | 83 +++++++++++ internal/cloud/workbench-projection-writer.ts | 18 +-- .../cloud/workbench-realtime-capabilities.ts | 42 ++---- internal/dev-entrypoint/cloud-web-runtime.mjs | 5 - .../dev-entrypoint/cloud-web-runtime.test.mjs | 38 +++-- .../scripts/workbench-server-state.test.ts | 6 +- web/hwlab-cloud-web/src/config/runtime.ts | 6 +- web/hwlab-cloud-web/src/types/global.d.ts | 5 - 13 files changed, 254 insertions(+), 246 deletions(-) create mode 100644 config/feature-config.schema.json diff --git a/config/feature-config.schema.json b/config/feature-config.schema.json new file mode 100644 index 00000000..e293b3a3 --- /dev/null +++ b/config/feature-config.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/pikasTech/HWLAB/config/feature-config.schema.json", + "title": "HWLAB product feature configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "tracePanel": { + "x-unidesk-feature": "workbench.trace-panel", + "type": "object", + "additionalProperties": false, + "properties": { + "autoExpandRunning": { "type": "boolean" }, + "autoCollapseTerminal": { "type": "boolean" } + } + }, + "rawEvents": { + "x-unidesk-feature": "workbench.raw-events", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "maxEntries": { "type": "integer", "minimum": 1 }, + "maxRetainedBytes": { "type": "integer", "minimum": 1 } + } + }, + "debugReplay": { + "x-unidesk-feature": "workbench.debug-replay", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" } + } + }, + "views": { + "x-unidesk-feature": "workbench.views", + "type": "object", + "additionalProperties": false, + "properties": { + "traceExplorerUrlTemplate": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/internal/cloud/kafka-event-bridge.test.ts b/internal/cloud/kafka-event-bridge.test.ts index 89512113..835d82c4 100644 --- a/internal/cloud/kafka-event-bridge.test.ts +++ b/internal/cloud/kafka-event-bridge.test.ts @@ -379,53 +379,42 @@ test("live Kafka OTel sampling is deterministic and bounded without payload attr assert.equal(JSON.stringify(attributes).includes("must-not-leak"), false); }); -test("composable Kafka capabilities reject consumer group collisions", () => { - assert.throws( - () => kafkaEventBridgeConfig({ - ...LIVE_ENV, - HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "true", - HWLAB_KAFKA_PROJECTOR_GROUP_ID: LIVE_ENV.HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID, - HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS: "1000" - }), - (error) => error?.code === "hwlab_kafka_consumer_groups_not_distinct" - ); +test("Workbench bridge ignores obsolete architecture env and fixes transactional authority", () => { + const config = kafkaEventBridgeConfig({ + ...PROJECTOR_ENV, + HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true", + HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true", + HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "true", + HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false", + HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false", + HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false" + }); + + assert.deepEqual(config?.capabilities, { + directPublish: false, + liveKafkaSse: false, + kafkaRefreshReplay: false, + transactionalProjector: true, + projectionOutboxRelay: true, + projectionRealtime: true + }); + assert.equal(config?.directPublishGroupId, null); + assert.equal(config?.hwlabEventGroupId, null); + assert.equal(config?.refreshReplay, null); }); -test("Workbench bridge fails closed unless transactional projection authority is complete", () => { - const invalidAuthorities = [ - { name: "direct/live-only", env: LIVE_ENV }, - { - name: "projection realtime only", - env: { - HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false", - HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "false", - HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "false", - HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false", - HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false", - HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "true" - } - }, - { name: "projector without outbox relay", env: { ...PROJECTOR_ENV, HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false" } }, - { name: "projector without projection realtime", env: { ...PROJECTOR_ENV, HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false" } }, - { - name: "direct and transactional dual authority", - env: { - ...PROJECTOR_ENV, - HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true", - HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true", - HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID: "hwlab-direct-authority-test", - HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID: "hwlab-live-authority-test" - } - } - ]; +test("Workbench bridge does not require the six obsolete architecture env", () => { + const env = { ...PROJECTOR_ENV }; + 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" + ]) delete env[name]; - for (const authority of invalidAuthorities) { - assert.throws( - () => startHwlabKafkaEventBridge({ env: authority.env, runtimeStore: {} }), - (error: any) => error?.code === "hwlab_workbench_transactional_authority_required", - authority.name - ); - } + assert.doesNotThrow(() => kafkaEventBridgeConfig(env)); }); test("projects AgentRun run-created envelope even when no source event is present", () => { diff --git a/internal/cloud/kafka-event-bridge.ts b/internal/cloud/kafka-event-bridge.ts index 225856f0..246c09e9 100644 --- a/internal/cloud/kafka-event-bridge.ts +++ b/internal/cloud/kafka-event-bridge.ts @@ -6,10 +6,7 @@ import { createHash, randomUUID } from "node:crypto"; import { Kafka, logLevel } from "kafkajs"; import { emitCodeAgentOtelSpan } from "./otel-trace.ts"; import { buildWorkbenchProjectionEventFacts } from "./workbench-projection-writer.ts"; -import { - WORKBENCH_REALTIME_CAPABILITY_ENVS, - workbenchRealtimeCapabilities -} from "./workbench-realtime-capabilities.ts"; +import { workbenchRealtimeCapabilities } from "./workbench-realtime-capabilities.ts"; const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); export const DEFAULT_AGENTRUN_EVENT_TOPIC = "agentrun.event.v1"; @@ -31,35 +28,20 @@ const REQUIRED_KAFKA_ENV = Object.freeze([ ]); export function kafkaEventBridgeConfig(env = process.env) { - const featureEnvPresent = Object.values(WORKBENCH_REALTIME_CAPABILITY_ENVS).some((name) => stringValue(env[name])); const legacyKafkaEnabled = truthy(env.HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED ?? env.HWLAB_KAFKA_AGENTRUN_CONSUME_ENABLED ?? env.HWLAB_KAFKA_ENABLED); - if (!featureEnvPresent && !legacyKafkaEnabled) return null; - const capabilities = workbenchRealtimeCapabilities(env); - if (capabilities.kafkaRefreshReplay && !capabilities.liveKafkaSse) { - throw contractError("hwlab_kafka_refresh_live_dependency_missing", "Kafka refresh replay requires the independently enabled live Kafka SSE capability for handoff."); - } - const kafkaEnabled = capabilities.directPublish || capabilities.liveKafkaSse || capabilities.kafkaRefreshReplay || capabilities.transactionalProjector || capabilities.projectionOutboxRelay; - const transactionalEnabled = capabilities.transactionalProjector || capabilities.projectionOutboxRelay || capabilities.projectionRealtime; - if (!kafkaEnabled && !transactionalEnabled) return null; - const required = [...REQUIRED_KAFKA_ENV]; - if (!kafkaEnabled) required.length = 0; - if (capabilities.directPublish) required.push("HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID"); - if (capabilities.liveKafkaSse) required.push("HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID"); - if (capabilities.kafkaRefreshReplay) required.push( - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT" - ); - if (capabilities.transactionalProjector) required.push("HWLAB_KAFKA_PROJECTOR_GROUP_ID", "HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS"); - if (capabilities.projectionOutboxRelay) required.push( + const kafkaConfigured = REQUIRED_KAFKA_ENV.every((name) => stringValue(env[name])); + if (!legacyKafkaEnabled && !kafkaConfigured) return null; + const capabilities = workbenchRealtimeCapabilities(); + const required = [ + ...REQUIRED_KAFKA_ENV, + "HWLAB_KAFKA_PROJECTOR_GROUP_ID", + "HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS", "HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS", "HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE", "HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS", "HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS", "HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS" - ); + ]; const missing = required.filter((name) => !stringValue(env[name])); if (missing.length > 0) { const error = new Error(`HWLAB Kafka capability configuration is incomplete: ${missing.join(", ")}`); @@ -71,47 +53,29 @@ export function kafkaEventBridgeConfig(env = process.env) { const agentRunTopic = stringValue(env.HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC); const hwlabTopic = stringValue(env.HWLAB_KAFKA_EVENT_TOPIC); const clientId = stringValue(env.HWLAB_KAFKA_CLIENT_ID); - const relay = capabilities.projectionOutboxRelay ? { + const relay = { intervalMs: strictPositiveInteger(env.HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS, "HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS"), batchSize: strictPositiveInteger(env.HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE, "HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE"), leaseMs: strictPositiveInteger(env.HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS, "HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS"), sendTimeoutMs: strictPositiveInteger(env.HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS, "HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS"), retryBackoffMs: strictPositiveInteger(env.HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS, "HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS") - } : null; - const refreshReplay = capabilities.kafkaRefreshReplay ? { - groupIdPrefix: stringValue(env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX), - timeoutMs: strictPositiveInteger(env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS"), - scanLimit: strictPositiveInteger(env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT"), - matchedEventLimit: strictPositiveInteger(env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT"), - liveBufferLimit: strictPositiveInteger(env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT") - } : null; + }; if (relay && relay.sendTimeoutMs + 5000 >= relay.leaseMs) { const error = new Error("HWLAB Kafka relay lease must exceed producer send timeout by at least 5000ms."); error.code = "hwlab_kafka_relay_lease_invalid"; throw error; } - const enabledConsumerGroups = [ - capabilities.directPublish ? stringValue(env.HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID) : null, - capabilities.transactionalProjector ? stringValue(env.HWLAB_KAFKA_PROJECTOR_GROUP_ID) : null, - capabilities.liveKafkaSse ? stringValue(env.HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID) : null - ].filter(Boolean); - if (new Set(enabledConsumerGroups).size !== enabledConsumerGroups.length) { - const error = new Error("HWLAB Kafka direct publisher, projector, and live SSE consumer groups must be distinct when enabled together."); - error.code = "hwlab_kafka_consumer_groups_not_distinct"; - error.groupIds = enabledConsumerGroups; - throw error; - } return { capabilities, brokers, agentRunTopic, hwlabTopic, clientId, - directPublishGroupId: stringValue(env.HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID), + directPublishGroupId: null, projectorGroupId: stringValue(env.HWLAB_KAFKA_PROJECTOR_GROUP_ID), - hwlabEventGroupId: stringValue(env.HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID), - refreshReplay, - projectorHeartbeatIntervalMs: capabilities.transactionalProjector ? strictPositiveInteger(env.HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS, "HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS") : null, + hwlabEventGroupId: null, + refreshReplay: null, + projectorHeartbeatIntervalMs: strictPositiveInteger(env.HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS, "HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS"), relay }; } @@ -119,29 +83,11 @@ export function kafkaEventBridgeConfig(env = process.env) { export function startHwlabKafkaEventBridge({ env = process.env, logger = console, kafkaFactory = defaultKafkaFactory, runtimeStore = null, now = () => new Date().toISOString(), otelSpanEmitter = emitCodeAgentOtelSpan } = {}) { const config = kafkaEventBridgeConfig(env); if (!config) return { started: false, reason: "disabled_or_unconfigured", stop() {}, subscribeProjectionCommits() { return () => {}; }, valuesPrinted: false }; - assertTransactionalWorkbenchRealtimeAuthority(config.capabilities); - const components = []; - if (config.capabilities.directPublish || config.capabilities.liveKafkaSse) { - components.push(startLiveHwlabKafkaEventBridge({ config, env, logger, kafkaFactory, otelSpanEmitter })); - } - if (config.capabilities.transactionalProjector || config.capabilities.projectionOutboxRelay || config.capabilities.projectionRealtime) { - components.push(startTransactionalHwlabKafkaEventBridge({ config, logger, kafkaFactory, runtimeStore, now })); - } - return components.length === 1 ? components[0] : combineKafkaEventBridgeComponents(config, components); -} - -function assertTransactionalWorkbenchRealtimeAuthority(capabilities = {}) { - const directAuthority = capabilities.directPublish || capabilities.liveKafkaSse || capabilities.kafkaRefreshReplay; - const completeProjectionAuthority = capabilities.transactionalProjector && capabilities.projectionOutboxRelay && capabilities.projectionRealtime; - if (!directAuthority && completeProjectionAuthority) return; - throw contractError( - "hwlab_workbench_transactional_authority_required", - "Workbench Kafka bridge requires transactionalProjector, projectionOutboxRelay, and projectionRealtime together, with directPublish, liveKafkaSse, and kafkaRefreshReplay disabled." - ); + return startTransactionalHwlabKafkaEventBridge({ config, logger, kafkaFactory, runtimeStore, now }); } function startTransactionalHwlabKafkaEventBridge({ config, logger = console, kafkaFactory = defaultKafkaFactory, runtimeStore = null, now = () => new Date().toISOString() } = {}) { - requireKafkaProjectorStore(runtimeStore, config.capabilities); + requireKafkaProjectorStore(runtimeStore); let stopped = false; let consumer = null; @@ -160,16 +106,14 @@ function startTransactionalHwlabKafkaEventBridge({ config, logger = console, kaf let startupReady = false; const ready = (async () => { await runtimeStore.assertWorkbenchTransactionalRealtimeReady(); - const kafka = config.capabilities.transactionalProjector || config.capabilities.projectionOutboxRelay ? kafkaFactory(config) : null; - if (config.capabilities.transactionalProjector) consumer = kafka.consumer({ groupId: config.projectorGroupId, allowAutoTopicCreation: false }); - if (config.capabilities.projectionOutboxRelay) producer = kafka.producer({ allowAutoTopicCreation: false }); - if (producer) await producer.connect(); - if (consumer) await consumer.connect(); - if (config.capabilities.projectionRealtime && typeof runtimeStore.subscribeWorkbenchProjectionCommits === "function") { - stopProjectionNotifications = await runtimeStore.subscribeWorkbenchProjectionCommits(publishProjectionCommit); - } - if (consumer) await consumer.subscribe({ topic: config.agentRunTopic, fromBeginning: false }); - if (consumer) await consumer.run({ + const kafka = kafkaFactory(config); + consumer = kafka.consumer({ groupId: config.projectorGroupId, allowAutoTopicCreation: false }); + producer = kafka.producer({ allowAutoTopicCreation: false }); + await producer.connect(); + await consumer.connect(); + stopProjectionNotifications = await runtimeStore.subscribeWorkbenchProjectionCommits(publishProjectionCommit); + await consumer.subscribe({ topic: config.agentRunTopic, fromBeginning: false }); + await consumer.run({ autoCommit: false, eachBatchAutoResolve: false, eachBatch: async ({ batch, resolveOffset, heartbeat, isRunning, isStale }) => { @@ -196,7 +140,6 @@ function startTransactionalHwlabKafkaEventBridge({ config, logger = console, kaf } }); const runRelay = async () => { - if (!config.capabilities.projectionOutboxRelay) return; if (stopped || relayRunning) return; relayRunning = true; try { @@ -205,11 +148,9 @@ function startTransactionalHwlabKafkaEventBridge({ config, logger = console, kaf relayRunning = false; } }; - if (config.capabilities.projectionOutboxRelay) { - relayTimer = setInterval(() => { void runRelay().catch((error) => logWarn(logger, "hwlab-kafka-outbox-relay-failed", { message: errorMessage(error), valuesPrinted: false })); }, config.relay.intervalMs); - relayTimer.unref?.(); - await runRelay(); - } + relayTimer = setInterval(() => { void runRelay().catch((error) => logWarn(logger, "hwlab-kafka-outbox-relay-failed", { message: errorMessage(error), valuesPrinted: false })); }, config.relay.intervalMs); + relayTimer.unref?.(); + await runRelay(); startupReady = true; logInfo(logger, "hwlab-kafka-event-bridge-started", { agentRunTopic: config.agentRunTopic, @@ -249,9 +190,7 @@ function startTransactionalHwlabKafkaEventBridge({ config, logger = console, kaf const transactionalReadiness = typeof runtimeStore.workbenchTransactionalRealtimeReadiness === "function" ? await runtimeStore.workbenchTransactionalRealtimeReadiness() : { ready: true, valuesRedacted: true }; - const projectorStatus = config.capabilities.transactionalProjector || config.capabilities.projectionOutboxRelay - ? await runtimeStore.hwlabKafkaProjectorStatus() - : {}; + const projectorStatus = await runtimeStore.hwlabKafkaProjectorStatus(); return { status: transactionalReadiness.ready === false ? "blocked" : "ready", capabilities: config.capabilities, transactionalReadiness, ...projectorStatus, valuesRedacted: true }; }, startupStatus() { @@ -953,13 +892,18 @@ function kafkaTransport(kafkaMessage = {}) { }; } -function requireKafkaProjectorStore(runtimeStore, capabilities = {}) { - const required = []; - if (capabilities.transactionalProjector) required.push("commitAgentRunKafkaProjection", "recordFailedAgentRunKafkaMessage", "recordIgnoredAgentRunKafkaMessage"); - if (capabilities.projectionOutboxRelay) required.push("claimHwlabKafkaOutbox", "completeHwlabKafkaOutbox", "retryHwlabKafkaOutbox"); - if (capabilities.projectionRealtime) required.push("subscribeWorkbenchProjectionCommits"); - if (capabilities.transactionalProjector || capabilities.projectionOutboxRelay) required.push("hwlabKafkaProjectorStatus"); - if (capabilities.transactionalProjector || capabilities.projectionOutboxRelay || capabilities.projectionRealtime) required.push("assertWorkbenchTransactionalRealtimeReady"); +function requireKafkaProjectorStore(runtimeStore) { + const required = [ + "commitAgentRunKafkaProjection", + "recordFailedAgentRunKafkaMessage", + "recordIgnoredAgentRunKafkaMessage", + "claimHwlabKafkaOutbox", + "completeHwlabKafkaOutbox", + "retryHwlabKafkaOutbox", + "subscribeWorkbenchProjectionCommits", + "hwlabKafkaProjectorStatus", + "assertWorkbenchTransactionalRealtimeReady" + ]; const missing = required.filter((name) => typeof runtimeStore?.[name] !== "function"); if (missing.length > 0) throw contractError("hwlab_kafka_projector_store_invalid", `Kafka durable capabilities require a runtime store: ${missing.join(", ")}`); } diff --git a/internal/cloud/server-code-agent-admission-http.ts b/internal/cloud/server-code-agent-admission-http.ts index 036e9aee..8bbff398 100644 --- a/internal/cloud/server-code-agent-admission-http.ts +++ b/internal/cloud/server-code-agent-admission-http.ts @@ -1046,8 +1046,7 @@ async function submitCodeAgentChatTurn({ params, options, traceId }) { } }); if (adapterEnabled) { - const realtimeCapabilities = workbenchRealtimeCapabilities(runtimeEnv); - const transactionalAdmission = realtimeCapabilities.transactionalProjector; + const transactionalAdmission = true; const pendingPromotion = results.get(traceId); if (pendingPromotion?.admissionState === "promotion-pending" && pendingPromotion?.agentRun?.durableDispatch === true) { const retryParams = { ...params, userBillingReservation: pendingPromotion.userBillingReservation ?? null }; @@ -1324,30 +1323,7 @@ async function promoteCodeAgentTurnAdmission({ payload = {}, params = {}, option throw error; } const lifecycle = codeAgentTurnLifecycleFields(traceId, params); - const realtimeCapabilities = workbenchRealtimeCapabilities(options.env ?? process.env); - if (!realtimeCapabilities.transactionalProjector) { - const runningOwner = await recordCodeAgentSessionOwner({ payload: { ...payload, status: "running" }, params, options, status: "running" }); - if (!runningOwner) { - const error = new Error("Code Agent live turn did not durably promote the session owner to running."); - error.code = "workbench_admission_owner_not_promoted"; - error.valuesRedacted = true; - throw error; - } - traceStore.append(traceId, { - type: "request", - status: "admitted", - label: "turn:admitted-live-kafka", - message: "Code Agent turn is live after AgentRun durable dispatch; lifecycle visibility is owned only by hwlab.event.v1.", - sessionId: safeSessionId(params.sessionId) || null, - runId: payload.agentRun?.runId ?? null, - commandId: payload.agentRun?.commandId ?? null, - dispatchIntentId: payload.agentRun?.dispatchIntentId ?? null, - waitingFor: "hwlab-live-kafka-sse", - terminal: false, - valuesPrinted: false - }); - return { written: false, promoted: true, outboxCommitted: false, realtimeCapabilities, liveOnly: realtimeCapabilities.liveKafkaSse, valuesPrinted: false }; - } + const realtimeCapabilities = workbenchRealtimeCapabilities(); const inputFact = buildCodeAgentSessionInputFact({ params, options, diff --git a/internal/cloud/server-workbench-realtime-http.ts b/internal/cloud/server-workbench-realtime-http.ts index 5f07bc32..82d257ff 100644 --- a/internal/cloud/server-workbench-realtime-http.ts +++ b/internal/cloud/server-workbench-realtime-http.ts @@ -189,10 +189,6 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option sendJson(response, 410, workbenchError("workbench_projection_events_route_removed", "Use /v1/workbench/events for the single transactional projection authority.")); return; } - if (!realtimeCapabilities.projectionRealtime) { - sendJson(response, 503, workbenchError("workbench_projection_realtime_disabled", "Workbench product realtime requires the transactional projection outbox authority.")); - return; - } const requestedSessionId = safeSessionId(url.searchParams.get("sessionId") ?? url.searchParams.get("includeSessionId")); const requestedTraceId = safeTraceId(url.searchParams.get("traceId")); const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_WORKBENCH_SSE_HEARTBEAT_MS, DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS); @@ -865,9 +861,9 @@ export function attachWorkbenchRealtimeOtelContext(request, fields = {}) { "workbench.thread_id": fields.threadId ?? null, "workbench.sse.heartbeat_ms": fields.heartbeatMs ?? null, "workbench.sse.realtime_source": fields.realtimeSource ?? null, - "workbench.sse.live_kafka_enabled": fields.realtimeCapabilities?.liveKafkaSse ?? null, - "workbench.sse.projection_realtime_enabled": fields.realtimeCapabilities?.projectionRealtime ?? null, - "workbench.sse.outbox_mode": fields.realtimeCapabilities?.liveKafkaSse === true ? false : true + "workbench.sse.live_kafka_enabled": false, + "workbench.sse.projection_realtime_enabled": true, + "workbench.sse.outbox_mode": true }; } diff --git a/internal/cloud/workbench-projection-writer.test.ts b/internal/cloud/workbench-projection-writer.test.ts index 3336f1c8..29d8c931 100644 --- a/internal/cloud/workbench-projection-writer.test.ts +++ b/internal/cloud/workbench-projection-writer.test.ts @@ -144,6 +144,89 @@ test("authoritative assistant final seals terminal with a final response", () => assert.equal(built.facts.checkpoints[0].projectionStatus, "caught_up"); }); +test("canonical event time owns running, terminal, replay, and duration", () => { + const running = buildWorkbenchProjectionEventFacts({ + projectedSeq: 20, + projectedAt: "2026-07-10T10:17:00.000Z", + event: { + traceId: "trc_canonical_duration", + sessionId: "ses_canonical_duration", + sourceEventId: "evt_canonical_running", + sourceSeq: 41, + type: "assistant", + eventType: "assistant_progress", + status: "running", + createdAt: "2026-07-10T10:00:01.000Z", + startedAt: "2026-07-10T09:43:00.000Z", + durationMs: 1020000 + } + }); + const runningCheckpoint = running.facts.checkpoints[0]; + assert.equal(runningCheckpoint.startedAt, "2026-07-10T10:00:01.000Z"); + assert.equal(runningCheckpoint.lastEventAt, "2026-07-10T10:00:01.000Z"); + assert.equal(runningCheckpoint.durationMs, null); + + const terminal = buildWorkbenchProjectionEventFacts({ + projectedSeq: 21, + projectedAt: "2026-07-10T10:18:00.000Z", + previousCheckpoint: runningCheckpoint, + event: { + traceId: "trc_canonical_duration", + sessionId: "ses_canonical_duration", + sourceEventId: "evt_canonical_terminal", + sourceSeq: 42, + type: "assistant", + eventType: "assistant", + status: "completed", + terminal: true, + replyAuthority: true, + text: "canonical final", + createdAt: "2026-07-10T10:00:04.000Z", + finishedAt: "2026-07-10T10:18:00.000Z", + durationMs: 1020000 + } + }); + const terminalCheckpoint = terminal.facts.checkpoints[0]; + assert.equal(terminalCheckpoint.startedAt, "2026-07-10T10:00:01.000Z"); + assert.equal(terminalCheckpoint.finishedAt, "2026-07-10T10:00:04.000Z"); + assert.equal(terminalCheckpoint.durationMs, 3000); + + const [replayed] = projectionOutboxRealtimeEvents({ + events: [{ + outboxSeq: 72, + entityFamily: "traceEvents", + entityId: terminal.facts.traceEvents[0].id, + projectedSeq: 21, + projectionRevision: 1, + traceId: "trc_canonical_duration", + sessionId: "ses_canonical_duration", + commitType: "event", + terminal: true, + sealed: true, + payload: { family: "traceEvents", fact: terminal.facts.traceEvents[0] } + }] + }); + assert.deepEqual(replayed.payload.cursor, { outboxSeq: 72, traceSeq: 21 }); + assert.equal(replayed.payload.event.durationMs, 3000); + const [refreshed] = projectionOutboxRealtimeEvents({ + events: [{ + outboxSeq: 72, + entityFamily: "traceEvents", + entityId: terminal.facts.traceEvents[0].id, + projectedSeq: 21, + projectionRevision: 1, + traceId: "trc_canonical_duration", + sessionId: "ses_canonical_duration", + commitType: "event", + terminal: true, + sealed: true, + payload: { family: "traceEvents", fact: terminal.facts.traceEvents[0] } + }] + }); + assert.deepEqual(refreshed.payload.cursor, replayed.payload.cursor); + assert.equal(refreshed.payload.event.durationMs, replayed.payload.event.durationMs); +}); + test("completed terminal without final response remains visibly unsealed", () => { const built = buildWorkbenchProjectionEventFacts({ projectedSeq: 1, diff --git a/internal/cloud/workbench-projection-writer.ts b/internal/cloud/workbench-projection-writer.ts index ab05e8d0..705da175 100644 --- a/internal/cloud/workbench-projection-writer.ts +++ b/internal/cloud/workbench-projection-writer.ts @@ -205,12 +205,9 @@ export function buildWorkbenchProjectionEventFacts({ event = {}, requestMeta = { valuesPrinted: false }; } - const explicitEventTiming = normalizeTimingProjection(event.timing) ?? normalizeTimingProjection(event); - const explicitDurationMs = durationValue(event.durationMs ?? explicitEventTiming?.durationMs); - const explicitStartedAt = optionalTimestampValue(event.startedAt ?? event.traceStartedAt ?? event.runnerStartedAt ?? explicitEventTiming?.startedAt); - const provisionalLastEventAt = latestTimestamp(previousTiming?.lastEventAt, explicitEventTiming?.lastEventAt, occurredAt); - const provisionalFinishedAt = sourceTerminal ? latestTimestamp(explicitEventTiming?.finishedAt, optionalTimestampValue(event.finishedAt), provisionalLastEventAt, occurredAt) : null; - const startedAt = previousTiming?.startedAt ?? explicitStartedAt ?? startedAtFromDuration(provisionalFinishedAt, explicitDurationMs) ?? (sourceSeq <= 1 ? occurredAt : null); + const provisionalLastEventAt = latestTimestamp(previousTiming?.lastEventAt, occurredAt); + const provisionalFinishedAt = sourceTerminal ? provisionalLastEventAt : null; + const startedAt = previousTiming?.startedAt ?? occurredAt; const lastEventAt = provisionalLastEventAt; const finishedAt = sourceTerminal ? latestTimestamp(provisionalFinishedAt, lastEventAt) : null; const sourceEventId = workbenchSourceEventId(event, { traceId, sourceSeq, occurredAt }); @@ -226,7 +223,7 @@ export function buildWorkbenchProjectionEventFacts({ event = {}, requestMeta = { const terminalSealBlocked = sourceTerminal && sourceStatus === "completed" && !messageFinalText; const effectiveTerminal = sourceTerminal && !terminalSealBlocked; const effectiveStatus = effectiveTerminal ? sourceStatus : "running"; - const timing = eventTimingProjection({ startedAt, lastEventAt, finishedAt, terminal: effectiveTerminal, durationMs: explicitDurationMs ?? previousTiming?.durationMs }); + const timing = eventTimingProjection({ startedAt, lastEventAt, finishedAt, terminal: effectiveTerminal }); const timingAuthorityIssue = terminalTimingAuthorityIssue(timing, { terminal: effectiveTerminal, traceId, source: "kafka", status: effectiveStatus, label: event.label, sourceSeq }); const projectionDiagnostic = { timingAuthorityIssue, terminalSealBlocked, sourceTerminal, sourceStatus, authority: "agentrun-kafka-projector", valuesRedacted: true }; const userMessageFact = sessionId && previousCheckpoint?.userMessage ? normalizeMessageFact(previousCheckpoint.userMessage, 0, { traceId, sessionId, turnId, terminal: false, terminalStatus: "sent", finalText: null, timestamp: projectedAt, timing }) : null; @@ -499,13 +496,6 @@ function elapsedBetween(startedAt, endedAt) { return Math.trunc(end - start); } -function startedAtFromDuration(finishedAt, durationMs) { - const end = Date.parse(String(finishedAt ?? "")); - const duration = durationValue(durationMs); - if (!Number.isFinite(end) || duration === null) return null; - return new Date(Math.max(0, end - duration)).toISOString(); -} - function terminalTimingAuthorityIssue(timing, { terminal = false, traceId = null, source = null, status = null, label = null, sourceSeq = null } = {}) { if (!terminal) return null; const startedAt = optionalTimestampValue(timing?.startedAt); diff --git a/internal/cloud/workbench-realtime-capabilities.ts b/internal/cloud/workbench-realtime-capabilities.ts index 8d87d200..01f73093 100644 --- a/internal/cloud/workbench-realtime-capabilities.ts +++ b/internal/cloud/workbench-realtime-capabilities.ts @@ -1,35 +1,15 @@ -// SPEC: PJ2026-0104010803 Workbench composable realtime capabilities. -// Responsibility: validate YAML-owned capability switches without code defaults or implied mode selection. +// SPEC: PJ2026-0104010803 Workbench fixed transactional realtime authority. +// Responsibility: expose the code-owned Workbench projector/outbox/SSE invariant. -export const WORKBENCH_REALTIME_CAPABILITY_ENVS = Object.freeze({ - directPublish: "HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", - liveKafkaSse: "HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", - kafkaRefreshReplay: "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", - transactionalProjector: "HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", - projectionOutboxRelay: "HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", - projectionRealtime: "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED" +export const WORKBENCH_REALTIME_CAPABILITIES = Object.freeze({ + directPublish: false, + liveKafkaSse: false, + kafkaRefreshReplay: false, + transactionalProjector: true, + projectionOutboxRelay: true, + projectionRealtime: true }); -export function workbenchRealtimeCapabilities(env = process.env) { - return Object.fromEntries(Object.entries(WORKBENCH_REALTIME_CAPABILITY_ENVS).map(([key, name]) => [key, requiredBooleanEnv(env, name)])); -} - -export function requiredBooleanEnv(env, name) { - const value = String(env?.[name] ?? "").trim().toLowerCase(); - if (value === "true" || value === "1") return true; - if (value === "false" || value === "0") return false; - throw capabilityError( - "hwlab_workbench_realtime_capability_invalid", - `${name} is required and must be explicitly true or false in the owning YAML.`, - name - ); -} - -function capabilityError(code, message, envName) { - return Object.assign(new Error(message), { - code, - envName, - statusCode: 503, - valuesRedacted: true - }); +export function workbenchRealtimeCapabilities() { + return WORKBENCH_REALTIME_CAPABILITIES; } diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 25f1e254..096f6cda 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -400,11 +400,6 @@ function validateDisplayLocale(locale) { function workbenchRuntimeConfigFromEnv() { const traceTimeline = {}; const result = { - realtimeFeatures: { - liveKafkaSse: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED, "HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED"), - kafkaRefreshReplay: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED"), - projectionRealtime: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED, "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED") - }, debugCapabilities: { isolatedKafka: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED, "HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED"), rawHwlabEventWindow: { diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 89504981..f2924882 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { connect } from "node:net"; import os from "node:os"; @@ -16,7 +16,25 @@ test("cloud web classifies Bun client disconnect ECONNRESET as non-fatal", () => assert.equal(isClientDisconnectError(new Error("database migration failed")), false); }); -test("serveCloudWeb rejects a missing refresh replay capability without opening health or SPA routes", async () => { +test("repo feature schema owns one unique key per product feature", async () => { + const schema = JSON.parse(await readFile(new URL("../../config/feature-config.schema.json", import.meta.url), "utf8")); + assert.equal(schema.$schema, "https://json-schema.org/draft/2020-12/schema"); + assert.deepEqual(Object.keys(schema.properties), ["tracePanel", "rawEvents", "debugReplay", "views"]); + const features = Object.values(schema.properties).map((property) => property["x-unidesk-feature"]); + assert.equal(features.every((feature) => typeof feature === "string" && feature.length > 0), true); + assert.equal(new Set(features).size, features.length); + const encoded = JSON.stringify(schema); + for (const architectureEnv 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(encoded.includes(architectureEnv), false); +}); + +test("serveCloudWeb starts without obsolete architecture capabilities", async () => { const restoreEnv = withEnv({ HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: "Asia/Shanghai", HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: "zh-CN", @@ -29,19 +47,21 @@ test("serveCloudWeb rejects a missing refresh replay capability without opening HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_ENTRIES: "200", HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_RETAINED_BYTES: "1048576" }); - delete process.env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED; const port = await unusedPort(); + let server = null; try { - await assert.rejects(serveCloudWeb({ + server = await serveCloudWeb({ port, serviceId: "hwlab-cloud-web", healthPayload: () => ({ status: "ok" }), - sendJson() { - assert.fail("missing runtime configuration must fail before serving any response"); + sendJson(response, statusCode, body) { + response.writeHead(statusCode, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); } - }), /HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED/u); - assert.equal(await canConnect(port), false, "serveCloudWeb must not bind before runtime configuration is valid"); + }); + assert.equal(await canConnect(port), true); } finally { + if (server) await close(server); restoreEnv(); } }); @@ -497,7 +517,7 @@ test("cloud web serves client deep links through the Vue shell", async () => { const html = await response.text(); assert.match(html, /
<\/div>/u, route); assert.match(html, /HWLAB_CLOUD_WEB_CONFIG/u, route); - assert.match(html, /"realtimeFeatures":\{"liveKafkaSse":true,"kafkaRefreshReplay":true,"projectionRealtime":false\}/u, route); + assert.doesNotMatch(html, /realtimeFeatures|liveKafkaSse|kafkaRefreshReplay|projectionRealtime/u, route); assert.match(html, /"debugCapabilities":\{"isolatedKafka":true,"rawHwlabEventWindow":\{"enabled":true,"maxEntries":200,"maxRetainedBytes":1048576\}\}/u, route); } diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index 21ee66a2..285b99d7 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -270,7 +270,7 @@ test("Workbench sealed completed final body survives stale running session messa assert.equal(selectActiveSession(state, "ses_final_seal")?.status, "completed"); }); -test("Workbench session messages repair terminal zero timing from running projection", () => { +test("Workbench session messages preserve canonical terminal zero timing", () => { let state = createWorkbenchServerState(); state = reduceWorkbenchServerState(state, { type: "session.messages", @@ -288,8 +288,8 @@ test("Workbench session messages repair terminal zero timing from running projec const agent = selectActiveMessages(state, "ses_terminal_zero_repair").find((message) => message.role === "agent"); assert.equal(agent?.status, "completed"); - assert.equal(agent?.durationMs, 6_000); - assert.equal(agent?.timing?.durationMs, 6_000); + assert.equal(agent?.durationMs, 0); + assert.equal(agent?.timing?.durationMs, 0); }); test("Workbench session messages can replace sealed terminal zero with positive timing", () => { diff --git a/web/hwlab-cloud-web/src/config/runtime.ts b/web/hwlab-cloud-web/src/config/runtime.ts index 5936ba7a..54387e08 100644 --- a/web/hwlab-cloud-web/src/config/runtime.ts +++ b/web/hwlab-cloud-web/src/config/runtime.ts @@ -98,11 +98,7 @@ export function workbenchTraceTimelinePolicy(): WorkbenchTraceTimelinePolicy { } export function workbenchRealtimeCapabilities(): WorkbenchRealtimeCapabilities { - const features = window.HWLAB_CLOUD_WEB_CONFIG?.workbench?.realtimeFeatures; - if (typeof features?.liveKafkaSse !== "boolean" || typeof features?.kafkaRefreshReplay !== "boolean" || typeof features?.projectionRealtime !== "boolean") { - throw new Error("HWLAB Cloud Web workbench.realtimeFeatures are required"); - } - return { liveKafkaSse: features.liveKafkaSse, kafkaRefreshReplay: features.kafkaRefreshReplay, projectionRealtime: features.projectionRealtime }; + return { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true }; } export function workbenchDebugCapabilities(): WorkbenchDebugCapabilities { diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index 3bafc4d3..5106f04e 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -8,11 +8,6 @@ declare global { label?: string; }; workbench?: { - realtimeFeatures?: { - liveKafkaSse?: boolean; - kafkaRefreshReplay?: boolean; - projectionRealtime?: boolean; - }; debugCapabilities?: { isolatedKafka?: boolean; rawHwlabEventWindow?: { From fc87a7e5f741ffedc137996f7f7d3d64f9486290 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 04:37:35 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=20Workbench=20?= =?UTF-8?q?=E6=97=A7=E5=AE=9E=E6=97=B6=E6=9E=B6=E6=9E=84=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/kafka-event-bridge.test.ts | 94 +--- internal/cloud/kafka-event-bridge.ts | 276 +----------- .../cloud/server-code-agent-admission-http.ts | 5 - .../server-workbench-realtime-http.test.ts | 76 +--- .../cloud/server-workbench-realtime-http.ts | 405 ------------------ internal/cloud/server.ts | 3 - .../cloud/workbench-realtime-capabilities.ts | 15 - ...h-fixed-transactional-architecture.test.ts | 57 +++ .../workbench-realtime-runtime.test.ts | 76 +--- .../src/api/workbench-events.test.ts | 37 +- .../src/api/workbench-events.ts | 24 +- web/hwlab-cloud-web/src/config/runtime.ts | 10 - .../src/stores/workbench-event-reducer.ts | 8 - .../stores/workbench-isolated-kafka-debug.ts | 17 +- .../workbench-kafka-refresh-policy.test.ts | 28 -- .../stores/workbench-kafka-refresh-policy.ts | 21 - .../src/stores/workbench-live-kafka-event.ts | 4 +- web/hwlab-cloud-web/src/stores/workbench.ts | 149 +------ .../src/utils/workbench-realtime-runtime.ts | 2 +- .../src/utils/workbench-stream-transport.ts | 13 +- 20 files changed, 122 insertions(+), 1198 deletions(-) delete mode 100644 internal/cloud/workbench-realtime-capabilities.ts create mode 100644 web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts delete mode 100644 web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.test.ts delete mode 100644 web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.ts diff --git a/internal/cloud/kafka-event-bridge.test.ts b/internal/cloud/kafka-event-bridge.test.ts index 835d82c4..9ed5f38b 100644 --- a/internal/cloud/kafka-event-bridge.test.ts +++ b/internal/cloud/kafka-event-bridge.test.ts @@ -6,7 +6,7 @@ import { test } from "bun:test"; import { createCloudApiBunServer } from "./bun-server.ts"; import { buildCloudApiReadiness } from "./health-contract.ts"; -import { decodeCanonicalAgentRunKafkaMessage, kafkaEventBridgeConfig, liveKafkaOtelSpanAttributes, projectAgentRunKafkaEventToHwlabEvent, publishAgentRunKafkaMessageLive, relayHwlabKafkaOutboxOnce, shouldEmitLiveKafkaOtelSpan, startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts"; +import { decodeCanonicalAgentRunKafkaMessage, kafkaEventBridgeConfig, projectAgentRunKafkaEventToHwlabEvent, relayHwlabKafkaOutboxOnce, startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts"; const PROJECTOR_ENV = Object.freeze({ HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false", @@ -31,23 +31,6 @@ const PROJECTOR_ENV = Object.freeze({ HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS: "1000" }); -const LIVE_ENV = Object.freeze({ - HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true", - HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true", - HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "false", - HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false", - HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false", - HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false", - HWLAB_KAFKA_ENABLED: "true", - HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED: "true", - HWLAB_KAFKA_BOOTSTRAP_SERVERS: "kafka.test:9092", - HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC: "agentrun.event.v1", - HWLAB_KAFKA_EVENT_TOPIC: "hwlab.event.v1", - HWLAB_KAFKA_CLIENT_ID: "hwlab-live-test", - HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID: "hwlab-live-bridge-fixed", - HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID: "hwlab-live-fanout-fixed" -}); - test("projects AgentRun assistant_message Kafka event into HWLAB trace event", () => { const projected = projectAgentRunKafkaEventToHwlabEvent({ schema: "agentrun.event.v1", @@ -322,63 +305,6 @@ test("projects AgentRun terminal_status Kafka event into terminal HWLAB event", assert.equal(projected.event.message, "AgentRun completed"); }); -test("live Kafka publisher has no PG inbox, facts, checkpoint, or outbox dependency", async () => { - const sent = []; - const runtimeStore = new Proxy({}, { get() { throw new Error("unexpected DB access"); } }); - void runtimeStore; - const result = await publishAgentRunKafkaMessageLive({ topic: "agentrun.event.v1", partition: 3, message: canonicalMessage({ offset: "71" }) }, { - producer: { async send(input) { sent.push(input); } }, - config: { clientId: "hwlab-live-test", hwlabTopic: "hwlab.event.v1" }, - logger: null - }); - assert.equal(result.published, true); - assert.equal(sent.length, 1); - assert.equal(JSON.parse(sent[0].messages[0].value).sourceEvent.offset, "71"); -}); - -test("live Kafka OTel sampling is deterministic and bounded without payload attributes", () => { - const envelope = (agentRunEventType, sourceSeq, event = {}) => ({ - schema: "hwlab.event.v1", - eventId: `hwlab:evt_otel_${sourceSeq}`, - sourceEventId: `evt_otel_${sourceSeq}`, - traceId: "trc_live_otel", - hwlabSessionId: "ses_live_otel", - runId: "run_live_otel", - commandId: "cmd_live_otel", - context: { agentRunEventType, sourceSeq, valuesRedacted: true }, - event: { type: "backend", eventType: "backend", terminal: false, message: "must-not-leak", payload: { password: "must-not-leak" }, ...event } - }); - - assert.equal(shouldEmitLiveKafkaOtelSpan(envelope("assistant_message", 1, { type: "assistant" })), true); - assert.equal(shouldEmitLiveKafkaOtelSpan(envelope("tool_call", 2, { type: "tool" })), true); - assert.equal(shouldEmitLiveKafkaOtelSpan(envelope("terminal_status", 3, { type: "result", terminal: true })), true); - const decisions = Array.from({ length: 64 }, (_, index) => shouldEmitLiveKafkaOtelSpan(envelope("command_output", index + 1, { type: "output" }))); - assert.deepEqual(decisions, Array.from({ length: 64 }, (_, index) => (index + 1) % 32 === 0)); - assert.deepEqual(decisions, Array.from({ length: 64 }, (_, index) => shouldEmitLiveKafkaOtelSpan(envelope("command_output", index + 1, { type: "output" })))); - - const attributes = liveKafkaOtelSpanAttributes(envelope("assistant_message", 1, { type: "assistant" }), { - topic: "hwlab.event.v1", - partition: 2, - offset: "14" - }); - assert.deepEqual(attributes, { - businessTraceId: "trc_live_otel", - hwlabSessionId: "ses_live_otel", - runId: "run_live_otel", - commandId: "cmd_live_otel", - topic: "hwlab.event.v1", - partition: 2, - offset: "14", - sourceTopic: null, - sourcePartition: null, - sourceOffset: null, - eventType: "assistant_message", - terminal: false, - valuesRedacted: true - }); - assert.equal(JSON.stringify(attributes).includes("must-not-leak"), false); -}); - test("Workbench bridge ignores obsolete architecture env and fixes transactional authority", () => { const config = kafkaEventBridgeConfig({ ...PROJECTOR_ENV, @@ -390,17 +316,15 @@ test("Workbench bridge ignores obsolete architecture env and fixes transactional HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false" }); - assert.deepEqual(config?.capabilities, { - directPublish: false, - liveKafkaSse: false, - kafkaRefreshReplay: false, - transactionalProjector: true, - projectionOutboxRelay: true, - projectionRealtime: true + assert.equal(config?.projectorGroupId, "hwlab-projector-test-v1"); + assert.deepEqual(config?.relay, { + intervalMs: 60000, + batchSize: 1, + leaseMs: 30000, + sendTimeoutMs: 10000, + retryBackoffMs: 1000 }); - assert.equal(config?.directPublishGroupId, null); - assert.equal(config?.hwlabEventGroupId, null); - assert.equal(config?.refreshReplay, null); + assert.deepEqual(Object.keys(config ?? {}).sort(), ["agentRunTopic", "brokers", "clientId", "hwlabTopic", "projectorGroupId", "projectorHeartbeatIntervalMs", "relay"]); }); test("Workbench bridge does not require the six obsolete architecture env", () => { diff --git a/internal/cloud/kafka-event-bridge.ts b/internal/cloud/kafka-event-bridge.ts index 246c09e9..6462721e 100644 --- a/internal/cloud/kafka-event-bridge.ts +++ b/internal/cloud/kafka-event-bridge.ts @@ -4,9 +4,7 @@ import { createHash, randomUUID } from "node:crypto"; import { Kafka, logLevel } from "kafkajs"; -import { emitCodeAgentOtelSpan } from "./otel-trace.ts"; import { buildWorkbenchProjectionEventFacts } from "./workbench-projection-writer.ts"; -import { workbenchRealtimeCapabilities } from "./workbench-realtime-capabilities.ts"; const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); export const DEFAULT_AGENTRUN_EVENT_TOPIC = "agentrun.event.v1"; @@ -19,7 +17,6 @@ export const AGENTRUN_STDIO_RECONSTRUCTION_KIND = "stdio-derived partial reconst const DEFAULT_CLIENT_ID = "hwlab-v03-cloud-api"; const DEFAULT_QUERY_TIMEOUT_MS = 5000; const DEFAULT_QUERY_LIMIT = 50; -const LIVE_KAFKA_COMMAND_OUTPUT_OTEL_SAMPLE_MODULUS = 32; const REQUIRED_KAFKA_ENV = Object.freeze([ "HWLAB_KAFKA_BOOTSTRAP_SERVERS", "HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC", @@ -31,7 +28,6 @@ export function kafkaEventBridgeConfig(env = process.env) { const legacyKafkaEnabled = truthy(env.HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED ?? env.HWLAB_KAFKA_AGENTRUN_CONSUME_ENABLED ?? env.HWLAB_KAFKA_ENABLED); const kafkaConfigured = REQUIRED_KAFKA_ENV.every((name) => stringValue(env[name])); if (!legacyKafkaEnabled && !kafkaConfigured) return null; - const capabilities = workbenchRealtimeCapabilities(); const required = [ ...REQUIRED_KAFKA_ENV, "HWLAB_KAFKA_PROJECTOR_GROUP_ID", @@ -66,21 +62,17 @@ export function kafkaEventBridgeConfig(env = process.env) { throw error; } return { - capabilities, brokers, agentRunTopic, hwlabTopic, clientId, - directPublishGroupId: null, projectorGroupId: stringValue(env.HWLAB_KAFKA_PROJECTOR_GROUP_ID), - hwlabEventGroupId: null, - refreshReplay: null, projectorHeartbeatIntervalMs: strictPositiveInteger(env.HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS, "HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS"), relay }; } -export function startHwlabKafkaEventBridge({ env = process.env, logger = console, kafkaFactory = defaultKafkaFactory, runtimeStore = null, now = () => new Date().toISOString(), otelSpanEmitter = emitCodeAgentOtelSpan } = {}) { +export function startHwlabKafkaEventBridge({ env = process.env, logger = console, kafkaFactory = defaultKafkaFactory, runtimeStore = null, now = () => new Date().toISOString() } = {}) { const config = kafkaEventBridgeConfig(env); if (!config) return { started: false, reason: "disabled_or_unconfigured", stop() {}, subscribeProjectionCommits() { return () => {}; }, valuesPrinted: false }; return startTransactionalHwlabKafkaEventBridge({ config, logger, kafkaFactory, runtimeStore, now }); @@ -157,7 +149,6 @@ function startTransactionalHwlabKafkaEventBridge({ config, logger = console, kaf hwlabTopic: config.hwlabTopic, clientId: config.clientId, projectorGroupId: config.projectorGroupId, - capabilities: config.capabilities, valuesPrinted: false }); })(); @@ -191,7 +182,7 @@ function startTransactionalHwlabKafkaEventBridge({ config, logger = console, kaf ? await runtimeStore.workbenchTransactionalRealtimeReadiness() : { ready: true, valuesRedacted: true }; const projectorStatus = await runtimeStore.hwlabKafkaProjectorStatus(); - return { status: transactionalReadiness.ready === false ? "blocked" : "ready", capabilities: config.capabilities, transactionalReadiness, ...projectorStatus, valuesRedacted: true }; + return { status: transactionalReadiness.ready === false ? "blocked" : "ready", transactionalReadiness, ...projectorStatus, valuesRedacted: true }; }, startupStatus() { if (startupError) return { status: "blocked", errorCode: startupError.code ?? "hwlab_kafka_projector_start_failed", message: errorMessage(startupError), valuesRedacted: true }; @@ -206,269 +197,6 @@ function startTransactionalHwlabKafkaEventBridge({ config, logger = console, kaf }; } -function startLiveHwlabKafkaEventBridge({ config, env = process.env, logger = console, kafkaFactory = defaultKafkaFactory, otelSpanEmitter = emitCodeAgentOtelSpan } = {}) { - let stopped = false; - let bridgeConsumer = null; - let fanoutConsumer = null; - let producer = null; - let startupError = null; - let startupReady = false; - const subscribers = new Set(); - const lagByPartition = new Map(); - const publishLiveEnvelope = (envelope, transport) => { - for (const listener of [...subscribers]) { - try { - listener(envelope, transport); - } catch (error) { - logWarn(logger, "hwlab-live-kafka-subscriber-failed", { message: errorMessage(error), valuesPrinted: false }); - } - } - }; - const ready = (async () => { - const kafka = kafkaFactory(config); - if (config.capabilities.directPublish) { - bridgeConsumer = kafka.consumer({ groupId: config.directPublishGroupId, allowAutoTopicCreation: false }); - producer = kafka.producer({ allowAutoTopicCreation: false }); - } - if (config.capabilities.liveKafkaSse) fanoutConsumer = kafka.consumer({ groupId: config.hwlabEventGroupId, allowAutoTopicCreation: false }); - if (producer) await producer.connect(); - if (bridgeConsumer) await bridgeConsumer.connect(); - if (fanoutConsumer) await fanoutConsumer.connect(); - if (bridgeConsumer) await bridgeConsumer.subscribe({ topic: config.agentRunTopic, fromBeginning: false }); - if (fanoutConsumer) await fanoutConsumer.subscribe({ topic: config.hwlabTopic, fromBeginning: false }); - if (fanoutConsumer) await fanoutConsumer.run({ - eachBatchAutoResolve: false, - eachBatch: async ({ batch, resolveOffset, heartbeat, isRunning, isStale }) => { - let lastOffset = null; - for (const message of batch.messages) { - if (stopped || !isRunning() || isStale()) break; - const envelope = parseJson(message?.value ? Buffer.from(message.value).toString("utf8") : ""); - if (!envelope || envelope.schema !== "hwlab.event.v1") { - logWarn(logger, "hwlab-live-kafka-envelope-ignored", { reason: "schema-invalid", valuesPrinted: false }); - } else { - const transport = { topic: batch.topic, partition: batch.partition, offset: message.offset }; - emitLiveKafkaOtelSpan("hwlab.kafka.live.fanout_receive", envelope, transport, { env, otelSpanEmitter }); - publishLiveEnvelope(envelope, transport); - } - resolveOffset(message.offset); - lastOffset = message.offset; - await heartbeat(); - } - if (lastOffset !== null) lagByPartition.set(batch.partition, kafkaPartitionLag(batch.highWatermark, lastOffset)); - } - }); - if (bridgeConsumer) await bridgeConsumer.run({ - eachMessage: async ({ topic, partition, message }) => { - if (stopped) return; - await publishAgentRunKafkaMessageLive({ topic, partition, message }, { producer, config, env, logger, otelSpanEmitter }); - } - }); - startupReady = true; - logInfo(logger, "hwlab-live-kafka-event-bridge-started", { - capabilities: config.capabilities, - agentRunTopic: config.agentRunTopic, - hwlabTopic: config.hwlabTopic, - clientId: config.clientId, - directPublishGroupId: config.directPublishGroupId, - hwlabEventGroupId: config.hwlabEventGroupId, - valuesPrinted: false - }); - })(); - void ready.catch((error) => { - startupError = error; - logWarn(logger, "hwlab-live-kafka-event-bridge-start-failed", { - message: errorMessage(error), - agentRunTopic: config.agentRunTopic, - hwlabTopic: config.hwlabTopic, - valuesPrinted: false - }); - }); - return { - started: true, - ...config, - ready, - liveReady: ready, - async stop() { - stopped = true; - subscribers.clear(); - await Promise.allSettled([bridgeConsumer?.stop?.(), fanoutConsumer?.stop?.()]); - await Promise.allSettled([bridgeConsumer?.disconnect?.(), fanoutConsumer?.disconnect?.(), producer?.disconnect?.()]); - }, - async status() { - if (startupError) return { status: "blocked", capabilities: config.capabilities, errorCode: startupError.code ?? "hwlab_live_kafka_start_failed", message: errorMessage(startupError), valuesRedacted: true }; - return { status: startupReady ? "ready" : "initializing", capabilities: config.capabilities, subscriberCount: subscribers.size, consumerLag: liveKafkaLagSummary(lagByPartition), lossPossible: true, valuesRedacted: true }; - }, - startupStatus() { - if (startupError) return { status: "blocked", capabilities: config.capabilities, errorCode: startupError.code ?? "hwlab_live_kafka_start_failed", message: errorMessage(startupError), valuesRedacted: true }; - return { status: startupReady ? "ready" : "initializing", capabilities: config.capabilities, valuesRedacted: true }; - }, - subscribeProjectionCommits() { return () => {}; }, - subscribeLiveHwlabEvents(listener) { - if (typeof listener !== "function") return () => {}; - subscribers.add(listener); - return () => subscribers.delete(listener); - }, - queryHwlabEventRetention(params = {}) { - return queryKafkaEventStream({ ...params, env, stream: "hwlab", topic: config.hwlabTopic, kafkaFactory }); - }, - liveSubscriberCount() { return subscribers.size; }, - valuesPrinted: false - }; -} - -function kafkaPartitionLag(highWatermark, lastOffset) { - try { - const lag = BigInt(String(highWatermark ?? "0")) - (BigInt(String(lastOffset ?? "0")) + 1n); - return Number(lag > 0n ? lag : 0n); - } catch { - return null; - } -} - -function liveKafkaLagSummary(lagByPartition) { - const partitions = [...lagByPartition.entries()].map(([partition, lag]) => ({ partition, lag })).sort((left, right) => left.partition - right.partition); - const known = partitions.map((item) => item.lag).filter((lag) => Number.isFinite(lag)); - return { - known: known.length > 0, - total: known.length > 0 ? known.reduce((sum, lag) => sum + lag, 0) : null, - partitions, - valuesRedacted: true - }; -} - -function combineKafkaEventBridgeComponents(config, components) { - const ready = Promise.all(components.map((component) => component.ready)); - const liveOwner = components.find((component) => typeof component.subscribeLiveHwlabEvents === "function"); - const componentStartup = () => components.map((component) => component.startupStatus?.() ?? { status: "initializing" }); - return { - started: true, - ...config, - ready, - liveReady: liveOwner?.liveReady ?? liveOwner?.ready ?? ready, - async stop() { await Promise.allSettled(components.map((component) => component.stop())); }, - async status() { - const statuses = await Promise.all(components.map((component) => component.status())); - const blocked = statuses.find((status) => status.status === "blocked"); - const initializing = statuses.some((status) => status.status !== "ready"); - return { - status: blocked ? "blocked" : initializing ? "initializing" : "ready", - capabilities: config.capabilities, - components: statuses, - errorCode: blocked?.errorCode ?? null, - message: blocked?.message ?? null, - valuesRedacted: true - }; - }, - startupStatus() { - const statuses = componentStartup(); - const blocked = statuses.find((status) => status.status === "blocked"); - return { - status: blocked ? "blocked" : statuses.every((status) => status.status === "ready") ? "ready" : "initializing", - capabilities: config.capabilities, - components: statuses, - errorCode: blocked?.errorCode ?? null, - message: blocked?.message ?? null, - valuesRedacted: true - }; - }, - subscribeProjectionCommits(listener) { - const stops = components.map((component) => component.subscribeProjectionCommits?.(listener)).filter((stop) => typeof stop === "function"); - return () => stops.forEach((stop) => stop()); - }, - subscribeLiveHwlabEvents(listener) { - return liveOwner?.subscribeLiveHwlabEvents(listener) ?? (() => {}); - }, - queryHwlabEventRetention(params = {}) { - if (typeof liveOwner?.queryHwlabEventRetention !== "function") throw contractError("hwlab_kafka_refresh_query_unconfigured", "Kafka refresh retention query is not configured."); - return liveOwner.queryHwlabEventRetention(params); - }, - liveSubscriberCount() { - const owner = components.find((component) => typeof component.liveSubscriberCount === "function"); - return owner?.liveSubscriberCount() ?? 0; - }, - valuesPrinted: false - }; -} - -export async function publishAgentRunKafkaMessageLive(kafkaMessage = {}, { producer, config, env = process.env, logger = console, otelSpanEmitter = emitCodeAgentOtelSpan } = {}) { - const transport = kafkaTransport(kafkaMessage); - const decoded = decodeCanonicalAgentRunKafkaMessage(kafkaMessage); - if (!decoded.ok) { - logWarn(logger, "hwlab-live-kafka-message-invalid", { topic: transport.sourceTopic, partition: transport.sourcePartition, offset: transport.sourceOffset, errorCode: decoded.error.code, valuesPrinted: false }); - return { handled: true, invalid: true, published: false, valuesPrinted: false }; - } - if (!stringValue(decoded.event.traceId) || !stringValue(decoded.event.hwlabSessionId)) { - return { handled: true, ignored: true, published: false, valuesPrinted: false }; - } - const projected = projectAgentRunKafkaEventToHwlabEvent(decoded.event, { - source: config.clientId, - sourceTopic: transport.sourceTopic, - sourcePartition: transport.sourcePartition, - sourceOffset: transport.sourceOffset, - sourceKey: transport.sourceKey, - inputSha256: transport.inputSha256 - }); - const publishMetadata = await producer.send({ topic: config.hwlabTopic, messages: [buildHwlabKafkaProducerMessage(projected)] }); - const publishedRecord = Array.isArray(publishMetadata) ? publishMetadata[0] : null; - emitLiveKafkaOtelSpan("hwlab.kafka.live.direct_publish", projected, { - topic: config.hwlabTopic, - partition: publishedRecord?.partition, - offset: publishedRecord?.baseOffset ?? publishedRecord?.offset, - sourceTopic: transport.sourceTopic, - sourcePartition: transport.sourcePartition, - sourceOffset: transport.sourceOffset - }, { env, otelSpanEmitter }); - return { handled: true, published: true, envelope: projected, sessionId: projected.sessionId, traceId: projected.traceId, valuesPrinted: false }; -} - -export function shouldEmitLiveKafkaOtelSpan(envelope = {}) { - const event = objectValue(envelope.event); - const context = objectValue(envelope.context); - const eventType = firstText(context.agentRunEventType, event.eventType, event.type, envelope.eventType)?.toLowerCase(); - if (event.terminal === true || eventType === "terminal" || eventType === "terminal_status") return true; - if (["assistant", "assistant_progress", "assistant_message", "tool", "tool_call"].includes(eventType)) return true; - if (eventType !== "command_output" && event.type !== "output") return true; - const sourceSeq = integerValue(context.sourceSeq ?? event.sourceSeq); - if (Number.isInteger(sourceSeq) && sourceSeq > 0) return sourceSeq % LIVE_KAFKA_COMMAND_OUTPUT_OTEL_SAMPLE_MODULUS === 0; - const identity = firstText(envelope.sourceEventId, envelope.eventId, envelope.traceId, envelope.hwlabSessionId, envelope.sessionId); - if (!identity) return false; - return createHash("sha256").update(identity).digest().readUInt32BE(0) % LIVE_KAFKA_COMMAND_OUTPUT_OTEL_SAMPLE_MODULUS === 0; -} - -export function liveKafkaOtelSpanAttributes(envelope = {}, transport = {}) { - const event = objectValue(envelope.event); - const context = objectValue(envelope.context); - const sourceEvent = objectValue(envelope.sourceEvent); - return { - businessTraceId: firstText(envelope.traceId, event.traceId), - hwlabSessionId: firstText(envelope.hwlabSessionId, envelope.sessionId, event.sessionId), - runId: firstText(envelope.runId, context.runId, event.runId), - commandId: firstText(envelope.commandId, context.commandId, event.commandId), - topic: firstText(transport.topic, sourceEvent.topic), - partition: integerValue(transport.partition ?? sourceEvent.partition), - offset: firstText(transport.offset, sourceEvent.offset), - sourceTopic: firstText(transport.sourceTopic), - sourcePartition: integerValue(transport.sourcePartition), - sourceOffset: firstText(transport.sourceOffset), - eventType: firstText(context.agentRunEventType, event.eventType, event.type, envelope.eventType), - terminal: event.terminal === true, - valuesRedacted: true - }; -} - -export function emitLiveKafkaOtelSpan(name, envelope = {}, transport = {}, { env = process.env, otelSpanEmitter = emitCodeAgentOtelSpan } = {}) { - if (typeof otelSpanEmitter !== "function" || !shouldEmitLiveKafkaOtelSpan(envelope)) return { emitted: false, valuesRedacted: true }; - const attributes = liveKafkaOtelSpanAttributes(envelope, transport); - if (!attributes.businessTraceId) return { emitted: false, valuesRedacted: true }; - try { - const pending = otelSpanEmitter(name, attributes.businessTraceId, env, { attributes }); - void Promise.resolve(pending).catch(() => undefined); - } catch { - // OTel is best effort and must never change the live Kafka delivery path. - } - return { emitted: true, valuesRedacted: true }; -} - export async function projectAgentRunKafkaMessage(kafkaMessage = {}, { runtimeStore, config, logger = console } = {}) { const transport = kafkaTransport(kafkaMessage); const decoded = decodeCanonicalAgentRunKafkaMessage(kafkaMessage); diff --git a/internal/cloud/server-code-agent-admission-http.ts b/internal/cloud/server-code-agent-admission-http.ts index 8bbff398..67f014d4 100644 --- a/internal/cloud/server-code-agent-admission-http.ts +++ b/internal/cloud/server-code-agent-admission-http.ts @@ -20,9 +20,6 @@ import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle import { messageAuthorityTextValue } from "./code-agent-agentrun-prompt.ts"; import { promoteWorkbenchTurnAdmission as persistWorkbenchTurnAdmissionPromotion, writeWorkbenchSessionAdmissionFact } from "./workbench-projection-writer.ts"; import { createWorkbenchReadModel } from "./workbench-read-model.ts"; -import { - workbenchRealtimeCapabilities -} from "./workbench-realtime-capabilities.ts"; import { codeAgentOtelTraceFields, emitCodeAgentOtelSpan } from "./otel-trace.ts"; import { firstHeaderValue, @@ -245,7 +242,6 @@ export async function handleCodeAgentChatHttp(request, response, options) { lastEventAt: submitted?.lastEventAt ?? admittedTiming.lastEventAt, finishedAt: submitted?.finishedAt ?? admittedTiming.finishedAt, durationMs: submitted?.durationMs ?? admittedTiming.durationMs, - realtimeCapabilities: workbenchRealtimeCapabilities(options.env ?? process.env), admission: submitted?.agentRun ? { state: submitted.admissionState ?? "promoted", runId: submitted.agentRun.runId ?? null, @@ -1323,7 +1319,6 @@ async function promoteCodeAgentTurnAdmission({ payload = {}, params = {}, option throw error; } const lifecycle = codeAgentTurnLifecycleFields(traceId, params); - const realtimeCapabilities = workbenchRealtimeCapabilities(); const inputFact = buildCodeAgentSessionInputFact({ params, options, diff --git a/internal/cloud/server-workbench-realtime-http.test.ts b/internal/cloud/server-workbench-realtime-http.test.ts index e904be6c..5b722a88 100644 --- a/internal/cloud/server-workbench-realtime-http.test.ts +++ b/internal/cloud/server-workbench-realtime-http.test.ts @@ -46,29 +46,10 @@ const TRANSACTIONAL_REALTIME_ENV = Object.freeze({ HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "true" }); -const REFRESH_REALTIME_ENV = Object.freeze({ - HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true", - HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true", - HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "true", - HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false", - HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false", - HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false" -}); - -function projectionRealtimeBridge(capabilities = {}) { +function projectionRealtimeBridge() { return { started: true, - capabilities: { - directPublish: false, - liveKafkaSse: false, - kafkaRefreshReplay: false, - transactionalProjector: false, - projectionOutboxRelay: false, - projectionRealtime: true, - ...capabilities - }, ready: Promise.resolve(), - subscribeLiveHwlabEvents() { return () => {}; }, subscribeProjectionCommits() { return () => {}; }, async stop() {} }; @@ -146,7 +127,7 @@ test("workbench realtime initial connection emits current snapshot without repla } }); -test("product realtime uses projection outbox even when obsolete live capability values remain present", async () => { +test("product realtime uses the projection outbox authority", async () => { const sessionId = "ses_composable_realtime"; const traceId = "trc_composable_realtime"; let projectionReads = 0; @@ -169,7 +150,7 @@ test("product realtime uses projection outbox even when obsolete live capability const server = createCloudApiServer({ accessController: realtimeAccessController(), workbenchRuntime: runtime, - kafkaEventBridge: projectionRealtimeBridge({ directPublish: true, liveKafkaSse: true }), + kafkaEventBridge: projectionRealtimeBridge(), env: { HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true", HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true", @@ -827,57 +808,6 @@ test("workbench trace event page keeps per-trace terminal status after later ses } }); -function refreshReplayBridge({ records = [], endOffset = "0", onQuery = null, beforeQueryResult = null, ready = Promise.resolve(), liveReady = Promise.resolve() } = {}) { - let liveListener = null; - return { - capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true, transactionalProjector: false, projectionOutboxRelay: false, projectionRealtime: false }, - refreshReplay: { - groupIdPrefix: "hwlab-v03-refresh-test", - timeoutMs: 2500, - scanLimit: 500, - matchedEventLimit: 50, - liveBufferLimit: 20 - }, - ready, - liveReady, - subscribeLiveHwlabEvents(listener) { liveListener = listener; return () => { if (liveListener === listener) liveListener = null; }; }, - async queryHwlabEventRetention(params) { - onQuery?.(params); - beforeQueryResult?.(liveListener); - return { - topic: "hwlab.event.v1", - events: records, - completionReason: "end-offset", - reachedEndOffsets: true, - endOffsetsAvailable: true, - endOffsets: [{ partition: 0, startOffset: "0", endOffset }], - completion: { reason: "end-offset", complete: true, barrierReached: true, retentionStartVerified: true } - }; - }, - async stop() {} - }; -} - -function refreshRecord(offset, sessionId, traceId, type, extra = {}) { - const sourceEventId = `evt_refresh_${offset}`; - return { - topic: "hwlab.event.v1", - partition: 0, - offset: String(offset), - value: { - schema: "hwlab.event.v1", - eventType: "hwlab.trace.event.projected", - eventId: `hwlab:${sourceEventId}`, - sourceEventId, - traceId, - hwlabSessionId: sessionId, - sessionId, - event: { type, eventType: type === "result" ? "terminal" : type, traceId, sessionId, sourceEventId, status: "running", ...extra }, - valuesPrinted: false - } - }; -} - function realtimeAccessController({ actor = ACTOR, sessions = [] } = {}) { const bySessionId = new Map(sessions.map((session) => [session.id, session])); const byTraceId = new Map(sessions.filter((session) => session.lastTraceId).map((session) => [session.lastTraceId, session])); diff --git a/internal/cloud/server-workbench-realtime-http.ts b/internal/cloud/server-workbench-realtime-http.ts index 82d257ff..3798ddcc 100644 --- a/internal/cloud/server-workbench-realtime-http.ts +++ b/internal/cloud/server-workbench-realtime-http.ts @@ -14,16 +14,11 @@ import { } from "./server-http-utils.ts"; import { createWorkbenchReadModel } from "./workbench-read-model.ts"; import { createWorkbenchRuntimeClient } from "./workbench-runtime-client.ts"; -import { emitLiveKafkaOtelSpan } from "./kafka-event-bridge.ts"; -import { createWorkbenchKafkaRefreshHandoff, workbenchKafkaRefreshErrorPayload } from "./workbench-kafka-refresh-handoff.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"; -import { - workbenchRealtimeCapabilities -} from "./workbench-realtime-capabilities.ts"; import * as workbenchFacts from "./server-workbench-facts.ts"; const DEFAULT_PAGE_LIMIT = 50; @@ -183,7 +178,6 @@ export async function drainWorkbenchRealtimeConnections(options = {}) { export async function handleWorkbenchRealtimeHttp(request, response, url, options = {}) { try { if (request.method !== "GET") return methodNotAllowed(response, "GET"); - const realtimeCapabilities = workbenchRealtimeCapabilities(options.env ?? process.env); const projectionOnly = url.pathname === "/v1/workbench/projection-events"; if (projectionOnly) { sendJson(response, 410, workbenchError("workbench_projection_events_route_removed", "Use /v1/workbench/events for the single transactional projection authority.")); @@ -449,405 +443,6 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option } } -async function handleLiveKafkaWorkbenchRealtimeHttp(request, response, url, options = {}) { - const requestedSessionId = safeSessionId(url.searchParams.get("sessionId") ?? url.searchParams.get("includeSessionId")); - const requestedTraceId = safeTraceId(url.searchParams.get("traceId")); - const realtimeCapabilities = workbenchRealtimeCapabilities(options.env ?? process.env); - const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_WORKBENCH_SSE_HEARTBEAT_MS, DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS); - attachWorkbenchRealtimeOtelContext(request, { - sessionId: requestedSessionId, - traceId: requestedTraceId, - heartbeatMs, - realtimeSource: "live-kafka-sse", - realtimeCapabilities - }); - const perf = options.backendPerformance; - const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options); - if (!auth) return; - if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) { - sendJson(response, 400, workbenchError("workbench_authority_removed", "Workbench realtime is keyed by sessionId/traceId only.")); - return; - } - if (!requestedSessionId && !requestedTraceId) { - sendJson(response, 400, workbenchError("workbench_realtime_scope_required", "Workbench realtime requires sessionId or traceId.")); - return; - } - const authorization = await authorizeLiveKafkaWorkbenchRealtimeScope(options, auth.actor, { - sessionId: requestedSessionId, - traceId: requestedTraceId - }); - if (!authorization.ok) { - sendJson(response, authorization.status, authorization.body); - return; - } - const authorizedSessionId = authorization.sessionId; - const bridge = options.kafkaEventBridge; - if (bridge?.capabilities?.liveKafkaSse !== true || typeof bridge?.subscribeLiveHwlabEvents !== "function") { - sendJson(response, 503, workbenchError("workbench_live_kafka_unconfigured", "Workbench live Kafka fanout is not configured.")); - return; - } - if (realtimeCapabilities.kafkaRefreshReplay) { - await handleKafkaRefreshReplayWorkbenchRealtimeHttp(request, response, options, { - bridge, - heartbeatMs, - realtimeCapabilities, - requestedSessionId: authorizedSessionId, - requestedTraceId - }); - return; - } - - let closed = false; - let realtimeCloseReason = "client_close"; - let realtimeCloseSignal = null; - const realtimeStartedAtMs = Date.now(); - const cleanup = []; - const isActive = () => !closed && !response.destroyed && !response.writableEnded; - const closeConnection = () => { - if (closed) return; - closed = true; - emitWorkbenchRealtimeClosedOtelSpan(request, options.env, { - reason: realtimeCloseReason, - signal: realtimeCloseSignal, - startedAtMs: realtimeStartedAtMs, - sessionId: authorizedSessionId, - threadId: null, - traceId: requestedTraceId, - activeConnectionCount: activeWorkbenchRealtimeConnections.size - }); - for (const item of cleanup.splice(0)) item(); - }; - - response.once("close", closeConnection); - request.once?.("aborted", closeConnection); - request.socket?.once?.("close", closeConnection); - cleanup.push(() => request.off?.("aborted", closeConnection)); - cleanup.push(() => request.socket?.off?.("close", closeConnection)); - - const bufferedEnvelopes = []; - let liveDeliveryStarted = false; - let enqueueEvent = null; - const unsubscribe = bridge.subscribeLiveHwlabEvents((envelope, transport) => { - if (!liveKafkaEnvelopeMatches(envelope, authorizedSessionId, requestedTraceId)) return; - if (!liveDeliveryStarted || typeof enqueueEvent !== "function") { - bufferedEnvelopes.push({ envelope, transport }); - return; - } - void enqueueEvent("hwlab.event.v1", envelope, transport); - }); - if (typeof unsubscribe === "function") cleanup.push(unsubscribe); - - try { - await (bridge.liveReady ?? bridge.ready); - } catch (error) { - closeConnection(); - throw error; - } - if (!isActive()) { - closeConnection(); - return; - } - - response.writeHead(200, { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-store, no-transform", - connection: "keep-alive", - "x-accel-buffering": "no", - "x-content-type-options": "nosniff" - }); - if (typeof response.flushHeaders === "function") response.flushHeaders(); - - const writeEvent = async (name, payload, transport = null) => { - if (!isActive()) return false; - const block = `event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`; - const writable = response.write(block); - if (!writable) await waitForResponseDrain(response, isActive); - const active = isActive(); - if (active && name === "hwlab.event.v1") { - emitLiveKafkaOtelSpan("hwlab.workbench.live_sse.business_event_write", payload, transport, { - env: options.env, - otelSpanEmitter: options.otelSpanEmitter ?? emitCodeAgentOtelSpan - }); - } - return active; - }; - let writeChain = Promise.resolve(true); - enqueueEvent = (name, payload, transport = null) => { - writeChain = writeChain.then(() => writeEvent(name, payload, transport)); - return writeChain; - }; - - const realtimeConnection = { - close(fields = {}) { - if (!isActive()) return false; - realtimeCloseReason = textValue(fields.reason) || "server_shutdown"; - realtimeCloseSignal = textValue(fields.signal) || null; - void enqueueEvent("workbench.server_draining", { - type: "server.draining", - status: "closing", - reason: realtimeCloseReason, - signal: realtimeCloseSignal, - capabilities: realtimeCapabilities, - lossPossible: true - }).finally(() => { - if (!response.writableEnded) response.end(); - }); - return true; - }, - isActive - }; - activeWorkbenchRealtimeConnections.add(realtimeConnection); - cleanup.push(() => activeWorkbenchRealtimeConnections.delete(realtimeConnection)); - - await enqueueEvent("workbench.connected", { - type: "connected", - status: "connected", - capabilities: realtimeCapabilities, - realtimeSource: "hwlab.event.v1", - deliverySemantics: "live-only", - liveOnly: true, - replay: false, - replaySupported: false, - lossPossible: true, - filters: { sessionId: authorizedSessionId, traceId: requestedTraceId } - }); - liveDeliveryStarted = true; - const readyBufferedEnvelopes = bufferedEnvelopes.splice(0); - for (const { envelope, transport } of readyBufferedEnvelopes) void enqueueEvent("hwlab.event.v1", envelope, transport); - await writeChain; - if (!isActive()) { - closeConnection(); - return; - } - const heartbeatTimer = setInterval(() => { - void enqueueEvent("workbench.heartbeat", { - type: "heartbeat", - status: "connected", - realtimeSource: "hwlab.event.v1", - liveOnly: true, - replay: false, - lossPossible: true, - serverSentAt: new Date().toISOString(), - valuesPrinted: false - }); - }, heartbeatMs); - heartbeatTimer.unref?.(); - cleanup.push(() => clearInterval(heartbeatTimer)); - emitWorkbenchRealtimeAcceptedOtelSpan(request, options.env); -} - -async function handleKafkaRefreshReplayWorkbenchRealtimeHttp(request, response, options, input) { - const { - bridge, - heartbeatMs, - realtimeCapabilities, - requestedSessionId, - requestedTraceId - } = input; - const refreshReplay = bridge?.refreshReplay; - if (!refreshReplay || typeof bridge?.queryHwlabEventRetention !== "function") { - sendJson(response, 503, workbenchError("workbench_kafka_refresh_unconfigured", "Workbench Kafka refresh replay is enabled without its retention query runtime.")); - return; - } - let closed = false; - let realtimeCloseReason = "client_close"; - let realtimeCloseSignal = null; - let failureClosing = false; - const realtimeStartedAtMs = Date.now(); - const cleanup = []; - const isActive = () => !closed && !response.destroyed && !response.writableEnded; - const closeConnection = () => { - if (closed) return; - closed = true; - emitWorkbenchRealtimeClosedOtelSpan(request, options.env, { - reason: realtimeCloseReason, - signal: realtimeCloseSignal, - startedAtMs: realtimeStartedAtMs, - sessionId: requestedSessionId, - threadId: null, - traceId: requestedTraceId, - activeConnectionCount: activeWorkbenchRealtimeConnections.size - }); - for (const item of cleanup.splice(0)) item(); - }; - - response.once("close", closeConnection); - request.once?.("aborted", closeConnection); - request.socket?.once?.("close", closeConnection); - cleanup.push(() => request.off?.("aborted", closeConnection)); - cleanup.push(() => request.socket?.off?.("close", closeConnection)); - - try { - await (bridge.liveReady ?? bridge.ready); - } catch (error) { - for (const item of cleanup.splice(0)) item(); - throw error; - } - if (!isActive()) { - closeConnection(); - return; - } - - response.writeHead(200, { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-store, no-transform", - connection: "keep-alive", - "x-accel-buffering": "no", - "x-content-type-options": "nosniff" - }); - if (typeof response.flushHeaders === "function") response.flushHeaders(); - - const writeEvent = async (name, payload, transport = null) => { - if (!isActive()) return false; - const block = `event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`; - const writable = response.write(block); - if (!writable) await waitForResponseDrain(response, isActive); - const active = isActive(); - if (active && name === "hwlab.event.v1") { - emitLiveKafkaOtelSpan("hwlab.workbench.live_sse.business_event_write", payload, transport, { - env: options.env, - otelSpanEmitter: options.otelSpanEmitter ?? emitCodeAgentOtelSpan - }); - } - return active; - }; - let writeChain = Promise.resolve(true); - const enqueueEvent = (name, payload, transport = null) => { - writeChain = writeChain.then(() => writeEvent(name, payload, transport)); - return writeChain; - }; - const closeWithRefreshFailure = async (error) => { - if (failureClosing || !isActive()) return; - failureClosing = true; - await enqueueEvent("workbench.error", workbenchKafkaRefreshErrorPayload(error, { - sessionId: requestedSessionId, - traceId: requestedTraceId - })); - await writeChain; - if (!response.writableEnded) response.end(); - }; - - const realtimeConnection = { - close(fields = {}) { - if (!isActive()) return false; - realtimeCloseReason = textValue(fields.reason) || "server_shutdown"; - realtimeCloseSignal = textValue(fields.signal) || null; - void enqueueEvent("workbench.server_draining", { - type: "server.draining", - status: "closing", - reason: realtimeCloseReason, - signal: realtimeCloseSignal, - capabilities: realtimeCapabilities, - lossPossible: false - }).finally(() => { - if (!response.writableEnded) response.end(); - }); - return true; - }, - isActive - }; - activeWorkbenchRealtimeConnections.add(realtimeConnection); - cleanup.push(() => activeWorkbenchRealtimeConnections.delete(realtimeConnection)); - - const handoff = createWorkbenchKafkaRefreshHandoff({ - sessionId: requestedSessionId, - traceId: requestedTraceId, - liveBufferLimit: refreshReplay.liveBufferLimit, - subscribeLive: (listener) => bridge.subscribeLiveHwlabEvents(listener), - queryRetention: ({ signal }) => bridge.queryHwlabEventRetention({ - sessionId: requestedSessionId, - traceId: requestedTraceId, - limit: refreshReplay.matchedEventLimit, - scanLimit: refreshReplay.scanLimit, - timeoutMs: refreshReplay.timeoutMs, - groupIdPrefix: refreshReplay.groupIdPrefix, - fromBeginning: true, - signal - }), - deliverEvent: (envelope, transport) => enqueueEvent("hwlab.event.v1", envelope, transport), - deliverConnected: (summary) => enqueueEvent("workbench.connected", { - type: "connected", - status: "connected", - capabilities: realtimeCapabilities, - realtimeSource: "hwlab.event.v1", - deliverySemantics: "kafka-retention-then-live", - liveOnly: false, - replay: true, - replaySupported: true, - lossPossible: false, - filters: { sessionId: requestedSessionId, traceId: requestedTraceId }, - refreshReplay: summary - }), - onFailure: closeWithRefreshFailure - }); - cleanup.push(() => handoff.stop("connection-closed")); - - try { - await handoff.start(); - } catch (error) { - if (isActive()) await closeWithRefreshFailure(error); - return; - } - if (!isActive()) return; - - const heartbeatTimer = setInterval(() => { - void enqueueEvent("workbench.heartbeat", { - type: "heartbeat", - status: "connected", - realtimeSource: "hwlab.event.v1", - deliverySemantics: "kafka-retention-then-live", - liveOnly: false, - replay: true, - lossPossible: false, - serverSentAt: new Date().toISOString(), - valuesPrinted: false - }); - }, heartbeatMs); - heartbeatTimer.unref?.(); - cleanup.push(() => clearInterval(heartbeatTimer)); - emitWorkbenchRealtimeAcceptedOtelSpan(request, options.env); -} - -async function authorizeLiveKafkaWorkbenchRealtimeScope(options, actor, { sessionId, traceId }) { - const access = options.accessController; - if ((sessionId && typeof access?.getAgentSession !== "function") || (traceId && typeof access?.getAgentSessionByTraceId !== "function")) { - return { - ok: false, - status: 503, - body: workbenchError("workbench_realtime_authorization_unconfigured", "Workbench live realtime ownership authorization is not configured.") - }; - } - - const [sessionById, sessionByTrace] = await Promise.all([ - sessionId ? access.getAgentSession(sessionId) : null, - traceId ? access.getAgentSessionByTraceId(traceId) : null - ]); - const sessionByIdId = sessionById ? safeSessionId(sessionById.id ?? sessionById.sessionId) : null; - const sessionByTraceId = sessionByTrace ? safeSessionId(sessionByTrace.id ?? sessionByTrace.sessionId) : null; - const scopeMissing = (sessionId && sessionByIdId !== sessionId) - || (traceId && !sessionByTraceId) - || (sessionId && traceId && sessionByIdId !== sessionByTraceId); - const resolvedSessions = [sessionById, sessionByTrace].filter(Boolean); - const ownedByActor = actor?.role === "admin" || (Boolean(actor?.id) - && resolvedSessions.length > 0 - && resolvedSessions.every((session) => textValue(session.ownerUserId) === actor.id)); - if (scopeMissing || !ownedByActor) { - return { - ok: false, - status: 404, - body: workbenchError("workbench_realtime_scope_not_found", "Workbench realtime scope is not visible to the current actor.") - }; - } - return { ok: true, sessionId: sessionByIdId ?? sessionByTraceId }; -} - -function liveKafkaEnvelopeMatches(envelope, sessionId, traceId) { - if (!envelope || envelope.schema !== "hwlab.event.v1") return false; - if (sessionId && safeSessionId(envelope.hwlabSessionId ?? envelope.sessionId) !== sessionId) return false; - if (traceId && safeTraceId(envelope.traceId) !== traceId) return false; - return true; -} - export function attachWorkbenchRealtimeOtelContext(request, fields = {}) { const context = request?.hwlabHttpRequestContext; if (!context) return; diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 148283d8..945259b4 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -372,10 +372,7 @@ export async function buildHealthPayload(options = {}) { async function kafkaProjectorHealth(projector, { liveProbe = false } = {}) { if (!projector?.started) return { started: false, reason: projector?.reason ?? "disabled", valuesRedacted: true }; const identity = { - capabilities: projector.capabilities ?? null, - directPublishGroupId: projector.directPublishGroupId ?? null, projectorGroupId: projector.projectorGroupId ?? null, - hwlabEventGroupId: projector.hwlabEventGroupId ?? null, agentRunTopic: projector.agentRunTopic, hwlabTopic: projector.hwlabTopic }; diff --git a/internal/cloud/workbench-realtime-capabilities.ts b/internal/cloud/workbench-realtime-capabilities.ts deleted file mode 100644 index 01f73093..00000000 --- a/internal/cloud/workbench-realtime-capabilities.ts +++ /dev/null @@ -1,15 +0,0 @@ -// SPEC: PJ2026-0104010803 Workbench fixed transactional realtime authority. -// Responsibility: expose the code-owned Workbench projector/outbox/SSE invariant. - -export const WORKBENCH_REALTIME_CAPABILITIES = Object.freeze({ - directPublish: false, - liveKafkaSse: false, - kafkaRefreshReplay: false, - transactionalProjector: true, - projectionOutboxRelay: true, - projectionRealtime: true -}); - -export function workbenchRealtimeCapabilities() { - return WORKBENCH_REALTIME_CAPABILITIES; -} diff --git a/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts b/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts new file mode 100644 index 00000000..173fa22a --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +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-code-agent-admission-http.ts", + "internal/cloud/server.ts", + "web/hwlab-cloud-web/src/config/runtime.ts", + "web/hwlab-cloud-web/src/api/workbench-events.ts", + "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-event-reducer.ts", + "web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts" +]; +const forbiddenArchitectureIdentifiers = [ + "directPublish", + "liveKafkaSse", + "kafkaRefreshReplay", + "transactionalProjector", + "projectionOutboxRelay", + "projectionRealtime", + "startLiveHwlabKafkaEventBridge", + "handleLiveKafkaWorkbenchRealtimeHttp", + "handleKafkaRefreshReplayWorkbenchRealtimeHttp", + "subscribeLiveHwlabEvents", + "queryHwlabEventRetention", + "WorkbenchRealtimeCapabilities", + "workbenchRealtimeCapabilities", + "workbenchHistoryAuthorityPolicy", + "workbenchRealtimeTransportEnabled", + "workbenchRealtimeTraceIdForCapabilities", + "workbenchProjectionEventStreamPath" +]; + +test("Workbench production source has one fixed transactional projection architecture", async () => { + const violations: string[] = []; + for (const relativePath of productionFiles) { + const source = await readFile(`${repositoryRoot}/${relativePath}`, "utf8"); + for (const identifier of forbiddenArchitectureIdentifiers) { + if (source.includes(identifier)) violations.push(`${relativePath}: ${identifier}`); + } + } + 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"); + assert.match(fixtureSource, /historical hwlab\.event\.debug\.v1 fixtures/u); + assert.match(fixtureSource, /never imported by the product Workbench SSE path/u); + assert.match(debugSource, /workbench-live-kafka-event/u); +}); diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index 3de3a4a5..fd4d943b 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -6,9 +6,9 @@ import test from "node:test"; import type { ChatMessage } from "../src/types/index.ts"; import { workbenchSessionDetailPathForTest, workbenchSessionMessagesPathForTest } from "../src/api/workbench.ts"; -import { workbenchEventStreamPath, workbenchProjectionEventStreamPath } from "../src/api/workbench-events.ts"; +import { workbenchEventStreamPath } from "../src/api/workbench-events.ts"; import type { WorkbenchStreamTransportRecovery } from "../src/utils/workbench-realtime-runtime.ts"; -import { workbenchRealtimeTraceIdForCapabilities, workbenchRealtimeTransportEnabled } from "../src/utils/workbench-stream-transport.ts"; +import { workbenchRealtimeTraceId } from "../src/utils/workbench-stream-transport.ts"; import { workbenchRuntimePolicy } from "../src/config/workbench-runtime-policy.ts"; import { AsyncQueue, work } from "../src/utils/scheduler/async-queue.ts"; import { createCoalescedEventQueue } from "../src/utils/scheduler/coalesced-event-queue.ts"; @@ -20,8 +20,7 @@ import { checkWorkbenchHealth, createWorkbenchHealthProbeCache } from "../src/ut import { messageDiagnosticView } from "../src/utils/workbench-error-runtime.ts"; import { projectRejectedWorkbenchAdmission } from "../src/stores/workbench-admission-failure.ts"; import { WORKBENCH_TIMELINE_OPENCODE_PARITY, buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts"; -import { reduceWorkbenchRealtimeEvent, workbenchRealtimeEventIsBusinessActivity } from "../src/stores/workbench-event-reducer.ts"; -import { reduceWorkbenchLiveKafkaMessageState, workbenchAgentMessageIdForTrace } from "../src/stores/workbench-live-kafka-event.ts"; +import { reduceWorkbenchRealtimeEvent } from "../src/stores/workbench-event-reducer.ts"; import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery } from "../src/stores/workbench-realtime-plan.ts"; 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"; @@ -83,24 +82,16 @@ test("Workbench API uses metadata-only session detail and bounded messages paths assert.equal(workbenchSessionMessagesPathForTest("ses_metadata", { limit: 9 }), "/v1/workbench/sessions/ses_metadata/messages?limit=9"); }); -test("only the projection authority sends the durable afterSeq cursor", () => { - assert.equal(workbenchEventStreamPath({ realtimeCapabilities: { liveKafkaSse: true, kafkaRefreshReplay: false, projectionRealtime: false }, sessionId: "ses_live", traceId: "trc_live", afterSeq: 42 }), "/v1/workbench/events?sessionId=ses_live&traceId=trc_live"); - assert.equal(workbenchEventStreamPath({ realtimeCapabilities: { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true }, sessionId: "ses_projection", traceId: "trc_projection", afterSeq: 42 }), "/v1/workbench/events?sessionId=ses_projection&traceId=trc_projection&afterSeq=42"); - assert.equal(workbenchEventStreamPath({ realtimeCapabilities: { liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: true }, sessionId: "ses_both", traceId: "trc_both", afterSeq: 42 }), "/v1/workbench/events?sessionId=ses_both&traceId=trc_both&afterSeq=42"); - assert.equal(workbenchProjectionEventStreamPath({ sessionId: "ses_both", traceId: "trc_both", afterSeq: 42 }), "/v1/workbench/projection-events?sessionId=ses_both&traceId=trc_both&afterSeq=42"); - assert.equal(workbenchRealtimeTransportEnabled({ liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: false }), false); - assert.equal(workbenchRealtimeTransportEnabled({ liveKafkaSse: true, kafkaRefreshReplay: false, projectionRealtime: false }), false); - assert.equal(workbenchRealtimeTransportEnabled({ liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true }), true); - assert.equal(workbenchRealtimeTransportEnabled({ liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: true }), true); +test("the product EventSource always sends the durable outbox cursor", () => { + assert.equal(workbenchEventStreamPath({ sessionId: "ses_projection", traceId: "trc_projection", afterSeq: 42 }), "/v1/workbench/events?sessionId=ses_projection&traceId=trc_projection&afterSeq=42"); }); -test("obsolete live flags cannot erase the active projection trace scope", () => { - const capabilities = { liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: false }; - const beforeSubmit = workbenchRealtimeScopeKey("ses_live", workbenchRealtimeTraceIdForCapabilities(capabilities, null, null)); - const afterSubmit = workbenchRealtimeScopeKey("ses_live", workbenchRealtimeTraceIdForCapabilities(capabilities, "trc_current_request", "trc_message")); +test("the active projection trace scope follows the current request", () => { + const beforeSubmit = workbenchRealtimeScopeKey("ses_projection", workbenchRealtimeTraceId(null, null)); + const afterSubmit = workbenchRealtimeScopeKey("ses_projection", workbenchRealtimeTraceId("trc_current_request", "trc_message")); assert.notEqual(afterSubmit, beforeSubmit); - assert.equal(afterSubmit, workbenchRealtimeScopeKey("ses_live", "trc_current_request")); - assert.equal(workbenchRealtimeTraceIdForCapabilities({ liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true }, "trc_current_request", "trc_message"), "trc_current_request"); + assert.equal(afterSubmit, workbenchRealtimeScopeKey("ses_projection", "trc_current_request")); + assert.equal(workbenchRealtimeTraceId(null, "trc_message"), "trc_message"); }); test("Error runtime owns Workbench message diagnostic view model", () => { @@ -533,53 +524,6 @@ test("realtime event reducer classifies SSE payloads before store side effects", assert.equal(error.diagnostic.traceId, "trc_2"); }); -test("live transport connected and heartbeat frames do not extend business activity timeouts", () => { - assert.equal(workbenchRealtimeEventIsBusinessActivity({ type: "connected" }, "workbench.connected", true), false); - assert.equal(workbenchRealtimeEventIsBusinessActivity({ type: "heartbeat" }, "workbench.heartbeat", true), false); - assert.equal(workbenchRealtimeEventIsBusinessActivity({ schema: "hwlab.event.v1", event: { type: "assistant" } }, "hwlab.event.v1", true), true); - assert.equal(workbenchRealtimeEventIsBusinessActivity({ type: "trace.event" }, "workbench.trace.event", false), true); -}); - -test("live hwlab envelope projects assistant, tool/output, and terminal without replay or finalizer", () => { - const envelope = (event: Record) => ({ - schema: "hwlab.event.v1", - eventType: "hwlab.trace.event.projected", - eventId: `hwlab:${String(event.sourceEventId ?? event.type)}`, - hwlabSessionId: "ses_live_web", - sessionId: "ses_live_web", - traceId: "trc_live_web", - runId: "run_live_web", - commandId: "cmd_live_web", - context: { runId: "run_live_web", commandId: "cmd_live_web", valuesRedacted: true }, - event - }); - const assistantEnvelope = envelope({ type: "assistant", eventType: "assistant", sourceEventId: "evt_assistant", traceId: "trc_live_web", sessionId: "ses_live_web", status: "running", assistantText: "running increment", terminal: false }); - const assistant = reduceWorkbenchRealtimeEvent(assistantEnvelope, "hwlab.event.v1"); - assert.equal(assistant.action.type, "trace.event"); - let visible = reduceWorkbenchLiveKafkaMessageState({ text: "", status: "running", terminal: false }, assistantEnvelope.event); - assert.deepEqual(visible, { text: "running increment", status: "running", terminal: false }); - - const toolEnvelope = envelope({ type: "tool", eventType: "tool", sourceEventId: "evt_tool", traceId: "trc_live_web", sessionId: "ses_live_web", status: "completed", toolName: "commandExecution", outputSummary: "ok", terminal: false }); - assert.equal(reduceWorkbenchRealtimeEvent(toolEnvelope, "hwlab.event.v1").action.type, "trace.event"); - visible = reduceWorkbenchLiveKafkaMessageState(visible, toolEnvelope.event); - assert.deepEqual(visible, { text: "running increment", status: "running", terminal: false }); - - const outputEnvelope = envelope({ type: "output", eventType: "status", sourceEventId: "evt_output", traceId: "trc_live_web", sessionId: "ses_live_web", status: "running", message: "stdout increment", terminal: false }); - visible = reduceWorkbenchLiveKafkaMessageState(visible, outputEnvelope.event); - assert.deepEqual(visible, { text: "running increment", status: "running", terminal: false }); - - const terminalEnvelope = envelope({ type: "result", eventType: "terminal", sourceEventId: "evt_terminal", traceId: "trc_live_web", sessionId: "ses_live_web", status: "completed", terminal: true }); - visible = reduceWorkbenchLiveKafkaMessageState(visible, terminalEnvelope.event); - assert.deepEqual(visible, { text: "running increment", status: "completed", terminal: true }); - assert.equal(workbenchAgentMessageIdForTrace("trc_live_web"), "msg_live_web_agent"); - - const failed = reduceWorkbenchLiveKafkaMessageState( - { text: "partial response", status: "running", terminal: false }, - { type: "result", eventType: "terminal", status: "failed", message: "provider stream disconnected", terminal: true } - ); - assert.deepEqual(failed, { text: "provider stream disconnected", status: "failed", terminal: true }); -}); - test("realtime apply planner turns reducer actions into store steps", () => { const reduced = 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"); const tracePlan = planWorkbenchRealtimeApply(reduced.action); diff --git a/web/hwlab-cloud-web/src/api/workbench-events.test.ts b/web/hwlab-cloud-web/src/api/workbench-events.test.ts index de21fcd2..dc773e52 100644 --- a/web/hwlab-cloud-web/src/api/workbench-events.test.ts +++ b/web/hwlab-cloud-web/src/api/workbench-events.test.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; import { connectWorkbenchEvents, realtimeCoalesceKey, workbenchEventStreamPath, type WorkbenchRealtimeEvent, type WorkbenchSseIngressFrame } from "./workbench-events"; -import { createCoalescedEventQueue } from "../utils/scheduler/coalesced-event-queue"; test("turn snapshot coalescing keys by trace instead of per sequence", () => { const first: WorkbenchRealtimeEvent = { type: "turn.snapshot", turn: { sessionId: "ses_queue", traceId: "trc_queue", status: "running" }, cursor: { traceSeq: 10 } }; @@ -21,7 +20,6 @@ test("trace events keep sequence-specific coalescing keys", () => { test("product EventSource resumes the transactional projection authority with one durable outbox cursor", () => { assert.equal( workbenchEventStreamPath({ - realtimeCapabilities: { liveKafkaSse: true, kafkaRefreshReplay: false, projectionRealtime: true }, sessionId: "ses_projection_authority", traceId: "trc_projection_authority", afterSeq: 42 @@ -30,29 +28,7 @@ test("product EventSource resumes the transactional projection authority with on ); }); -test("live Kafka envelopes from one trace retain every assistant, tool, output, and terminal event", () => { - const delivered: WorkbenchRealtimeEvent[] = []; - const queue = createCoalescedEventQueue({ - keyOf: (event) => realtimeCoalesceKey(event, "hwlab.event.v1"), - onFlush: (events) => delivered.push(...events) - }); - for (const sourceEventId of ["evt_assistant", "evt_tool", "evt_output", "evt_terminal"]) { - queue.push({ - schema: "hwlab.event.v1", - eventId: `hwlab:${sourceEventId}`, - sourceEventId, - sessionId: "ses_live_queue", - traceId: "trc_live_queue", - event: { sourceEventId, traceId: "trc_live_queue", sessionId: "ses_live_queue" } - }); - } - - queue.flush(); - - assert.deepEqual(delivered.map((event) => event.sourceEventId), ["evt_assistant", "evt_tool", "evt_output", "evt_terminal"]); -}); - -test("product EventSource tees every raw HWLAB frame once before decode and reducer delivery", async () => { +test("product EventSource tees projection frames once before reducer delivery", async () => { const originalEventSource = globalThis.EventSource; const ingress: WorkbenchSseIngressFrame[] = []; const delivered: WorkbenchRealtimeEvent[] = []; @@ -60,7 +36,6 @@ test("product EventSource tees every raw HWLAB frame once before decode and redu globalThis.EventSource = FakeProductEventSource as unknown as typeof EventSource; try { const stream = connectWorkbenchEvents({ - realtimeCapabilities: { liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: false }, sessionId: "ses_raw_tee", flushYieldMs: 1, onIngress: (frame) => ingress.push(frame), @@ -69,17 +44,17 @@ test("product EventSource tees every raw HWLAB frame once before decode and redu assert.ok(stream); const source = FakeProductEventSource.instances[0]; assert.ok(source); - source.emitRaw("hwlab.event.v1", '{"schema":"hwlab.event.v1","eventId":"evt_raw_tee"}'); - source.emitRaw("hwlab.event.v1", "{invalid-json"); + source.emitRaw("workbench.trace.event", '{"type":"trace.event","traceId":"trc_raw_tee","event":{"traceId":"trc_raw_tee","projectedSeq":1}}'); + source.emitRaw("workbench.trace.event", "{invalid-json"); await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(FakeProductEventSource.instances.length, 1); assert.deepEqual(ingress.map((frame) => [frame.eventName, frame.decodeStatus]), [ - ["hwlab.event.v1", "accepted"], - ["hwlab.event.v1", "invalid-json"] + ["workbench.trace.event", "accepted"], + ["workbench.trace.event", "invalid-json"] ]); assert.equal(delivered.length, 1); - assert.equal(delivered[0]?.eventId, "evt_raw_tee"); + assert.equal(delivered[0]?.traceId, "trc_raw_tee"); stream.close(); } finally { globalThis.EventSource = originalEventSource; diff --git a/web/hwlab-cloud-web/src/api/workbench-events.ts b/web/hwlab-cloud-web/src/api/workbench-events.ts index 38700e39..f86142a1 100644 --- a/web/hwlab-cloud-web/src/api/workbench-events.ts +++ b/web/hwlab-cloud-web/src/api/workbench-events.ts @@ -6,7 +6,6 @@ import type { ApiResult, ChatMessage, ProjectionDiagnostic, TraceEvent } from "@ import { createCoalescedEventQueue } from "@/utils/scheduler/coalesced-event-queue"; import { composeWorkbenchScopedKey, firstScopePart } from "@/utils/workbench-key"; import { recordWorkbenchRuntimeDiagnostic, recordWorkbenchSseLifecycle } from "@/utils/workbench-performance"; -import type { WorkbenchRealtimeCapabilities } from "@/config/runtime"; import { decodeWorkbenchRealtimeEventFrame } from "./workbench-realtime-codec"; export interface WorkbenchRealtimeTraceSnapshot { @@ -29,11 +28,6 @@ export interface WorkbenchRealtimeEvent { eventType?: string | null; eventId?: string | null; sourceEventId?: string | null; - capabilities?: WorkbenchRealtimeCapabilities | null; - liveOnly?: boolean | null; - replay?: boolean | null; - deliverySemantics?: string | null; - lossPossible?: boolean | null; type?: string; contractVersion?: string | null; realtimeAuthority?: string | null; @@ -76,7 +70,6 @@ export interface WorkbenchRealtimeEvent { } export interface WorkbenchEventStreamOptions { - realtimeCapabilities: WorkbenchRealtimeCapabilities; sessionId?: string | null; traceId?: string | null; afterSeq?: number | null; @@ -105,7 +98,6 @@ interface QueuedRealtimeEvent { } const WORKBENCH_EVENT_NAMES = [ - "hwlab.event.v1", "workbench.connected", "workbench.trace.snapshot", "workbench.trace.event", @@ -183,20 +175,12 @@ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): Wo }; } -export function workbenchEventStreamPath(options: Pick): string { - const params = new URLSearchParams(); - appendParam(params, "sessionId", options.sessionId); - appendParam(params, "traceId", options.traceId); - if (options.realtimeCapabilities.projectionRealtime) appendNumberParam(params, "afterSeq", options.afterSeq); - return `/v1/workbench/events?${params.toString()}`; -} - -export function workbenchProjectionEventStreamPath(options: Pick): string { +export function workbenchEventStreamPath(options: Pick): string { const params = new URLSearchParams(); appendParam(params, "sessionId", options.sessionId); appendParam(params, "traceId", options.traceId); appendNumberParam(params, "afterSeq", options.afterSeq); - return `/v1/workbench/projection-events?${params.toString()}`; + return `/v1/workbench/events?${params.toString()}`; } function appendParam(params: URLSearchParams, key: string, value: string | null | undefined): void { @@ -228,10 +212,6 @@ function scheduleRealtimeFlushYield(flush: () => void, yieldMs: number | null | } export function realtimeCoalesceKey(event: WorkbenchRealtimeEvent, eventName: string): string | null { - if (eventName === "hwlab.event.v1" || event.schema === "hwlab.event.v1") { - const eventId = firstScopePart(event.eventId, event.sourceEventId, event.event?.sourceEventId); - return eventId ? composeWorkbenchScopedKey("workbench.sse.live-kafka-event", eventId) : null; - } const entity = event.entity; const entityFamily = firstScopePart(entity?.family); const entityId = firstScopePart(entity?.id); diff --git a/web/hwlab-cloud-web/src/config/runtime.ts b/web/hwlab-cloud-web/src/config/runtime.ts index 54387e08..a5be0cec 100644 --- a/web/hwlab-cloud-web/src/config/runtime.ts +++ b/web/hwlab-cloud-web/src/config/runtime.ts @@ -10,12 +10,6 @@ export interface OpenCodeFrameConfig { url: string; } -export interface WorkbenchRealtimeCapabilities { - liveKafkaSse: boolean; - kafkaRefreshReplay: boolean; - projectionRealtime: boolean; -} - export interface WorkbenchDebugCapabilities { isolatedKafka: boolean; rawHwlabEventWindow: WorkbenchRawHwlabEventWindowCapability; @@ -97,10 +91,6 @@ export function workbenchTraceTimelinePolicy(): WorkbenchTraceTimelinePolicy { }; } -export function workbenchRealtimeCapabilities(): WorkbenchRealtimeCapabilities { - return { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true }; -} - export function workbenchDebugCapabilities(): WorkbenchDebugCapabilities { const features = window.HWLAB_CLOUD_WEB_CONFIG?.workbench?.debugCapabilities; const rawWindow = features?.rawHwlabEventWindow; diff --git a/web/hwlab-cloud-web/src/stores/workbench-event-reducer.ts b/web/hwlab-cloud-web/src/stores/workbench-event-reducer.ts index e9984b5c..dcf4da5e 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-event-reducer.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-event-reducer.ts @@ -48,15 +48,7 @@ export function reduceWorkbenchRealtimeEvent(event: WorkbenchRealtimeEvent, even }; } -export function workbenchRealtimeEventIsBusinessActivity(event: WorkbenchRealtimeEvent, eventName: string, liveKafkaSse: boolean): boolean { - if (!liveKafkaSse) return true; - return eventName === "hwlab.event.v1" || event.schema === "hwlab.event.v1"; -} - function reduceRealtimeAction(event: WorkbenchRealtimeEvent, eventName: string): WorkbenchRealtimeAction { - if (event.schema === "hwlab.event.v1" && event.event) { - return { type: "trace.event", traceId: realtimeTraceId(event), event: event.event, snapshot: null, realtimeEvent: event }; - } const authority = primaryAuthority(event); if (authority) return authority; switch (event.type) { diff --git a/web/hwlab-cloud-web/src/stores/workbench-isolated-kafka-debug.ts b/web/hwlab-cloud-web/src/stores/workbench-isolated-kafka-debug.ts index 1fdce381..1eb88acc 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-isolated-kafka-debug.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-isolated-kafka-debug.ts @@ -5,9 +5,7 @@ import type { WorkbenchKafkaSseDebugEvent } from "@/api/workbench-debug"; import type { WorkbenchRealtimeEvent } from "@/api/workbench-events"; import type { ChatMessage } from "@/types"; import { firstNonEmptyString } from "@/utils"; -import { reduceWorkbenchRealtimeEvent } from "./workbench-event-reducer"; import { projectWorkbenchLiveKafkaMessage } from "./workbench-live-kafka-event"; -import { planWorkbenchRealtimeApply } from "./workbench-realtime-plan"; export interface WorkbenchIsolatedKafkaDebugLog { id: string; @@ -48,22 +46,17 @@ export function applyWorkbenchIsolatedKafkaDebugEvent( if (!traceId) return rejected(base, debugEvent, null, "debug-envelope-trace-missing"); if (expected && traceId !== expected) return rejected(base, debugEvent, traceId, "debug-envelope-trace-mismatch"); - const event = { ...debugEvent, schema: "hwlab.event.v1" } as WorkbenchRealtimeEvent; - const reduced = reduceWorkbenchRealtimeEvent(event, "hwlab.event.v1"); - const plan = planWorkbenchRealtimeApply(reduced.action); - const traceStep = plan.steps.find((step) => step.type === "apply-trace-event"); - if (!traceStep || traceStep.type !== "apply-trace-event" || !traceStep.event) { - return withLog(base, event, traceId, reduced.action.type, plan.steps.map((step) => step.type), false, reduced.action.type === "ignore" ? reduced.action.reason : "debug-envelope-not-a-trace-event"); - } + const traceEvent = debugEvent.event; + if (!traceEvent) return withLog(base, debugEvent, traceId, "ignore", [], false, "debug-envelope-not-a-trace-event"); const message = projectWorkbenchLiveKafkaMessage({ previous: state.message, traceId, - sessionId: firstNonEmptyString(event.hwlabSessionId, event.sessionId, traceStep.event.sessionId, state.message?.sessionId) ?? "ses_workbench_isolated_debug", - event: traceStep.event, + sessionId: firstNonEmptyString(debugEvent.hwlabSessionId, debugEvent.sessionId, traceEvent.sessionId, state.message?.sessionId) ?? "ses_workbench_isolated_debug", + event: traceEvent, receivedAt: new Date().toISOString(), title: "Code Agent · 隔离调试" }); - return withLog({ ...base, message, appliedCount: state.appliedCount + 1, error: null }, event, traceId, reduced.action.type, plan.steps.map((step) => step.type), true, null); + return withLog({ ...base, message, appliedCount: state.appliedCount + 1, error: null }, debugEvent, traceId, "debug.trace.event", ["debug-project-message"], true, null); } export function workbenchCurrentDebugTraceId(messages: ChatMessage[], sessionLastTraceId?: string | null): string | null { diff --git a/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.test.ts b/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.test.ts deleted file mode 100644 index ac2a1034..00000000 --- a/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "bun:test"; - -import { workbenchHistoryAuthorityPolicy } from "./workbench-kafka-refresh-policy"; - -test("every capability combination keeps automatic HTTP terminal and history readers disabled", () => { - for (const liveKafkaSse of [false, true]) { - for (const kafkaRefreshReplay of [false, true]) { - for (const projectionRealtime of [false, true]) { - const policy = workbenchHistoryAuthorityPolicy({ liveKafkaSse, kafkaRefreshReplay, projectionRealtime }); - const calls = { fetchSessionMessagesPage: 0, fetchTurn: 0, fetchTraceEvents: 0 }; - if (policy.sessionMessagesHydrate) calls.fetchSessionMessagesPage += 1; - if (policy.turnStatusHydrate) calls.fetchTurn += 1; - if (policy.traceEventsHydrate) calls.fetchTraceEvents += 1; - - assert.deepEqual(policy, { - kafkaRetention: false, - sessionMetadataRead: true, - sessionMessagesHydrate: false, - turnStatusHydrate: false, - traceEventsHydrate: false, - syncReplay: projectionRealtime - }); - assert.deepEqual(calls, { fetchSessionMessagesPage: 0, fetchTurn: 0, fetchTraceEvents: 0 }); - } - } - } -}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.ts b/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.ts deleted file mode 100644 index d0ae5f33..00000000 --- a/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { WorkbenchRealtimeCapabilities } from "@/config/runtime"; - -export interface WorkbenchHistoryAuthorityPolicy { - kafkaRetention: boolean; - sessionMetadataRead: true; - sessionMessagesHydrate: false; - turnStatusHydrate: false; - traceEventsHydrate: false; - syncReplay: boolean; -} - -export function workbenchHistoryAuthorityPolicy(capabilities: WorkbenchRealtimeCapabilities): WorkbenchHistoryAuthorityPolicy { - return { - kafkaRetention: false, - sessionMetadataRead: true, - sessionMessagesHydrate: false, - turnStatusHydrate: false, - traceEventsHydrate: false, - syncReplay: capabilities.projectionRealtime - }; -} diff --git a/web/hwlab-cloud-web/src/stores/workbench-live-kafka-event.ts b/web/hwlab-cloud-web/src/stores/workbench-live-kafka-event.ts index abee3c58..0bbd4b72 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-live-kafka-event.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-live-kafka-event.ts @@ -1,5 +1,5 @@ -// SPEC: PJ2026-0104010803 Workbench live Kafka SSE. -// Responsibility: project one transparent hwlab.event.v1 business event into visible turn state without replay/finalizer/polling. +// SPEC: PJ2026-0104010803 isolated Kafka debug fixture. +// Responsibility: project historical hwlab.event.debug.v1 fixtures for the admin-only isolated debugger; never imported by the product Workbench SSE path. import { mergeRunnerTrace } from "../composables/workbench-trace-snapshot"; import type { ChatMessage, TraceEvent, WorkbenchTurnTimingProjection } from "../types"; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 582724da..a4ba37fd 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,13 +4,13 @@ import { computed, nextTick, ref } from "vue"; import { defineStore } from "pinia"; import { api } from "@/api"; -import { workbenchDebugCapabilities, workbenchRealtimeCapabilities } from "@/config/runtime"; +import { workbenchDebugCapabilities } from "@/config/runtime"; import { workbenchRuntimePolicy } from "@/config/workbench-runtime-policy"; import { createKeyedSingleflight } from "@/utils/scheduler/keyed-singleflight"; import { createWorkbenchHealthProbeCache } from "@/utils/workbench-health"; import { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagnostic, normalizeProjectionDiagnostic, projectionDiagnosticFromApiFailure, projectionDiagnosticFromFailure } from "@/utils/workbench-error-runtime"; import { readWorkbenchJson, readWorkbenchNumber, readWorkbenchString, removeWorkbenchStorageKey, writeWorkbenchJson, writeWorkbenchString } from "@/utils/workbench-storage-runtime"; -import { createWorkbenchStreamTransportRuntime, workbenchRealtimeTraceIdForCapabilities, type WorkbenchRealtimeEvent, type WorkbenchStreamTransportRecovery } from "@/utils/workbench-realtime-runtime"; +import { createWorkbenchStreamTransportRuntime, workbenchRealtimeTraceId, type WorkbenchRealtimeEvent, type WorkbenchStreamTransportRecovery } from "@/utils/workbench-realtime-runtime"; import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/workbench-trace-snapshot"; import type { WorkbenchMessagePageResponse, WorkbenchSessionDetailResponse } from "@/api/workbench"; import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiError, ApiResult, ChatMessage, ErrorDiagnostic, LiveSurface, ProjectionBlocker, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types"; @@ -21,10 +21,8 @@ import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOpt 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, workbenchRealtimeEventIsBusinessActivity, type WorkbenchRealtimeAction } from "./workbench-event-reducer"; -import { projectWorkbenchLiveKafkaMessage, projectWorkbenchLiveKafkaUserMessage, workbenchAgentMessageIdForTrace, workbenchLiveKafkaEnvelope, workbenchLiveKafkaProjectionTarget, workbenchUserMessageIdForTrace } from "./workbench-live-kafka-event"; +import { reduceWorkbenchRealtimeEvent, type WorkbenchRealtimeAction } from "./workbench-event-reducer"; import { projectRejectedWorkbenchAdmission } from "./workbench-admission-failure"; -import { workbenchHistoryAuthorityPolicy } from "./workbench-kafka-refresh-policy"; import { messageHasSealedTerminalResult, messageIsSealedTerminal, traceAuthorityIsSealed } from "./workbench-terminal-authority"; import { boundedProjectionMessageLimit, mergeBoundedProjectionMessages, selectProjectionMessageWindow, traceProjectionIsTerminalSealed } from "./workbench-message-projection-budget"; import { @@ -108,10 +106,17 @@ interface RealtimeTurnProjectionItem { terminalTurn: boolean; } +function workbenchMessageIdForTrace(traceId: string, role: "user" | "agent"): string { + const suffix = firstNonEmptyString(traceId) + ?.replace(/^trc_/u, "") + .replace(/[^A-Za-z0-9_.:-]/gu, "_") + .slice(0, 48); + if (!suffix) throw new Error("traceId must produce a stable Workbench message identity"); + return `msg_${suffix}_${role}`; +} + export const useWorkbenchStore = defineStore("workbench", () => { const runtimePolicy = workbenchRuntimePolicy(); - const realtimeCapabilities = workbenchRealtimeCapabilities(); - const historyAuthorityPolicy = workbenchHistoryAuthorityPolicy(realtimeCapabilities); const debugCapabilities = workbenchDebugCapabilities(); const workbenchColadaReducer = useWorkbenchColadaReducer(); const workbenchColadaQueries = useWorkbenchColadaQueries(); @@ -138,7 +143,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { const sessionListLoadingMore = ref(false); const sessionListLoadMoreError = ref(null); const chatPending = ref(false); - const liveRealtimeReadySessionId = ref(null); const rawHwlabIngress = ref(createRawHwlabIngressState()); const error = ref(null); const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null }); @@ -169,7 +173,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const sessionListLoadedCount = computed(() => sessionTabs.value.length); const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, sessionsReady: sessionsReady.value })); const sessionDetailLoading = computed(() => Boolean(sessionDetailLoadingId.value || switchingSessionId.value || (loading.value && messages.value.length === 0))); - const composer = computed(() => resolveComposerState({ messages: messages.value, sessions: sessions.value, activeSessionId: activeSessionId.value, chatPending: chatPending.value, realtimeReady: !realtimeCapabilities.liveKafkaSse || liveRealtimeReadySessionId.value === activeSessionId.value, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value })); + const composer = computed(() => resolveComposerState({ messages: messages.value, sessions: sessions.value, activeSessionId: activeSessionId.value, chatPending: chatPending.value, realtimeReady: true, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value })); function recordActivity(label = "user-activity"): void { const now = Date.now(); @@ -376,7 +380,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { rememberSessionList(next); applySessionPagination(response.data); sessionsReady.value = true; - if (historyAuthorityPolicy.turnStatusHydrate) await refreshSessionStatusAuthority(next); return; } sessionsReady.value = sessions.value.length > 0; @@ -384,7 +387,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function scheduleSessionListRefresh(includeSessionId: string | null | undefined = activeSessionId.value, delayMs = runtimePolicy.sessionListRealtimeRefreshDelayMs): void { - if (realtimeCapabilities.liveKafkaSse) return; void includeSessionId; if (typeof window === "undefined") { void workbenchColadaQueries.invalidateSessionList(); @@ -412,7 +414,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { rememberSessionList(next); applySessionPagination(response.data); sessionsReady.value = true; - if (historyAuthorityPolicy.turnStatusHydrate) await refreshSessionStatusAuthority(next); } function currentSessionListLimit(): number { @@ -763,14 +764,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { const submitEntry = steerMode ? "steer" : "existing"; const traceId = steerMode && composer.value.targetTraceId ? composer.value.targetTraceId : nextProtocolId("trc"); const steerTraceId = steerMode ? nextProtocolId("trc_steer") : null; - const userMessageId = workbenchUserMessageIdForTrace(steerTraceId ?? traceId); - const agentMessageId = workbenchAgentMessageIdForTrace(traceId); + const userMessageId = workbenchMessageIdForTrace(steerTraceId ?? traceId, "user"); + const agentMessageId = workbenchMessageIdForTrace(traceId, "agent"); const sessionId = composer.value.sessionId; - if (realtimeCapabilities.liveKafkaSse && liveRealtimeReadySessionId.value !== sessionId) { - error.value = "realtime_connecting"; - restartRealtime("submit-readiness"); - return false; - } const threadId = composer.value.threadId; const providerThreadId = providerThreadIdForRequest(threadId); const submittedAt = new Date().toISOString(); @@ -1128,10 +1124,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { void reason; const realtimeScopeKey = workbenchRealtimeScopeKey(sessionId, traceId); const scopeChanged = realtimeTransport.currentKey() !== realtimeScopeKey; - if (realtimeCapabilities.liveKafkaSse && scopeChanged) liveRealtimeReadySessionId.value = null; if (debugCapabilities.rawHwlabEventWindow.enabled && scopeChanged) rawHwlabIngress.value = createRawHwlabIngressState(realtimeScopeKey); realtimeTransport.restart({ - realtimeCapabilities, sessionId, traceId, forceReconnect, @@ -1142,31 +1136,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { onIngress: debugCapabilities.rawHwlabEventWindow.enabled ? (frame) => { rawHwlabIngress.value = appendRawHwlabIngressFrame(rawHwlabIngress.value, frame, debugCapabilities.rawHwlabEventWindow); } : undefined, - onOpen: () => undefined, - onError: () => { - if (realtimeCapabilities.liveKafkaSse) liveRealtimeReadySessionId.value = null; - }, - onState: (state) => { - if (realtimeCapabilities.liveKafkaSse && ["connecting", "error", "closed", "blocked"].includes(state.phase)) liveRealtimeReadySessionId.value = null; - }, onRecovery: (recovery) => handleRealtimeRecovery(recovery), - onEvent: (event, eventName) => { - if (realtimeCapabilities.liveKafkaSse && eventName === "workbench.connected") { - const filters = recordValue(event.filters); - const connectedSessionId = normalizeWorkbenchSessionId(filters?.sessionId); - const refreshReplay = realtimeCapabilities.kafkaRefreshReplay; - const deliveryValid = refreshReplay - ? event.deliverySemantics === "kafka-retention-then-live" && event.liveOnly === false && event.replay === true && event.lossPossible === false - : event.deliverySemantics === "live-only" && event.liveOnly === true && event.replay === false; - const valid = deliveryValid - && event.capabilities?.liveKafkaSse === true - && event.capabilities?.kafkaRefreshReplay === refreshReplay - && connectedSessionId === sessionId; - liveRealtimeReadySessionId.value = valid ? connectedSessionId : null; - if (!valid) error.value = "workbench_live_realtime_contract_invalid"; - } - applyRealtimeEvent(event, eventName); - } + onEvent: (event, eventName) => applyRealtimeEvent(event, eventName) }); } @@ -1193,13 +1164,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function stopRealtime(): void { - liveRealtimeReadySessionId.value = null; realtimeTransport.stop(); } function realtimeTraceId(): string | null { - return workbenchRealtimeTraceIdForCapabilities( - realtimeCapabilities, + return workbenchRealtimeTraceId( currentRequest.value?.traceId, activeTraceIdFromMessages(messages.value, turnStatusAuthority.value) ); @@ -1217,7 +1186,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const snapshot = recordValue(event.snapshot); const traceEvent = recordValue(event.event); const sessionId = firstNonEmptyString(event.hwlabSessionId, event.sessionId, turn?.sessionId, snapshot?.sessionId, traceEvent?.sessionId); - return event.schema === "hwlab.event.v1" ? normalizeWorkbenchSessionId(sessionId) : normalizeTraceAuthoritySessionId(sessionId); + return normalizeTraceAuthoritySessionId(sessionId); } function traceResultSessionId(result: AgentChatResultResponse | TraceSnapshot | Record | null | undefined): string | null { @@ -1284,7 +1253,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { function applyRealtimeEvent(event: WorkbenchRealtimeEvent, eventName: string): void { const reduced = reduceWorkbenchRealtimeEvent(event, eventName); - if (workbenchRealtimeEventIsBusinessActivity(event, eventName, realtimeCapabilities.liveKafkaSse)) recordActivity(reduced.activityLabel); + recordActivity(reduced.activityLabel); applyWorkbenchRealtimeAction(reduced.action); } @@ -1340,77 +1309,18 @@ export const useWorkbenchStore = defineStore("workbench", () => { function applyRealtimeTraceEvent(traceId: string | null | undefined, event: WorkbenchRealtimeEvent["event"], snapshot: WorkbenchRealtimeEvent["snapshot"], realtimeEvent?: WorkbenchRealtimeEvent | null): void { const id = firstNonEmptyString(traceId, event?.traceId, snapshot?.traceId); if (!id) return; - const liveKafkaEnvelope = workbenchLiveKafkaEnvelope(realtimeEvent?.schema); const sessionId = realtimeEvent ? realtimeEventSessionId(realtimeEvent) : traceResultSessionId(snapshot ?? event ?? null); - if (!shouldApplyActiveTraceAuthority(id, sessionId, liveKafkaEnvelope)) return; - if (traceTerminalBodyIsVisible(id, sessionId) && !liveKafkaEnvelope) { + if (!shouldApplyActiveTraceAuthority(id, sessionId)) return; + if (traceTerminalBodyIsVisible(id, sessionId)) { recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId, traceId: id, outcome: "ok", diagnostic: { code: "terminal_low_priority_sse_trace_skip", source: "realtime-trace-event", valuesRedacted: true } }); return; } const events = event ? [event] : Array.isArray(snapshot?.events) ? snapshot.events : []; markWorkbenchTraceEventsReceived({ traceId: id, events, transport: "sse", serverSentAt: realtimeEvent?.serverSentAt, eventCreatedAt: realtimeEvent?.eventCreatedAt, traceSeq: realtimeEvent?.traceSeq ?? realtimeEvent?.cursor?.traceSeq }); - if (liveKafkaEnvelope && event) { - applyLiveKafkaBusinessEvent(id, sessionId, event, new Date().toISOString()); - return; - } const eventSnapshot = snapshot ?? { traceId: id, status: event?.status, events }; applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, eventSnapshot, events)); } - function applyLiveKafkaBusinessEvent(traceId: string, authoritySessionId: string | null, event: TraceEvent, receivedAt: string): void { - const eventSessionId = firstNonEmptyString(event.sessionId); - const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId ?? eventSessionId, true); - if (!ownerSessionId) return; - if (workbenchLiveKafkaProjectionTarget(event) === "user") { - const messageId = firstNonEmptyString(event.userMessageId, event.messageId); - const previousUserMessage = messageId - ? (serverState.value.messagesBySessionId[ownerSessionId] ?? []).find((message) => (message.messageId ?? message.id) === messageId) ?? null - : null; - const userMessage = projectWorkbenchLiveKafkaUserMessage({ previous: previousUserMessage, traceId, sessionId: ownerSessionId, event, receivedAt }); - if (userMessage) reduceServerState({ type: "message.upsert", sessionId: ownerSessionId, message: userMessage }); - else error.value = "workbench_live_user_message_invalid"; - return; - } - const previousMessage = (serverState.value.messagesBySessionId[ownerSessionId] ?? []).find((message) => messageMatchesTraceAuthority(message, traceId, authoritySessionId ?? eventSessionId, ownerSessionId, true)) ?? null; - const message = projectWorkbenchLiveKafkaMessage({ - previous: previousMessage, - traceId, - sessionId: ownerSessionId, - event, - receivedAt - }); - reduceServerState({ - type: "message.upsert", - sessionId: ownerSessionId, - message - }); - if (message.runnerTrace) rememberTraceAuthority(message.runnerTrace); - markWorkbenchTraceProjected(traceId); - const terminal = message.traceAutoLifecycle === "terminal"; - const status = firstNonEmptyString(message.status) ?? "running"; - const finalResponse = terminal && message.text ? { text: message.text, status, traceId } : undefined; - rememberTurnStatus(traceId, { - traceId, - sessionId: ownerSessionId, - status, - running: !terminal, - terminal, - finalResponse, - timing: message.timing, - startedAt: message.timing?.startedAt, - lastEventAt: message.timing?.lastEventAt, - finishedAt: message.timing?.finishedAt, - durationMs: message.timing?.durationMs, - updatedAt: receivedAt - } as AgentChatResultResponse); - const existing = sessions.value.find((session) => session.sessionId === ownerSessionId) ?? null; - if (existing) rememberSessionList(mergeSessionIntoList(sessions.value, { ...existing, status, lastTraceId: traceId, updatedAt: receivedAt })); - if (terminal && currentRequest.value?.traceId === traceId) { - chatPending.value = false; - currentRequest.value = null; - } - } - function applyRealtimeTurnSnapshot(turn: Record): void { const traceId = firstNonEmptyString(turn.traceId); if (!traceId) return; @@ -1513,7 +1423,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function publishWorkbenchProjectionSignal(sessionId: string | null | undefined, traceId: string | null | undefined, reason: string): void { - if (!realtimeCapabilities.projectionRealtime) return; if (typeof window === "undefined") return; const id = normalizeWorkbenchSessionId(sessionId); if (!id) return; @@ -1523,7 +1432,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function handleWorkbenchProjectionSignal(value: unknown): void { - if (!realtimeCapabilities.projectionRealtime) return; const record = recordValue(value); if (!record || record.sourceId === workbenchProjectionSignalSourceId) return; if (firstNonEmptyString(record.type) !== "session-projection") return; @@ -1619,8 +1527,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 实时状态更新异常,最新运行记录暂不可见。"; - liveRealtimeReadySessionId.value = null; - 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 实时状态更新异常,最新运行记录暂不可见。") }); @@ -1793,10 +1700,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { rememberSessionDetail({ ...session, messages: projectedMessages, messageCount: session.messageCount ?? projectedMessages.length }); sessionsReady.value = true; currentRequest.value = null; - if (historyAuthorityPolicy.kafkaRetention) { - restartRealtime("apply-selected-session:kafka-retention"); - return; - } void hydrateTurnStatusAuthority(messages.value); void hydrateTerminalMessageDiagnostics(); void readTraceEventsForMessages(messages.value); @@ -1824,8 +1727,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { const requestId = normalizeWorkbenchSessionRouteId(sessionId); if (!requestId) return null; const normalizedRequestId = normalizeWorkbenchSessionId(requestId); - const messageLimit = sessionMessageProjectionWindowLimit(); - const eagerMessages = historyAuthorityPolicy.sessionMessagesHydrate && normalizedRequestId ? fetchSessionMessagesPage(normalizedRequestId, { limit: messageLimit, reason: "load-session:eager", force: true }) : null; const detail = await fetchSessionDetailPage(requestId, { reason: "load-session:detail", force: true }); if (!detail.ok) return null; const detailSession = sessionFromWorkbenchSession(detail.data?.session, { includeMessages: false }); @@ -1834,11 +1735,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const fallbackMessages = seed?.sessionId === id ? seed.messages ?? [] : []; const base = detailSession ? { ...detailSession, messages: fallbackMessages } : seed; if (!base) return null; - if (!historyAuthorityPolicy.sessionMessagesHydrate) return { ...base, sessionId: id, messages: [], messageCount: base.messageCount ?? 0 }; - const messages = eagerMessages && id === normalizedRequestId ? await eagerMessages : await fetchSessionMessagesPage(id, { limit: messageLimit, reason: "load-session", force: true }); - const page = messages.ok ? messages.data : null; - const pageMessages = Array.isArray(page?.messages) ? await sealRestoredActiveTurnMessages(page.messages.map((message) => normalizeChatMessage(message as ChatMessage))) : fallbackMessages; - return { ...base, sessionId: id, messages: pageMessages, messageCount: page?.total ?? pageMessages?.length ?? base.messageCount }; + return { ...base, sessionId: id, messages: [], messageCount: base.messageCount ?? 0 }; } async function sealRestoredActiveTurnMessages(source: ChatMessage[]): Promise { diff --git a/web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts b/web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts index b1eddb6a..771674b5 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts @@ -1,4 +1,4 @@ // SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first. // Responsibility: Production Workbench realtime runtime entry point for SSE transport, event coalescing, and scoped cursor ownership. -export { createWorkbenchStreamTransportRuntime, WorkbenchStreamTransportRuntime, workbenchRealtimeTraceIdForCapabilities, type WorkbenchStreamTransportRecovery, type WorkbenchStreamTransportRestartInput, type WorkbenchStreamTransportRestartResult, type WorkbenchStreamTransportState, type WorkbenchRealtimeEvent } from "@/utils/workbench-stream-transport"; +export { createWorkbenchStreamTransportRuntime, WorkbenchStreamTransportRuntime, workbenchRealtimeTraceId, type WorkbenchStreamTransportRecovery, type WorkbenchStreamTransportRestartInput, type WorkbenchStreamTransportRestartResult, type WorkbenchStreamTransportState, type WorkbenchRealtimeEvent } from "@/utils/workbench-stream-transport"; diff --git a/web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts b/web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts index 39b5ea1d..6417daa9 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts @@ -8,13 +8,11 @@ // - OpenCode stream.transport.ts:504-530 reduce output/trace. import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent, type WorkbenchSseIngressFrame } from "@/api/workbench-events"; -import type { WorkbenchRealtimeCapabilities } from "@/config/runtime"; import { workbenchRealtimeScopeKey } from "@/utils/workbench-key"; export type { WorkbenchRealtimeEvent }; export interface WorkbenchStreamTransportRestartInput { - realtimeCapabilities: WorkbenchRealtimeCapabilities; sessionId?: string | null; traceId?: string | null; afterSeq?: number | null; @@ -78,8 +76,7 @@ interface WorkbenchTransportCursor { traceSeq: number | null; } -export function workbenchRealtimeTraceIdForCapabilities( - _capabilities: WorkbenchRealtimeCapabilities, +export function workbenchRealtimeTraceId( ...candidates: Array ): string | null { for (const candidate of candidates) { @@ -89,10 +86,6 @@ export function workbenchRealtimeTraceIdForCapabilities( return null; } -export function workbenchRealtimeTransportEnabled(capabilities: WorkbenchRealtimeCapabilities): boolean { - return capabilities.projectionRealtime; -} - export class WorkbenchStreamTransportRuntime { private stream: WorkbenchEventStream | null = null; private key = ""; @@ -107,11 +100,9 @@ export class WorkbenchStreamTransportRuntime { this.stop(); this.key = key; this.armWait(key); - if (!workbenchRealtimeTransportEnabled(input.realtimeCapabilities)) return { key, changed: true, streamStarted: false }; if (!input.sessionId && !input.traceId) return { key, changed: true, streamStarted: false }; this.emitState(input, "connecting", null); this.stream = connectWorkbenchEvents({ - realtimeCapabilities: input.realtimeCapabilities, sessionId: input.sessionId ?? null, traceId: input.traceId ?? null, afterSeq: input.afterSeq ?? this.cursorByKey.get(key)?.outboxSeq ?? null, @@ -202,7 +193,7 @@ export class WorkbenchStreamTransportRuntime { const sessionId = input.sessionId ?? null; const traceId = input.traceId ?? null; const cursor = this.currentCursor(key); - const actions: WorkbenchStreamTransportRecoveryAction[] = input.realtimeCapabilities.projectionRealtime && (sessionId || traceId) ? ["events-reconnect"] : []; + const actions: WorkbenchStreamTransportRecoveryAction[] = sessionId || traceId ? ["events-reconnect"] : []; const diagnostic = this.diagnosticEnvelope("workbench_sse_recovery", reason, key, actions); input.onRecovery?.({ key, sessionId, traceId, tick: this.wait?.tick ?? this.tick, reason, actions, outboxSeq: cursor.outboxSeq, traceSeq: cursor.traceSeq, diagnostic }); } From bef232804f12da0784bd7a97b4d139ad496baeb9 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 07:18:51 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=20Workbench=20HTT?= =?UTF-8?q?P=20=E8=87=AA=E5=8A=A8=E8=A1=A5=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/deploy.yaml | 8 - .../cloud/server-code-agent-admission-http.ts | 73 +++---- internal/cloud/server-workbench-facts.ts | 1 - .../server-workbench-realtime-http.test.ts | 16 -- .../cloud/server-workbench-realtime-http.ts | 1 - scripts/gitops-render.test.ts | 23 +- ...time-realtime-capabilities-config.test.mjs | 26 ++- web/hwlab-cloud-web/scripts/check.ts | 1 - ...h-fixed-transactional-architecture.test.ts | 33 ++- .../workbench-realtime-runtime.test.ts | 37 ---- web/hwlab-cloud-web/src/api/workbench.ts | 44 ---- .../src/config/workbench-runtime-policy.ts | 9 - .../src/stores/workbench-colada-keys.ts | 4 - .../src/stores/workbench-colada-queries.ts | 14 +- ...rkbench-message-projection-runtime.test.ts | 12 +- ...bench-session-messages-read-budget.test.ts | 32 +-- .../workbench-session-messages-read-budget.ts | 34 --- .../src/stores/workbench-session.test.ts | 18 +- .../src/stores/workbench-session.ts | 31 --- web/hwlab-cloud-web/src/stores/workbench.ts | 196 ++---------------- web/hwlab-cloud-web/src/types/global.d.ts | 3 - 21 files changed, 116 insertions(+), 500 deletions(-) diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 5ac749c0..2ae60ebb 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -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 diff --git a/internal/cloud/server-code-agent-admission-http.ts b/internal/cloud/server-code-agent-admission-http.ts index 67f014d4..029837b9 100644 --- a/internal/cloud/server-code-agent-admission-http.ts +++ b/internal/cloud/server-code-agent-admission-http.ts @@ -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 }); diff --git a/internal/cloud/server-workbench-facts.ts b/internal/cloud/server-workbench-facts.ts index 6b5805e4..a3600a63 100644 --- a/internal/cloud/server-workbench-facts.ts +++ b/internal/cloud/server-workbench-facts.ts @@ -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"; diff --git a/internal/cloud/server-workbench-realtime-http.test.ts b/internal/cloud/server-workbench-realtime-http.test.ts index 5b722a88..1de352a2 100644 --- a/internal/cloud/server-workbench-realtime-http.test.ts +++ b/internal/cloud/server-workbench-realtime-http.test.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"; diff --git a/internal/cloud/server-workbench-realtime-http.ts b/internal/cloud/server-workbench-realtime-http.ts index 3798ddcc..4f6a7115 100644 --- a/internal/cloud/server-workbench-realtime-http.ts +++ b/internal/cloud/server-workbench-realtime-http.ts @@ -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"; diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 5902b4f7..eb9d7663 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -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"); diff --git a/scripts/runtime-realtime-capabilities-config.test.mjs b/scripts/runtime-realtime-capabilities-config.test.mjs index 86b74b8f..a037d55d 100644 --- a/scripts/runtime-realtime-capabilities-config.test.mjs +++ b/scripts/runtime-realtime-capabilities-config.test.mjs @@ -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"); }); diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 2c7f9d1b..c8c2eab8 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -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"); diff --git a/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts b/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts index 173fa22a..50b05742 100644 --- a/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts @@ -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"); diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index fd4d943b..19e74333 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -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"); diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index e19e14ca..7f351581 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -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> => 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> => normalizeWorkbenchTurnResult(await fetchJson>(`/v1/workbench/turns/${encodeURIComponent(traceId)}`, { timeoutMs, timeoutName: "workbench turn", activityRef }), traceId), traceEvents: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: WorkbenchTraceRequestOptions): Promise> => normalizeWorkbenchTraceResult(await fetchJson>(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>, traceId: string): ApiResult { - if (!result.ok || !result.data) return result as ApiResult; - 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>, traceId: string): ApiResult { if (!result.ok || !result.data) return result as ApiResult; 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 | null { return value && typeof value === "object" ? value as Record : null; } diff --git a/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts b/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts index ac94a12c..ec8b778b 100644 --- a/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts +++ b/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts @@ -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), diff --git a/web/hwlab-cloud-web/src/stores/workbench-colada-keys.ts b/web/hwlab-cloud-web/src/stores/workbench-colada-keys.ts index 7767983f..2714e3a3 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-colada-keys.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-colada-keys.ts @@ -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"], diff --git a/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts b/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts index 5b992c37..97844916 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-colada-queries.ts @@ -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>; fetchSession: (sessionId: string, options?: WorkbenchSessionDetailQueryOptions) => Promise>; fetchSessionMessages: (sessionId: string, options?: SessionMessageOptions & QueryRunOptions) => Promise>; - fetchTurn: (traceId: string, options?: WorkbenchTurnQueryOptions) => Promise>; fetchTraceEvents: (traceId: string, options?: WorkbenchTraceEventsQueryOptions) => Promise>; fetchProviderProfiles: (options?: QueryRunOptions) => Promise>; - invalidateSessionList: () => Promise; invalidateSession: (sessionId: string | null | undefined) => Promise; invalidateSessionMessages: (sessionId: string | null | undefined) => Promise; - invalidateTurn: (traceId: string | null | undefined) => Promise; invalidateTraceEvents: (traceId: string | null | undefined) => Promise; invalidateTraceScope: (input: { sessionId?: string | null; traceId?: string | null }) => Promise; } @@ -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() }), diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts index df1eea60..aa5498c2 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts @@ -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>/u); + assert.match(source, /const traceDetailReadSingleflight = createKeyedSingleflight\(\)/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"); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.test.ts b/web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.test.ts index 7a21db54..d319205d 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.test.ts @@ -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 }); -}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.ts b/web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.ts index 125593b8..abf2adfe 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session-messages-read-budget.ts @@ -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; diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.test.ts b/web/hwlab-cloud-web/src/stores/workbench-session.test.ts index e24615f0..86d69f48 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.test.ts @@ -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 & Pick): 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"; diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index f4c3deca..56a89311 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -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(); - 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); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index a4ba37fd..49edf41e 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -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>(); const traceDetailReadSingleflight = createKeyedSingleflight(); const sessionMessagesReadSingleflight = createKeyedSingleflight>(); const sessionDetailReadSingleflight = createKeyedSingleflight>(); @@ -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 { 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> { - 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> { 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 { - 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> { 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 { - 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 { - 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 { @@ -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 { - 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 { @@ -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); } diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index 5106f04e..88fad71f 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -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. */ From 2603b00caafcc27affb223f45cbde117f8647a25 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 08:21:58 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E6=B8=85=E7=90=86=20Workbench=20?= =?UTF-8?q?=E6=97=A0=E6=95=88=E6=8A=95=E5=BD=B1=E6=AE=8B=E4=BD=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...h-fixed-transactional-architecture.test.ts | 7 ++++ .../workbench-realtime-runtime.test.ts | 2 - .../src/config/workbench-runtime-policy.ts | 3 -- ...rkbench-message-projection-runtime.test.ts | 10 ++--- web/hwlab-cloud-web/src/stores/workbench.ts | 39 +------------------ web/hwlab-cloud-web/src/types/global.d.ts | 1 - 6 files changed, 11 insertions(+), 51 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts b/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts index 50b05742..a46acec0 100644 --- a/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-fixed-transactional-architecture.test.ts @@ -11,6 +11,8 @@ const productionFiles = [ "internal/cloud/server-code-agent-admission-http.ts", "internal/cloud/server.ts", "web/hwlab-cloud-web/src/config/runtime.ts", + "web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts", + "web/hwlab-cloud-web/src/types/global.d.ts", "web/hwlab-cloud-web/src/api/workbench-events.ts", "web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts", "web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts", @@ -48,6 +50,11 @@ const forbiddenArchitectureIdentifiers = [ "sessionDetailAutoReadDecision", "projectionMergeCommitSummary", "forceRead", + "sealRestoredActiveTurnMessages", + "messageNeedsRestoredTurnSeal", + "refreshSessionStatusAuthority", + "readTerminalTraceDetailGaps", + "workbenchTraceMessagesWindowLimit", "handleWorkbenchSyncHttp", "/v1/workbench/sync" ]; diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index 19e74333..32dbd148 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -39,7 +39,6 @@ test("Workbench runtime policy reads injected config while preserving defaults", traceDetailMaxPages: 2, workbenchSessionDetailMinRefreshMs: 1234, workbenchSessionMessagesWindowLimit: 9, - workbenchTraceMessagesWindowLimit: 4, workbenchRealtimeErrorSyncReplayMinMs: 0, workbenchRealtimeFlushMaxItemsPerChunk: 2, workbenchRealtimeFlushMaxChunkMs: 6, @@ -51,7 +50,6 @@ test("Workbench runtime policy reads injected config while preserving defaults", assert.equal(policy.traceDetailMaxPages, 2); assert.equal(policy.workbenchSessionDetailMinRefreshMs, 1234); assert.equal(policy.workbenchSessionMessagesWindowLimit, 9); - assert.equal(policy.workbenchTraceMessagesWindowLimit, 4); assert.equal(policy.workbenchRealtimeErrorSyncReplayMinMs, 0); assert.equal(policy.workbenchRealtimeFlushMaxItemsPerChunk, 2); assert.equal(policy.workbenchRealtimeFlushMaxChunkMs, 6); diff --git a/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts b/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts index ec8b778b..b9eaf661 100644 --- a/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts +++ b/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts @@ -18,7 +18,6 @@ export interface WorkbenchRuntimePolicy { workbenchSessionDetailMinRefreshMs: number; workbenchSessionMessagesMinRefreshMs: number; workbenchSessionMessagesWindowLimit: number; - workbenchTraceMessagesWindowLimit: number; workbenchRealtimeSessionMessagesMinRefreshMs: number; workbenchTraceEventsTimeoutMs: number; sessionListMinRefreshIntervalMs: number; @@ -45,7 +44,6 @@ const DEFAULT_WORKBENCH_RUNTIME_POLICY: WorkbenchRuntimePolicy = Object.freeze({ workbenchSessionDetailMinRefreshMs: 5_000, workbenchSessionMessagesMinRefreshMs: 5_000, workbenchSessionMessagesWindowLimit: 20, - workbenchTraceMessagesWindowLimit: 8, workbenchRealtimeSessionMessagesMinRefreshMs: 1_000, workbenchTraceEventsTimeoutMs: 5_000, sessionListMinRefreshIntervalMs: 15_000, @@ -74,7 +72,6 @@ export function workbenchRuntimePolicy(input: unknown = runtimePolicyConfig()): workbenchSessionDetailMinRefreshMs: nonNegativeNumber(source.workbenchSessionDetailMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchSessionDetailMinRefreshMs), workbenchSessionMessagesMinRefreshMs: nonNegativeNumber(source.workbenchSessionMessagesMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchSessionMessagesMinRefreshMs), workbenchSessionMessagesWindowLimit: positiveInteger(source.workbenchSessionMessagesWindowLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchSessionMessagesWindowLimit), - 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), sessionListMinRefreshIntervalMs: nonNegativeNumber(source.sessionListMinRefreshIntervalMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListMinRefreshIntervalMs), diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts index aa5498c2..2dfb8f36 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts @@ -142,8 +142,7 @@ test("workbench active terminal paths seal final response from turn authority", 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")); - const loadBlock = source.slice(source.indexOf("async function loadWorkbenchSession"), source.indexOf("async function sealRestoredActiveTurnMessages")); - const restoreSealBlock = source.slice(source.indexOf("async function sealRestoredActiveTurnMessages"), source.indexOf("function reattachRestoredActiveTrace")); + const loadBlock = source.slice(source.indexOf("async function loadWorkbenchSession"), source.indexOf("function reattachRestoredActiveTrace")); assert.match(projectBlock, /terminalAuthorityMessageFromTurnResult\(result\)/u); assert.match(projectBlock, /type:\s*"message\.upsert"/u); @@ -174,9 +173,7 @@ test("workbench active terminal paths seal final response from turn authority", 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); - assert.doesNotMatch(restoreSealBlock, /force:\s*true/u); + assert.doesNotMatch(source, /sealRestoredActiveTurnMessages|messageNeedsRestoredTurnSeal|refreshSessionStatusAuthority|readTerminalTraceDetailGaps/u); }); test("workbench active SSE path only recovers by reconnecting the events cursor", () => { @@ -185,8 +182,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("function applyTerminalResultDiagnostics")), - source.slice(source.indexOf("async function sealRestoredActiveTurnMessages"), source.indexOf("function reattachRestoredActiveTrace")) + source.slice(source.indexOf("function completeTrace"), source.indexOf("function applyTerminalResultDiagnostics")) ].join("\n"); assert.doesNotMatch(activeBlocks, /refreshWorkbenchSyncReplay|scheduleActiveTraceSyncReplay|setTimeout|setInterval/u); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 49edf41e..1c7f909d 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -24,7 +24,7 @@ import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from import { reduceWorkbenchRealtimeEvent, type WorkbenchRealtimeAction } from "./workbench-event-reducer"; import { projectRejectedWorkbenchAdmission } from "./workbench-admission-failure"; import { messageHasSealedTerminalResult, messageIsSealedTerminal, traceAuthorityIsSealed } from "./workbench-terminal-authority"; -import { boundedProjectionMessageLimit, traceProjectionIsTerminalSealed } from "./workbench-message-projection-budget"; +import { boundedProjectionMessageLimit } from "./workbench-message-projection-budget"; import { agentErrorDisplayText, agentErrorFromApiFailure, @@ -228,7 +228,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { const nextSessions = stableSessionList(sessions.value, listedSessions, stableSelected?.sessionId ?? selected?.sessionId ?? routeSessionId ?? includeSessionId, stableSelected); rememberSessionList(nextSessions); sessionsReady.value = sessionsResult.ok || nextSessions.length > 0; - void refreshSessionStatusAuthority(nextSessions); clearSessionDetailLoading(targetSessionId); loading.value = false; recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "hydrate", sessionId: targetSessionId }); @@ -499,10 +498,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { return projectedActiveSession.value ?? null; } - async function refreshSessionStatusAuthority(source: WorkbenchSessionRecord[] = sessions.value): Promise { - void source; - } - function fetchWorkbenchTraceEvents(traceId: string, afterProjectedSeq: number, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise> { 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 }); @@ -807,13 +802,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { return fetchWorkbenchTraceEvents(traceId, afterProjectedSeq, shouldUseActivityTimeoutForTrace(traceId), { force: options.force }); } - function readTerminalTraceDetailGaps(source: ChatMessage[], reason: string): void { - // Terminal trace rows are loaded on explicit force/detail paths. Auto-filling - // historical terminal traces makes multi-turn Workbench sessions main-thread bound. - void source; - void reason; - } - async function readTraceEventsForMessages(source: ChatMessage[] = messages.value, options: { force?: boolean } = {}): Promise { const candidates = traceDetailReadCandidates(source); if (options.force !== true) { @@ -1571,23 +1559,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { return { ...base, sessionId: id, messages: hydratedMessages, messageCount: base.messageCount ?? hydratedMessages.length }; } - async function sealRestoredActiveTurnMessages(source: ChatMessage[]): Promise { - const targets = source.filter(messageNeedsRestoredTurnSeal).slice(-1); - if (targets.length === 0) return source; - for (const message of targets) { - const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); - if (!traceId || traceProjectionIsTerminalSealed(traceId, source)) continue; - recordWorkbenchRuntimeDiagnostic({ - module: "workbench-turn-status", - sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null, - traceId, - outcome: "ok", - diagnostic: { code: "workbench_restored_turn_auto_read_disabled", reason: "load-session:restored-turn", source: "projection-sse-authority", valuesRedacted: true } - }); - } - return source; - } - function reattachRestoredActiveTrace(): void { const traceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value); if (traceId) reattachTrace(traceId); @@ -1665,14 +1636,6 @@ function routeSelectedSessionRecord(sessionId: string | null | undefined, loadin } as WorkbenchSessionRecord; } -function messageNeedsRestoredTurnSeal(message: ChatMessage): boolean { - if (message.role !== "agent") return false; - if (messageHasTerminalResponse(message)) return false; - const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); - if (!traceId) return false; - return isTraceActiveStatus(message.status) || isTerminalMessageStatus(message.status) || message.traceAutoLifecycle === "running"; -} - function providerThreadIdForRequest(threadId: string | null | undefined): string | null { const value = firstNonEmptyString(threadId) ?? null; if (!value) return null; diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index 88fad71f..203fbdcc 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -54,7 +54,6 @@ declare global { workbenchSessionDetailMinRefreshMs?: number; workbenchSessionMessagesMinRefreshMs?: number; workbenchSessionMessagesWindowLimit?: number; - workbenchTraceMessagesWindowLimit?: number; workbenchRealtimeSessionMessagesMinRefreshMs?: number; workbenchTraceEventsTimeoutMs?: number; sessionListMinRefreshIntervalMs?: number;