Merge pull request #2300 from pikasTech/issue-1297-workbench-opencode-modules
feat: migrate workbench realtime runtime modules
This commit is contained in:
@@ -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("|");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user