diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 05f6ad3a..7bd9092b 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -22,10 +22,19 @@ const requiredFiles = Object.freeze([ "src/api/workbench.ts", "src/api/workbench-events.ts", "src/api/agent.ts", + "src/utils/workbench-key.ts", + "src/utils/scoped-cache.ts", + "src/utils/scheduler/async-queue.ts", + "src/utils/scheduler/coalesced-event-queue.ts", + "src/utils/scheduler/keyed-singleflight.ts", + "src/utils/safe-storage.ts", + "src/utils/workbench-health.ts", "src/stores/index.ts", "src/stores/app.ts", "src/stores/auth.ts", "src/stores/workbench.ts", + "src/stores/workbench-timeline-model.ts", + "src/stores/workbench-session-cache.ts", "src/composables/useTraceSubscription.ts", "src/composables/useAutoRefresh.ts", "src/composables/useClipboard.ts", diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts new file mode 100644 index 00000000..c5d4c958 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -0,0 +1,244 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first. +// Responsibility: Regression tests for pure Workbench realtime runtime helpers before Vue/store integration. + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { AsyncQueue, work } from "../src/utils/scheduler/async-queue.ts"; +import { createCoalescedEventQueue } from "../src/utils/scheduler/coalesced-event-queue.ts"; +import { createKeyedSingleflight } from "../src/utils/scheduler/keyed-singleflight.ts"; +import { createScopedCache } from "../src/utils/scoped-cache.ts"; +import { composeWorkbenchScopedKey, splitWorkbenchScopedKey, workbenchPathKey, workbenchRealtimeScopeKey } from "../src/utils/workbench-key.ts"; +import { createSafeStorageRuntime, isStorageQuotaError, migrateLegacyStorage, normalizePersistedValue, readJsonStorage, removePersistedTarget, removeStorageKey, writeJsonStorage, type StorageLike } from "../src/utils/safe-storage.ts"; +import { checkWorkbenchHealth, createWorkbenchHealthProbeCache } from "../src/utils/workbench-health.ts"; +import { buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts"; +import { trimWorkbenchSessionCache } from "../src/stores/workbench-session-cache.ts"; + +test("Workbench scoped keys encode delimiter characters", () => { + assert.deepEqual(splitWorkbenchScopedKey(workbenchRealtimeScopeKey("ses|one", "trc/two")), ["workbench.realtime", "ses|one", "trc/two"]); + assert.deepEqual(splitWorkbenchScopedKey(composeWorkbenchScopedKey("queue", null, "", "a b")), ["queue", "~", "~", "a b"]); + assert.throws(() => composeWorkbenchScopedKey("queue", "bad\0part"), /null bytes/u); + assert.equal(workbenchPathKey("C:\\Users\\foo\\"), "C:/Users/foo"); +}); + +test("scoped cache keeps OpenCode LRU and TTL semantics", () => { + const disposed: string[] = []; + let clock = 0; + let count = 0; + const cache = createScopedCache((key) => ({ key, count: ++count }), { + maxEntries: 2, + ttlMs: 10, + now: () => clock, + dispose: (value) => disposed.push(`${value.key}:${value.count}`) + }); + + assert.equal(cache.get("a").count, 1); + assert.equal(cache.get("b").count, 2); + cache.get("a"); + assert.equal(cache.get("c").count, 3); + assert.equal(cache.peek("b"), undefined); + assert.deepEqual(disposed, ["b:2"]); + + clock = 11; + assert.equal(cache.peek("a"), undefined); + assert.deepEqual(disposed, ["b:2", "a:1", "c:3"]); +}); + +test("async queue and bounded work preserve OpenCode utility behavior", async () => { + const queue = new AsyncQueue(); + const first = queue.next(); + queue.push(1); + assert.equal(await first, 1); + queue.push(2); + assert.equal(await queue.next(), 2); + + const visited: number[] = []; + await work(2, [1, 2, 3], async (item) => { + visited.push(item); + }); + assert.deepEqual(visited.sort(), [1, 2, 3]); +}); + +test("coalesced event queue replaces keyed snapshots and preserves unkeyed events", () => { + const flushed: string[][] = []; + const queue = createCoalescedEventQueue<{ key?: string; value: string }>({ + keyOf: (item) => item.key, + schedule: () => undefined, + onFlush: (items) => flushed.push(items.map((item) => item.value)) + }); + + queue.push({ key: "snapshot:trc", value: "old" }); + queue.push({ value: "event-1" }); + queue.push({ key: "snapshot:trc", value: "new" }); + queue.push({ value: "event-2" }); + + assert.equal(queue.size, 3); + queue.flush(); + assert.deepEqual(flushed, [["new", "event-1", "event-2"]]); +}); + +test("keyed single-flight coalesces duplicate work and supports replacement", async () => { + const flight = createKeyedSingleflight(); + let calls = 0; + const first = flight.run("sessions", () => { + calls += 1; + return 7; + }); + const second = flight.run("sessions", () => { + calls += 1; + return 9; + }); + + assert.equal(await first, 7); + assert.equal(await second, 7); + assert.equal(calls, 1); + + assert.equal(await flight.run("sessions", () => 11, { replace: true, reason: "manual-refresh" }), 11); + assert.equal(calls, 1); +}); + +test("safe storage reports failures instead of throwing", () => { + const storage = new MemoryStorage(); + assert.equal(writeJsonStorage(storage, "k", { a: 1 }).ok, true); + assert.deepEqual(readJsonStorage(storage, "k", { a: 0 }).value, { a: 1 }); + assert.equal(removeStorageKey(storage, "k").ok, true); + + const broken: StorageLike = { + getItem: () => "{not-json", + setItem: () => { throw new Error("quota"); }, + removeItem: () => { throw new Error("security"); } + }; + assert.equal(readJsonStorage(broken, "bad", { safe: true }).status, "parse_error"); + assert.equal(writeJsonStorage(broken, "bad", { safe: true }).status, "write_error"); + assert.equal(removeStorageKey(broken, "bad").status, "remove_error"); +}); + +test("safe storage preserves OpenCode quota fallback, prefix isolation and legacy migration", () => { + const storage = new MemoryStorage(); + const runtime = createSafeStorageRuntime({ cacheMaxEntries: 3, cacheMaxBytes: 200, evictPrefix: "hwlab." }); + const bad = runtime.localStorageWithPrefix("hwlab.throw.scope", storage); + bad.setItem("value", '{"value":1}'); + const before = storage.calls.set; + bad.setItem("value", '{"value":2}'); + assert.equal(storage.calls.set, before); + + const safe = runtime.localStorageWithPrefix("hwlab.safe.scope", storage); + safe.setItem("value", '{"value":3}'); + assert.equal(storage.getItem("hwlab.safe.scope:value"), '{"value":3}'); + + assert.equal(isStorageQuotaError(new DOMException("quota", "QuotaExceededError")), true); + assert.equal(normalizePersistedValue({ value: 1, nested: { a: true } }, '{"nested":{"b":2}}'), '{"value":1,"nested":{"a":true,"b":2}}'); + assert.equal(normalizePersistedValue({ value: "ok" }, '{"value":"\\x"}'), undefined); + + storage.setItem("legacy.workspace", '{"value":2}'); + const current = runtime.localStorageWithPrefix("hwlab.current", storage); + const migrated = migrateLegacyStorage({ current, legacyStore: runtime.localStorageDirect(storage), stores: [], keys: ["legacy.workspace"], key: "workspace:demo", defaults: { value: 1 } }); + assert.equal(migrated, '{"value":2}'); + assert.equal(storage.getItem("hwlab.current:workspace:demo"), '{"value":2}'); + assert.equal(storage.getItem("legacy.workspace"), null); + + storage.setItem("hwlab.current:workspace:demo", '{"value":2}'); + storage.setItem("hwlab.legacy:workspace:demo", '{"value":3}'); + removePersistedTarget({ storage: "hwlab.current", legacyStorageNames: ["hwlab.legacy"], key: "workspace:demo" }, runtime, storage); + assert.equal(storage.getItem("hwlab.current:workspace:demo"), null); + assert.equal(storage.getItem("hwlab.legacy:workspace:demo"), null); +}); + +test("timeline model deduplicates authoritative messages with stable row keys", () => { + const rows = buildWorkbenchTimelineRows([ + { id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-06-30T00:00:00.000Z", sessionId: "ses_a", traceId: "trc_a" }, + { id: "msg_agent_old", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a" }, + { id: "msg_agent_new", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "done", status: "completed", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a" } + ]); + + assert.equal(rows.length, 2); + assert.deepEqual(rows.map((row) => `${row.role}:${row.message.text}`), ["user:hi", "agent:done"]); + assert.match(workbenchTimelineSignature(rows), /completed:4/u); + assert.deepEqual(normalizeWorkbenchTimelineMessages(rows.map((row) => row.message)).map((message) => message.messageId), ["msg_user", "msg_agent"]); +}); + +test("session cache trim keeps retained sessions and trims messages by explicit policy", () => { + const result = trimWorkbenchSessionCache({ + sessionOrder: ["ses_1", "ses_2", "ses_3"], + sessionsById: { + ses_1: { sessionId: "ses_1" }, + ses_2: { sessionId: "ses_2" }, + ses_3: { sessionId: "ses_3" } + }, + messagesBySessionId: { + ses_1: [{ id: "m1", role: "user", title: "用户", text: "1", status: "sent", createdAt: "2026-06-30T00:00:00.000Z" }], + ses_2: [ + { id: "m2", role: "user", title: "用户", text: "2", status: "sent", createdAt: "2026-06-30T00:00:00.000Z" }, + { id: "m3", role: "agent", title: "Code Agent", text: "3", status: "completed", createdAt: "2026-06-30T00:00:01.000Z" } + ], + ses_3: [{ id: "m4", role: "user", title: "用户", text: "4", status: "sent", createdAt: "2026-06-30T00:00:00.000Z" }] + } + }, { retainSessionIds: ["ses_3"], maxSessions: 1, maxMessagesPerSession: 1 }); + + assert.deepEqual(result.state.sessionOrder, ["ses_1", "ses_3"]); + assert.deepEqual(result.evictedSessionIds, ["ses_2"]); + assert.deepEqual(Object.keys(result.state.sessionsById).sort(), ["ses_1", "ses_3"]); +}); + +test("health probe cache records ok and unavailable states", async () => { + const cache = createWorkbenchHealthProbeCache({ cacheMs: 100 }); + const ok = await cache.probe({ key: "workbench", fetcher: async () => ({ ready: true }), classify: (value) => value.ready ? "ok" : "degraded" }); + assert.equal(ok.state, "ok"); + assert.deepEqual(cache.get("workbench")?.value, { ready: true }); + + cache.clear("workbench"); + const failed = await cache.probe({ key: "workbench", fetcher: async () => { throw new Error("offline"); } }); + assert.equal(failed.state, "unavailable"); + assert.ok(failed.error instanceof Error); +}); + +test("health probe retries transient failures with caller supplied policy", async () => { + let calls = 0; + const result = await checkWorkbenchHealth({ + key: "workbench", + retryCount: 2, + retryDelayMs: 1, + fetcher: async () => { + calls += 1; + if (calls < 3) throw new TypeError("network"); + return { ready: true, version: "v" }; + }, + classify: (value) => value.ready ? "ok" : "degraded" + }); + assert.equal(calls, 3); + assert.equal(result.state, "ok"); + assert.deepEqual(result.value, { ready: true, version: "v" }); +}); + +class MemoryStorage implements StorageLike { + private readonly values = new Map(); + readonly calls = { get: 0, set: 0, remove: 0 }; + + get length(): number { + return this.values.size; + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + getItem(key: string): string | null { + this.calls.get += 1; + if (key.startsWith("hwlab.throw")) throw new Error("storage get failed"); + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.calls.set += 1; + if (key.startsWith("hwlab.quota")) throw new DOMException("quota", "QuotaExceededError"); + if (key.startsWith("hwlab.throw")) throw new Error("storage set failed"); + this.values.set(key, value); + } + + removeItem(key: string): void { + this.calls.remove += 1; + if (key.startsWith("hwlab.throw")) throw new Error("storage remove failed"); + this.values.delete(key); + } +} + diff --git a/web/hwlab-cloud-web/src/api/workbench-events.ts b/web/hwlab-cloud-web/src/api/workbench-events.ts index d99c34a3..5bab01d9 100644 --- a/web/hwlab-cloud-web/src/api/workbench-events.ts +++ b/web/hwlab-cloud-web/src/api/workbench-events.ts @@ -1,7 +1,9 @@ -// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // Responsibility: Workbench SSE client. Realtime events accelerate UI projection; REST snapshots remain gap-fill authority. import type { ChatMessage, ProjectionDiagnostic, TraceEvent } from "@/types"; +import { createCoalescedEventQueue } from "@/utils/scheduler/coalesced-event-queue"; +import { composeWorkbenchScopedKey, firstScopePart } from "@/utils/workbench-key"; import { recordWorkbenchSseLifecycle } from "@/utils/workbench-performance"; export interface WorkbenchRealtimeTraceSnapshot { @@ -52,6 +54,11 @@ export interface WorkbenchEventStream { close: () => void; } +interface QueuedRealtimeEvent { + payload: WorkbenchRealtimeEvent; + eventName: string; +} + const WORKBENCH_EVENT_NAMES = [ "workbench.connected", "workbench.trace.snapshot", @@ -73,6 +80,12 @@ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): Wo const connectStartedAt = typeof performance === "undefined" ? Date.now() : performance.now(); recordWorkbenchSseLifecycle({ state: "connect", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId }); const source = new EventSource(eventRoute, { withCredentials: true }); + const queue = createCoalescedEventQueue({ + keyOf: (item) => realtimeCoalesceKey(item.payload, item.eventName), + onFlush: (items) => { + for (const item of items) options.onEvent(item.payload, item.eventName); + } + }); source.onopen = () => { const openedAt = typeof performance === "undefined" ? Date.now() : performance.now(); recordWorkbenchSseLifecycle({ state: "open", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId, valueMs: Math.max(0, Math.round(openedAt - connectStartedAt)) }); @@ -85,17 +98,18 @@ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): Wo const listeners = WORKBENCH_EVENT_NAMES.map((name) => { const listener = (event: MessageEvent) => { const payload = parseRealtimeEvent(event.data); - if (payload) options.onEvent(payload, name); + if (payload) queue.push({ payload, eventName: name }); }; source.addEventListener(name, listener); return { name, listener }; }); source.onmessage = (event) => { const payload = parseRealtimeEvent(event.data); - if (payload) options.onEvent(payload, "message"); + if (payload) queue.push({ payload, eventName: "message" }); }; return { close() { + queue.flush(); for (const { name, listener } of listeners) source.removeEventListener(name, listener); recordWorkbenchSseLifecycle({ state: "close", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId }); source.close(); @@ -122,3 +136,21 @@ function parseRealtimeEvent(raw: string): WorkbenchRealtimeEvent | null { return null; } } + +function realtimeCoalesceKey(event: WorkbenchRealtimeEvent, eventName: string): string | null { + const sessionId = firstScopePart(event.sessionId, event.message?.sessionId, event.snapshot?.sessionId, event.event?.sessionId); + const traceId = firstScopePart(event.traceId, event.message?.traceId, event.snapshot?.traceId, event.event?.traceId); + const outboxSeq = numericCursor(event.cursor?.outboxSeq ?? event.outboxSeq); + const traceSeq = numericCursor(event.cursor?.traceSeq ?? event.traceSeq ?? event.event?.seq ?? event.event?.projectedSeq); + if (eventName === "workbench.trace.event" || eventName === "message") { + const eventSeq = outboxSeq ?? traceSeq; + return eventSeq === null ? null : composeWorkbenchScopedKey("workbench.sse.event", sessionId, traceId, eventName, eventSeq); + } + const messageId = firstScopePart(event.message?.messageId, event.message?.id); + return composeWorkbenchScopedKey("workbench.sse.snapshot", sessionId, traceId, eventName, messageId, outboxSeq ?? traceSeq); +} + +function numericCursor(value: unknown): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null; +} diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 652b1b62..425221fb 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -1,5 +1,5 @@