Merge pull request #1539 from pikasTech/codex/1535-workbench-trace-policy
fix(workbench): make trace disclosure lifecycle configurable
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"]`);
|
||||
|
||||
Reference in New Issue
Block a user