Merge pull request #1539 from pikasTech/codex/1535-workbench-trace-policy

fix(workbench): make trace disclosure lifecycle configurable
This commit is contained in:
Lyon
2026-06-19 00:19:35 +08:00
committed by GitHub
11 changed files with 169 additions and 27 deletions
+16
View File
@@ -333,6 +333,7 @@
"artifactCatalog": { "type": "string" },
"runtimePath": { "type": "string" },
"imageTagMode": { "type": "string", "enum": ["short", "full"] },
"workbench": { "$ref": "#/$defs/workbenchUiPolicy" },
"sourceRepo": { "type": "string" },
"envReuseServices": {
"type": "array",
@@ -358,6 +359,21 @@
},
"additionalProperties": false
},
"workbenchUiPolicy": {
"type": "object",
"properties": {
"traceTimeline": { "$ref": "#/$defs/workbenchTraceTimelinePolicy" }
},
"additionalProperties": false
},
"workbenchTraceTimelinePolicy": {
"type": "object",
"properties": {
"autoExpandRunning": { "type": "boolean" },
"autoCollapseTerminal": { "type": "boolean" }
},
"additionalProperties": false
},
"externalPostgres": {
"type": "object",
"required": ["enabled", "serviceName", "endpointAddress", "port"],
+4
View File
@@ -251,6 +251,10 @@ lanes:
artifactCatalog: deploy/artifact-catalog.v03.json
runtimePath: runtime-v03
imageTagMode: full
workbench:
traceTimeline:
autoExpandRunning: false
autoCollapseTerminal: false
externalPostgres:
enabled: true
serviceName: g14-platform-postgres
+45 -1
View File
@@ -70,8 +70,8 @@ export function createCloudWebServer({
try {
const info = await stat(candidate);
if (!info.isFile()) continue;
const body = await readFile(candidate);
const type = contentType(candidate);
const body = await staticResponseBody({ candidate, relativePath, type });
const encoded = await encodeStaticBody(request, body, type);
response.writeHead(200, {
"content-type": type,
@@ -108,6 +108,50 @@ export function createCloudWebServer({
});
}
async function staticResponseBody({ candidate, relativePath, type }) {
const body = await readFile(candidate);
if (relativePath !== "index.html" || !String(type).startsWith("text/html")) return body;
return injectCloudWebRuntimeConfig(body);
}
function injectCloudWebRuntimeConfig(body) {
const config = runtimeConfigFromEnv();
if (Object.keys(config).length === 0) return body;
const html = body.toString("utf8");
const script = `<script>window.HWLAB_CLOUD_WEB_CONFIG=${safeInlineJson(config)};</script>`;
if (html.includes("</head>")) return Buffer.from(html.replace("</head>", `${script}</head>`), "utf8");
if (html.includes("</body>")) return Buffer.from(html.replace("</body>", `${script}</body>`), "utf8");
return Buffer.from(`${html}${script}`, "utf8");
}
function runtimeConfigFromEnv() {
const config = {};
const workbench = workbenchRuntimeConfigFromEnv();
if (workbench) config.workbench = workbench;
return config;
}
function workbenchRuntimeConfigFromEnv() {
const traceTimeline = {};
const autoExpandRunning = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING);
const autoCollapseTerminal = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL);
if (typeof autoExpandRunning === "boolean") traceTimeline.autoExpandRunning = autoExpandRunning;
if (typeof autoCollapseTerminal === "boolean") traceTimeline.autoCollapseTerminal = autoCollapseTerminal;
return Object.keys(traceTimeline).length > 0 ? { traceTimeline } : null;
}
function parseEnvBoolean(value) {
if (value === undefined || value === null || value === "") return null;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return null;
}
function safeInlineJson(value) {
return JSON.stringify(value).replace(/</gu, "\\u003c");
}
function isCloudWebWorkbenchSessionRoute(routePath) {
return /^\/(?:workbench|workspace)\/sessions\/cnv_[A-Za-z0-9_.:-]+$/u.test(routePath);
}
+21
View File
@@ -962,6 +962,20 @@ function serviceDeclarationEnvForProfile(deploy, profile, serviceId) {
return cloneJson(env);
}
function runtimeWorkbenchTraceTimelinePolicy(deploy, profile) {
if (!isRuntimeLane(profile)) return null;
const traceTimeline = runtimeLaneConfig(deploy, profile)?.workbench?.traceTimeline;
if (!traceTimeline || typeof traceTimeline !== "object" || Array.isArray(traceTimeline)) return null;
const result = {};
if (typeof traceTimeline.autoExpandRunning === "boolean") result.autoExpandRunning = traceTimeline.autoExpandRunning;
if (typeof traceTimeline.autoCollapseTerminal === "boolean") result.autoCollapseTerminal = traceTimeline.autoCollapseTerminal;
return Object.keys(result).length > 0 ? result : null;
}
function booleanEnv(value) {
return value ? "1" : "0";
}
function mergeEnvMaps(...values) {
const result = {};
for (const value of values) {
@@ -1188,6 +1202,13 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
if (serviceId === "hwlab-cloud-web") {
upsertEnv(container.env, "HWLAB_API_BASE_URL", `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667`);
upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint);
const traceTimelinePolicy = runtimeWorkbenchTraceTimelinePolicy(deploy, profile);
if (typeof traceTimelinePolicy?.autoExpandRunning === "boolean") {
upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING", booleanEnv(traceTimelinePolicy.autoExpandRunning));
}
if (typeof traceTimelinePolicy?.autoCollapseTerminal === "boolean") {
upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL", booleanEnv(traceTimelinePolicy.autoCollapseTerminal));
}
}
}
rewritePodSecretRefs(podTemplate.spec, profile);
@@ -4,16 +4,18 @@ import test from "node:test";
import type { ChatMessage } from "../src/types/index.ts";
import { traceLifecycleExpanded } from "../src/components/workbench/trace-lifecycle.ts";
test("Trace lifecycle expands only active agent turns", () => {
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } })), true);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "pending" } })), true);
test("Trace lifecycle respects the workbench timeline policy", () => {
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } })), null);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "pending" } })), null);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", runnerTrace: { status: "running" } })), null);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), true);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running", finalResponse: "ok" } })), true);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), null);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running", finalResponse: "ok" } })), null);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "failed", runnerTrace: { status: "failed" } })), null);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", traceAutoLifecycle: null, runnerTrace: { status: "completed", finalResponse: "ok" } })), null);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", traceAutoLifecycle: "terminal", runnerTrace: { status: "completed", finalResponse: "ok" } })), false);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", traceAutoLifecycle: "running", runnerTrace: { status: "completed", finalResponse: "ok" } })), true);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", traceAutoLifecycle: "terminal", runnerTrace: { status: "completed", finalResponse: "ok" } })), null);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", traceAutoLifecycle: "terminal", runnerTrace: { status: "completed", finalResponse: "ok" } }), { autoCollapseTerminal: true }), false);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", traceAutoLifecycle: "running", runnerTrace: { status: "completed", finalResponse: "ok" } }), { autoExpandRunning: true }), true);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } }), { autoExpandRunning: true }), true);
assert.equal(traceLifecycleExpanded({ ...agentMessage({ status: "running" }), role: "user", title: "用户" }), null);
});
@@ -20,6 +20,7 @@ const emptyTraceLabel = computed(() => {
return props.trace?.fullTraceLoaded === true ? "思考中..." : "加载中...";
});
const toolPreviewLimit = 140;
let programmaticToggleTarget: boolean | null = null;
watch(() => [eventCount.value, props.trace?.status, following.value, expanded.value], async () => {
if (!following.value || !expanded.value) return;
@@ -27,21 +28,10 @@ watch(() => [eventCount.value, props.trace?.status, following.value, expanded.va
jumpToBottom();
});
watch(() => [props.trace?.traceId, props.storageKey, props.defaultExpanded] as const, () => {
if (typeof props.autoExpanded === "boolean") return;
expanded.value = readStoredExpanded() ?? props.defaultExpanded ?? false;
watch(() => [props.trace?.traceId, props.storageKey, props.defaultExpanded, props.autoExpanded] as const, () => {
setExpandedProgrammatically(readStoredExpanded() ?? props.autoExpanded ?? props.defaultExpanded ?? false);
}, { immediate: true });
watch(() => [props.trace?.traceId, props.autoExpanded] as const, () => {
if (typeof props.autoExpanded !== "boolean") return;
expanded.value = props.autoExpanded;
}, { immediate: true });
watch(expanded, (value) => {
if (!props.storageKey) return;
try { window.localStorage.setItem(props.storageKey, value ? "1" : "0"); } catch { /* ignore */ }
});
function jumpToBottom(): void {
const list = listRef.value;
if (!list) return;
@@ -60,7 +50,20 @@ function rowKey(row: TraceEventRow, index: number): string {
function onDisclosureToggle(event: Event): void {
const target = event.currentTarget;
if (target instanceof HTMLDetailsElement) expanded.value = target.open;
if (!(target instanceof HTMLDetailsElement)) return;
expanded.value = target.open;
if (programmaticToggleTarget === target.open) {
programmaticToggleTarget = null;
return;
}
programmaticToggleTarget = null;
writeStoredExpanded(target.open);
}
function setExpandedProgrammatically(value: boolean): void {
if (expanded.value === value) return;
programmaticToggleTarget = value;
expanded.value = value;
}
function readStoredExpanded(): boolean | null {
@@ -73,6 +76,11 @@ function readStoredExpanded(): boolean | null {
return null;
}
function writeStoredExpanded(value: boolean): void {
if (!props.storageKey) return;
try { window.localStorage.setItem(props.storageKey, value ? "1" : "0"); } catch { /* ignore */ }
}
function rowIsTool(row: TraceEventRow): boolean {
return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
}
@@ -11,6 +11,7 @@ import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummar
import MessageActions from "@/components/workbench/MessageActions.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue";
import { workbenchTraceTimelinePolicy } from "@/config/runtime";
import { useClipboard } from "@/composables/useClipboard";
import { useWorkbenchStore } from "@/stores/workbench";
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
@@ -23,6 +24,7 @@ const panelRef = ref<HTMLElement | null>(null);
const following = ref(true);
const detailMessageId = ref<string | null>(null);
const detailMessage = computed(() => workbench.messages.find((message) => message.id === detailMessageId.value) ?? null);
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
const scrollSignature = computed(() => workbench.messages.map((message) => [
message.id,
message.status,
@@ -89,6 +91,10 @@ function acknowledgeVisibleMessages(): void {
function traceStorageKey(message: ChatMessage): string {
return `hwlab.workbench.trace-open.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
}
function traceAutoExpanded(message: ChatMessage): boolean | null {
return traceLifecycleExpanded(message, traceTimelinePolicy.value);
}
</script>
<template>
@@ -103,7 +109,7 @@ function traceStorageKey(message: ChatMessage): string {
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(message)">!</button>
</div>
</header>
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="traceLifecycleExpanded(message)" :storage-key="traceStorageKey(message)" />
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" />
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
<MessageMarkdown v-if="showMessageText(message)" class="message-text" :source="message.text" />
</article>
@@ -1,10 +1,15 @@
import type { ChatMessage } from "@/types";
export function traceLifecycleExpanded(message: ChatMessage): boolean | null {
export interface WorkbenchTraceTimelinePolicy {
autoExpandRunning?: boolean;
autoCollapseTerminal?: boolean;
}
export function traceLifecycleExpanded(message: ChatMessage, policy: WorkbenchTraceTimelinePolicy = {}): boolean | null {
if (message.role !== "agent") return null;
if (message.traceAutoLifecycle === "running") return true;
if (message.traceAutoLifecycle === "terminal") return false;
return isTraceActiveStatus(message.status) ? true : null;
if (message.traceAutoLifecycle === "running") return policy.autoExpandRunning === true ? true : null;
if (message.traceAutoLifecycle === "terminal") return policy.autoCollapseTerminal === true ? false : null;
return policy.autoExpandRunning === true && isTraceActiveStatus(message.status) ? true : null;
}
function normalizeTraceStatus(value: unknown): string | null {
@@ -0,0 +1,9 @@
import type { WorkbenchTraceTimelinePolicy } from "@/components/workbench/trace-lifecycle";
export function workbenchTraceTimelinePolicy(): WorkbenchTraceTimelinePolicy {
const config = window.HWLAB_CLOUD_WEB_CONFIG?.workbench?.traceTimeline;
return {
autoExpandRunning: config?.autoExpandRunning === true,
autoCollapseTerminal: config?.autoCollapseTerminal === true
};
}
+6
View File
@@ -2,6 +2,12 @@ declare global {
interface Window {
HWLAB_CLOUD_WEB_CONFIG?: {
auth?: { mode?: "auto" | "manual"; username?: string; password?: string };
workbench?: {
traceTimeline?: {
autoExpandRunning?: boolean;
autoCollapseTerminal?: boolean;
};
};
};
}
}
@@ -16,6 +16,27 @@ test("TraceTimeline renders readable rows and hides backend noise", async ({ pag
await saveScreenshot(page, testInfo, "trace-rendering-detail");
});
test.describe("trace disclosure policy", () => {
test.use({ scenarioId: "event-replay" });
test("manual trace disclosure survives terminal lifecycle updates", async ({ page }, testInfo) => {
await gotoWorkbench(page);
const runningTrace = page.locator(`${selectors.traceTimeline}[data-status="running"]`).first();
const runningDetails = runningTrace.locator("details.trace-disclosure");
await expect(runningTrace).toBeVisible();
await expect(runningDetails).toHaveJSProperty("open", false);
await runningTrace.locator("summary.trace-disclosure-summary").click();
await expect(runningDetails).toHaveJSProperty("open", true);
const completedTrace = page.locator(`${selectors.traceTimeline}[data-status="completed"]`).first();
const completedDetails = completedTrace.locator("details.trace-disclosure");
await expect(completedTrace).toBeVisible();
await expect(completedDetails).toHaveJSProperty("open", true);
await saveScreenshot(page, testInfo, "trace-disclosure-policy-manual-terminal");
});
});
test("failed trace exposes readable failure row", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/ses_failed");
const trace = page.locator(`${selectors.traceTimeline}[data-status="failed"]`);