Merge pull request #2300 from pikasTech/issue-1297-workbench-opencode-modules

feat: migrate workbench realtime runtime modules
This commit is contained in:
Lyon
2026-06-30 18:41:14 +08:00
committed by GitHub
14 changed files with 1353 additions and 17 deletions
+9
View File
@@ -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",
@@ -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<number>();
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<number>();
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<string, string>();
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);
}
}
@@ -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<QueuedRealtimeEvent>({
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;
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response; PJ2026-010401 Web工作台 draft-2026-06-20-p1-view-local-timing-ticker.
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response; PJ2026-010401 Web工作台 draft-2026-06-20-p1-view-local-timing-ticker.
// Confirms Workbench message visibility after Vue render and keeps sealed final responses separate from diagnostics.
import { computed, nextTick, onMounted, ref, watch } from "vue";
@@ -17,6 +17,7 @@ import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { useClipboard } from "@/composables/useClipboard";
import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker";
import { useWorkbenchStore } from "@/stores/workbench";
import { buildWorkbenchTimelineRows, workbenchTimelineSignature } from "@/stores/workbench-timeline-model";
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
import { traceIdentityText } from "./message-rendering";
import { traceLifecycleExpanded } from "./trace-lifecycle";
@@ -27,15 +28,12 @@ const panelRef = ref<HTMLElement | null>(null);
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 });
const detailMessageId = ref<string | null>(null);
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value));
const hasRunningAgentMessages = computed(() => visibleMessages.value.some(isRunningMessage));
const { nowMs } = useWorkbenchNowTicker(hasRunningAgentMessages);
const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null);
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
const scrollSignature = computed(() => workbench.activeMessages.map((message) => [
message.id,
message.status,
visibleMessageText(message).length
].join(":")).join("|"));
const scrollSignature = computed(() => workbenchTimelineSignature(visibleTimelineRows.value));
watch(scrollSignature, async () => {
await keepBottomAfterUpdate();
@@ -304,8 +302,8 @@ function formatDuration(ms: number): string {
<section id="conversation-list" ref="panelRef" class="conversation-panel" :data-following="following ? 'true' : 'false'" @scroll="onPanelScroll">
<LoadingState v-if="workbench.sessionDetailLoading" class="conversation-detail-loading" label="加载中" />
<article
v-for="message in visibleMessages"
:key="message.id"
v-for="{ key, message } in visibleTimelineRows"
:key="key"
class="message-card"
:data-role="message.role"
:data-status="message.status"
@@ -0,0 +1,81 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-01060505 Workbench性能 draft-2026-06-19-p0.
// Responsibility: Policy-driven Workbench session cache trimming without hidden capacity defaults.
import type { ChatMessage, WorkbenchSessionRecord } from "@/types";
export interface WorkbenchSessionCacheState {
sessionOrder: string[];
sessionsById: Record<string, WorkbenchSessionRecord>;
messagesBySessionId: Record<string, ChatMessage[]>;
}
export interface WorkbenchSessionCachePolicy {
retainSessionIds?: Iterable<string | null | undefined>;
maxSessions?: number | null;
maxMessagesPerSession?: number | null;
}
export interface WorkbenchSessionCacheTrimResult<T extends WorkbenchSessionCacheState> {
state: T;
evictedSessionIds: string[];
trimmedMessageSessionIds: string[];
}
export function trimWorkbenchSessionCache<T extends WorkbenchSessionCacheState>(state: T, policy: WorkbenchSessionCachePolicy): WorkbenchSessionCacheTrimResult<T> {
const retain = new Set(normalizeIds(policy.retainSessionIds));
const maxSessions = positiveIntegerOrNull(policy.maxSessions);
const maxMessages = positiveIntegerOrNull(policy.maxMessagesPerSession);
const keptOrder = maxSessions === null ? [...state.sessionOrder] : keepSessionOrder(state.sessionOrder, retain, maxSessions);
const kept = new Set(keptOrder);
const evictedSessionIds = Object.keys(state.sessionsById).filter((sessionId) => !kept.has(sessionId));
const sessionsById = pickRecordKeys(state.sessionsById, kept);
const messagesBySessionId: Record<string, ChatMessage[]> = {};
const trimmedMessageSessionIds: string[] = [];
for (const sessionId of Object.keys(state.messagesBySessionId)) {
if (!kept.has(sessionId)) continue;
const messages = state.messagesBySessionId[sessionId] ?? [];
if (maxMessages !== null && messages.length > maxMessages) {
messagesBySessionId[sessionId] = messages.slice(messages.length - maxMessages);
trimmedMessageSessionIds.push(sessionId);
} else {
messagesBySessionId[sessionId] = messages;
}
}
return {
state: { ...state, sessionOrder: keptOrder, sessionsById, messagesBySessionId },
evictedSessionIds,
trimmedMessageSessionIds
};
}
function keepSessionOrder(order: string[], retain: Set<string>, maxSessions: number): string[] {
const kept: string[] = [];
for (const sessionId of order) {
if (retainedOrBelowLimit(sessionId, retain, kept.length, maxSessions)) kept.push(sessionId);
}
for (const sessionId of retain) {
if (order.includes(sessionId) && !kept.includes(sessionId)) kept.push(sessionId);
}
return kept;
}
function retainedOrBelowLimit(sessionId: string, retain: Set<string>, count: number, maxSessions: number): boolean {
return retain.has(sessionId) || count < maxSessions;
}
function pickRecordKeys<T>(record: Record<string, T>, keys: Set<string>): Record<string, T> {
const next: Record<string, T> = {};
for (const key of Object.keys(record)) if (keys.has(key)) next[key] = record[key] as T;
return next;
}
function normalizeIds(values: Iterable<string | null | undefined> | null | undefined): string[] {
if (!values) return [];
return [...values].map((value) => String(value ?? "").trim()).filter(Boolean);
}
function positiveIntegerOrNull(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
return Math.trunc(value);
}
@@ -0,0 +1,67 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-28-p0-d518-session-timeline-consistency.
// Responsibility: Stable Workbench timeline row identities derived from authoritative message projections.
import type { ChatMessage } from "@/types";
import { firstScopePart, workbenchTimelineRowScopeKey } from "@/utils/workbench-key";
export interface WorkbenchMessageTimelineRow {
type: "message";
key: string;
identity: string;
message: ChatMessage;
role: ChatMessage["role"];
sessionId: string | null;
traceId: string | null;
}
export type WorkbenchTimelineRow = WorkbenchMessageTimelineRow;
export function buildWorkbenchTimelineRows(messages: ChatMessage[]): WorkbenchTimelineRow[] {
const rows: WorkbenchTimelineRow[] = [];
const indexByIdentity = new Map<string, number>();
messages.forEach((message, index) => {
const identity = workbenchMessageIdentity(message, index);
const row: WorkbenchMessageTimelineRow = {
type: "message",
key: workbenchTimelineRowScopeKey({
sessionId: message.sessionId,
traceId: message.traceId,
type: "message",
role: message.role,
rowId: identity
}),
identity,
message,
role: message.role,
sessionId: firstScopePart(message.sessionId),
traceId: firstScopePart(message.traceId)
};
const existing = indexByIdentity.get(identity);
if (existing === undefined) {
indexByIdentity.set(identity, rows.length);
rows.push(row);
} else {
rows[existing] = row;
}
});
return rows;
}
export function normalizeWorkbenchTimelineMessages(messages: ChatMessage[]): ChatMessage[] {
return buildWorkbenchTimelineRows(messages).map((row) => row.message);
}
export function workbenchMessageIdentity(message: ChatMessage, fallbackIndex: number | null = null): string {
const authorityId = firstScopePart(message.messageId, message.id);
if (authorityId) return authorityId;
const turnId = firstScopePart(message.turnId);
if (turnId) return `turn:${turnId}`;
const traceId = firstScopePart(message.traceId);
if (traceId && message.role === "agent") return `agent-trace:${traceId}`;
return `message-index:${fallbackIndex ?? message.createdAt}`;
}
export function workbenchTimelineSignature(rows: WorkbenchTimelineRow[]): string {
return rows.map((row) => [row.key, row.message.status, String(row.message.text ?? "").length].join(":")).join("|");
}
+7 -6
View File
@@ -1,4 +1,4 @@
// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; draft-2026-06-28-p0-d518-session-timeline-consistency; 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-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; draft-2026-06-28-p0-d518-session-timeline-consistency; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Session-first Workbench state orchestration for selection, turn admission, and trace lifecycle rendering.
import { computed, nextTick, ref } from "vue";
@@ -8,6 +8,7 @@ import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealti
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiError, ApiResult, ChatMessage, ErrorDiagnostic, LiveSurface, ProjectionBlocker, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types";
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { workbenchRealtimeScopeKey } from "@/utils/workbench-key";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
@@ -1164,7 +1165,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
function restartRealtime(reason: string): void {
const traceId = realtimeTraceId();
const sessionId = selectedSessionId.value ?? null;
const key = [sessionId ?? "", traceId ?? ""].join("|");
const key = workbenchRealtimeScopeKey(sessionId, traceId);
if (key === realtimeKey && realtimeStream) return;
stopRealtime();
realtimeKey = key;
@@ -1183,8 +1184,8 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
function handleRealtimeStreamError(sessionId: string | null, traceId: string | null): void {
const activeId = normalizeWorkbenchSessionId(sessionId ?? selectedSessionId.value);
const activeTraceId = firstNonEmptyString(traceId, realtimeTraceId());
const key = [activeId ?? "", activeTraceId ?? ""].join("|");
if (!key.trim()) return;
if (!activeId && !activeTraceId) return;
const key = workbenchRealtimeScopeKey(activeId, activeTraceId);
const lastAt = realtimeErrorGapFillLastAtByKey.get(key) ?? 0;
if (lastAt > 0 && Date.now() - lastAt < WORKBENCH_REALTIME_ERROR_GAP_FILL_MIN_MS) return;
realtimeErrorGapFillLastAtByKey.set(key, Date.now());
@@ -1261,8 +1262,8 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
if (outboxSeq === null) return;
const sessionId = realtimeEventSessionId(event) ?? selectedSessionId.value ?? null;
const traceId = firstNonEmptyString(event.traceId, event.event?.traceId, event.snapshot?.traceId, currentRequest.value?.traceId, activeTraceIdFromMessages(messages.value, turnStatusAuthority.value));
const key = [sessionId ?? "", traceId ?? ""].join("|");
if (!key.trim()) return;
if (!sessionId && !traceId) return;
const key = workbenchRealtimeScopeKey(sessionId, traceId);
const previous = realtimeOutboxSeqByKey.get(key) ?? 0;
if (outboxSeq > previous) realtimeOutboxSeqByKey.set(key, outboxSeq);
}
@@ -0,0 +1,319 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Storage quota/fallback/cache/migration helpers. Mechanically migrated from OpenCode app/utils/persist.ts with all capacities supplied by runtime policy.
export interface StorageLike {
readonly length?: number;
key?(index: number): string | null;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export interface SafeStorageRuntimePolicy {
cacheMaxEntries?: number | null;
cacheMaxBytes?: number | null;
evictPrefix?: string | null;
}
export type SafeStorageStatus = "ok" | "missing" | "storage_unavailable" | "parse_error" | "write_error" | "remove_error";
export interface SafeStorageResult<T> {
ok: boolean;
status: SafeStorageStatus;
value: T;
error?: unknown;
}
export interface PersistTarget {
storage?: string;
legacyStorageNames?: string[];
key: string;
legacy?: string[];
migrate?: (value: unknown) => unknown;
}
interface CacheEntry {
value: string;
bytes: number;
}
export function createSafeStorageRuntime(policy: SafeStorageRuntimePolicy = {}) {
const cache = new Map<string, CacheEntry>();
const cacheTotal = { bytes: 0 };
const fallback = new Map<string, boolean>();
const cacheDelete = (key: string) => {
const entry = cache.get(key);
if (!entry) return;
cacheTotal.bytes -= entry.bytes;
cache.delete(key);
};
const cachePrune = () => {
for (;;) {
const entryLimitOk = policy.cacheMaxEntries === undefined || policy.cacheMaxEntries === null || cache.size <= policy.cacheMaxEntries;
const byteLimitOk = policy.cacheMaxBytes === undefined || policy.cacheMaxBytes === null || cacheTotal.bytes <= policy.cacheMaxBytes;
if (entryLimitOk && byteLimitOk) return;
const oldest = cache.keys().next().value as string | undefined;
if (!oldest) return;
cacheDelete(oldest);
}
};
const cacheSet = (key: string, value: string) => {
const bytes = value.length * 2;
if (policy.cacheMaxBytes !== undefined && policy.cacheMaxBytes !== null && bytes > policy.cacheMaxBytes) {
cacheDelete(key);
return;
}
const entry = cache.get(key);
if (entry) cacheTotal.bytes -= entry.bytes;
cache.delete(key);
cache.set(key, { value, bytes });
cacheTotal.bytes += bytes;
cachePrune();
};
const cacheGet = (key: string) => {
const entry = cache.get(key);
if (!entry) return undefined;
cache.delete(key);
cache.set(key, entry);
return entry.value;
};
const fallbackDisabled = (scope: string) => fallback.get(scope) === true;
const fallbackSet = (scope: string) => fallback.set(scope, true);
const write = (storage: StorageLike, key: string, value: string) => {
try {
storage.setItem(key, value);
cacheSet(key, value);
return true;
} catch (error) {
if (!isStorageQuotaError(error)) throw error;
}
try {
storage.removeItem(key);
cacheDelete(key);
storage.setItem(key, value);
cacheSet(key, value);
return true;
} catch (error) {
if (!isStorageQuotaError(error)) throw error;
}
return evict(storage, key, value, policy.evictPrefix ?? "", cacheDelete, cacheSet);
};
const direct = (storage: StorageLike | null | undefined, scope = "direct"): StorageLike => ({
getItem: (key) => {
const cached = cacheGet(key);
if (!storage || fallbackDisabled(scope)) return cached ?? null;
const stored = safeCall(() => storage.getItem(key), () => fallbackSet(scope));
if (stored === null || stored === undefined) return cached ?? null;
cacheSet(key, stored);
return stored;
},
setItem: (key, value) => {
if (!storage || fallbackDisabled(scope)) return;
try {
if (write(storage, key, value)) return;
} catch {
fallbackSet(scope);
return;
}
fallbackSet(scope);
},
removeItem: (key) => {
cacheDelete(key);
if (!storage || fallbackDisabled(scope)) return;
safeCall(() => storage.removeItem(key), () => fallbackSet(scope));
}
});
const prefixed = (prefix: string, storage: StorageLike | null | undefined): StorageLike => {
const base = `${prefix}:`;
const item = (key: string) => base + key;
const scoped = direct(storage, `prefix:${prefix}`);
return {
getItem: (key) => scoped.getItem(item(key)),
setItem: (key, value) => scoped.setItem(item(key), value),
removeItem: (key) => scoped.removeItem(item(key))
};
};
return {
cacheGet,
cacheSet,
cacheDelete,
localStorageDirect: direct,
localStorageWithPrefix: prefixed,
cacheSize: () => cache.size,
fallbackDisabled
};
}
export function readJsonStorage<T>(storage: StorageLike | null | undefined, key: string, fallback: T): SafeStorageResult<T> {
if (!storage) return { ok: false, status: "storage_unavailable", value: fallback };
try {
const raw = storage.getItem(key);
if (raw === null) return { ok: true, status: "missing", value: fallback };
return { ok: true, status: "ok", value: JSON.parse(raw) as T };
} catch (error) {
return { ok: false, status: "parse_error", value: fallback, error };
}
}
export function writeJsonStorage<T>(storage: StorageLike | null | undefined, key: string, value: T): SafeStorageResult<T> {
if (!storage) return { ok: false, status: "storage_unavailable", value };
try {
storage.setItem(key, JSON.stringify(value));
return { ok: true, status: "ok", value };
} catch (error) {
return { ok: false, status: "write_error", value, error };
}
}
export function removeStorageKey(storage: StorageLike | null | undefined, key: string): SafeStorageResult<null> {
if (!storage) return { ok: false, status: "storage_unavailable", value: null };
try {
storage.removeItem(key);
return { ok: true, status: "ok", value: null };
} catch (error) {
return { ok: false, status: "remove_error", value: null, error };
}
}
export function normalizePersistedValue(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown): string | undefined {
const parsed = parseJson(raw);
if (parsed === undefined) return undefined;
const migrated = migrate ? migrate(parsed) : parsed;
return JSON.stringify(mergePersistedDefaults(defaults, migrated));
}
export function migrateLegacyStorage(input: { current: StorageLike; legacyStore?: StorageLike; stores: StorageLike[]; keys: string[]; key: string; defaults: unknown; migrate?: (value: unknown) => unknown }): string | null {
for (const store of input.stores) {
const raw = store.getItem(input.key);
if (raw === null) continue;
const next = normalizePersistedValue(input.defaults, raw, input.migrate);
if (next === undefined) {
store.removeItem(input.key);
continue;
}
input.current.setItem(input.key, next);
store.removeItem(input.key);
return next;
}
if (!input.legacyStore) return null;
for (const key of input.keys) {
const raw = input.legacyStore.getItem(key);
if (raw === null) continue;
const next = normalizePersistedValue(input.defaults, raw, input.migrate);
if (next === undefined) {
input.legacyStore.removeItem(key);
continue;
}
input.current.setItem(input.key, next);
input.legacyStore.removeItem(key);
return next;
}
return null;
}
export function removePersistedTarget(target: { storage?: string; legacyStorageNames?: string[]; key: string }, runtime = createSafeStorageRuntime(), storage: StorageLike | null | undefined = globalStorage()): void {
if (!target.storage) {
runtime.localStorageDirect(storage).removeItem(target.key);
return;
}
runtime.localStorageWithPrefix(target.storage, storage).removeItem(target.key);
for (const legacy of target.legacyStorageNames ?? []) runtime.localStorageWithPrefix(legacy, storage).removeItem(target.key);
}
export function isStorageQuotaError(error: unknown): boolean {
if (typeof DOMException !== "undefined" && error instanceof DOMException) {
if (error.name === "QuotaExceededError") return true;
if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true;
if (error.name === "QUOTA_EXCEEDED_ERR") return true;
if (error.code === 22 || error.code === 1014) return true;
return false;
}
if (!error || typeof error !== "object") return false;
const name = (error as { name?: string }).name;
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true;
if (name && /quota/iu.test(name)) return true;
const code = (error as { code?: number }).code;
if (code === 22 || code === 1014) return true;
const message = (error as { message?: string }).message;
return typeof message === "string" && /quota/iu.test(message);
}
export function mergePersistedDefaults(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults;
if (value === null) return value;
if (Array.isArray(defaults)) return Array.isArray(value) ? value : defaults;
if (isRecord(defaults)) {
if (!isRecord(value)) return defaults;
const result: Record<string, unknown> = { ...defaults };
for (const key of Object.keys(value)) {
result[key] = key in defaults ? mergePersistedDefaults((defaults as Record<string, unknown>)[key], value[key]) : value[key];
}
return result;
}
return value;
}
function evict(storage: StorageLike, keep: string, value: string, prefix: string, cacheDelete: (key: string) => void, cacheSet: (key: string, value: string) => void): boolean {
const total = storage.length ?? 0;
const items: Array<{ key: string; size: number }> = [];
for (const index of Array.from({ length: total }, (_, item) => item)) {
const name = storage.key?.(index);
if (!name) continue;
if (prefix && !name.startsWith(prefix)) continue;
if (name === keep) continue;
const stored = storage.getItem(name);
items.push({ key: name, size: stored?.length ?? 0 });
}
items.sort((a, b) => b.size - a.size);
for (const item of items) {
storage.removeItem(item.key);
cacheDelete(item.key);
try {
storage.setItem(keep, value);
cacheSet(keep, value);
return true;
} catch (error) {
if (!isStorageQuotaError(error)) throw error;
}
}
return false;
}
function parseJson(value: string): unknown | undefined {
try {
return JSON.parse(value);
} catch {
return undefined;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function safeCall<T>(fn: () => T, onError: () => void): T | null {
try {
return fn();
} catch {
onError();
return null;
}
}
function globalStorage(): StorageLike | null {
if (typeof globalThis === "undefined") return null;
return (globalThis as typeof globalThis & { localStorage?: StorageLike }).localStorage ?? null;
}
@@ -0,0 +1,36 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Async queue and bounded worker pool. Mechanically migrated from OpenCode opencode/src/util/queue.ts.
export class AsyncQueue<T> implements AsyncIterable<T> {
private queue: T[] = [];
private resolvers: Array<(value: T) => void> = [];
push(item: T): void {
const resolve = this.resolvers.shift();
if (resolve) resolve(item);
else this.queue.push(item);
}
async next(): Promise<T> {
if (this.queue.length > 0) return this.queue.shift() as T;
return new Promise((resolve) => this.resolvers.push(resolve));
}
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) yield await this.next();
}
}
export async function work<T>(concurrency: number, items: T[], fn: (item: T) => Promise<void>): Promise<void> {
const pending = [...items];
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (true) {
const item = pending.pop();
if (item === undefined) return;
await fn(item);
}
})
);
}
@@ -0,0 +1,100 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Framework-agnostic event coalescing for Workbench SSE and refresh dispatch.
export type CoalescedQueueKey = string | null | undefined;
export type CoalescedQueueCancel = () => void;
export type CoalescedQueueScheduler = (flush: () => void) => CoalescedQueueCancel | void;
export interface CoalescedEventQueueOptions<T> {
keyOf: (item: T) => CoalescedQueueKey;
onFlush: (items: T[]) => void;
schedule?: CoalescedQueueScheduler;
}
export class CoalescedEventQueue<T> {
private readonly keyOf: (item: T) => CoalescedQueueKey;
private readonly onFlush: (items: T[]) => void;
private readonly scheduleFlush: CoalescedQueueScheduler;
private items: T[] = [];
private keyIndex = new Map<string, number>();
private cancelScheduled: CoalescedQueueCancel | null = null;
constructor(options: CoalescedEventQueueOptions<T>) {
this.keyOf = options.keyOf;
this.onFlush = options.onFlush;
this.scheduleFlush = options.schedule ?? defaultScheduler;
}
get size(): number {
return this.items.length;
}
push(item: T): void {
const key = normalizedKey(this.keyOf(item));
if (key && this.keyIndex.has(key)) {
this.items[this.keyIndex.get(key) ?? 0] = item;
} else {
if (key) this.keyIndex.set(key, this.items.length);
this.items.push(item);
}
this.ensureScheduled();
}
flush(): T[] {
if (this.cancelScheduled) {
this.cancelScheduled();
this.cancelScheduled = null;
}
if (this.items.length === 0) return [];
const items = this.items;
this.items = [];
this.keyIndex = new Map();
this.onFlush(items);
return items;
}
clear(): void {
if (this.cancelScheduled) {
this.cancelScheduled();
this.cancelScheduled = null;
}
this.items = [];
this.keyIndex = new Map();
}
private ensureScheduled(): void {
if (this.cancelScheduled) return;
const cancel = this.scheduleFlush(() => this.flush());
this.cancelScheduled = typeof cancel === "function" ? cancel : null;
}
}
export function createCoalescedEventQueue<T>(options: CoalescedEventQueueOptions<T>): CoalescedEventQueue<T> {
return new CoalescedEventQueue(options);
}
function defaultScheduler(flush: () => void): CoalescedQueueCancel {
if (typeof queueMicrotask === "function") {
let canceled = false;
queueMicrotask(() => {
if (!canceled) flush();
});
return () => {
canceled = true;
};
}
let canceled = false;
void Promise.resolve().then(() => {
if (!canceled) flush();
});
return () => {
canceled = true;
};
}
function normalizedKey(value: CoalescedQueueKey): string | null {
if (typeof value !== "string") return null;
const text = value.trim();
return text ? text : null;
}
@@ -0,0 +1,83 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Keyed single-flight primitive for Workbench REST refresh and health probes.
export interface KeyedSingleflightContext {
key: string;
signal: AbortSignal;
generation: number;
reason?: string | null;
}
export interface KeyedSingleflightRunOptions {
replace?: boolean;
reason?: string | null;
}
interface InflightEntry<T> {
generation: number;
controller: AbortController;
promise: Promise<T>;
}
export class KeyedSingleflight<T> {
private readonly inflight = new Map<string, InflightEntry<T>>();
private readonly generations = new Map<string, number>();
get size(): number {
return this.inflight.size;
}
run(key: string, task: (context: KeyedSingleflightContext) => Promise<T> | T, options: KeyedSingleflightRunOptions = {}): Promise<T> {
const normalized = normalizeKey(key);
const existing = this.inflight.get(normalized);
if (existing && !options.replace) return existing.promise;
if (existing) existing.controller.abort(options.reason ?? "singleflight-replaced");
const generation = (this.generations.get(normalized) ?? 0) + 1;
this.generations.set(normalized, generation);
const controller = new AbortController();
const promise = Promise.resolve()
.then(() => task({ key: normalized, signal: controller.signal, generation, reason: options.reason ?? null }))
.finally(() => {
const current = this.inflight.get(normalized);
if (current?.generation === generation) this.inflight.delete(normalized);
});
this.inflight.set(normalized, { generation, controller, promise });
return promise;
}
cancel(key: string, reason?: string): boolean {
const normalized = normalizeKey(key);
const existing = this.inflight.get(normalized);
if (!existing) return false;
existing.controller.abort(reason ?? "singleflight-canceled");
this.inflight.delete(normalized);
return true;
}
invalidate(key: string): void {
const normalized = normalizeKey(key);
this.generations.set(normalized, (this.generations.get(normalized) ?? 0) + 1);
this.cancel(normalized, "singleflight-invalidated");
}
isCurrent(key: string, generation: number): boolean {
return (this.generations.get(normalizeKey(key)) ?? 0) === generation;
}
clear(reason = "singleflight-cleared"): void {
for (const entry of this.inflight.values()) entry.controller.abort(reason);
this.inflight.clear();
}
}
export function createKeyedSingleflight<T>(): KeyedSingleflight<T> {
return new KeyedSingleflight<T>();
}
function normalizeKey(key: string): string {
const text = key.trim();
if (!text) throw new Error("singleflight key is required");
return text;
}
@@ -0,0 +1,107 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: LRU/TTL scoped cache for Workbench runtime objects. Mechanically migrated from OpenCode app/utils/scoped-cache.ts with policy supplied by caller.
export interface ScopedCacheOptions<T> {
maxEntries?: number | null;
ttlMs?: number | null;
dispose?: (value: T, key: string) => void;
now?: () => number;
}
interface Entry<T> {
value: T;
touchedAt: number;
}
export function createScopedCache<T>(createValue: (key: string) => T, options: ScopedCacheOptions<T> = {}) {
const store = new Map<string, Entry<T>>();
const now = options.now ?? Date.now;
const dispose = (key: string, entry: Entry<T>) => {
options.dispose?.(entry.value, key);
};
const expired = (entry: Entry<T>) => {
if (options.ttlMs === undefined || options.ttlMs === null) return false;
return now() - entry.touchedAt >= options.ttlMs;
};
const sweep = () => {
if (options.ttlMs === undefined || options.ttlMs === null) return;
for (const [key, entry] of store) {
if (!expired(entry)) continue;
store.delete(key);
dispose(key, entry);
}
};
const touch = (key: string, entry: Entry<T>) => {
entry.touchedAt = now();
store.delete(key);
store.set(key, entry);
};
const prune = () => {
if (options.maxEntries === undefined || options.maxEntries === null) return;
while (store.size > options.maxEntries) {
const key = store.keys().next().value as string | undefined;
if (!key) return;
const entry = store.get(key);
store.delete(key);
if (!entry) continue;
dispose(key, entry);
}
};
const remove = (key: string) => {
const entry = store.get(key);
if (!entry) return undefined;
store.delete(key);
dispose(key, entry);
return entry.value;
};
const peek = (key: string) => {
sweep();
const entry = store.get(key);
if (!entry) return undefined;
if (!expired(entry)) return entry.value;
store.delete(key);
dispose(key, entry);
return undefined;
};
const get = (key: string) => {
sweep();
const entry = store.get(key);
if (entry && !expired(entry)) {
touch(key, entry);
return entry.value;
}
if (entry) {
store.delete(key);
dispose(key, entry);
}
const created = {
value: createValue(key),
touchedAt: now()
};
store.set(key, created);
prune();
return created.value;
};
const clear = () => {
for (const [key, entry] of store) dispose(key, entry);
store.clear();
};
return {
get,
peek,
delete: remove,
clear,
size: () => store.size
};
}
@@ -0,0 +1,165 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-01060505 Workbench性能 draft-2026-06-19-p0.
// Responsibility: Health probe retry/cache helpers. Mechanically migrated from OpenCode app/utils/server-health.ts with all timing supplied by runtime policy.
export type WorkbenchHealthState = "ok" | "degraded" | "unavailable" | "blocked";
export interface WorkbenchHealthSnapshot<T = unknown> {
key: string;
state: WorkbenchHealthState;
value: T | null;
error: unknown | null;
observedAt: string;
}
export interface CheckWorkbenchHealthOptions<T> {
key: string;
fetcher: (signal: AbortSignal | undefined) => Promise<T>;
classify?: (value: T) => WorkbenchHealthState;
timeoutMs?: number | null;
signal?: AbortSignal | null;
retryCount?: number | null;
retryDelayMs?: number | null;
}
export interface WorkbenchHealthCacheOptions {
cacheMs?: number | null;
now?: () => number;
}
interface HealthCacheEntry<T> {
at: number;
done: boolean;
promise: Promise<WorkbenchHealthSnapshot<T>>;
}
export class WorkbenchHealthProbeCache {
private readonly latest = new Map<string, WorkbenchHealthSnapshot>();
private readonly inflight = new Map<string, HealthCacheEntry<unknown>>();
private readonly now: () => number;
private readonly cacheMs: number | null;
constructor(options: WorkbenchHealthCacheOptions = {}) {
this.now = options.now ?? Date.now;
this.cacheMs = positiveIntegerOrNull(options.cacheMs);
}
get(key: string): WorkbenchHealthSnapshot | null {
return this.latest.get(normalizeKey(key)) ?? null;
}
probe<T>(input: CheckWorkbenchHealthOptions<T>): Promise<WorkbenchHealthSnapshot<T>> {
const key = normalizeKey(input.key);
const hit = this.inflight.get(key) as HealthCacheEntry<T> | undefined;
const now = this.now();
if (hit && (!hit.done || (this.cacheMs !== null && now - hit.at < this.cacheMs))) return hit.promise;
const promise = checkWorkbenchHealth(input).then((snapshot) => {
this.latest.set(key, snapshot);
return snapshot;
}).finally(() => {
const current = this.inflight.get(key);
if (!current || current.promise !== promise) return;
current.done = true;
current.at = this.now();
});
this.inflight.set(key, { at: now, done: false, promise });
return promise;
}
clear(key?: string): void {
if (key) {
const normalized = normalizeKey(key);
this.latest.delete(normalized);
this.inflight.delete(normalized);
} else {
this.latest.clear();
this.inflight.clear();
}
}
}
export function createWorkbenchHealthProbeCache(options: WorkbenchHealthCacheOptions = {}): WorkbenchHealthProbeCache {
return new WorkbenchHealthProbeCache(options);
}
export async function checkWorkbenchHealth<T>(input: CheckWorkbenchHealthOptions<T>): Promise<WorkbenchHealthSnapshot<T>> {
const timeout = input.signal ? undefined : timeoutSignal(input.timeoutMs);
const signal = input.signal ?? timeout?.signal;
const retryCount = positiveIntegerOrNull(input.retryCount) ?? 0;
const retryDelayMs = positiveIntegerOrNull(input.retryDelayMs) ?? 0;
const startedAt = new Date().toISOString();
const next = (count: number, error: unknown): Promise<WorkbenchHealthSnapshot<T>> => {
if (count >= retryCount || !retryableHealthError(error, signal)) return Promise.resolve(unavailableSnapshot(input.key, error, startedAt));
return wait(retryDelayMs * (count + 1), signal).then(() => attempt(count + 1)).catch((waitError) => unavailableSnapshot(input.key, waitError, startedAt));
};
const attempt = async (count: number): Promise<WorkbenchHealthSnapshot<T>> => {
try {
const value = await input.fetcher(signal);
return {
key: normalizeKey(input.key),
state: input.classify?.(value) ?? "ok",
value,
error: null,
observedAt: new Date().toISOString()
};
} catch (error) {
return next(count, error);
}
};
return attempt(0).finally(() => timeout?.clear?.());
}
function timeoutSignal(timeoutMs: number | null | undefined): { signal: AbortSignal; clear?: () => void } | undefined {
const parsed = positiveIntegerOrNull(timeoutMs);
if (parsed === null) return undefined;
const nativeTimeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout;
if (nativeTimeout) {
try {
return { signal: nativeTimeout.call(AbortSignal, parsed) };
} catch {}
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), parsed);
return { signal: controller.signal, clear: () => clearTimeout(timer) };
}
function wait(ms: number, signal?: AbortSignal | null): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function retryableHealthError(error: unknown, signal?: AbortSignal | null): boolean {
if (signal?.aborted) return false;
if (!(error instanceof Error)) return false;
if (error.name === "AbortError" || error.name === "TimeoutError") return false;
if (error instanceof TypeError) return true;
return /network|fetch|econnreset|econnrefused|enotfound|timedout/iu.test(error.message);
}
function unavailableSnapshot<T>(key: string, error: unknown, observedAt: string): WorkbenchHealthSnapshot<T> {
return { key: normalizeKey(key), state: "unavailable", value: null, error, observedAt };
}
function positiveIntegerOrNull(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
return Math.trunc(value);
}
function normalizeKey(key: string): string {
const text = key.trim();
if (!text) throw new Error("health probe key is required");
return text;
}
@@ -0,0 +1,94 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-28-p0-d518-session-timeline-consistency.
// Responsibility: Stable Workbench scoped keys for queues, caches, storage, timeline rows and diagnostics. Adapted from OpenCode app/utils/server-scope.ts.
import type { ChatMessage } from "@/types";
export type WorkbenchScopePart = unknown;
const EMPTY_SCOPE_PART = "~";
const SCOPE_SEPARATOR = "\u0000";
export function composeWorkbenchScopedKey(scope: string, ...parts: WorkbenchScopePart[]): string {
const normalizedScope = checkedScopePart("Workbench scope", normalizeScopePart(scope));
return [normalizedScope, ...parts.map((part) => checkedScopePart("Workbench scoped key part", normalizeScopePart(part)))].join(SCOPE_SEPARATOR);
}
export function workbenchRealtimeScopeKey(sessionId: WorkbenchScopePart, traceId: WorkbenchScopePart): string {
return composeWorkbenchScopedKey("workbench.realtime", sessionId, traceId);
}
export function workbenchSessionScopeKey(sessionId: WorkbenchScopePart): string {
return composeWorkbenchScopedKey("workbench.session", sessionId);
}
export function workbenchTraceScopeKey(traceId: WorkbenchScopePart): string {
return composeWorkbenchScopedKey("workbench.trace", traceId);
}
export function workbenchMessageScopeKey(message: Pick<ChatMessage, "id" | "messageId" | "turnId" | "traceId" | "sessionId" | "role" | "createdAt">): string {
return composeWorkbenchScopedKey(
"workbench.message",
firstScopePart(message.sessionId),
firstScopePart(message.messageId, message.id, message.turnId, message.traceId, message.createdAt),
firstScopePart(message.role)
);
}
export function workbenchTimelineRowScopeKey(input: { sessionId?: WorkbenchScopePart; traceId?: WorkbenchScopePart; rowId?: WorkbenchScopePart; role?: WorkbenchScopePart; type?: WorkbenchScopePart }): string {
return composeWorkbenchScopedKey("workbench.timeline.row", input.sessionId, input.traceId, input.type, input.role, input.rowId);
}
export function firstScopePart(...values: WorkbenchScopePart[]): string | null {
for (const value of values) {
const text = normalizeScopeText(value);
if (text) return text;
}
return null;
}
export function normalizeScopeText(value: WorkbenchScopePart): string | null {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text ? text : null;
}
export function splitWorkbenchScopedKey(key: string): string[] {
return key.split(SCOPE_SEPARATOR);
}
export function workbenchPathKey(path: string): string {
const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path;
const trimmed = trimTrailingSlashes(value);
if (!trimmed && value.startsWith("/")) return "/";
if (isDrivePath(trimmed)) return `${trimmed}/`;
return trimmed;
}
function normalizeScopePart(value: WorkbenchScopePart): string {
const text = normalizeScopeText(value);
return text ?? EMPTY_SCOPE_PART;
}
function checkedScopePart(label: string, value: string): string {
if (value.includes(SCOPE_SEPARATOR)) throw new Error(`${label} cannot contain null bytes`);
return value;
}
function trimTrailingSlashes(value: string): string {
for (let index = value.length - 1; index >= 0; index -= 1) {
if (value[index] !== "/") return value.slice(0, index + 1);
}
return "";
}
function isWindowsPath(value: string): boolean {
return value[1] === ":" || value.startsWith("\\\\");
}
function isDrivePath(value: string): boolean {
if (value.length !== 2) return false;
const code = value.charCodeAt(0);
return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122));
}