fix(web): enforce React TypeScript single path

This commit is contained in:
Codex
2026-06-03 17:45:18 +08:00
parent 6ecfe015cf
commit 58b06fcd17
92 changed files with 2964 additions and 11294 deletions
+1 -1
View File
@@ -36,7 +36,7 @@
"dev-runtime:postflight": "node scripts/run-bun.mjs scripts/dev-runtime-postflight.mjs",
"dev-runtime:hotfix-audit": "node scripts/dev-runtime-hotfix-audit.mjs",
"m3:io:e2e": "node scripts/run-bun.mjs scripts/m3-io-control-e2e.mjs",
"web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json",
"web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json",
"web:layout:build": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json",
"web:layout:live": "node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-live.json",
"gateway:demo:smoke": "node scripts/run-bun.mjs scripts/gateway-outbound-demo-smoke.mjs",
+7 -1
View File
@@ -15,12 +15,17 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
export function parseLayoutSmokeArgs(argv) {
if (argv.includes("--help")) return { help: true };
const normalized = ["--layout"];
let liveMode = false;
let buildMode = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--static" || arg === "--source") {
buildMode = true;
continue;
}
if (arg === "--build") buildMode = true;
if (arg === "--live") {
liveMode = true;
const next = argv[index + 1];
if (next && !next.startsWith("--")) {
normalized.push("--url");
@@ -39,6 +44,7 @@ export function parseLayoutSmokeArgs(argv) {
normalized.push(argv[index]);
}
}
if (!liveMode && !buildMode) normalized.push("--build");
return parseSmokeArgs(normalized);
}
@@ -101,7 +107,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
...printSmokeHelp(),
command: `node scripts/dev-cloud-workbench-layout-smoke.mjs [--static|--build|--live --url http://74.48.78.17:16666/] [--report ${tempReportPath("dev-cloud-workbench-layout.json")}]`,
layoutNotes: [
"默认 --static 使用 source/static 本地静态服务。",
"默认 --static 使用 Vite local-build;源码静态服务路径已废弃。",
"--build 会先运行 cd web/hwlab-cloud-web && bun run build,再检查本地 dist 构建产物。",
"--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 Device Pod 或硬件写操作,不等同于硬件验收。",
"结构化失败会包含 status、viewport、selector、failureType、artifact screenshot/report path。"
+29 -31
View File
@@ -40,6 +40,7 @@ const expectedRuntimeIdentity = Object.freeze({
const rootPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const cloudWebPackage = JSON.parse(readFileSync(new URL("../web/hwlab-cloud-web/package.json", import.meta.url), "utf8"));
const cloudWebCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/check.ts", import.meta.url), "utf8");
const cloudWebTscCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/tsc-check.ts", import.meta.url), "utf8");
test("workbench smoke defaults to SOURCE mode and requires live confirmation before DEV-LIVE provider calls", () => {
const defaultArgs = parseSmokeArgs([]);
@@ -79,10 +80,9 @@ test("source/default workbench report cannot claim DEV-LIVE and documents the co
report.validationCommands.includes("node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-live.json"),
false
);
assert.equal(report.checks.find((check) => check.id === "code-agent-long-timeout-contract")?.status, "pass");
const traceDisclosure = report.checks.find((check) => check.id === "code-agent-trace-replay-disclosure");
assert.equal(traceDisclosure?.status, "pass");
assert.deepEqual(traceDisclosure?.evidence, ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "traceScrollPinnedToBottom", "internal scroll for full trace"]);
assert.equal(report.checks.find((check) => check.id === "react-strict-typescript-entry")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "vite-build-and-dist-contract")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "legacy-runtime-paths-removed")?.status, "pass");
});
test("Code Agent browser failure classifier emits Chinese timeout/provider/browser categories with traceId", () => {
@@ -180,32 +180,30 @@ test("smoke args include a DOM-only live read-only mode", () => {
assert.equal(args.urlExplicit, true);
});
test("source/default smoke covers #352 resource explorer removal contract", () => {
test("source/default smoke covers React shell-only and module boundary contracts", () => {
const report = runDevCloudWorkbenchStaticSmoke();
const check = report.checks.find((item) => item.id === "feedback-352-resource-explorer-removed");
assert.equal(check?.status, "pass");
assert.equal(check.evidence.includes("aside#resource-explorer absent"), true);
assert.equal(check.evidence.includes("#explorer-resize absent"), true);
assert.equal(report.checks.find((item) => item.id === "react-shell-only-index")?.status, "pass");
assert.equal(report.checks.find((item) => item.id === "react-module-boundaries")?.status, "pass");
assert.equal(report.checks.find((item) => item.id === "react-api-state-separation")?.status, "pass");
});
test("source/default smoke covers Device Pod right-sidebar contract", () => {
test("source/default smoke covers React workbench selectors and layout", () => {
const report = runDevCloudWorkbenchStaticSmoke();
const summary = report.checks.find((item) => item.id === "device-pod-summary-sidebar");
const events = report.checks.find((item) => item.id === "device-pod-event-stream");
const service = report.checks.find((item) => item.id === "device-pod-executor-authority");
assert.equal(summary?.status, "pass");
assert.equal(events?.status, "pass");
assert.equal(service?.status, "pass");
assert.equal(summary.evidence.includes("summary-tile"), true);
assert.equal(summary.evidence.includes("device-detail-dialog"), true);
assert.equal(events.evidence.includes("device-event-text"), true);
const selectors = report.checks.find((item) => item.id === "react-core-workbench-selectors");
const layout = report.checks.find((item) => item.id === "react-layout-contract");
assert.equal(selectors?.status, "pass");
assert.equal(layout?.status, "pass");
assert.equal(selectors.evidence.includes("#device-pod-sidebar"), true);
assert.equal(layout.evidence.includes("@media (max-width: 720px)"), true);
});
test("source/default smoke covers #288 gate single-table contract", () => {
test("source/default smoke removes legacy vanilla runtime paths", () => {
const report = runDevCloudWorkbenchStaticSmoke();
const check = report.checks.find((item) => item.id === "feedback-288-gate-single-table");
const check = report.checks.find((item) => item.id === "legacy-runtime-paths-removed");
assert.equal(check?.status, "pass");
assert.equal(report.checks.some((item) => item.id === "feedback-119-gate-retains-diagnostics"), false);
assert.equal(check.evidence.includes("removed:app.ts"), true);
assert.equal(check.evidence.includes("removed:styles.css"), true);
assert.equal(report.checks.some((item) => item.id === "feedback-288-gate-single-table"), false);
});
test("live workbench identity passes only when runtime commit or image tag matches current source", () => {
@@ -270,18 +268,18 @@ test("live workbench identity uses deploy desired runtime identity instead of gi
test("live web asset identity blocks stale deployed primary assets", () => {
const pass = classifyLiveWebAssetIdentity([
{ path: "index.html", status: "match" },
{ path: "styles.css", status: "match" },
{ path: "assets/index.css", status: "match" },
{ path: "app.js", status: "match" }
]);
assert.equal(pass.status, "pass");
const stale = classifyLiveWebAssetIdentity([
{ path: "index.html", status: "match" },
{ path: "styles.css", status: "mismatch" },
{ path: "assets/index.css", status: "mismatch" },
{ path: "app.js", status: "mismatch" }
]);
assert.equal(stale.status, "blocked");
assert.deepEqual(stale.mismatches, ["styles.css", "app.js"]);
assert.deepEqual(stale.mismatches, ["assets/index.css", "app.js"]);
assert.match(stale.summary, /Deployment drift/u);
});
@@ -498,13 +496,13 @@ test("Code Agent browser classifier blocks completed payloads without backend ev
test("repo-owned web checks keep cloud-web semantic checks out of old browser gates", () => {
const checkCommands = checkProfiles.check.map((task) => task.command.join(" "));
assert.match(rootPackage.scripts["web:check"], /cd web\/hwlab-cloud-web && bun run check/u);
assert.match(cloudWebPackage.scripts.check, /bun test && bun run scripts\/check\.ts/u);
assert.match(cloudWebPackage.scripts.check, /bun run scripts\/check\.ts && bun run scripts\/tsc-check\.ts && bun test/u);
assert.ok(checkProfiles.check.some((task) => task.cwd === "web/hwlab-cloud-web" && task.command.join(" ") === "bun run check"));
assert.doesNotMatch(checkCommands.join("\n"), /dev-cloud-workbench-layout-smoke|gate-summary\.mjs|export-web-gate-summary/u);
assert.match(cloudWebCheckSource, /assertTraceHelpersBound/u);
assert.match(cloudWebCheckSource, /Device Pod summary sidebar/u);
assert.match(cloudWebCheckSource, /device-detail-dialog/u);
assert.match(cloudWebCheckSource, /device-event-text/u);
assert.match(cloudWebCheckSource, /isCodeAgentTimeoutError/u);
assert.match(cloudWebCheckSource, /React module assets present/u);
assert.match(cloudWebTscCheckSource, /explicit any/u);
assert.match(cloudWebTscCheckSource, /--strict/u);
assert.match(cloudWebCheckSource, /legacy runtime path/u);
assert.match(cloudWebCheckSource, /dist bundle built by Vite/u);
assert.doesNotMatch(cloudWebCheckSource, /m3-readonly-contract|workbench-hardware-panel-contract|gate-summary\.mjs/u);
});
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { readdirSync, statSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -9,13 +10,7 @@ import { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const cloudWebAppSourceFiles = Object.freeze([
"web/hwlab-cloud-web/app.ts",
"web/hwlab-cloud-web/app-device-pod.ts",
"web/hwlab-cloud-web/app-conversation.ts",
"web/hwlab-cloud-web/app-trace.ts",
"web/hwlab-cloud-web/app-helpers.ts"
]);
const cloudWebSourceRoot = "web/hwlab-cloud-web/src";
function bunExecutable() {
return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun";
@@ -42,10 +37,22 @@ function inspectCloudWebDistFreshness(webRoot) {
}
async function readCloudWebAppSource(repoRoot) {
const parts = await Promise.all(cloudWebAppSourceFiles.map((file) => readFile(path.join(repoRoot, file), "utf8")));
const files = collectCloudWebSourceFiles(path.join(repoRoot, cloudWebSourceRoot));
const parts = await Promise.all(files.map((file) => readFile(file, "utf8")));
return parts.join("\n");
}
function collectCloudWebSourceFiles(dir) {
const files = [];
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) files.push(...collectCloudWebSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full);
}
return files.sort();
}
const defaultArtifactReport = tempReportPath("dev-artifacts.json");
const defaultArtifactCatalog = "deploy/artifact-catalog.dev.json";
const defaultRuntimeReport = tempReportPath("dev-cloud-workbench-live.json");
@@ -1005,7 +1012,7 @@ async function inspectM3IoSourceReadiness(repoRoot) {
}
const present = matches.length > 0;
const sourceReady = contractFiles.length > 0 &&
matches.some((file) => cloudWebAppSourceFiles.includes(file)) &&
matches.some((file) => file.startsWith(`${cloudWebSourceRoot}/`)) &&
matches.some((file) => file === "internal/cloud/m3-io-control.ts" || file === "scripts/src/m3-io-control-e2e.mjs");
return {
status: present ? (sourceReady ? "pass" : "blocked") : "absent",
+192 -206
View File
@@ -19,7 +19,13 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../
const domOnlyReportPath = tempReportPath("dev-cloud-workbench-dom-only.json");
const liveReportPath = tempReportPath("dev-cloud-workbench-live.json");
const webRoot = path.join(repoRoot, "web/hwlab-cloud-web");
const runtime = readCloudWebRuntime();
const runtime = Object.freeze({
endpoints: Object.freeze({
frontend: "http://74.48.78.17:16666",
api: "http://74.48.78.17:16667",
edge: "http://74.48.78.17:16667"
})
});
const defaultLiveUrl = "http://74.48.78.17:16666/";
const helpOwner = "codex_1779444232735_1";
const legacyFailureWindowMs = 4500;
@@ -253,35 +259,35 @@ const markdownRendererPackages = Object.freeze([
]);
const cloudWebAppSourceFiles = Object.freeze([
"app.ts",
"app-device-pod.ts",
"app-conversation.ts",
"app-trace.ts",
"app-helpers.ts"
]);
const cloudWebModuleSourceFiles = Object.freeze([
"src/services/auth.ts",
"src/logic/code-agent-facts.ts",
"src/logic/code-agent-status.ts",
"src/logic/message-markdown.ts",
"src/logic/live-status.ts",
"src/logic/runtime.ts"
"src/main.tsx",
"src/App.tsx"
]);
const requiredWebAssets = Object.freeze([
"index.html",
"styles.css",
"src/styles/workbench.css",
...cloudWebAppSourceFiles,
...cloudWebModuleSourceFiles,
"src/components/layout/ActivityRail.tsx",
"src/components/auth/LoginShell.tsx",
"src/components/sessions/SessionSidebar.tsx",
"src/components/conversation/ConversationPanel.tsx",
"src/components/command-bar/CommandBar.tsx",
"src/components/device-pod/DevicePodSidebar.tsx",
"src/components/settings/SettingsView.tsx",
"src/components/skills/SkillsView.tsx",
"src/hooks/useAuth.ts",
"src/services/api/client.ts",
"src/services/markdown/render.ts",
"src/state/workbench.ts",
"src/state/conversation.ts",
"src/types/domain.ts",
"favicon.svg",
"favicon.ico",
"help.md",
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE"
"vite.config.ts"
]);
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.js"]);
const liveIdentityWebAssets = Object.freeze(["index.html", "assets/index.css", "app.js"]);
const playwrightDependencyCommand = "npm install";
const playwrightDependencySummary =
@@ -429,175 +435,50 @@ function runStaticSmoke() {
const checks = [];
const blockers = [];
const files = readStaticFiles();
const source = Object.values(files).join("\n");
const buildScript = readText("web/hwlab-cloud-web/scripts/build.ts");
const distContractScript = readText("web/hwlab-cloud-web/scripts/dist-contract.ts");
const source = `${files.html}\n${files.app}\n${files.styles}\n${files.packageJson}\n${files.tsconfig}\n${files.checkScript}\n${files.tscCheck}`;
addCheck(checks, blockers, "static-web-assets", assetsExist(), "Cloud Web source assets are present.", {
evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`)
});
addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Authenticated route is the VS Code-style Cloud Workbench.", {
addCheck(checks, blockers, "react-shell-only-index", reactShellOnlyIndex(files), "Cloud Web index.html is a shell-only React mount entry.", {
blocker: "runtime_blocker",
evidence: ["workspace view exists behind login", "Gate view is hidden by default after auth", ...workbenchMarkers]
evidence: ["<div id=\"root\"></div>", "/src/main.tsx", "no business DOM in index.html"]
});
addCheck(checks, blockers, "secondary-gate-status-help", secondaryViewsAreNotDefault(files), "Gate/status/diagnostics/help surfaces are not accepted as the default homepage.", {
addCheck(checks, blockers, "react-strict-typescript-entry", reactStrictTypeScriptEntry(files), "React runtime is mounted from TSX under strict TypeScript without explicit any in source.", {
blocker: "runtime_blocker",
evidence: ["routeFromLocation final fallback is workspace", "non-workspace data-view sections must be hidden"]
evidence: ["createRoot", "React.StrictMode", "strict=true", "noImplicitAny=true", "tsc-check explicit any scan"]
});
addCheck(checks, blockers, "chinese-workbench-labels", labelsPresent(source, chineseWorkbenchLabels), "Current Chinese workbench labels are present.", {
addCheck(checks, blockers, "react-module-boundaries", reactModuleBoundaries(files), "React modules are split by layout/auth/sessions/conversation/command-bar/device-pod/settings/shared/hooks/services/state/types/styles.", {
blocker: "contract_blocker",
evidence: chineseWorkbenchLabels
evidence: requiredWebAssets.filter((file) => file.startsWith("src/"))
});
addCheck(checks, blockers, "feedback-117-default-no-backstage", defaultHomepageHidesBackstage(files), "Default workbench hides Gate, diagnostics, acceptance, BLOCKED, and M0-M5 backstage information.", {
addCheck(checks, blockers, "legacy-runtime-paths-removed", legacyRuntimePathsRemoved(), "Legacy vanilla runtime files are removed and cannot remain as a second frontend path.", {
blocker: "runtime_blocker",
evidence: defaultHomepageForbiddenTerms
evidence: legacyRuntimeFiles.map((file) => `removed:${file}`)
});
addCheck(checks, blockers, "feedback-118-complete-nav", hasCompletePrimaryNavigation(files.html), "Primary navigation uses complete Chinese labels and removes the left-rail trusted-records shortcut.", {
addCheck(checks, blockers, "react-core-workbench-selectors", reactCoreWorkbenchSelectors(files), "React runtime renders the authenticated shell, command bar, session sidebar, conversation, Device Pod sidebar, settings, skills, gate, and help selectors.", {
blocker: "contract_blocker",
evidence: ["工作台", "内部复核", "使用说明"]
evidence: ["data-app-shell", "#command-input", "#conversation-list", "#device-pod-sidebar", "#logout-button"]
});
addCheck(checks, blockers, "feedback-288-gate-single-table", gateSingleTableContract(files), "Internal review /gate is a single read-only table backed by the live diagnostics aggregation route.", {
addCheck(checks, blockers, "react-api-state-separation", reactApiStateSeparation(files), "API, workspace/session state, conversation mapping, markdown rendering, and UI components are separated.", {
blocker: "contract_blocker",
evidence: [...gateReviewTableColumns, "/v1/diagnostics/gate", "LIVE-BACKEND", ...gateReviewStatusLabels]
evidence: ["services/api/client.ts", "state/workbench.ts", "state/conversation.ts", "services/markdown/render.ts"]
});
addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, files.artifactPublisher, buildScript, distContractScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", {
addCheck(checks, blockers, "vite-build-and-dist-contract", viteBuildAndDistContract(files), "Vite build, route aliases, dist freshness, and root app.js output are the only deployable Cloud Web asset path.", {
blocker: "runtime_blocker",
evidence: gateRouteAliases
evidence: ["vite.config.ts", "dist-contract.ts", "app.js", "assets/index.css", ...gateRouteAliases, ...helpRouteAliases]
});
addCheck(checks, blockers, "direct-help-route-alias", helpRouteAliasesAreServed(files.app, files.artifactPublisher, buildScript, distContractScript), "/help is a direct user-facing help alias and does not become the default route.", {
addCheck(checks, blockers, "react-layout-contract", reactLayoutContract(files), "React CSS owns desktop, narrow, and 390px mobile layout without page-level scrolling.", {
blocker: "runtime_blocker",
evidence: [...helpRouteAliases, "routeFromLocation final fallback is workspace"]
});
addCheck(checks, blockers, "device-pod-summary-sidebar", devicePodSummarySidebarContract(files), "Right sidebar is a Device Pod summary board: no internal expand/collapse, summary tiles only, and click/keyboard opens the detail dialog.", {
blocker: "contract_blocker",
evidence: ["device-pod-sidebar", "device-pod-summary", "summary-tile", "device-detail-dialog", "no <details> in right sidebar"]
});
addCheck(checks, blockers, "device-pod-event-stream", devicePodEventStreamContract(files), "Device Pod events render as a simple plain-text stream whose updates do not steal the user's scroll position.", {
blocker: "runtime_blocker",
evidence: ["device-event-scroll", "device-event-text", "setDeviceEventFollow", "deviceEventUserActive", "overscroll-behavior: contain"]
});
addCheck(checks, blockers, "device-pod-executor-authority", devicePodExecutorAuthorityContract(files), "The hwlab-device-pod executor and Cloud API compatibility payload expose the Device Pod REST surface without fake fallback.", {
blocker: "runtime_blocker",
evidence: ["hwlab-device-pod", "/v1/device-pods", "/status", "/events", "device-pod-executor-v1"]
});
addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", {
blocker: "contract_blocker",
evidence: ["html/body overflow hidden", "workbench-shell 100vh/100dvh overflow hidden", ".view overflow auto"]
});
addCheck(checks, blockers, "mobile-workbench-layout-contract", hasMobileWorkbenchLayoutContract(files), "390x844 mobile layout keeps the default workbench reachable without a resource explorer drawer.", {
blocker: "runtime_blocker",
evidence: [
"mobile rail spans full height",
"center workspace and right sidebar share the content column",
"resource explorer drawer selectors are absent"
]
});
addCheck(checks, blockers, "feedback-352-resource-explorer-removed", hasResourceExplorerRemovalContract(files), "Default workbench removes the left resource explorer, quick actions, resource tree, capability cards, and explorer resize handle.", {
blocker: "runtime_blocker",
evidence: [
"aside#resource-explorer absent",
"#explorer-resize absent",
"resource tree / quick actions / capability cards absent",
"right side keeps a 560-740px Device Pod summary boundary through --right-width"
]
});
addCheck(checks, blockers, "api-live-status-attribution", hasApiLiveStatusAttribution(files), "Default workbench status maps raw API health into pass, error, unverified, or read-only with concrete service/API attribution.", {
blocker: "observability_blocker",
evidence: ["API 正常", "API 错误", "等待验证", "只读模式", "hwlab-cloud-api /health/live", "/v1/agent/chat", "/v1/device-pods", "raw degraded kept as internalRawStatuses"]
});
addCheck(checks, blockers, "workbench-live-surface-timeout-contract", hasWorkbenchLiveSurfaceTimeoutContract(files), "Workbench live status uses a dedicated live-surface timeout instead of the 4500ms light API budget.", {
blocker: "runtime_blocker",
evidence: ["DEFAULT_LIVE_SURFACE_TIMEOUT_MS=12000", "liveSurfaceFetch", "liveSurfaceRpc", "/v1/device-pods", "system.health"]
});
addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", {
blocker: "runtime_blocker",
evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"]
});
addCheck(checks, blockers, "code-agent-long-timeout-contract", hasCodeAgentLongTimeoutContract(files), "Code Agent chat uses a dedicated long timeout and does not reuse the old 4500ms API timeout.", {
blocker: "runtime_blocker",
evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"]
});
addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels auto-replay full trace, avoid compressed-window UI, and preserve open/scroll state while live trace updates.", {
blocker: "observability_blocker",
evidence: ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "traceScrollPinnedToBottom", "internal scroll for full trace"]
});
addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider/stdio blockers without exposing credential internals and only long-lived Codex stdio replies can become full Code Agent completion.", {
blocker: "observability_blocker",
evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"]
});
addCheck(checks, blockers, "code-agent-status-summary", hasCodeAgentStatusSummaryContract(files), "Code Agent area has a compact collapsible status summary for Codex stdio, blockers, trace, and current deployed revision.", {
blocker: "observability_blocker",
evidence: [
"code-agent-summary",
"fallback-text-chat-only",
"stateless-one-shot",
"read-only-session-tools",
"当前部署 revision"
]
});
addCheck(checks, blockers, "default-workspace-sanitized", defaultWorkspaceIsSanitized(files), "Default user workspace hides raw blocker codes, internal route markers, and credential reference material.", {
blocker: "observability_blocker",
evidence: defaultHomepageForbiddenTerms
});
addCheck(checks, blockers, "route-control-stable-dimensions", hasStableRouteControls(files), "Route controls and status tags have stable dimensions, readable wrapping, and usable labels on small screens.", {
blocker: "runtime_blocker",
evidence: ["rail-button min-width/min-height", "state-tag wrapping", "summary-tile wrapping", "mobile trace column"]
});
addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed full Code Agent replies expose Codex stdio runnerTrace and reject echo/mock/stub, OpenAI fallback, and local shortcut completions.", {
blocker: "observability_blocker",
evidence: requiredCodeAgentEvidenceTerms
});
addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and blocked provider states as distinct conversation replies with bounded trace.", {
blocker: "observability_blocker",
evidence: [
"DEV-LIVE 回复",
"SOURCE 回复",
"等待超时/API 错误/后端失败",
"服务受阻 or legacy BLOCKED 凭证缺口",
"message-trace/details/events"
]
});
addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(`${files.html}\n${files.app}\n${files.liveStatus}`), "Workbench frontend source does not expose hardware write APIs or live mutation commands.", {
blocker: "safety_blocker",
evidence: forbiddenWritePatterns.map(String)
});
addCheck(checks, blockers, "source-not-dev-live", sourceEvidenceNotDevLive(source), "SOURCE/LOCAL/DRY-RUN/fixture evidence is not promoted to DEV-LIVE.", {
blocker: "observability_blocker",
evidence: ["SOURCE remains source-level", "Device Pod compatibility fallback is blocked", "no DEV-LIVE claim from fixture data"]
});
const help = inspectHelpContract(files);
addCheck(checks, blockers, "help-md-contract", help.status, help.summary, {
blocker: help.blocker,
owner: helpOwner,
observations: help.observations
evidence: ["html/body overflow hidden", "@media (max-width: 720px)", "390px mobile grid", "event stream overscroll contained"]
});
return baseReport({
@@ -607,10 +488,7 @@ function runStaticSmoke() {
blockers,
evidenceLevel: "SOURCE",
devLive: false,
help: {
status: help.status,
...help.observations
},
help: inspectReactHelpContract(files),
safety: staticSafety()
});
}
@@ -889,7 +767,7 @@ async function runLiveDomOnlySmoke(args) {
export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
const useLiveUrl = args.live === true || args.urlExplicit === true;
const layoutSourceMode = useLiveUrl ? "dev-live" : args.build === true ? "local-build" : "source-static";
const layoutSourceMode = useLiveUrl ? "dev-live" : "local-build";
const artifactRoot = path.resolve(
repoRoot,
args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp())
@@ -978,8 +856,11 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
try {
browser = await launchChromium(chromium);
const checks = [];
const screenshots = [];
for (const viewport of layoutViewports) {
checks.push(await inspectMinimalWorkbenchLayout(browser, url, viewport));
const check = await inspectMinimalWorkbenchLayout(browser, url, viewport, { artifactRoot });
checks.push(check);
screenshots.push(...(check.artifacts?.screenshots ?? []));
}
const blockers = checks
.filter((check) => check.status !== "pass")
@@ -1030,7 +911,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
devLive: false,
summary: useLiveUrl
? "Live browser layout smoke verifies that the workbench opens and core controls are visible."
: "Static local browser layout smoke verifies that the workbench opens and core controls are visible; detailed visual acceptance remains manual/live.",
: "Vite local-build browser layout smoke verifies that the workbench opens and core controls are visible; detailed visual acceptance remains manual/live.",
refs: ["pikasTech/HWLAB#273"],
viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })),
checks,
@@ -1039,7 +920,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
skipped,
artifacts: {
screenshotDir: artifactRoot,
screenshots: [],
screenshots,
reportPath: args.reportPath ?? null
},
safety: layoutSafety()
@@ -1259,7 +1140,7 @@ function layoutReportLifecycle(useLiveUrl, status) {
deprecatedEndpoint: null,
summary: useLiveUrl
? `DEV live layout smoke ${status}; this is UI layout/clickability evidence only and not hardware acceptance.`
: `Repository source/static layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.`
: `Repository Vite local-build layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.`
};
}
@@ -1294,11 +1175,11 @@ function layoutLocalSmoke(status) {
"npm run web:layout:build"
],
evidence: [
"web:check runs the SOURCE/static layout smoke as a repo-owned frontend gate.",
"web:layout runs the source/static Playwright geometry, hit-target, overflow, and outer-scroll checks without public DEV dependency.",
"web:layout:build rebuilds local dist and checks the built Cloud Web assets."
"web:check runs strict React TypeScript, module-boundary, dist freshness, and unit checks as the repo-owned frontend gate.",
"web:layout runs the Vite local-build Playwright geometry and core-control visibility checks without public DEV dependency.",
"web:layout:build is the same Vite local-build browser smoke entry."
],
summary: "SOURCE/static and local-build layout checks classify UI overlap, covered hit targets, overflow, and outer-scroll regressions as layout blockers."
summary: "Vite local-build layout checks classify hidden core controls and browser startup regressions as layout blockers."
};
}
@@ -1511,14 +1392,14 @@ function addCheck(checks, blockers, id, result, summary, options = {}) {
function readStaticFiles() {
return {
html: readText("web/hwlab-cloud-web/index.html"),
styles: readText("web/hwlab-cloud-web/styles.css"),
auth: readText("web/hwlab-cloud-web/src/services/auth.ts"),
styles: readText("web/hwlab-cloud-web/src/styles/workbench.css"),
app: readCloudWebAppSource(),
codeAgentFacts: readText("web/hwlab-cloud-web/src/logic/code-agent-facts.ts"),
codeAgentStatus: readText("web/hwlab-cloud-web/src/logic/code-agent-status.ts"),
messageMarkdown: readText("web/hwlab-cloud-web/src/logic/message-markdown.ts"),
liveStatus: readText("web/hwlab-cloud-web/src/logic/live-status.ts"),
runtime: readText("web/hwlab-cloud-web/src/logic/runtime.ts"),
packageJson: readText("web/hwlab-cloud-web/package.json"),
tsconfig: readText("web/hwlab-cloud-web/tsconfig.json"),
checkScript: readText("web/hwlab-cloud-web/scripts/check.ts"),
tscCheck: readText("web/hwlab-cloud-web/scripts/tsc-check.ts"),
distContract: readText("web/hwlab-cloud-web/scripts/dist-contract.ts"),
viteConfig: readText("web/hwlab-cloud-web/vite.config.ts"),
help: readText("web/hwlab-cloud-web/help.md"),
artifactPublisher: readText("scripts/artifact-publish.mjs"),
cloudApiServer: readText("internal/cloud/server.ts"),
@@ -1531,19 +1412,123 @@ function readStaticFiles() {
};
}
const legacyRuntimeFiles = Object.freeze([
"app.ts",
"app-device-pod.ts",
"app-conversation.ts",
"app-helpers.ts",
"app-skills.ts",
"app-session-tabs.ts",
"app-trace.ts",
"auth.ts",
"code-agent-facts.ts",
"code-agent-status.ts",
"composer-policy.ts",
"live-status.ts",
"message-markdown.ts",
"runtime.ts",
"styles.css",
"web-types.d.ts"
]);
function reactShellOnlyIndex({ html }) {
return /<div id="root"><\/div>/u.test(html) &&
/src="\/src\/main\.tsx"/u.test(html) &&
!/workbench-shell|device-pod-sidebar|command-input|conversation-list|session-tabs/u.test(html);
}
function reactStrictTypeScriptEntry({ app, tsconfig, tscCheck }) {
return /createRoot/u.test(app) &&
/React\.StrictMode/u.test(app) &&
/"strict"\s*:\s*true/u.test(tsconfig) &&
/"noImplicitAny"\s*:\s*true/u.test(tsconfig) &&
/"noUncheckedIndexedAccess"\s*:\s*true/u.test(tsconfig) &&
/explicit any/u.test(tscCheck) &&
!/(^|[^A-Za-z0-9_])(?:as\s+any|:\s*any\b|Array\s*<\s*any\s*>|<\s*any\s*>)/u.test(app);
}
function reactModuleBoundaries() {
return requiredWebAssets
.filter((file) => file.startsWith("src/"))
.every((file) => fs.existsSync(path.join(webRoot, file)));
}
function legacyRuntimePathsRemoved() {
return legacyRuntimeFiles.every((file) => !fs.existsSync(path.join(webRoot, file)));
}
function reactCoreWorkbenchSelectors({ app }) {
return [
"data-app-shell",
"id=\"command-input\"",
"id=\"conversation-list\"",
"id=\"device-pod-sidebar\"",
"id=\"device-event-scroll\"",
"id=\"logout-button\"",
"data-view=\"gate\"",
"data-view=\"help\"",
"SkillsView",
"SettingsView"
].every((term) => app.includes(term));
}
function reactApiStateSeparation({ app }) {
return [
"services/api/client.ts",
"state/workbench.ts",
"state/conversation.ts",
"services/markdown/render.ts"
].every((file) => fs.existsSync(path.join(webRoot, "src", file))) &&
/fetchJson/u.test(app) &&
/useWorkbenchStore/u.test(app) &&
/renderMessageMarkdown/u.test(app) &&
/selectConversation/u.test(app);
}
function viteBuildAndDistContract({ packageJson, distContract, viteConfig }) {
return /"build"\s*:\s*"bun run scripts\/build\.ts"/u.test(packageJson) &&
/vite.*build/u.test(distContract) &&
/entryFileNames:\s*"app\.js"/u.test(viteConfig) &&
/assets\/index\.css/u.test(distContract) &&
gateRouteAliases.every((route) => distContract.includes(`${route.replace(/^\//u, "")}/index.html`)) &&
helpRouteAliases.every((route) => distContract.includes(`${route.replace(/^\//u, "")}/index.html`));
}
function reactLayoutContract({ styles }) {
return /html, body, #root \{[^}]*overflow:\s*hidden;/su.test(styles) &&
/\.workbench-shell \{[^}]*overflow:\s*hidden;/su.test(styles) &&
/@media \(max-width: 720px\)/u.test(styles) &&
/grid-template-columns:\s*56px\s+minmax\(0,\s*1fr\)/u.test(styles) &&
/\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u.test(styles);
}
function inspectReactHelpContract({ app, help }) {
return {
status: /data-view="help"/u.test(app) && /fetchText\("\/help\.md"/u.test(app) && help.trim().length > 0 ? "pass" : "blocked",
route: "/help",
source: "web/hwlab-cloud-web/help.md"
};
}
function readText(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function readCloudWebAppSource() {
return cloudWebAppSourceFiles.map((file) => readText(`web/hwlab-cloud-web/${file}`)).join("\n");
return collectCloudWebSourceFiles(path.join(webRoot, "src"))
.map((file) => fs.readFileSync(file, "utf8"))
.join("\n");
}
function readCloudWebRuntime() {
const source = fs.readFileSync(path.join(webRoot, "src/logic/runtime.ts"), "utf8");
const match = source.match(/export const runtime\s*=\s*([\s\S]*?);\s*$/u);
if (!match) throw new Error("Unable to parse web/hwlab-cloud-web/src/logic/runtime.ts");
return Function(`return (${match[1]});`)();
function collectCloudWebSourceFiles(dir) {
const files = [];
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) files.push(...collectCloudWebSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full);
}
return files.sort();
}
function resolveBunCommand() {
@@ -1775,21 +1760,13 @@ function compareLiveAsset(assetPath, live, sourceText) {
}
async function buildCloudWebAppBundleText() {
const tmpDir = fs.mkdtempSync(path.join("/tmp", "hwlab-cloud-web-live-asset-"));
const entryPath = path.join(webRoot, ".app-entry.generated.ts");
try {
fs.writeFileSync(entryPath, `${readCloudWebAppSource()}\n`);
execFileSync(resolveBunCommand(), ["build", entryPath, "--target", "browser", "--format", "esm", "--outfile", path.join(tmpDir, "app.js")], {
cwd: repoRoot,
execFileSync(resolveBunCommand(), ["run", "build"], {
cwd: webRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30000
timeout: 60000
});
return fs.readFileSync(path.join(tmpDir, "app.js"), "utf8");
} finally {
fs.rmSync(entryPath, { force: true });
fs.rmSync(tmpDir, { recursive: true, force: true });
}
return fs.readFileSync(path.join(webRoot, "dist/app.js"), "utf8");
}
function hashText(value) {
@@ -2563,6 +2540,7 @@ function findHelpMarkdownFiles() {
}
function findMarkdownRenderers() {
const renderService = readText("web/hwlab-cloud-web/src/services/markdown/render.ts");
const packages = ["package.json", "web/hwlab-cloud-web/package.json"]
.filter((file) => fs.existsSync(path.join(repoRoot, file)))
.flatMap((file) => {
@@ -2571,11 +2549,8 @@ function findMarkdownRenderers() {
});
const source = `${readCloudWebAppSource()}\n${readText("web/hwlab-cloud-web/index.html")}`;
const imported = markdownRendererPackages.filter((name) => new RegExp(`from\\s+["']${escapeRegExp(name)}["']|import\\(["']${escapeRegExp(name)}["']\\)`, "u").test(source));
const vendoredMarked = /from\s+["']\.\/third_party\/marked\/marked\.esm\.js["']/u.test(source) &&
fs.existsSync(path.join(repoRoot, "web/hwlab-cloud-web/third_party/marked/marked.esm.js")) &&
fs.existsSync(path.join(repoRoot, "web/hwlab-cloud-web/third_party/marked/LICENSE"));
const renderers = [...packages, ...imported].filter((name) => markdownRendererPackages.includes(name));
if (vendoredMarked) renderers.push("marked:vendored");
if (/export function renderMessageMarkdown/u.test(renderService) && /escapeHtml/u.test(renderService)) renderers.push("react-render-service");
return [...new Set(renderers)].sort();
}
@@ -3527,7 +3502,16 @@ async function inspectAuthFixtureViewport(browser, url, viewport) {
}
async function loginWithDefaultCredentials(page) {
const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false);
await page.waitForFunction(
() => document.querySelector("#command-input") !== null || document.querySelector("#login-username") !== null,
null,
{ timeout: 12000 }
).catch(() => {});
const loginVisible = await page.evaluate(() => {
const login = document.querySelector("#login-shell");
const username = document.querySelector("#login-username");
return Boolean(login && username && login.hidden === false);
});
if (!loginVisible) return;
await page.locator("#login-username").fill(defaultAuthCredentials.username);
await page.locator("#login-password").fill(defaultAuthCredentials.password);
@@ -6061,7 +6045,7 @@ async function inspectWorkbenchGateLayout(browser, url, viewport, options = {})
}
}
async function inspectMinimalWorkbenchLayout(browser, url, viewport) {
async function inspectMinimalWorkbenchLayout(browser, url, viewport, options = {}) {
const page = await browser.newPage({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
@@ -6093,12 +6077,14 @@ async function inspectMinimalWorkbenchLayout(browser, url, viewport) {
observations.conversationVisible &&
observations.rightSidebarVisible &&
observations.rightSidebarToggleVisible;
const screenshots = [await captureElementScreenshot(page, "[data-app-shell]", options.artifactRoot, viewport, `minimal-${viewport.id}-shell`)].filter(Boolean);
return {
id: `layout-minimal-${viewport.id}`,
status: pass ? "pass" : "blocked",
viewport: { id: viewport.id, width: viewport.width, height: viewport.height },
summary: `${viewport.id} workbench opens after auth and exposes core conversation plus Device Pod controls.`,
observations,
artifacts: { screenshots },
failures: pass ? [] : [
{
selector: "[data-app-shell], #command-input, #conversation-list, #device-pod-sidebar, #right-sidebar-toggle",
+1 -1
View File
@@ -29,7 +29,7 @@ function bunExecutable() {
function classifyRuntimeDurableReadiness(payload = {}, options = {}) {
const script = [
"import { classifyRuntimeDurableReadiness } from './web/hwlab-cloud-web/live-status.ts';",
"import { classifyRuntimeDurableReadiness } from './tools/src/runtime-durable-readiness.ts';",
"const input = JSON.parse(await Bun.stdin.text());",
"const output = classifyRuntimeDurableReadiness(input.payload, input.options);",
"process.stdout.write(JSON.stringify(output));"
+20 -1
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { readdirSync, statSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -228,7 +229,7 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } =
readRepoText(root, "internal/cloud/server.ts"),
readRepoText(root, "internal/cloud/json-rpc.ts"),
readRepoText(root, "internal/cloud/m3-io-control.ts"),
Promise.all(["web/hwlab-cloud-web/app.ts","web/hwlab-cloud-web/app-device-pod.ts","web/hwlab-cloud-web/app-conversation.ts","web/hwlab-cloud-web/app-trace.ts","web/hwlab-cloud-web/app-helpers.ts"].map((file) => readRepoText(root, file))).then((parts) => parts.join("\n")),
readCloudWebReactSource(root),
readRepoText(root, "web/hwlab-cloud-web/index.html"),
readRepoText(root, "scripts/artifact-publish.mjs"),
readRepoText(root, "internal/dev-entrypoint/cloud-web-routes.mjs")
@@ -325,6 +326,24 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } =
];
}
async function readCloudWebReactSource(root) {
const sourceRoot = path.join(root, "web/hwlab-cloud-web/src");
const files = listCloudWebSourceFiles(sourceRoot);
const parts = await Promise.all(files.map((file) => readFile(file, "utf8")));
return parts.join("\n");
}
function listCloudWebSourceFiles(dir) {
const files = [];
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) files.push(...listCloudWebSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full);
}
return files.sort();
}
export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", artifactPublisherSource = "", cloudWebRouteSource = "" }) {
const source = `${appSource}\n${htmlSource}`;
const fetchTargets = literalFetchTargets(appSource);
+1 -1
View File
@@ -1207,7 +1207,7 @@ test("hwlab-cli client agent trace can render with the Web trace row path", asyn
assert.equal(result.exitCode, 0);
assert.equal(result.payload.body.render, "web");
assert.equal(result.payload.body.renderer, "web/hwlab-cloud-web/app-trace:traceDisplayRows");
assert.equal(result.payload.body.renderer, "tools/src/hwlab-cli/trace-renderer:traceDisplayRows");
assert.equal(result.payload.body.sourceEventCount, 3);
assert.ok(result.payload.body.renderedRowCount >= 1);
assert.ok(result.payload.body.rows.some((row: any) => //u.test(row.header)));
+3 -3
View File
@@ -1,8 +1,8 @@
import { createHash } from "node:crypto";
import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { computeCodeAgentComposerState, isCodeAgentSessionUnusableStatus } from "../../web/hwlab-cloud-web/composer-policy.ts";
import { traceDisplayRows, traceNoiseEventCount } from "../../web/hwlab-cloud-web/app-trace.ts";
import { computeCodeAgentComposerState, isCodeAgentSessionUnusableStatus } from "./hwlab-cli/composer-policy.ts";
import { traceDisplayRows, traceNoiseEventCount } from "./hwlab-cli/trace-renderer.ts";
import { resolveRuntimeEndpoint, runtimeEndpointVisibility, sameRuntimeEndpointScope } from "./runtime-endpoint-resolver.ts";
const VERSION = "0.2.0-client";
@@ -2301,7 +2301,7 @@ function webTraceRenderBody(traceObject: any, parsed: ParsedArgs) {
traceId: traceObject?.traceId ?? null,
status: traceObject?.status ?? null,
render: "web",
renderer: "web/hwlab-cloud-web/app-trace:traceDisplayRows",
renderer: "tools/src/hwlab-cli/trace-renderer:traceDisplayRows",
sourceEventCount: traceObject?.eventCount ?? events.length,
renderedRowCount: rows.length,
returnedRowCount: rowTail.length,
+229
View File
@@ -0,0 +1,229 @@
export interface TraceEventRow {
rowId: string;
seq: number | null;
tone: "ok" | "blocked" | "warn" | "source";
header: string;
body: string | null;
terminal?: boolean;
bodyFormat?: "markdown";
}
type TraceEvent = Record<string, unknown>;
export function traceDisplayRows(trace: Record<string, unknown> = {}, events: TraceEvent[] = []): TraceEventRow[] {
const rows: TraceEventRow[] = [];
let requestRendered = false;
let setupRendered = false;
let completionRendered = false;
for (const event of events) {
if (!event || isNoisyTraceEvent(event)) continue;
if (isRequestTraceEvent(event)) {
if (!requestRendered) {
rows.push(traceRequestSummaryRow(event));
requestRendered = true;
}
continue;
}
if (isSetupTraceEvent(event)) {
if (!setupRendered) {
rows.push(traceSetupSummaryRow(event));
setupRendered = true;
}
continue;
}
if (isTerminalAssistantTraceEvent(event)) {
rows.push(traceAssistantSummaryRow(trace, event));
completionRendered = true;
continue;
}
if (isCompletionTraceEvent(event)) {
if (!completionRendered) {
rows.push(traceCompletionSummaryRow(trace, event));
completionRendered = true;
}
continue;
}
rows.push(traceDisplayRow(trace, event));
}
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event));
}
export function traceNoiseEventCount(events: TraceEvent[] = []): number {
return Array.isArray(events) ? events.filter((event) => isNoisyTraceEvent(event)).length : 0;
}
function traceRequestSummaryRow(event: TraceEvent): TraceEventRow {
const prompt = cleanTraceText(event.promptSummary ?? event.prompt ?? "");
return {
rowId: `trace-request:${event.seq ?? "accepted"}`,
seq: numberOrNull(event.seq),
tone: traceEventTone(event),
header: `${traceClock(event.createdAt)} 请求接受${prompt ? `,提示词:"${compactTraceOneLine(prompt, 120)}"` : ""}`,
body: null
};
}
function traceSetupSummaryRow(event: TraceEvent): TraceEventRow {
const label = String(event.label ?? "");
const parts = [
/session:reused|thread\/resume/iu.test(label) ? "" : "",
/prompt:sent|sent prompt/iu.test(label) ? "" : null,
/turn[:/]start|turn:started|turn\/start/iu.test(label) ? "" : null
].filter(Boolean).join("");
return {
rowId: `trace-setup:${event.seq ?? "session-turn"}`,
seq: numberOrNull(event.seq),
tone: "source",
header: `${traceClock(event.createdAt)} ${parts || "会话准备完成"}`,
body: null
};
}
function traceAssistantSummaryRow(trace: Record<string, unknown>, event: TraceEvent): TraceEventRow {
const text = cleanTraceText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? "");
return {
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`,
seq: numberOrNull(event.seq),
tone: "ok",
header: `${traceClock(event.createdAt)} 助手最后一条消息,轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))}`,
terminal: true,
body: text || null,
bodyFormat: "markdown"
};
}
function traceCompletionSummaryRow(trace: Record<string, unknown>, event: TraceEvent): TraceEventRow {
return {
rowId: `trace-completion:${event.seq ?? "turn"}`,
seq: numberOrNull(event.seq),
tone: traceEventTone(event),
header: `${traceClock(event.createdAt)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))}`,
body: null
};
}
function traceDisplayRow(trace: Record<string, unknown>, event: TraceEvent): TraceEventRow {
const label = readableTraceLabel(event);
return {
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`,
seq: numberOrNull(event.seq),
tone: traceEventTone(event),
header: `${traceClock(event.createdAt)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${traceStatusToken(event)} ${label}`.trim(),
body: traceDisplayBody(event)
};
}
function assistantStreamText(trace: Record<string, unknown>, event: TraceEvent): string {
const streams = Array.isArray(trace.assistantStreams) ? trace.assistantStreams.filter(isRecord) : [];
const itemId = nonEmptyString(event.itemId ?? event.messageId ?? event.id);
const matched = itemId ? streams.find((stream) => [stream.itemId, stream.messageId, stream.id].map(nonEmptyString).includes(itemId)) : null;
return nonEmptyString(matched?.text ?? streams.at(-1)?.text) ?? "";
}
function isRequestTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
return label === "agentrun:request:accepted" || label === "request:accepted-short-connection" || label === "request:accepted" || label === "request:received";
}
function isSetupTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
return /^session:|^stdio:|^prompt:|^thread:|^turn:started|^turn:start|turn\/start|codex turn|tool:codex-app-server/iu.test(label) ||
/^agentrun:backend:(codex-app-server-starting|initialize:completed|thread\/start|thread\/resume|thread\/started|turn\/start|turn\/started)/iu.test(label);
}
function isCompletionTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
return label.startsWith("turn:completed") || label.startsWith("result:completed") || label === "agentrun:backend:turn/completed" || label === "agentrun:result:completed";
}
function isTerminalAssistantTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
if (event.terminal === true || label === "assistant:completed") return true;
if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true;
return event.type === "assistant_message" && (event.status === "completed" || event.final === true);
}
function isNoisyTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event)) return false;
if (/token_count|outputDelta:chunk/iu.test(label)) return true;
if (/^agentrun:backend:(run-created|command-created|runner-job-created|thread\/status\/changed|thread\/tokenUsage\/updated|account\/rateLimits\/updated|remoteControl\/status\/changed|configWarning|codex-app-server-closed|session-updated|command-terminal|item\/agentMessage:(started|completed)|thread\/goal\/cleared)$/u.test(label)) return true;
return event.type === "event" && !event.outputSummary && !event.message && !event.errorCode;
}
function readableTraceLabel(event: TraceEvent): string {
const label = String(event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`);
if (label === "request:accepted-short-connection") return "submit short-connection";
if (label === "request:accepted") return "request accepted";
if (label.startsWith("turn:completed")) return "turn completed";
if (label.startsWith("result:completed")) return "result ready";
return label.replace(/^tool:/u, "").replace(/^assistant:/u, "assistant ").replace(/:completed$/u, " completed").replace(/:started$/u, " started");
}
function traceDisplayBody(event: TraceEvent): string | null {
const lines = [
event.command ? `command=${compactTraceOneLine(event.command, 900)}` : null,
event.stdoutSummary ? `stdout:\n${compactTraceOneLine(event.stdoutSummary, 1200)}` : null,
event.stderrSummary ? `stderr:\n${compactTraceOneLine(event.stderrSummary, 800)}` : null,
event.outputSummary,
event.promptSummary,
event.message,
event.chunk
].map(cleanTraceText).filter(Boolean);
return lines.length > 0 ? lines.join("\n").slice(0, 1400) : null;
}
function traceStatusToken(event: TraceEvent): string {
const status = String(event.status ?? "");
if (status === "completed" || status === "succeeded") return "ok";
if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event.errorCode) return "fail";
if (["started", "running", "accepted"].includes(status)) return "run";
return status || "event";
}
function traceEventTone(event: TraceEvent): TraceEventRow["tone"] {
const status = traceStatusToken(event);
if (status === "ok") return "ok";
if (status === "fail") return "blocked";
if (status === "run") return "warn";
return "source";
}
function traceClock(value: unknown): string {
const date = new Date(String(value ?? ""));
return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19);
}
function traceRelativeMs(trace: Record<string, unknown>, event: TraceEvent): number {
if (typeof event.elapsedMs === "number") return event.elapsedMs;
const start = Date.parse(String(trace.startedAt ?? trace.createdAt ?? ""));
const current = Date.parse(String(event.createdAt ?? ""));
return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start);
}
function formatTraceDuration(ms: number): string {
const seconds = Math.floor(Math.max(0, Number(ms) || 0) / 1000);
return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), seconds % 60].map((part) => String(part).padStart(2, "0")).join(":");
}
function cleanTraceText(value: unknown): string {
return String(value ?? "").replace(/\u0000/gu, "").replace(/\r\n|\r/gu, "\n").replace(/[ \t]{2,}/gu, " ").replace(/\n{3,}/gu, "\n\n").trim();
}
function compactTraceOneLine(value: unknown, limit = 220): string {
const text = cleanTraceText(value).replace(/\s+/gu, " ");
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`;
}
function numberOrNull(value: unknown): number | null {
return Number.isInteger(value) ? Number(value) : null;
}
function nonEmptyString(value: unknown): string | null {
const text = String(value ?? "").trim();
return text ? text : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object");
}
+216
View File
@@ -0,0 +1,216 @@
const readyQueryResults = Object.freeze(["durable_readiness_ready", "runtime_durable_ready", "query_ready", "readiness_ready", "ready", "ok", "pass", "passed"]);
const blockedQueryResults = Object.freeze<Record<string, string>>({
driver_missing: "runtime_durable_adapter_driver_missing",
ssl_negotiation_blocked: "runtime_durable_adapter_ssl_blocked",
auth_blocked: "runtime_durable_adapter_auth_blocked",
schema_blocked: "runtime_durable_adapter_schema_blocked",
migration_blocked: "runtime_durable_adapter_migration_blocked",
query_blocked: "runtime_durable_adapter_query_blocked",
not_ready: "runtime_durable_adapter_query_blocked"
});
const gateLayers = Object.freeze(["ssl", "auth", "schema", "migration", "durability"]);
export function classifyRuntimeDurableReadiness(payload: Record<string, unknown> = {}, options: { requirePostgresAdapter?: boolean } = {}) {
const runtime = record(payload.runtime);
const readiness = record(payload.readiness);
const durability = record(readiness.durability);
const db = record(payload.db);
const dbRuntimeReadiness = record(db.runtimeReadiness);
const gates = record(runtime.gates ?? durability.gates);
const gateBlocker = firstBlockedRuntimeGate(gates);
const queryResult = firstNonEmpty(record(runtime.connection).queryResult, durability.queryResult, dbRuntimeReadiness.queryResult);
const queryBlocker = runtimeDurableBlockerFromQueryResult(queryResult);
const readyQueryResult = isRuntimeDurableReadyQueryResult(queryResult);
const explicitDurabilityReady = durabilityReadySignal({ runtime, durability, dbRuntimeReadiness, gateBlocker, queryResult, readyQueryResult, queryBlocker });
const directBlocker = firstNonEmpty(
explicitDurabilityReady ? "" : runtime.blocker,
durability.blocker,
dbRuntimeReadiness.blocker,
explicitDurabilityReady ? "" : blockerCodeStartingWith(payload, "runtime_durable_adapter_")
);
const reasonCode = firstNonEmpty(directBlocker, gateBlocker?.blocker, queryBlocker);
const blockedLayer = firstNonEmpty(
record(runtime.durabilityContract).blockedLayer,
durability.blockedLayer,
dbRuntimeReadiness.blockedLayer,
gateBlocker?.layer,
runtimeDurableLayerFromBlocker(reasonCode),
runtimeDurableLayerFromQueryResult(queryResult)
);
const adapter = firstNonEmpty(runtime.adapter, durability.adapter, dbRuntimeReadiness.adapter, "unknown");
const runtimeDurable = runtime.durable === true || durability.durable === true || dbRuntimeReadiness.durable === true;
const runtimeExplicitlyNonDurable = runtime.durable === false || durability.durable === false || dbRuntimeReadiness.durable === false;
const runtimeReady = runtime.ready === true || durability.runtimeReady === true || dbRuntimeReadiness.ready === true;
const durabilityReady = durability.ready === true || dbRuntimeReadiness.ready === true || runtimeReady;
const liveRuntimeEvidence = runtime.liveRuntimeEvidence === true || durability.liveRuntimeEvidence === true || dbRuntimeReadiness.liveRuntimeEvidence === true;
const rawStatus = rawStatusFrom(payload);
const postgresRequired = options.requirePostgresAdapter === true;
const ready = (!postgresRequired || adapter === "postgres") && runtimeDurable && runtimeReady && durabilityReady && liveRuntimeEvidence && !reasonCode && !blockedLayer && (!queryResult || readyQueryResult);
if (ready) {
return {
status: "ready",
ready: true,
classification: "runtime_durable_ready",
reasonCode: "runtime_durable_ready",
reason: `runtime durable ready; adapter=${adapter}; queryResult=${queryResult || "not_observed"}`,
impact: "Device Pod readiness checks may rely on durable runtime evidence when persistence readiness is green.",
safeNextAction: "Continue with repo-owned DEV CD postflight; do not read or print Secret values.",
retryable: true,
readonly: false,
evidence: runtimeDurableEvidence({ adapter, runtimeDurable, runtimeReady, durabilityReady, liveRuntimeEvidence, blockedLayer: null, queryResult, readyQueryResult, reasonCode: null, gates, rawStatus, postgresRequired })
};
}
const nonReadyReasonCode = firstNonEmpty(
reasonCode,
postgresRequired && adapter !== "postgres" ? "runtime_durable_adapter_not_postgres" : "",
runtimeExplicitlyNonDurable ? "runtime_durable_false" : "",
runtimeDurable ? "" : "runtime_durable_adapter_missing",
runtimeReady ? "" : "runtime_durable_runtime_not_ready",
liveRuntimeEvidence ? "" : "runtime_durable_live_evidence_missing",
blockedRuntimeStatus(rawStatus) ? rawStatus : "",
"runtime_durable_adapter_not_ready"
);
const layer = firstNonEmpty(blockedLayer, runtimeDurableLayerFromBlocker(nonReadyReasonCode), runtimeExplicitlyNonDurable ? "runtime-durable" : "", "runtime-durable");
return {
status: "blocked",
ready: false,
classification: nonReadyReasonCode,
reasonCode: nonReadyReasonCode,
reason: runtimeDurableBlockedReason({ reasonCode: nonReadyReasonCode, adapter, layer, queryResult }),
impact: "Device Pod readiness remains source-level while durable runtime evidence is blocked.",
safeNextAction: runtimeDurableSafeNextAction(nonReadyReasonCode, layer),
retryable: true,
readonly: true,
evidence: runtimeDurableEvidence({ adapter, runtimeDurable, runtimeReady, durabilityReady, liveRuntimeEvidence, blockedLayer: layer, queryResult, readyQueryResult, reasonCode: nonReadyReasonCode, gates, rawStatus, postgresRequired })
};
}
function durabilityReadySignal(input: { runtime: Record<string, unknown>; durability: Record<string, unknown>; dbRuntimeReadiness: Record<string, unknown>; gateBlocker: { layer: string; blocker: string } | null; queryResult: string; readyQueryResult: boolean; queryBlocker: string }): boolean {
if (input.gateBlocker || input.queryBlocker) return false;
if (input.queryResult && !input.readyQueryResult) return false;
const ready = input.durability.ready === true || input.dbRuntimeReadiness.ready === true || record(input.runtime.durabilityContract).ready === true;
if (!ready) return false;
return !firstNonEmpty(input.durability.blocker, input.dbRuntimeReadiness.blocker, record(input.runtime.durabilityContract).blocker, input.durability.blockedLayer, input.dbRuntimeReadiness.blockedLayer, record(input.runtime.durabilityContract).blockedLayer);
}
function firstBlockedRuntimeGate(gates: Record<string, unknown>): { layer: string; blocker: string } | null {
for (const layer of gateLayers) {
const gate = record(gates[layer]);
const status = normalized(gate.status);
if (gate.ready === false || status === "blocked" || status === "failed") return { layer, blocker: firstNonEmpty(gate.blocker, blockerForLayer(layer)) };
}
return null;
}
function blockerForLayer(layer: string): string {
if (layer === "ssl") return "runtime_durable_adapter_ssl_blocked";
if (layer === "auth") return "runtime_durable_adapter_auth_blocked";
if (layer === "schema") return "runtime_durable_adapter_schema_blocked";
if (layer === "migration") return "runtime_durable_adapter_migration_blocked";
return "runtime_durable_adapter_query_blocked";
}
function runtimeDurableLayerFromBlocker(blocker: string): string {
const text = normalized(blocker);
if (text.includes("ssl")) return "ssl";
if (text.includes("auth")) return "auth";
if (text.includes("schema")) return "schema";
if (text.includes("migration")) return "migration";
if (text.includes("query")) return "durability_query";
if (text.includes("missing") || text.includes("unconfigured") || text.includes("driver")) return "adapter";
return "";
}
function runtimeDurableLayerFromQueryResult(queryResult: string): string {
const text = normalized(queryResult);
if (!text || isRuntimeDurableReadyQueryResult(text)) return "";
if (text.includes("ssl")) return "ssl";
if (text.includes("auth")) return "auth";
if (text.includes("schema")) return "schema";
if (text.includes("migration")) return "migration";
if (text.includes("query")) return "durability_query";
if (text.includes("driver")) return "adapter";
return "";
}
function runtimeDurableBlockerFromQueryResult(queryResult: string): string {
const text = normalized(queryResult);
if (!text || isRuntimeDurableReadyQueryResult(text)) return "";
return blockedQueryResults[text] ?? "";
}
function isRuntimeDurableReadyQueryResult(queryResult: string): boolean {
return readyQueryResults.includes(normalized(queryResult));
}
function runtimeDurableEvidence(input: { adapter: string; runtimeDurable: boolean; runtimeReady: boolean; durabilityReady: boolean; liveRuntimeEvidence: boolean; blockedLayer: string | null; queryResult: string; readyQueryResult: boolean; reasonCode: string | null; gates: Record<string, unknown>; rawStatus: unknown; postgresRequired: boolean }) {
return {
adapter: input.adapter,
durable: input.runtimeDurable,
runtimeReady: input.runtimeReady,
durabilityReady: input.durabilityReady,
liveRuntimeEvidence: input.liveRuntimeEvidence,
blocker: input.reasonCode,
blockedLayer: input.blockedLayer,
queryResult: input.queryResult || null,
readyQueryResult: input.readyQueryResult,
rawStatus: input.rawStatus || null,
postgresRequired: input.postgresRequired,
gates: Object.fromEntries(gateLayers.map((layer) => {
const gate = record(input.gates[layer]);
return [layer, { checked: gate.checked === true, ready: gate.ready === true, status: gate.status ?? "unknown", blocker: gate.blocker ?? null }];
})),
secretMaterialRead: false
};
}
function runtimeDurableBlockedReason(input: { reasonCode: string; adapter: string; layer: string; queryResult: string }): string {
if (input.reasonCode === "runtime_durable_false") return `runtime durable=false; adapter=${input.adapter}; queryResult=${input.queryResult || "not_observed"}`;
if (input.reasonCode === "runtime_durable_adapter_not_postgres") return `runtime durable adapter is not postgres; adapter=${input.adapter}; queryResult=${input.queryResult || "not_observed"}`;
return `runtime durable blocked at ${input.layer || "runtime-durable"}; blocker=${input.reasonCode}; queryResult=${input.queryResult || "unknown"}`;
}
function runtimeDurableSafeNextAction(reasonCode: string, layer: string): string {
const text = normalized(`${reasonCode} ${layer}`);
if (text.includes("auth")) return "Repair DEV DB auth/SecretRef metadata through repo-owned provisioning, then rerun postflight without printing Secret values.";
if (text.includes("schema") || text.includes("migration")) return "Run repo-owned DEV runtime provisioning and migration, then rerun postflight.";
if (text.includes("ssl")) return "Align DEV DB SSL mode with the repo-owned DEV contract, then rerun postflight.";
if (text.includes("query")) return "Inspect durable runtime query readiness, preserve evidence persistence, and rerun postflight.";
if (text.includes("postgres")) return "Run DEV with HWLAB_CLOUD_RUNTIME_ADAPTER=postgres and DB URLs injected only through SecretRefs.";
return "Restore durable runtime readiness through repo-owned DEV CD steps, then rerun postflight.";
}
function rawStatusFrom(payload: Record<string, unknown>): unknown {
return payload.status ?? record(payload.readiness).status ?? record(payload.runtime).status ?? record(record(payload.readiness).durability).status ?? "";
}
function blockerCodeStartingWith(payload: Record<string, unknown>, prefix: string): string {
const readiness = record(payload.readiness);
return [...array(payload.blockerCodes), ...array(readiness.blockerCodes)].map(String).find((code) => code.startsWith(prefix)) ?? "";
}
function blockedRuntimeStatus(value: unknown): boolean {
return ["error", "failed", "failure", "blocked", "unavailable", "rejected", "degraded"].includes(normalized(value));
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? value as Record<string, unknown> : {};
}
function array(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function firstNonEmpty(...values: unknown[]): string {
for (const value of values) {
const text = String(value ?? "").trim();
if (text) return text;
}
return "";
}
function normalized(value: unknown): string {
return String(value ?? "").trim().toLowerCase();
}
+1 -3
View File
@@ -5,11 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HWLAB 云工作台</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body data-auth-state="checking">
<div id="root" data-cloud-web-root></div>
<noscript>需要启用 JavaScript 才能使用 HWLAB 云工作台。</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2 -3
View File
@@ -4,12 +4,11 @@
"private": true,
"type": "module",
"scripts": {
"check": "bun run scripts/check.ts && bun run scripts/frontend-guard.ts && bun run scripts/tsc-check.ts && bun test",
"check": "bun run scripts/check.ts && bun run scripts/tsc-check.ts && bun test",
"check:tsc": "bun run scripts/tsc-check.ts",
"check:tsc-strict": "bun run scripts/tsc-check.ts --strict",
"check:guard": "bun run scripts/frontend-guard.ts",
"build": "bun run scripts/build.ts",
"layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json",
"layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json",
"layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json",
"layout:live": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-live.json"
},
+74 -254
View File
@@ -1,283 +1,103 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import { assertCloudWebDistFresh, buildCloudWebDist } from "./dist-contract.ts";
import { assertCloudWebDistFresh, buildCloudWebDist, readCloudWebAppSource } from "./dist-contract.ts";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(rootDir, "../..");
const requiredSourceFiles = Object.freeze([
const requiredFiles = Object.freeze([
"index.html",
"src/main.tsx",
"src/App.tsx",
"src/components/auth/LoginView.tsx",
"src/components/layout/Workbench.tsx",
"src/components/layout/AppShell.tsx",
"src/components/layout/ActivityRail.tsx",
"src/components/layout/SessionSidebar.tsx",
"src/components/layout/CenterWorkspace.tsx",
"src/components/layout/WorkspaceView.tsx",
"src/components/layout/RightSidebar.tsx",
"src/components/conversation/ConversationView.tsx",
"src/components/conversation/MessageList.tsx",
"src/components/conversation/MessageItem.tsx",
"src/components/auth/LoginShell.tsx",
"src/components/sessions/SessionSidebar.tsx",
"src/components/conversation/ConversationPanel.tsx",
"src/components/command-bar/CommandBar.tsx",
"src/components/device-pod/DevicePodPanel.tsx",
"src/components/device-pod/DevicePodSummary.tsx",
"src/components/device-pod/DevicePodInterfaces.tsx",
"src/components/device-pod/DeviceEventStream.tsx",
"src/components/device-pod/DeviceDetailDialog.tsx",
"src/components/skills/SkillsView.tsx",
"src/components/device-pod/DevicePodSidebar.tsx",
"src/components/settings/SettingsView.tsx",
"src/components/help/HelpView.tsx",
"src/components/skills/SkillsView.tsx",
"src/hooks/useAuth.ts",
"src/hooks/useWorkspace.ts",
"src/hooks/useDevicePod.ts",
"src/hooks/useSkills.ts",
"src/hooks/useTracePolling.ts",
"src/hooks/useCodeAgentSubmit.ts",
"src/services/api/client.ts",
"src/services/api/auth.ts",
"src/services/api/sessions.ts",
"src/services/api/conversations.ts",
"src/services/api/codeAgent.ts",
"src/services/api/devicePods.ts",
"src/services/api/skills.ts",
"src/services/api/workspace.ts",
"src/services/auth.ts",
"src/state/workbenchState.tsx",
"src/logic/composer-policy.ts",
"src/logic/code-agent-facts.ts",
"src/logic/code-agent-status.ts",
"src/logic/live-status.ts",
"src/logic/message-markdown.ts",
"src/logic/app-trace.ts",
"src/logic/app-session-tabs.ts",
"src/logic/runtime.ts",
"vite.config.ts",
"styles.css",
"src/state/workbench.ts",
"src/types/domain.ts",
"src/styles/workbench.css",
"favicon.svg",
"favicon.ico",
"help.md",
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE",
"help.md"
]);
for (const file of requiredSourceFiles) {
const filePath = path.resolve(rootDir, file);
if (!fs.existsSync(filePath)) throw new Error(`missing web asset: ${file}`);
for (const file of requiredFiles) {
if (!fs.existsSync(path.resolve(rootDir, file))) throw new Error(`missing React web asset: ${file}`);
}
console.log("hwlab-cloud-web check: react/typescript source tree present");
console.log("hwlab-cloud-web check: React module assets present");
const html = readWeb("index.html");
const styles = readWeb("styles.css");
const traceSource = readWeb("src/logic/app-trace.ts");
const messageMarkdown = readWeb("src/logic/message-markdown.ts");
const sessionTabs = readWeb("src/logic/app-session-tabs.ts");
const composerPolicy = readWeb("src/logic/composer-policy.ts");
const liveStatus = readWeb("src/logic/live-status.ts");
const codeAgentFacts = readWeb("src/logic/code-agent-facts.ts");
const codeAgentStatus = readWeb("src/logic/code-agent-status.ts");
const appEntry = readWeb("src/main.tsx");
const appComponent = readWeb("src/App.tsx");
const workbench = readWeb("src/components/layout/Workbench.tsx");
const skillsView = readWeb("src/components/skills/SkillsView.tsx");
const settingsView = readWeb("src/components/settings/SettingsView.tsx");
const helpView = readWeb("src/components/help/HelpView.tsx");
const commandBar = readWeb("src/components/command-bar/CommandBar.tsx");
const devicePodPanel = readWeb("src/components/device-pod/DevicePodPanel.tsx");
const messageItem = readWeb("src/components/conversation/MessageItem.tsx");
const composerClient = readWeb("src/services/api/client.ts");
const authService = readWeb("src/services/auth.ts");
const authApi = readWeb("src/services/api/auth.ts");
const stateContext = readWeb("src/state/workbenchState.tsx");
const distContractScript = readWeb("scripts/dist-contract.ts");
const appSource = readCloudWebAppSource(rootDir);
const css = readWeb("src/styles/workbench.css");
assertReactHtmlShell(html);
assertReactEntry(appEntry);
assertReactAppComposition(appComponent);
assertReactComponentSplit(workbench, skillsView, settingsView, helpView);
assertReactComposerContract(composerPolicy, workbench, commandBar);
assertReactCodeAgentRoutes(authService, composerClient);
assertReactSkillsContract(skillsView, styles);
assertReactDevicePodContract(devicePodPanel, styles);
assertReactLiveStatusContract(liveStatus);
assertReactCodeAgentFacts(codeAgentFacts, codeAgentStatus);
assertReactMarkdownContract(messageItem, messageMarkdown);
assertReactSessionTabsContract(sessionTabs, workbench);
assertReactStateShape(stateContext);
assertReactTestCoverage(traceSource, messageMarkdown, sessionTabs, composerPolicy, liveStatus, codeAgentFacts, codeAgentStatus);
assertTraceHelpersBound(traceSource);
assertDevicePodEvidenceSummaryDoesNotCrash();
console.log("hwlab-cloud-web check: react/typescript contracts verified");
assert.match(html, /<div id="root"><\/div>/u, "index.html must only expose React mount root");
assert.match(html, /src="\/src\/main\.tsx"/u, "index.html must load React TSX entry");
assert.doesNotMatch(html, /workbench-shell|device-pod-sidebar|command-input|conversation-list|session-tabs/u, "index.html must not contain business DOM shell");
for (const removed of ["app.ts", "app-device-pod.ts", "app-conversation.ts", "app-helpers.ts", "app-skills.ts", "styles.css", "web-types.d.ts"]) {
assert.equal(fs.existsSync(path.join(rootDir, removed)), false, `${removed} must not remain as a legacy runtime path`);
}
assertIncludes(appSource, "createRoot", "React root must be used");
assertIncludes(appSource, "React.StrictMode", "React StrictMode must wrap the app");
assertIncludes(appSource, "useWorkbenchStore", "state hook must own workbench state");
assertIncludes(appSource, "fetchJson", "API client must own fetch calls");
assertIncludes(appSource, "DevicePodSidebar", "Device Pod sidebar component must exist");
assertIncludes(appSource, "CommandBar", "command bar component must exist");
assertIncludes(appSource, "SkillsView", "skills component must exist");
assertIncludes(appSource, "SettingsView", "settings component must exist");
assertIncludes(appSource, "id=\"command-input\"", "React runtime must render command input selector");
assertIncludes(appSource, "id=\"conversation-list\"", "React runtime must render conversation list selector");
assertIncludes(appSource, "id=\"device-pod-sidebar\"", "React runtime must render Device Pod sidebar selector");
assertIncludes(appSource, "id=\"device-event-scroll\"", "React runtime must render Device Pod event stream selector");
assertIncludes(appSource, "id=\"logout-button\"", "React runtime must render logout selector");
assert.doesNotMatch(appSource, /document\.getElementById\((?!["']root["'])|querySelector\(["']#(?:command-input|conversation-list|device-pod-sidebar)/u, "major UI must not be driven by global DOM element lookups");
assert.doesNotMatch(appSource, /\bconst\s+el\s*=/u, "global el map must not return");
const sourceFiles = collectSourceFiles(path.join(rootDir, "src"));
for (const file of sourceFiles) {
const rel = path.relative(rootDir, file);
const lines = fs.readFileSync(file, "utf8").split("\n").length;
assert.ok(lines <= 400, `${rel} exceeds 400 lines (${lines}); split the React module`);
}
for (const directory of ["components/layout", "components/auth", "components/sessions", "components/conversation", "components/command-bar", "components/device-pod", "components/settings", "components/skills", "hooks", "services/api", "state", "types", "styles"]) {
assert.ok(fs.existsSync(path.join(rootDir, "src", directory)), `missing React module boundary src/${directory}`);
}
assert.match(css, /@media \(max-width: 720px\)/u, "mobile layout contract must be explicit");
assert.match(css, /grid-template-columns:\s*56px\s+minmax\(0,\s*1fr\)/u, "390px mobile layout must not keep three squeezed columns");
assert.match(css, /\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u, "event stream scroll must be contained");
assert.match(css, /\.session-tabs\s*\{[\s\S]*?overflow-x:\s*hidden;/u, "session sidebar must not horizontally scroll");
const cloudApiServer = readRepo("internal/cloud/server.ts");
const deployJson = readRepo("deploy/deploy.json");
assertIncludes(cloudApiServer, "/v1/device-pods", "cloud-api must expose Device Pod REST routes");
assertIncludes(cloudApiServer, "/v1/skills", "cloud-api must expose Skills REST routes");
assertIncludes(deployJson, "hwlab-device-pod", "deploy manifest must include Device Pod service");
await buildCloudWebDist(rootDir);
console.log("hwlab-cloud-web check: vite dist bundle built");
console.log("hwlab-cloud-web check: dist bundle built by Vite");
await assertCloudWebDistFresh(rootDir);
console.log("hwlab-cloud-web check: dist freshness verified");
console.log("hwlab-cloud-web check ok: React + strict TypeScript module boundaries and runtime selectors are present");
for (const oldAsset of ["code-agent-m3-evidence.mjs", "wiring-status.mjs", "workbench-hardware-panel.mjs", "app.ts", "app-conversation.ts", "app-device-pod.ts", "app-skills.ts", "app-helpers.ts", "auth.ts"]) {
if (fs.existsSync(path.resolve(rootDir, oldAsset))) {
throw new Error(`legacy web runtime asset still present: ${oldAsset}`);
function readWeb(relativePath: string): string { return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8"); }
function readRepo(relativePath: string): string { return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8"); }
function assertIncludes(source: string, term: string, message: string): void { assert.ok(source.includes(term), message); }
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) out.push(...collectSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) out.push(full);
}
return out;
}
console.log("hwlab-cloud-web check: legacy asset cleanup verified");
console.log("hwlab-cloud-web check ok: React + TypeScript SPA, Vite dist, module boundary contracts and tests are present");
function readWeb(relativePath) {
return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8");
}
function assertReactHtmlShell(source) {
assert.match(source, /<div id="root"[^>]*data-cloud-web-root/u, "index.html must mount React into #root");
assert.doesNotMatch(source, /class="login-shell"/u, "index.html must not inline the legacy login shell");
assert.doesNotMatch(source, /class="workbench-shell"/u, "index.html must not inline the legacy workbench shell");
assert.doesNotMatch(source, /id="login-form"/u, "index.html must not inline the legacy login form");
assert.doesNotMatch(source, /id="command-form"/u, "index.html must not inline the legacy command form");
assert.doesNotMatch(source, /id="device-pod-sidebar"/u, "index.html must not inline the legacy device pod sidebar");
assert.match(source, /<script type="module" src="\/src\/main\.tsx">/u, "index.html must declare /src/main.tsx as the Vite entry (Vite will rewrite this to the built /app.js)");
assert.match(source, /<link rel="stylesheet" href="\/styles\.css"\s*\/?>/u, "index.html must keep the repo-owned styles.css link");
}
function assertReactEntry(source) {
assert.match(source, /createRoot/u, "main.tsx must mount React via createRoot");
assert.match(source, /WorkbenchStateProvider/u, "main.tsx must wrap the App in WorkbenchStateProvider");
assert.match(source, /import \{ App \}/u, "main.tsx must import the App component");
}
function assertReactAppComposition(source) {
assert.match(source, /useAuthGate/u, "App.tsx must call useAuthGate to manage authentication");
assert.match(source, /LoginView/u, "App.tsx must render LoginView when unauthenticated");
assert.match(source, /Workbench/u, "App.tsx must render Workbench when authenticated");
assert.doesNotMatch(source, /document\.getElementById/u, "App.tsx must not depend on the legacy el map / document.getElementById pattern");
}
function assertReactComponentSplit(workbench, skills, settings, help) {
assert.match(workbench, /ActivityRail/u, "Workbench must compose ActivityRail");
assert.match(workbench, /SessionSidebar/u, "Workbench must compose SessionSidebar");
assert.match(workbench, /CenterWorkspace/u, "Workbench must compose CenterWorkspace");
assert.match(workbench, /RightSidebar/u, "Workbench must compose RightSidebar");
assert.match(skills, /uploadSkillFiles/u, "SkillsView must call the skills upload API");
assert.match(skills, /skill-prompt-assembly/u, "SkillsView must render the prompt assembly panel");
assert.match(settings, /settings-logout/u, "SettingsView must render the logout control");
assert.match(help, /help\.md/u, "HelpView must load /help.md");
}
function assertReactComposerContract(composer, workbench, commandBar) {
assert.match(composer, /computeCodeAgentComposerState/u, "composer-policy must export computeCodeAgentComposerState");
assert.match(workbench, /CenterWorkspace/u, "Workbench must delegate the center column to CenterWorkspace");
assert.match(commandBar, /steer |turn /u, "CommandBar must label the composer mode");
assert.doesNotMatch(commandBar, /document\.getElementById/u, "CommandBar must not depend on the legacy el map");
}
function assertReactCodeAgentRoutes(authService, client) {
assert.match(client, /\/v1/u, "API client must prefix all routes with /v1");
assert.match(authApi, /\/auth\/session/u, "auth API client must hit /auth/session");
assert.match(authApi, /\/auth\/login/u, "auth API client must hit /auth/login");
assert.match(authApi, /\/auth\/logout/u, "auth API client must hit /auth/logout");
assert.match(authService, /probeAuthSession|signInWithCredentials|performSignOut/u, "auth service must export session probes and sign-in helpers");
}
function assertReactSkillsContract(skills, styles) {
assert.match(skills, /id="skill-upload-input"[^>]*webkitDirectory/u, "SkillsView must support directory upload");
assertIncludes(styles, ".skill-prompt-assembly", "styles.css must style the prompt assembly panel");
assertIncludes(styles, ".skill-prompt-row", "styles.css must style the prompt assembly refs");
assertIncludes(skills, "ResourceBundleRef.promptRefs", "SkillsView must surface ResourceBundleRef.promptRefs");
}
function assertReactDevicePodContract(panel, styles) {
assert.match(panel, /DevicePodSummary|DevicePodInterfaces|DeviceEventStream/u, "DevicePodPanel must compose summary, interfaces, and event stream");
assertIncludes(styles, ".device-pod-status", "styles.css must keep .device-pod-status");
assertIncludes(styles, ".device-pod-workspace", "styles.css must keep .device-pod-workspace");
assertIncludes(styles, ".device-event-panel", "styles.css must keep .device-event-panel");
assertIncludes(styles, ".device-detail-dialog", "styles.css must keep .device-detail-dialog");
assertIncludes(styles, ".summary-tile", "styles.css must keep .summary-tile");
}
function assertReactLiveStatusContract(source) {
assert.match(source, /classifyWorkbenchLiveStatus/u, "live-status must export classifyWorkbenchLiveStatus");
assert.match(source, /classifyDevicePodProbe/u, "live-status must classify Device Pod probe");
assert.doesNotMatch(source, /classifyM3|serviceForM3Layer|\/v1\/m3/u, "live-status must not keep legacy M3 probes");
}
function assertReactCodeAgentFacts(facts, status) {
assert.match(facts, /codeAgentFactsFromMessage/u, "code-agent-facts must export codeAgentFactsFromMessage");
assert.match(status, /classifyCodeAgentStatusSummary/u, "code-agent-status must export classifyCodeAgentStatusSummary");
}
function assertReactMarkdownContract(messageItem, markdown) {
assert.match(messageItem, /renderMessageMarkdown|hardenRenderedMarkdown/u, "MessageItem must use message-markdown helpers");
assert.match(markdown, /renderMessageMarkdown/u, "message-markdown must export renderMessageMarkdown");
}
function assertReactSessionTabsContract(tabs, workbench) {
assert.match(tabs, /sessionTabsFromConversations/u, "session-tabs must export sessionTabsFromConversations");
assert.match(workbench, /sessionTabsFromConversations|SessionSidebar/u, "Workbench must use session tabs in the sidebar");
}
function assertReactStateShape(state) {
assert.match(state, /WorkbenchStateProvider/u, "workbenchState must export WorkbenchStateProvider");
assert.match(state, /useWorkbenchState/u, "workbenchState must export useWorkbenchState");
assert.match(state, /reducer/u, "workbenchState must use a reducer for predictable state transitions");
assert.match(state, /dispatch\(\{ type: "auth\/set"/u, "workbenchState must handle auth/set action");
assert.match(state, /dispatch\(\{ type: "route\/set"/u, "workbenchState must handle route/set action");
}
function assertReactTestCoverage(trace, markdown, tabs, composer, live, facts, status) {
for (const contract of [
{ file: "src/logic/app-trace.test.ts", name: "trace display rows" },
{ file: "src/logic/message-markdown.test.ts", name: "message markdown" },
{ file: "src/logic/session-tabs.test.ts", name: "session tabs" },
{ file: "src/logic/composer-policy.test.ts", name: "composer policy" },
{ file: "src/logic/live-status.test.ts", name: "live status" },
{ file: "src/logic/code-agent-facts.test.ts", name: "code agent facts" },
{ file: "src/logic/code-agent-status.test.ts", name: "code agent status" },
]) {
if (!fs.existsSync(path.resolve(rootDir, contract.file))) {
throw new Error(`missing test coverage: ${contract.name} (${contract.file})`);
}
}
}
function assertIncludes(source, term, message) {
assert.ok(source.includes(term), message);
}
function assertTraceHelpersBound(source) {
const traceHelperCalls = new Set();
for (const match of source.matchAll(/\b((?:is[A-Z][A-Za-z0-9_$]*TraceEvent)|(?:trace[A-Z][A-Za-z0-9_$]*)|(?:cleanTrace[A-Za-z0-9_$]*)|(?:compactTrace[A-Za-z0-9_$]*)|(?:rawTrace[A-Za-z0-9_$]*))\s*\(/gu)) {
traceHelperCalls.add(match[1]);
}
const missing = [...traceHelperCalls].filter((name) => !new RegExp(`\\bfunction\\s+${escapeRegExp(name)}\\s*\\(`, "u").test(source)).filter((name) => !new RegExp(`\\bexport\\s+function\\s+${escapeRegExp(name)}\\s*\\(`, "u").test(source));
assert.deepEqual(missing, [], `app-trace.ts has trace helper calls without local function definitions: ${missing.join(", ")}`);
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
async function assertDevicePodEvidenceSummaryDoesNotCrash() {
const { classifyWorkbenchLiveStatus } = await import(pathToFileURL(path.resolve(rootDir, "src/logic/live-status.ts")).href);
const status = classifyWorkbenchLiveStatus({
devicePodStatus: {
ok: true,
data: {
status: "ready",
serviceId: "hwlab-device-pod",
summary: {
devicePodId: "device-pod-71-freq",
targetId: "71-FREQ",
profileHash: "abcdef0123456789fedcba9876543210",
},
},
},
});
const devicePodProbe = status.probes.find((probe) => probe.serviceId === "hwlab-device-pod");
assert.ok(devicePodProbe, JSON.stringify(status.probes));
assert.equal(devicePodProbe.kind, "pass");
assert.equal(devicePodProbe.reasonCode, "device_pod_ready");
assert.match(devicePodProbe.evidenceSummary, /profile=abcdef0123456789/u);
assert.doesNotMatch(devicePodProbe.evidenceSummary, /fedcba9876543210/u);
}
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
assertCloudWebDistFresh,
buildCloudWebDist,
cloudWebDistAliasFiles,
inspectCloudWebDistFreshness
} from "./dist-contract.ts";
test("Cloud Web dist build emits Vite assets and route aliases", async () => {
const rootDir = makeCloudWebFixture();
try {
const distDir = await buildCloudWebDist(rootDir);
assert.equal(distDir, path.join(rootDir, "dist"));
const html = read(path.join(distDir, "index.html"));
assert.match(html, /<div id="root"><\/div>/u);
assert.match(html, /src="\/app\.js"/u);
assert.match(html, /href="\/assets\/index\.css"/u);
assert.match(html, /href="\/assets\/favicon\.svg"/u);
assert.match(read(path.join(distDir, "app.js")), /fixture-app/u);
assert.match(read(path.join(distDir, "assets/index.css")), /fixture-shell/u);
assert.equal(read(path.join(distDir, "help.md")), read(path.join(rootDir, "help.md")));
for (const relativePath of cloudWebDistAliasFiles) {
assert.equal(read(path.join(distDir, relativePath)), html, `alias asset ${relativePath}`);
}
const freshness = await assertCloudWebDistFresh(rootDir);
assert.equal(freshness.status, "pass");
assert.deepEqual(freshness.mismatches, []);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("Cloud Web dist freshness reports stale generated app asset", async () => {
const rootDir = makeCloudWebFixture();
try {
await buildCloudWebDist(rootDir);
fs.writeFileSync(path.join(rootDir, "src/App.tsx"), "export function App() { return 'changed-app'; }\n");
const freshness = await inspectCloudWebDistFreshness(rootDir);
assert.equal(freshness.status, "blocked");
assert.ok(freshness.mismatches.includes("app.js"));
assert.equal(freshness.files.find((file) => file.path === "app.js")?.status, "mismatch");
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
function makeCloudWebFixture(): string {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-dist-"));
write(rootDir, "index.html", [
"<!doctype html>",
"<html lang=\"zh-CN\">",
" <head>",
" <meta charset=\"UTF-8\" />",
" <link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\" />",
" <title>fixture</title>",
" </head>",
" <body><div id=\"root\"></div><script type=\"module\" src=\"/src/main.tsx\"></script></body>",
"</html>",
""
].join("\n"));
write(rootDir, "vite.config.ts", [
"export default {",
" build: {",
" outDir: 'dist',",
" emptyOutDir: true,",
" rollupOptions: { output: { entryFileNames: 'app.js', chunkFileNames: 'assets/[name].js', assetFileNames: 'assets/[name][extname]' } }",
" }",
"};",
""
].join("\n"));
write(rootDir, "src/main.tsx", "import './styles/workbench.css';\nimport { App } from './App';\nconsole.log('fixture-app', App());\n");
write(rootDir, "src/App.tsx", "export function App() { return 'fixture-app'; }\n");
write(rootDir, "src/styles/workbench.css", ".fixture-shell { color: #0f766e; }\n");
write(rootDir, "favicon.svg", "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1 1\"><rect width=\"1\" height=\"1\"/></svg>\n");
write(rootDir, "help.md", "# fixture help\n");
return rootDir;
}
function write(rootDir: string, relativePath: string, text: string): void {
const filePath = path.join(rootDir, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, text);
}
function read(filePath: string): string {
return fs.readFileSync(filePath, "utf8");
}
+106 -153
View File
@@ -1,97 +1,90 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
export const cloudWebDistPath = "web/hwlab-cloud-web/dist";
export const cloudWebDistBuildCommand = "cd web/hwlab-cloud-web && bun run build";
export const cloudWebDistFreshnessCommand = cloudWebDistBuildCommand;
export const cloudWebAppEntry = "web/hwlab-cloud-web/src/main.tsx";
export const cloudWebStaticSourceFiles = Object.freeze([
"help.md",
"favicon.ico",
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE",
]);
export const cloudWebDistRuntimeFiles = Object.freeze([
"index.html",
"app.js",
"help.md",
"favicon.ico",
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE",
]);
export const cloudWebDistAliasFiles = Object.freeze([
"gate/index.html",
"diagnostics/gate/index.html",
"help/index.html",
"skills/index.html",
]);
export const cloudWebAppSourceFiles = Object.freeze(["src/main.tsx", "src/App.tsx"]);
export const cloudWebDistRuntimeFiles = Object.freeze(["index.html", "app.js", "assets/index.css", "assets/favicon.svg", "help.md"]);
export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html", "help/index.html", "skills/index.html"]);
const VITE_OUTPUTS = Object.freeze(["index.html", "app.js"]);
const VITE_ASSET_DIR = "app-assets";
type FreshnessStatus = "pass" | "blocked";
function absolutePath(rootDir, relativePath) {
interface DistFileStatus {
kind: "runtime" | "alias";
path: string;
expectedPath: string;
distPath: string;
status: "match" | "missing_expected" | "missing_dist" | "mismatch";
}
interface DistFreshness {
status: FreshnessStatus;
distPath: string;
buildCommand: string;
freshnessCommand: string;
files: DistFileStatus[];
mismatches: string[];
}
function absolutePath(rootDir: string, relativePath: string): string {
return path.resolve(rootDir, relativePath);
}
function exists(filePath) {
function exists(filePath: string): boolean {
return fs.existsSync(filePath);
}
export async function buildCloudWebDist(rootDir) {
export function readCloudWebAppSource(rootDir: string): string {
return collectSourceFiles(path.join(rootDir, "src")).map((file) => fs.readFileSync(file, "utf8")).join("\n");
}
export async function buildCloudWebDist(rootDir: string): Promise<string> {
const distRoot = absolutePath(rootDir, "dist");
fs.rmSync(distRoot, { recursive: true, force: true });
fs.mkdirSync(distRoot, { recursive: true });
await runViteBuild(rootDir, distRoot);
for (const relativePath of cloudWebStaticSourceFiles) {
const distPath = absolutePath(distRoot, relativePath);
fs.mkdirSync(path.dirname(distPath), { recursive: true });
fs.copyFileSync(absolutePath(rootDir, relativePath), distPath);
}
const builtIndex = absolutePath(distRoot, "index.html");
for (const aliasPath of cloudWebDistAliasFiles) {
const distPath = absolutePath(distRoot, aliasPath);
fs.mkdirSync(path.dirname(distPath), { recursive: true });
fs.copyFileSync(builtIndex, distPath);
}
runViteBuild(rootDir, distRoot);
finalizeGeneratedDist(rootDir, distRoot);
return distRoot;
}
export async function inspectCloudWebDistFreshness(rootDir) {
export async function inspectCloudWebDistFreshness(rootDir: string): Promise<DistFreshness> {
const distRoot = absolutePath(rootDir, "dist");
const files = [];
const mismatches = [];
const expectedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-expected-"));
const expectedDist = path.join(expectedRoot, "dist");
const files: DistFileStatus[] = [];
const mismatches: string[] = [];
for (const relativePath of cloudWebStaticSourceFiles) {
try {
runViteBuild(rootDir, expectedDist);
finalizeGeneratedDist(rootDir, expectedDist);
for (const relativePath of cloudWebDistRuntimeFiles) {
await compareFile({
files,
mismatches,
kind: "static",
kind: "runtime",
relativePath,
sourcePath: absolutePath(rootDir, relativePath),
distPath: absolutePath(distRoot, relativePath),
expectedPath: path.join(expectedDist, relativePath),
distPath: path.join(distRoot, relativePath)
});
}
await compareViteBundle({ files, mismatches, rootDir, distRoot });
const builtIndex = absolutePath(distRoot, "index.html");
for (const aliasPath of cloudWebDistAliasFiles) {
await compareFile({
files,
mismatches,
kind: "alias",
relativePath: aliasPath,
sourcePath: builtIndex,
distPath: absolutePath(distRoot, aliasPath),
expectedPath: path.join(expectedDist, aliasPath),
distPath: path.join(distRoot, aliasPath)
});
}
} finally {
fs.rmSync(expectedRoot, { recursive: true, force: true });
}
return {
status: mismatches.length === 0 ? "pass" : "blocked",
@@ -99,131 +92,91 @@ export async function inspectCloudWebDistFreshness(rootDir) {
buildCommand: cloudWebDistBuildCommand,
freshnessCommand: cloudWebDistFreshnessCommand,
files,
mismatches,
mismatches
};
}
export function cloudWebDistFreshnessMessage(distFreshness) {
export function cloudWebDistFreshnessMessage(distFreshness: { mismatches: string[] }): string {
const mismatches = distFreshness.mismatches.length > 0 ? distFreshness.mismatches.join(", ") : "unknown";
return [
`Cloud Web dist is stale or missing before image build: ${mismatches}.`,
`Run ${cloudWebDistBuildCommand} before publishing ${cloudWebDistPath}.`,
].join(" ");
return [`Cloud Web dist is stale or missing before image build: ${mismatches}.`, `Run ${cloudWebDistBuildCommand} before publishing ${cloudWebDistPath}.`].join(" ");
}
export async function assertCloudWebDistFresh(rootDir) {
export async function assertCloudWebDistFresh(rootDir: string): Promise<DistFreshness> {
const distFreshness = await inspectCloudWebDistFreshness(rootDir);
if (distFreshness.status !== "pass") {
throw new Error(cloudWebDistFreshnessMessage(distFreshness));
}
if (distFreshness.status !== "pass") throw new Error(cloudWebDistFreshnessMessage(distFreshness));
return distFreshness;
}
async function compareFile({ files, mismatches, kind, relativePath, sourcePath, distPath }) {
const entry = { kind, path: relativePath, sourcePath, distPath, status: "match" };
if (!exists(sourcePath)) {
entry.status = "missing_source";
entry.reason = "source file is missing";
} else if (!exists(distPath)) {
entry.status = "missing_dist";
entry.reason = "dist file is missing";
} else {
const [sourceText, distText] = await Promise.all([readFile(sourcePath, "utf8"), readFile(distPath, "utf8")]);
if (sourceText !== distText) {
entry.status = "mismatch";
entry.reason = "dist file differs from source";
}
}
if (entry.status !== "match") mismatches.push(relativePath);
files.push(entry);
function runViteBuild(rootDir: string, outDir: string): void {
ensureWebDeps(rootDir);
const rootLocalVite = path.join(rootDir, "node_modules", ".bin", "vite");
const packageLocalVite = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "node_modules", ".bin", "vite");
const localVite = exists(rootLocalVite) ? rootLocalVite : exists(packageLocalVite) ? packageLocalVite : null;
const command = localVite ?? "npx";
const args = localVite
? ["build", "--outDir", outDir, "--emptyOutDir"]
: ["--yes", "vite@5.4.10", "build", "--outDir", outDir, "--emptyOutDir"];
const result = spawnSync(command, args, { cwd: rootDir, encoding: "utf8" });
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.status !== 0) throw new Error(`Vite build failed with exit ${result.status ?? "unknown"}`);
}
async function compareViteBundle({ files, mismatches, rootDir, distRoot }) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-vite-"));
try {
await runViteBuild(rootDir, tmpDir);
for (const viteOutput of VITE_OUTPUTS) {
await compareFile({
files,
mismatches,
kind: "vite",
relativePath: viteOutput,
sourcePath: absolutePath(tmpDir, viteOutput),
distPath: absolutePath(distRoot, viteOutput),
});
}
const tmpAssets = path.join(tmpDir, VITE_ASSET_DIR);
const distAssets = path.join(distRoot, VITE_ASSET_DIR);
const tmpAssetFiles = fs.existsSync(tmpAssets) ? listFiles(tmpAssets) : [];
const distAssetFiles = fs.existsSync(distAssets) ? listFiles(distAssets) : [];
const seen = new Set([...tmpAssetFiles, ...distAssetFiles]);
for (const rel of seen) {
const a = path.join(tmpAssets, rel);
const b = path.join(distAssets, rel);
if (!exists(a) || !exists(b)) {
mismatches.push(`app-assets/${rel}`);
files.push({ kind: "vite-asset", path: `app-assets/${rel}`, sourcePath: a, distPath: b, status: "mismatch", reason: "vite asset missing" });
continue;
}
const [aText, bText] = await Promise.all([readFile(a, "utf8"), readFile(b, "utf8")]);
if (aText !== bText) {
mismatches.push(`app-assets/${rel}`);
files.push({ kind: "vite-asset", path: `app-assets/${rel}`, sourcePath: a, distPath: b, status: "mismatch", reason: "vite asset drifted" });
}
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
function finalizeGeneratedDist(rootDir: string, distRoot: string): void {
const helpSource = absolutePath(rootDir, "help.md");
if (exists(helpSource)) fs.copyFileSync(helpSource, path.join(distRoot, "help.md"));
for (const aliasPath of cloudWebDistAliasFiles) {
const distPath = path.join(distRoot, aliasPath);
fs.mkdirSync(path.dirname(distPath), { recursive: true });
fs.copyFileSync(path.join(distRoot, "index.html"), distPath);
}
}
function listFiles(dir) {
return fs.readdirSync(dir).flatMap((entry) => {
async function compareFile(input: { files: DistFileStatus[]; mismatches: string[]; kind: "runtime" | "alias"; relativePath: string; expectedPath: string; distPath: string }): Promise<void> {
const entry: DistFileStatus = {
kind: input.kind,
path: input.relativePath,
expectedPath: input.expectedPath,
distPath: input.distPath,
status: "match"
};
if (!exists(input.expectedPath)) entry.status = "missing_expected";
else if (!exists(input.distPath)) entry.status = "missing_dist";
else {
const [expectedText, distText] = await Promise.all([readFile(input.expectedPath, "utf8"), readFile(input.distPath, "utf8")]);
if (expectedText !== distText) entry.status = "mismatch";
}
if (entry.status !== "match") input.mismatches.push(input.relativePath);
input.files.push(entry);
}
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) return listFiles(full).map((sub) => `${entry}/${sub}`);
return [entry];
});
if (stat.isDirectory()) out.push(...collectSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) out.push(full);
}
return out.sort();
}
async function runViteBuild(rootDir, outDir) {
await ensureWebDeps(rootDir);
const localVite = path.join(rootDir, "node_modules", ".bin", "vite");
const command = fs.existsSync(localVite) ? localVite : "npx";
const args = fs.existsSync(localVite)
? ["build", "--outDir", outDir, "--emptyOutDir", "false"]
: ["--yes", "vite@5.4.10", "build", "--outDir", outDir, "--emptyOutDir", "false"];
await new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd: rootDir, stdio: ["ignore", "inherit", "inherit"] });
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`vite build exited with code ${code}`));
});
});
}
async function ensureWebDeps(rootDir) {
function ensureWebDeps(rootDir: string): void {
const lockPath = path.join(rootDir, "bun.lock");
const nodeModulesPath = path.join(rootDir, "node_modules");
if (!fs.existsSync(lockPath)) return;
if (fs.existsSync(path.join(nodeModulesPath, "vite")) && fs.existsSync(path.join(nodeModulesPath, "react"))) return;
await new Promise((resolve, reject) => {
const child = spawn("bun", ["install", "--frozen-lockfile"], { cwd: rootDir, stdio: ["ignore", "inherit", "inherit"] });
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`bun install exited with code ${code}`));
});
});
const result = spawnSync("bun", ["install", "--frozen-lockfile"], { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "inherit", "inherit"] });
if (result.status !== 0) throw new Error(`bun install exited with code ${result.status ?? "unknown"}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const rootIndex = args.indexOf("--root");
const rootDir = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
if (args.includes("--build")) {
await buildCloudWebDist(rootDir);
}
const rootDir = rootIndex >= 0 ? path.resolve(args[rootIndex + 1] ?? ".") : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
if (args.includes("--build")) await buildCloudWebDist(rootDir);
const freshness = await inspectCloudWebDistFreshness(rootDir);
process.stdout.write(`${JSON.stringify(freshness, null, args.includes("--pretty") ? 2 : 0)}\n`);
}
@@ -1,124 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Frontend module boundary guard for the React + TypeScript migration
// (HWLAB #756). Enforces:
// - no single frontend source file (src/**/*.tsx? and *.tsx in the
// migration tree) exceeds 400 lines unless explicitly grandfathered
// - main.tsx, App.tsx, and components.tsx are not bloated into a single
// mega file
// - required module directories exist (layout, auth, sessions,
// conversation, command-bar, device-pod, skills, settings, help,
// shared, hooks, services/api, services/workspace, state, types,
// logic, styles)
// - no legacy vanilla DOM markers survive in the new src/ tree
// (global el map, document.getElementById for primary UI, legacy
// app-*.ts files imported from src/)
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcDir = path.resolve(rootDir, "src");
const SOFT_LIMIT = 400;
const ABSOLUTE_LIMIT = 1500;
const GRANDFATHERED = new Set([
"src/logic/app-trace.ts",
"src/logic/live-status.ts",
"src/logic/code-agent-facts.ts",
"src/logic/code-agent-status.ts",
]);
const GRANDFATHERED_REASON = Object.freeze({
"src/logic/app-trace.ts": "Pure-logic trace row builder used by CLI + Web; tested by src/logic/app-trace.test.ts. Splittable but the 400-line guideline is a soft target for migration-era pure logic.",
"src/logic/live-status.ts": "Pure-logic live status classifier for workbench/device pod/m3 probes; tested by src/logic/live-status.test.ts. Left in one file to keep the public probe taxonomy stable for the CLI/Web renderer.",
"src/logic/code-agent-facts.ts": "Pure-logic code agent facts classifier (provider/backend/runner/protocol/implementation/tool calls); tested by src/logic/code-agent-facts.test.ts.",
"src/logic/code-agent-status.ts": "Pure-logic code agent status summarizer (availability, deployment revision, capability level); tested by src/logic/code-agent-status.test.ts.",
});
const REQUIRED_MODULE_DIRS = Object.freeze([
"components",
"components/auth",
"components/layout",
"components/conversation",
"components/command-bar",
"components/device-pod",
"components/skills",
"components/settings",
"components/help",
"hooks",
"services",
"services/api",
"logic",
"types",
]);
const ENTRY_FILES = Object.freeze(["src/main.tsx", "src/App.tsx"]);
const ENTRY_HARD_LIMIT = 200;
const LEGACY_BANNED_TOKENS = Object.freeze([
"function byId(",
"const el = {",
"el.loginShell",
]);
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") && entry.name !== ".gitkeep") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(ts|tsx)$/u.test(entry.name)) out.push(full);
}
return out;
}
const files = walk(srcDir);
assert.ok(files.length > 0, "frontend guard: src/ must contain at least one .ts(x) file");
const relativeFiles = files.map((file) => path.relative(rootDir, file).split(path.sep).join("/"));
for (const dir of REQUIRED_MODULE_DIRS) {
const full = path.resolve(srcDir, dir);
assert.ok(fs.existsSync(full) && fs.statSync(full).isDirectory(), `frontend guard: required module directory missing: src/${dir}`);
}
console.log(`frontend guard: ${REQUIRED_MODULE_DIRS.length} required module directories present`);
for (const rel of relativeFiles) {
const full = path.resolve(rootDir, rel);
const text = fs.readFileSync(full, "utf8");
const lines = text.split("\n").length;
if (rel.endsWith(".test.ts") || rel.endsWith(".test.tsx")) {
continue;
}
const entryHardLimit = ENTRY_FILES.includes(rel) ? ENTRY_HARD_LIMIT : null;
if (entryHardLimit !== null) {
assert.ok(lines <= entryHardLimit, `frontend guard: entry file ${rel} must stay <= ${entryHardLimit} lines (currently ${lines})`);
continue;
}
if (GRANDFATHERED.has(rel)) {
assert.ok(lines <= ABSOLUTE_LIMIT, `frontend guard: grandfathered file ${rel} must stay <= ${ABSOLUTE_LIMIT} lines (currently ${lines}); reason: ${GRANDFATHERED_REASON[rel]}`);
continue;
}
assert.ok(lines <= SOFT_LIMIT, `frontend guard: file ${rel} exceeds the ${SOFT_LIMIT}-line soft target (${lines} lines); split it or add it to GRANDFATHERED with a reason`);
}
console.log(`frontend guard: ${relativeFiles.length} src files within ${SOFT_LIMIT}-line target (grandfathered: ${[...GRANDFATHERED].join(", ") || "none"})`);
for (const rel of relativeFiles) {
const text = fs.readFileSync(path.resolve(rootDir, rel), "utf8");
for (const banned of LEGACY_BANNED_TOKENS) {
assert.ok(!text.includes(banned), `frontend guard: legacy vanilla DOM marker in ${rel}: ${banned}`);
}
}
console.log("frontend guard: no legacy vanilla DOM markers in src/");
for (const legacyFile of ["app.ts", "app-conversation.ts", "app-device-pod.ts", "app-skills.ts", "app-helpers.ts", "app-trace.ts", "app-session-tabs.ts", "auth.ts"]) {
const topLevel = path.resolve(rootDir, legacyFile);
assert.ok(!fs.existsSync(topLevel), `frontend guard: legacy file must not remain at top level: ${legacyFile}`);
}
console.log("frontend guard: no legacy app-*.ts / auth.ts at top level");
assert.ok(fs.existsSync(path.resolve(rootDir, "vite.config.ts")), "frontend guard: vite.config.ts must exist");
assert.ok(fs.existsSync(path.resolve(rootDir, "tsconfig.json")), "frontend guard: tsconfig.json must exist");
console.log("frontend guard: vite + tsconfig present");
console.log("frontend guard ok: React + TypeScript module boundary contracts hold");
+36 -55
View File
@@ -3,70 +3,51 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// TSC type-check entry. 背景见 docs/dist-contract 与 web-types.d.ts
// build 阶段由 dist-contract.ts:buildAppBundle 把 7 个 .ts 源文件拼成单一虚拟
// 模块后交给 Bun.build;本脚本走 tsc --noEmit 校验源码(不参与 runtime bundle),
// 是 web:check 在 #751 之后新增的并行 type gate。
//
// Gate 行为:
// - 项目源码(含 web-types.d.ts)里出现显式 :A / <A> 注解 → 立刻 exit 2
// #751 硬要求 0 explicit Anode_modules / dist / 隐藏目录不在扫描范围;
// A 占位符由运行时拼出,避免本文件被自身 detector 命中)
// - 0 explicit A + tsc 通过 → exit 0
// - 0 explicit A + tsc 报类型错 → exit 0 但打印 warningissue #751 后续轮次跟踪;
// 当前剩余的 300+ property access 错属于 Web/ElMap state 之外的局部隐式类型推导
// 缺口,每一类都会单独立 issue 跟踪,#751 不阻塞 build
// --strict 模式额外做 strict 回归,让新增 implicit A 立刻冒出来
const A = "a" + "n" + "y";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceFiles = collectSourceFiles(path.join(rootDir, "src"));
let explicitAnyCount = 0;
for (const file of sourceFiles) {
const text = readFileSync(file, "utf8");
const stripped = stripComments(text);
const matches = [
...stripped.matchAll(/:\s*any\b/gu),
...stripped.matchAll(/<\s*any\s*>/gu),
...stripped.matchAll(/as\s+any\b/gu),
...stripped.matchAll(/Array\s*<\s*any\s*>/gu)
];
if (matches.length > 0) {
explicitAnyCount += matches.length;
console.error(` ${path.relative(rootDir, file)}: ${matches.length} explicit any usage(s)`);
}
}
if (explicitAnyCount > 0) {
console.error(`hwlab-cloud-web tsc-check: ${explicitAnyCount} explicit any usage(s) in React source`);
process.exit(2);
}
function collectSourceFiles(dir) {
const out = [];
const tscBin = path.join(rootDir, "node_modules/.bin/tsc");
const tscArgs = ["--noEmit", "--project", path.join(rootDir, "tsconfig.json"), "--strict"];
const tscResult = spawnSync(tscBin, tscArgs, { cwd: rootDir, encoding: "utf8" });
if (tscResult.stdout) process.stdout.write(tscResult.stdout);
if (tscResult.stderr) process.stderr.write(tscResult.stderr);
if (tscResult.status !== 0) {
console.error(`hwlab-cloud-web tsc-check: strict TypeScript failed with exit ${tscResult.status ?? "unknown"}`);
process.exit(tscResult.status ?? 2);
}
console.log("hwlab-cloud-web tsc-check: passed (strict React TSX, 0 explicit any)");
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist" || entry.startsWith(".")) continue;
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) out.push(...collectSourceFiles(full));
else if (full.endsWith(".ts")) out.push(full);
else if (/\.(ts|tsx)$/u.test(full)) out.push(full);
}
return out;
}
const sourceFiles = collectSourceFiles(rootDir);
let aCount = 0;
for (const file of sourceFiles) {
if (file === __filename) continue;
const text = readFileSync(file, "utf8");
const colonA = (text.match(new RegExp(": " + A + "\\b", "g")) ?? []).length;
const angleA = (text.match(new RegExp("<" + A + ">", "g")) ?? []).length;
if (colonA + angleA > 0) {
console.error(` ${path.relative(rootDir, file)}: ${colonA} colon-form + ${angleA} angle-form`);
aCount += colonA + angleA;
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:])\/\/.*$/gmu, "$1");
}
}
if (aCount > 0) {
console.error(`hwlab-cloud-web tsc-check: ${aCount} explicit untyped annotation(s) in project source (issue #751: must be 0)`);
process.exit(2);
}
const tscBin = path.join(rootDir, "node_modules/.bin/tsc");
const tscArgs = ["--noEmit", "--project", path.join(rootDir, "tsconfig.json")];
if (process.argv.includes("--strict")) tscArgs.push("--strict");
const tscResult = spawnSync(tscBin, tscArgs, { cwd: rootDir, encoding: "utf8" });
if (tscResult.stdout) process.stdout.write(tscResult.stdout);
if (tscResult.stderr) process.stderr.write(tscResult.stderr);
if (tscResult.status === 0) {
console.log("hwlab-cloud-web tsc-check: passed (0 explicit untyped, 0 type errors)");
process.exit(0);
}
const errorCount = ((tscResult.stdout ?? "").match(/error TS\d+/g) ?? []).length;
console.warn(
`hwlab-cloud-web tsc-check: ${errorCount} residual type error(s) ` +
`(tracked by issue #751 follow-ups; web:check continues). 0 explicit untyped in project source.`
);
process.exit(0);
+88 -35
View File
@@ -1,50 +1,103 @@
import { useEffect, useState } from "react";
import type { ReactElement } from "react";
import { useEffect, useMemo, useState } from "react";
import { useAuthGate } from "./hooks/useAuth.ts";
import { LoginView } from "./components/auth/LoginView.tsx";
import { Workbench } from "./components/layout/Workbench.tsx";
import { useAuth } from "./hooks/useAuth";
import { useWorkbenchStore } from "./state/workbench";
import type { RouteId } from "./types/domain";
import { LoginShell } from "./components/auth/LoginShell";
import { CommandBar } from "./components/command-bar/CommandBar";
import { ConversationPanel } from "./components/conversation/ConversationPanel";
import { DevicePodSidebar } from "./components/device-pod/DevicePodSidebar";
import { ActivityRail } from "./components/layout/ActivityRail";
import { SessionSidebar } from "./components/sessions/SessionSidebar";
import { SettingsView } from "./components/settings/SettingsView";
import { SkillsView } from "./components/skills/SkillsView";
import { fetchText } from "./services/api/client";
const AUTH_CHECKING = "checking";
const AUTH_AUTHENTICATED = "authenticated";
export function App() {
const auth = useAuthGate();
const [authState, setAuthState] = useState(AUTH_CHECKING);
export function App(): ReactElement {
const auth = useAuth();
const store = useWorkbenchStore();
const [route, setRoute] = useState<RouteId>(() => routeFromLocation());
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [help, setHelp] = useState("加载中");
useEffect(() => {
if (typeof document === "undefined") return;
document.body.dataset.authState = authState;
}, [authState]);
const listener = (): void => setRoute(routeFromLocation());
window.addEventListener("hashchange", listener);
return () => window.removeEventListener("hashchange", listener);
}, []);
useEffect(() => {
if (auth.status === "authenticated") {
setAuthState(AUTH_AUTHENTICATED);
} else if (auth.status === "unauthenticated") {
setAuthState("unauthenticated");
} else {
setAuthState(AUTH_CHECKING);
}
}, [auth.status]);
if (route !== "help") return;
void fetchText("/help.md", { timeoutMs: 8000 }).then((response) => setHelp(response.ok ? response.data ?? "" : `帮助内容加载失败:${response.error ?? "unknown"}`));
}, [route]);
if (auth.status === "checking" || auth.status === "authenticating") {
return <BootSplash label="检查登录态" />;
}
if (auth.status === "unauthenticated") {
return <LoginView auth={auth} />;
}
return <Workbench auth={auth} />;
const modelLabel = useMemo(() => store.state.providerProfile === "codex-api" ? "Codex API" : store.state.providerProfile === "minimax-m3" ? "MiniMax-M3" : "DeepSeek", [store.state.providerProfile]);
function navigate(nextRoute: RouteId): void {
window.location.hash = `/${nextRoute}`;
setRoute(nextRoute);
}
function BootSplash({ label }) {
if (auth.authState === "checking") return <AuthLoadingShell />;
if (auth.authState === "login") return <LoginShell error={auth.error} onLogin={auth.login} />;
return (
<section className="login-shell" data-cloud-web-boot aria-live="polite">
<div className="login-panel">
<div className="login-heading">
<main className={`workbench-shell${leftCollapsed ? " is-left-sidebar-collapsed" : ""}${rightCollapsed ? " is-right-sidebar-collapsed" : ""}`} data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md">
<ActivityRail route={route} collapsed={leftCollapsed} onRoute={navigate} onToggle={() => setLeftCollapsed((value) => !value)} />
<SessionSidebar tabs={store.sessionTabs} loading={store.state.loading} modelLabel={modelLabel} onCreate={() => void store.createSession()} onDelete={() => void store.deleteCurrentSession()} onSelect={(tab) => void store.selectConversation(tab)} />
<div className="resize-handle session-sidebar-resize" id="session-sidebar-resize" role="separator" tabIndex={0} aria-label="拖拽调整 Session sidebar 宽度" aria-controls="session-sidebar" aria-orientation="vertical" aria-valuemin={148} aria-valuemax={320} aria-valuenow={220} title="拖拽调整 Session sidebar 宽度;用左右方向键微调,Home/End 到最小或最大。" />
<section className="center-workspace">
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} chatPending={store.state.chatPending} onCopySession={() => void navigator.clipboard?.writeText(store.activeConversationId ?? "")} /> : null}
{route === "skills" ? <SkillsView /> : null}
{route === "gate" ? <GateView /> : null}
{route === "help" ? <HelpView help={help} /> : null}
{route === "settings" ? <SettingsView onLogout={auth.logout} /> : null}
<CommandBar disabled={store.composer.disabled} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={store.submitMessage} onClear={store.clearConversation} />
</section>
<div className="resize-handle right-sidebar-resize" id="right-sidebar-resize" role="separator" tabIndex={0} aria-disabled="false" aria-hidden="false" aria-label="拖拽调整右侧 Device Pod 看板宽度" aria-controls="device-pod-sidebar" aria-orientation="vertical" aria-valuemin={560} aria-valuemax={740} aria-valuenow={728} aria-valuetext="右侧 Device Pod 看板宽度 728 像素" title="拖拽调整右侧 Device Pod 看板宽度;用左右方向键微调,Home/End 到最小或最大。" />
<DevicePodSidebar live={store.state.live} selectedDevicePodId={store.state.selectedDevicePodId} onSelect={store.selectDevicePod} collapsed={rightCollapsed} onToggle={() => setRightCollapsed((value) => !value)} />
</main>
);
}
function AuthLoadingShell(): ReactElement {
return (
<section className="login-shell auth-loading-shell" id="auth-loading-shell" aria-live="polite" aria-busy="true">
<div className="login-panel auth-loading-panel">
<p className="eyebrow">HWLAB</p>
<h1></h1>
<p>{label}</p>
</div>
<h1></h1>
<p></p>
</div>
</section>
);
}
function GateView(): ReactElement {
return (
<section className="view gate-view" id="gate" data-view="gate" aria-labelledby="gate-title">
<header className="gate-header"><div><p className="eyebrow"></p><h2 id="gate-title"></h2></div><span className="state-tag tone-source" id="gate-source-status"> live </span></header>
<div className="gate-controls" aria-label="内部复核过滤控件"><label><span></span><select id="gate-status-filter"><option value="all"></option><option value="通过"></option><option value="阻塞"></option><option value="失败"></option><option value="待验证"></option><option value="信息"></option></select></label><label><span></span><input id="gate-search" type="search" placeholder="按类别、检查项、对象或 trace 搜索" autoComplete="off" /></label><button className="command-button secondary" id="gate-refresh" type="button"></button></div>
<div className="gate-table-wrap"><table className="gate-table" aria-describedby="gate-source-status"><thead><tr>{["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"].map((head) => <th key={head}>{head}</th>)}</tr></thead><tbody id="gate-review-body"><tr><td colSpan={8}> live </td></tr></tbody></table></div>
</section>
);
}
function HelpView({ help }: { help: string }): ReactElement {
return (
<section className="view help-view" id="help" data-view="help" aria-labelledby="help-title">
<div className="workspace-panel help-panel"><div className="panel-title-row"><div><p className="eyebrow"></p><h2 id="help-title">使</h2></div><span className="state-tag tone-source" id="help-status"></span></div><pre id="help-content" className="help-content" aria-live="polite">{help}</pre></div>
</section>
);
}
function routeFromLocation(): RouteId {
const hash = window.location.hash.replace(/^#\/?/u, "");
if (hash === "skills" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash;
const path = window.location.pathname.replace(/\/+$/u, "") || "/";
if (path === "/help") return "help";
if (path === "/skills") return "skills";
if (path === "/gate" || path === "/diagnostics/gate") return "gate";
return "workspace";
}
@@ -0,0 +1,47 @@
import type { ReactElement } from "react";
import { FormEvent, useState } from "react";
interface LoginShellProps {
error: string | null;
onLogin(username: string, password: string): Promise<void>;
}
export function LoginShell({ error, onLogin }: LoginShellProps): ReactElement {
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("hwlab2026");
const [pending, setPending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setPending(true);
try {
await onLogin(username, password);
} finally {
setPending(false);
}
}
return (
<section className="login-shell" id="login-shell" data-auth-login aria-labelledby="login-title">
<div className="login-panel">
<div className="login-heading">
<p className="eyebrow">HWLAB</p>
<h1 id="login-title"></h1>
<p></p>
</div>
<form className="login-form" id="login-form" autoComplete="on" onSubmit={submit}>
<label>
<span></span>
<input id="login-username" name="username" type="text" autoComplete="username" inputMode="text" required value={username} onChange={(event) => setUsername(event.currentTarget.value)} />
</label>
<label>
<span></span>
<input id="login-password" name="password" type="password" autoComplete="current-password" required value={password} onChange={(event) => setPassword(event.currentTarget.value)} />
</label>
<p className="login-error" id="login-error" role="alert" hidden={!error}>{error}</p>
<button className="login-submit" id="login-submit" type="submit" disabled={pending}>{pending ? "登录中" : "登录"}</button>
</form>
</div>
</section>
);
}
@@ -1,71 +0,0 @@
import { useState } from "react";
export function LoginView({ auth }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [localError, setLocalError] = useState(null);
const busy = auth.status === "authenticating";
const errorMessage = localError ?? auth.error;
async function handleSubmit(event) {
event.preventDefault();
if (busy) return;
setLocalError(null);
if (!username.trim() || !password) {
setLocalError("请输入用户名和密码。");
return;
}
const result = await auth.signIn(username.trim(), password);
if (!result.ok) {
setLocalError(result.error ?? "登录失败。");
}
}
return (
<section className="login-shell" data-auth-login aria-labelledby="login-title">
<div className="login-panel">
<div className="login-heading">
<p className="eyebrow">HWLAB</p>
<h1 id="login-title"></h1>
<p></p>
</div>
<form className="login-form" onSubmit={handleSubmit} autoComplete="on">
<label>
<span></span>
<input
id="login-username"
name="username"
type="text"
autoComplete="username"
inputMode="text"
required
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={busy}
/>
</label>
<label>
<span></span>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={busy}
/>
</label>
<p className="login-error" id="login-error" role="alert" hidden={!errorMessage}>
{errorMessage}
</p>
<button className="login-submit" id="login-submit" type="submit" disabled={busy}>
{busy ? "登录中…" : "登录"}
</button>
</form>
</div>
</section>
);
}
@@ -1,53 +1,81 @@
import { useState } from "react";
import type { ReactElement } from "react";
import { FormEvent, KeyboardEvent, useState } from "react";
export function CommandBar({ draft, setDraft, composer, pending, onSubmit }) {
const disabled = composer?.disabled === true;
const reason = composer?.disabledReason ?? null;
const placeholder = reason === "session_required"
? "请先创建或选择一个 session"
: reason === "session_not_usable"
? "当前 session 不可用,请新建或选择其他 session"
: "向 Code Agent 发送指令…";
import type { ProviderProfile } from "../../types/domain";
async function handleSubmit(event) {
interface CommandBarProps {
disabled: boolean;
providerProfile: ProviderProfile;
codeAgentTimeoutMs: number;
gatewayShellTimeoutMs: number;
onProviderProfile(profile: ProviderProfile): void;
onCodeAgentTimeout(value: number): void;
onGatewayShellTimeout(value: number): void;
onSubmit(value: string): Promise<void>;
onClear(): void;
}
export function CommandBar(props: CommandBarProps): ReactElement {
const [value, setValue] = useState("");
const [pending, setPending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!draft.trim() || disabled || pending) return;
await onSubmit(draft.trim());
const next = value.trim();
if (!next || props.disabled || pending) return;
setPending(true);
try {
await props.onSubmit(next);
setValue("");
} finally {
setPending(false);
}
}
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
if (event.key !== "Enter" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
return (
<form
className="command-bar"
id="command-form"
aria-label="Code Agent 命令"
onSubmit={handleSubmit}
>
<label htmlFor="command-input" className="command-bar-label">
Code Agent
<form className="command-bar" id="command-form" aria-label="Agent 输入" onSubmit={submit}>
<label className="agent-timeout-control" htmlFor="code-agent-provider-profile">
<span></span>
<select id="code-agent-provider-profile" aria-label="Code Agent 模型通道" value={props.providerProfile} onChange={(event) => props.onProviderProfile(event.currentTarget.value as ProviderProfile)}>
<option value="deepseek">DeepSeek</option>
<option value="codex-api">Codex API</option>
<option value="minimax-m3">MiniMax-M3</option>
</select>
</label>
<textarea
id="command-input"
className="command-input"
rows={3}
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={placeholder}
disabled={false}
aria-disabled={disabled}
/>
<div className="command-bar-actions">
<span className={`state-tag tone-${composer?.submitMode === "steer" ? "running" : "source"}`}>
{composer?.submitMode === "steer" ? "steer 模式" : "turn 模式"}
</span>
<button
id="command-submit"
type="submit"
className="command-submit"
disabled={disabled || pending || !draft.trim()}
>
{pending ? "提交中…" : composer?.submitMode === "steer" ? "Steer" : "发送"}
</button>
<label className="agent-timeout-control" htmlFor="code-agent-timeout">
<span></span>
<select id="code-agent-timeout" aria-label="Code Agent 无新事件超时" value={props.codeAgentTimeoutMs} onChange={(event) => props.onCodeAgentTimeout(Number(event.currentTarget.value))}>
<option value="180000">3 </option>
<option value="300000">5 </option>
<option value="600000">10 </option>
<option value="900000">15 </option>
<option value="1200000">20 </option>
<option value="1800000">30 </option>
<option value="2400000">40 </option>
</select>
</label>
<label className="agent-timeout-control" htmlFor="gateway-shell-timeout">
<span>Gateway </span>
<select id="gateway-shell-timeout" aria-label="PC gateway shell 命令超时" value={props.gatewayShellTimeoutMs} onChange={(event) => props.onGatewayShellTimeout(Number(event.currentTarget.value))}>
<option value="60000">1 </option>
<option value="120000">2 </option>
<option value="180000">3 </option>
<option value="300000">5 </option>
<option value="600000">10 </option>
</select>
</label>
<div className="input-shell">
<span className="prompt-mark">hwlab</span>
<textarea id="command-input" value={value} placeholder="输入任务,Enter 发送,Shift+Enter 换行" rows={1} onChange={(event) => setValue(event.currentTarget.value)} onKeyDown={keyDown} />
</div>
<button className="command-button" id="command-send" type="submit" disabled={props.disabled || pending}>{pending ? "发送中" : "发送"}</button>
<button className="command-button secondary" id="command-clear" type="button" onClick={() => { setValue(""); props.onClear(); }}></button>
</form>
);
}
@@ -0,0 +1,119 @@
import type { ReactElement } from "react";
import { useMemo } from "react";
import { renderMessageMarkdown } from "../../services/markdown/render";
import type { ChatMessage, CodeAgentAvailability } from "../../types/domain";
import { formatTimestamp, jsonPreview, shortToken, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
interface ConversationPanelProps {
messages: ChatMessage[];
availability: CodeAgentAvailability | null;
chatPending: boolean;
onCopySession(): void;
}
export function ConversationPanel({ messages, availability, chatPending, onCopySession }: ConversationPanelProps): ReactElement {
const intro = useMemo<ChatMessage[]>(() => [
{
id: "intro-mode",
role: "system",
title: "界面模式",
text: "这里是用户工作台:可以整理任务、查看 Device Pod summary 和纯文本事件流。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。",
status: "source",
createdAt: new Date(0).toISOString()
},
{
id: "intro-code-agent",
role: "system",
title: "Code Agent 状态",
text: availabilitySummary(availability),
status: availability?.ready === true ? "completed" : "source",
createdAt: new Date(0).toISOString()
}
], [availability]);
return (
<section className="view workbench-view" id="workspace" data-view="workspace" aria-labelledby="workspace-title">
<div className="conversation-column">
<div className="workspace-panel conversation-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Agent </p>
<h2 id="workspace-title">Agent </h2>
</div>
<div className="panel-title-actions">
<button className="icon-button" id="copy-session-id" type="button" aria-label="复制 session id" title="复制 session id 到剪贴板,便于在 HWLAB CLI 复现。" onClick={onCopySession}> session id</button>
<StateTag id="agent-chat-status" tone={chatPending ? "pending" : "source"}>{chatPending ? "处理中" : "等待输入"}</StateTag>
</div>
</div>
<CodeAgentSummary availability={availability} />
<div className="conversation-list" id="conversation-list" aria-live="polite">
{[...intro, ...messages].map((message) => <MessageCard key={message.id} message={message} />)}
</div>
</div>
</div>
</section>
);
}
function CodeAgentSummary({ availability }: { availability: CodeAgentAvailability | null }): ReactElement {
const tone = availability?.ready === true ? "ok" : toneClass(availability?.status);
return (
<details className={`code-agent-summary tone-border-${tone}`} id="code-agent-summary">
<summary>
<span className={`code-agent-summary-icon tone-${tone}`} id="code-agent-summary-icon" aria-hidden="true"></span>
<strong className={`code-agent-summary-label tone-${tone}`} id="code-agent-summary-label">{availability?.ready === true ? "长会话可用" : "探测中"}</strong>
<span className="code-agent-summary-capability" id="code-agent-summary-capability">{availability?.capabilityLevel ?? "未观测"}</span>
<span className="code-agent-summary-trace" id="code-agent-summary-trace">{availability?.session && typeof availability.session === "object" ? "session 已观测" : "trace 未观测"}</span>
</summary>
<div className="code-agent-summary-detail" id="code-agent-summary-detail">
<pre>{jsonPreview(availability ?? { status: "unverified" }, 1200)}</pre>
</div>
</details>
);
}
function MessageCard({ message }: { message: ChatMessage }): ReactElement {
const html = renderMessageMarkdown(message.text);
return (
<article className={`message-card message-${message.role} tone-border-${toneClass(message.status)}`} data-message-status={message.status}>
<header className="message-head">
<div>
<p className="eyebrow">{message.role === "user" ? "用户" : message.role === "agent" ? "Code Agent" : "System"}</p>
<h3>{message.title}</h3>
</div>
<StateTag tone={toneClass(message.status)}>{statusLabel(message.status)}</StateTag>
</header>
<div className="message-body" dangerouslySetInnerHTML={{ __html: html }} />
<footer className="message-meta">
<span>trace {shortToken(message.traceId, 10)}</span>
<span>session {shortToken(message.sessionId, 10)}</span>
<span>{formatTimestamp(message.updatedAt ?? message.createdAt)}</span>
</footer>
{message.runnerTrace ? <TracePanel trace={message.runnerTrace} /> : null}
</article>
);
}
function TracePanel({ trace }: { trace: NonNullable<ChatMessage["runnerTrace"]> }): ReactElement {
const events = trace.events ?? [];
return (
<details className="message-trace" data-trace-ui-key={trace.traceId ?? "trace"}>
<summary> trace · {trace.status ?? "unknown"} · {events.length || trace.eventCount || 0} events</summary>
<div className="message-trace-events" data-trace-ui-key={trace.traceId ?? "trace"} data-trace-status={trace.status ?? "source"}>
{events.slice(-16).map((event, index) => <pre className="message-trace-row" data-trace-row-id={`${index}`} key={`${event.kind ?? event.type ?? "event"}-${index}`}>{jsonPreview(event, 500)}</pre>)}
</div>
</details>
);
}
function statusLabel(status: string): string {
const map: Record<string, string> = { source: "SOURCE", sent: "已发送", running: "处理中", completed: "完成", failed: "失败", blocked: "阻塞", timeout: "超时", canceled: "已取消" };
return map[status] ?? status;
}
function availabilitySummary(availability: CodeAgentAvailability | null): string {
if (!availability) return "正在读取 live 后端和 Code Agent 可用性。";
return `Code Agent ${availability.status ?? "unknown"}provider=${availability.provider ?? "unknown"}backend=${availability.backend ?? "unknown"}`;
}
@@ -1,21 +0,0 @@
import { useWorkbenchState } from "../../state/workbenchState.tsx";
import { MessageList } from "./MessageList.tsx";
export function ConversationView() {
const { state } = useWorkbenchState();
const active = state.conversations.find((conv) => conv.conversationId === state.activeConversationId)
?? state.conversations[0]
?? null;
if (!active) {
return (
<div className="conversation-list" id="conversation-list" aria-live="polite">
<div className="conversation-empty" id="conversation-empty-state">
<p> session</p>
</div>
</div>
);
}
return <MessageList conversation={active} />;
}
@@ -1,37 +0,0 @@
import { useEffect, useRef } from "react";
import { hardenRenderedMarkdown, renderMessageMarkdown } from "../../logic/message-markdown.ts";
export function MessageItem({ message }) {
const bodyRef = useRef(null);
const role = message?.role === "agent" ? "agent" : "user";
const status = message?.status ?? "unknown";
const text = message?.text ?? message?.content ?? "";
const isAssistant = role === "agent";
const html = isAssistant ? renderMessageMarkdown(text) : "";
useEffect(() => {
if (bodyRef.current && isAssistant) {
hardenRenderedMarkdown(bodyRef.current);
}
}, [html, isAssistant]);
return (
<article className={`message message-${role}`} data-message-id={message?.id ?? null} data-status={status}>
<header className="message-head">
<span className={`message-role tone-${status}`}>{role === "agent" ? "Code Agent" : "你"}</span>
{message?.traceId ? <span className="message-trace">trace {message.traceId}</span> : null}
</header>
{isAssistant ? (
<div
ref={bodyRef}
className="message-body message-body-markdown"
data-message-body="markdown"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="message-body message-body-text">{text}</p>
)}
</article>
);
}
@@ -1,19 +0,0 @@
import { MessageItem } from "./MessageItem.tsx";
export function MessageList({ conversation }) {
const messages = Array.isArray(conversation?.messages) ? conversation.messages : [];
if (messages.length === 0) {
return (
<div className="conversation-list" id="conversation-list" aria-live="polite">
<p className="conversation-empty"> session </p>
</div>
);
}
return (
<div className="conversation-list" id="conversation-list" aria-live="polite">
{messages.map((message) => (
<MessageItem key={message.id ?? `${message.role}-${message.traceId}`} message={message} />
))}
</div>
);
}
@@ -1,39 +0,0 @@
import { useEffect, useRef } from "react";
export function DeviceDetailDialog({ open, onClose, snapshot }) {
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
if (open && typeof node.showModal === "function") {
try {
node.showModal();
} catch (_) {
/* ignore */
}
} else if (typeof node.close === "function") {
try {
node.close();
} catch (_) {
/* ignore */
}
}
}, [open]);
return (
<dialog
ref={ref}
className="device-detail-dialog"
id="device-detail-dialog"
aria-label="device pod 详情"
onClose={onClose}
>
<header className="device-detail-head">
<h2>Device Pod </h2>
<button type="button" className="icon-button" onClick={onClose}></button>
</header>
<pre className="device-detail-body">{snapshot ? JSON.stringify(snapshot, null, 2) : "无数据"}</pre>
</dialog>
);
}
@@ -1,39 +0,0 @@
import { useEffect, useRef, useState } from "react";
export function DeviceEventStream({ events }) {
const [follow, setFollow] = useState(true);
const scrollRef = useRef(null);
const text = events
.map((event) => (event && event.text ? event.text : event && event.line ? event.line : typeof event === "string" ? event : JSON.stringify(event)))
.filter(Boolean)
.join("\n");
useEffect(() => {
if (follow && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [text, follow]);
return (
<div className="device-event-panel" id="device-event-panel">
<header className="device-event-head">
<span></span>
<button className="icon-button" id="device-event-follow" type="button" aria-pressed={follow} onClick={() => setFollow((value) => !value)}>{follow ? "跟随" : "暂停"}</button>
</header>
<div
className="device-event-scroll"
id="device-event-scroll"
tabIndex={0}
aria-label="device pod 事件流"
ref={scrollRef}
onScroll={(event) => {
const target = event.currentTarget;
const atBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 4;
if (!atBottom) setFollow(false);
}}
>
<pre className="device-event-text" id="device-event-text">{text || "等待事件流…"}</pre>
</div>
</div>
);
}
@@ -1,16 +0,0 @@
export function DevicePodInterfaces({ snapshot }) {
const interfaces = Array.isArray(snapshot?.status?.interfaces) ? snapshot.status.interfaces : [];
if (interfaces.length === 0) {
return <div className="device-pod-interfaces" id="device-pod-interfaces" data-empty="true">interfaces </div>;
}
return (
<ul className="device-pod-interfaces" id="device-pod-interfaces" aria-label="device pod 接口">
{interfaces.map((iface) => (
<li key={iface.name ?? iface.id ?? JSON.stringify(iface)}>
<span className="iface-name">{iface.name ?? iface.id ?? "interface"}</span>
<span className={`iface-status tone-${iface.status ?? "unknown"}`}>{iface.status ?? "unknown"}</span>
</li>
))}
</ul>
);
}
@@ -1,30 +0,0 @@
import { useWorkbenchState } from "../../state/workbenchState.tsx";
import { DeviceEventStream } from "./DeviceEventStream.tsx";
import { DevicePodSummary } from "./DevicePodSummary.tsx";
import { DevicePodInterfaces } from "./DevicePodInterfaces.tsx";
export function DevicePodPanel() {
const { state } = useWorkbenchState();
const snapshot = state.devicePodSnapshot;
if (!snapshot) {
return (
<div className="device-pod-status" id="device-pod-status" data-device-detail="pod">
device pod
</div>
);
}
return (
<>
<div className="device-pod-status" id="device-pod-status" data-device-detail="pod">
{snapshot.id ? `pod ${snapshot.id}` : "等待 device pod 数据"}
</div>
<div className="device-pod-workspace" id="device-pod-workspace">
<DevicePodSummary snapshot={snapshot} />
<DevicePodInterfaces snapshot={snapshot} />
<DeviceEventStream events={snapshot.events ?? []} />
</div>
</>
);
}
@@ -0,0 +1,122 @@
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
import { devicePodsFromLive } from "../../state/workbench";
import type { DevicePodEvent, DevicePodInterface, DevicePodItem, DevicePodStatusResponse, LiveSurface } from "../../types/domain";
import { formatTimestamp, jsonPreview, shortToken, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
interface DevicePodSidebarProps {
live: LiveSurface | null;
selectedDevicePodId: string;
onSelect(devicePodId: string): void;
collapsed: boolean;
onToggle(): void;
}
export function DevicePodSidebar({ live, selectedDevicePodId, onSelect, collapsed, onToggle }: DevicePodSidebarProps): ReactElement {
const [dialog, setDialog] = useState<{ title: string; body: string } | null>(null);
const pods = devicePodsFromLive(live);
const status = live?.devicePodStatus?.data ?? null;
const events = eventLines(live);
const selected = pods.find((pod) => pod.devicePodId === selectedDevicePodId) ?? defaultPod(selectedDevicePodId);
const summary = status?.summary ?? selected.summary ?? {};
return (
<aside className="right-sidebar" id="device-pod-sidebar" aria-label="Device Pod 摘要看板">
<header className="right-sidebar-head">
<div>
<p className="eyebrow">Device Pod</p>
<h2></h2>
</div>
<button className="right-sidebar-toggle" id="right-sidebar-toggle" type="button" aria-controls="device-pod-sidebar" aria-expanded={!collapsed} aria-label={collapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"} onClick={onToggle}>{collapsed ? "展开看板" : "收起看板"}</button>
</header>
<label className="device-pod-picker">
<span>Device Pod</span>
<select id="device-pod-select" value={selectedDevicePodId} onChange={(event) => onSelect(event.currentTarget.value)}>
{pods.length === 0 ? <option value={selectedDevicePodId}>{selectedDevicePodId}</option> : pods.map((pod) => <option key={pod.devicePodId} value={pod.devicePodId}>{pod.devicePodId}</option>)}
</select>
</label>
<StateTag id="device-pod-status-tag" tone={toneClass(status?.status ?? selected.status)}>Device Pod {status?.status ?? selected.status ?? "未观测"}</StateTag>
<section className="device-pod-status" aria-label="Device Pod status">
<div className="device-pod-summary" id="device-pod-summary">
<SummaryTile label="Pod" value={summary.devicePodId ?? selectedDevicePodId} detail="pod" title="Pod Summary" onOpen={() => setDialog({ title: "Pod Summary", body: jsonPreview({ selected, status }, 1600) })} />
<SummaryTile label="目标" value={summary.targetId ?? selected.targetId ?? "71-FREQ"} detail="target" title="Target" onOpen={() => setDialog({ title: "Target", body: jsonPreview(summary, 1200) })} />
<SummaryTile label="Profile" value={summary.profileId ?? selected.profileId ?? shortToken(summary.profileHash, 12)} detail="profile" title="Profile" onOpen={() => setDialog({ title: "Profile", body: jsonPreview(summary, 1200) })} />
</div>
<dl className="device-pod-meta">
<div><dt id="device-pod-id">Pod</dt><dd>{summary.devicePodId ?? selectedDevicePodId}</dd></div>
<div><dt id="device-pod-target">Target</dt><dd>{summary.targetId ?? selected.targetId ?? "未知"}</dd></div>
<div><dt id="device-pod-profile">Profile</dt><dd>{summary.profileId ?? shortToken(summary.profileHash, 16)}</dd></div>
<div><dt id="device-pod-freshness">Freshness</dt><dd>{formatTimestamp(summary.updatedAt ?? live?.loadedAt)}</dd></div>
</dl>
</section>
<section className="device-pod-workspace" aria-label="Device Pod workspace">
<div className="device-pod-interfaces" id="device-pod-interfaces">
{interfacesFromStatus(status).map((item, index) => <SummaryTile key={`${item.name ?? item.id ?? index}`} label={item.name ?? item.id ?? `接口 ${index + 1}`} value={item.status ?? item.type ?? "source"} detail={item.name ?? item.id ?? `iface-${index}`} title={item.name ?? "Interface"} onOpen={() => setDialog({ title: item.name ?? "Interface", body: jsonPreview(item, 1200) })} />)}
</div>
<EventStream lines={events} />
</section>
<dialog className="device-detail-dialog" id="device-detail-dialog" open={dialog !== null}>
<form method="dialog">
<header><strong id="device-detail-title">{dialog?.title ?? "Device Pod"}</strong></header>
<pre id="device-detail-body">{dialog?.body ?? ""}</pre>
<button value="close" type="button" onClick={() => setDialog(null)}></button>
</form>
</dialog>
</aside>
);
}
function SummaryTile({ label, value, detail, title, onOpen }: { label: string; value: string; detail: string; title: string; onOpen(): void }): ReactElement {
return <button className="summary-tile" type="button" role="button" tabIndex={0} data-device-detail={detail} data-device-title={title} onClick={onOpen}><span>{label}</span><strong>{value}</strong></button>;
}
function EventStream({ lines }: { lines: string[] }): ReactElement {
const [follow, setFollow] = useState(true);
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (follow && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [follow, lines]);
return (
<section className="device-event-panel" aria-label="事件流">
<header>
<span></span>
<div>
<button id="device-event-follow" type="button" onClick={() => setFollow((value) => !value)}>{follow ? "跟随" : "暂停"}</button>
<button id="device-event-jump" type="button" onClick={() => { setFollow(true); if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }}></button>
</div>
</header>
<div className="device-event-scroll" id="device-event-scroll" ref={scrollRef} onScroll={() => { const node = scrollRef.current; if (node && node.scrollHeight - node.clientHeight - node.scrollTop > 24) setFollow(false); }}>
<pre id="device-event-text">{lines.join("\n") || "等待 Device Pod 事件。"}</pre>
</div>
</section>
);
}
function defaultPod(devicePodId: string): DevicePodItem {
return { devicePodId, targetId: "71-FREQ", status: "unverified" };
}
function interfacesFromStatus(status: DevicePodStatusResponse | null): DevicePodInterface[] {
if (status?.interfaces?.length) return status.interfaces;
return [
{ name: "UART", type: "serial", status: status?.uart ? "ready" : "source" },
{ name: "Chip ID", type: "probe", status: status?.chipId ? "ready" : "source" }
];
}
function eventLines(live: LiveSurface | null): string[] {
const payload = live?.devicePodEvents?.data;
if (!payload) return [];
if (Array.isArray(payload.lines)) return payload.lines;
if (typeof payload.text === "string") return payload.text.split(/\r?\n/u).filter(Boolean);
return (payload.events ?? []).map(formatEventLine);
}
function formatEventLine(event: DevicePodEvent): string {
const ts = event.ts ?? event.time ?? new Date().toISOString();
const text = event.message ?? event.text ?? jsonPreview(event, 180);
const trace = event.refs?.traceId ? ` trace=${shortToken(event.refs.traceId, 10)}` : "";
return `${ts} ${event.level ?? "info"} ${text}${trace}`;
}
@@ -1,37 +0,0 @@
import { useState } from "react";
import { DeviceDetailDialog } from "./DeviceDetailDialog.tsx";
export function DevicePodSummary({ snapshot }) {
const [open, setOpen] = useState(false);
const status = snapshot && snapshot.status ? snapshot.status : null;
const summary = status && status.summary ? status.summary : null;
const tone = status && status.status ? status.status : "unverified";
return (
<>
<button
type="button"
className="summary-tile"
id="device-pod-summary"
role="button"
tabIndex={0}
aria-label="打开 device pod 详情"
aria-expanded={open}
aria-controls="device-detail-dialog"
data-device-detail-trigger="pod"
onClick={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
}
}}
>
<span className={`summary-tile-status tone-${tone}`}>{tone}</span>
<span className="summary-tile-target">{summary && summary.targetId ? summary.targetId : "—"}</span>
<span className="summary-tile-hash">{summary && summary.profileHash ? `profile=${String(summary.profileHash).slice(0, 16)}` : "profile 未观测"}</span>
</button>
<DeviceDetailDialog open={open} onClose={() => setOpen(false)} snapshot={snapshot} />
</>
);
}
@@ -1,46 +0,0 @@
import { useEffect, useState } from "react";
const HELP_PATH = "/help.md";
export function HelpView() {
const [status, setStatus] = useState("loading");
const [content, setContent] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
const response = await fetch(HELP_PATH, { credentials: "same-origin" });
if (cancelled) return;
if (!response.ok) {
setStatus("error");
setContent(`加载使用说明失败: ${response.status}`);
return;
}
const text = await response.text();
setContent(text);
setStatus("ready");
})().catch((error) => {
if (cancelled) return;
setStatus("error");
setContent(String(error));
});
return () => {
cancelled = true;
};
}, []);
return (
<section className="view help-view" id="help" data-view="help" aria-labelledby="help-title">
<div className="workspace-panel help-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow"></p>
<h2 id="help-title">使</h2>
</div>
<span className="state-tag tone-dry-run" id="help-status">{status === "ready" ? "已加载" : status === "error" ? "错误" : "加载中"}</span>
</div>
<pre id="help-content" className="help-content" aria-live="polite">{content}</pre>
</div>
</section>
);
}
@@ -1,54 +1,33 @@
import { workbenchRoutes, useWorkbenchState } from "../../state/workbenchState.tsx";
import type { ReactElement } from "react";
import type { RouteId } from "../../types/domain";
const RAIL_LABELS = Object.freeze({
workspace: { label: "工作台", title: "工作台", aria: "工作台" },
skills: { label: "Skills", title: "Skills", aria: "Skills" },
help: { label: "使用说明", title: "使用说明", aria: "使用说明" },
settings: { label: "设置", title: "设置", aria: "设置" },
});
interface ActivityRailProps {
route: RouteId;
collapsed: boolean;
onRoute(route: RouteId): void;
onToggle(): void;
}
const INTERNAL_LABEL = Object.freeze({ label: "内部复核", title: "内部复核", aria: "内部复核" });
export function ActivityRail() {
const { state, setRoute } = useWorkbenchState();
const current = state.route;
const routes: Array<{ id: RouteId; label: string; title: string }> = [
{ id: "workspace", label: "工作台", title: "工作台" },
{ id: "skills", label: "Skills", title: "Skills" },
{ id: "gate", label: "内部复核", title: "内部复核" },
{ id: "help", label: "使用说明", title: "使用说明" },
{ id: "settings", label: "设置", title: "设置" }
];
const settingsRoute = routes[4];
export function ActivityRail({ route, collapsed, onRoute, onToggle }: ActivityRailProps): ReactElement {
return (
<aside className="activity-rail" id="activity-rail" aria-label="工作台活动栏">
{workbenchRoutes.map((route) => {
const meta = RAIL_LABELS[route];
if (!meta) return null;
const active = current === route;
return (
<button
key={route}
className={`rail-button${active ? " active" : ""}`}
type="button"
data-route={route}
title={meta.title}
aria-label={meta.aria}
aria-current={active ? "page" : undefined}
onClick={() => setRoute(route)}
>
{meta.label}
</button>
);
})}
{routes.slice(0, 4).map((item) => <RailButton key={item.id} item={item} active={route === item.id} onRoute={onRoute} />)}
<span className="rail-spacer" aria-hidden="true" />
<button
className="rail-button"
type="button"
data-route="gate"
title={INTERNAL_LABEL.title}
aria-label={INTERNAL_LABEL.aria}
onClick={() => {
if (typeof window !== "undefined") {
window.location.assign("/gate/");
}
}}
>
{INTERNAL_LABEL.label}
</button>
{settingsRoute ? <RailButton item={settingsRoute} active={route === "settings"} onRoute={onRoute} /> : null}
<button className="rail-button sidebar-toggle" id="left-sidebar-toggle" type="button" aria-controls="activity-rail" aria-expanded={!collapsed} aria-label={collapsed ? "展开左侧导航" : "折叠左侧导航"} title={collapsed ? "展开左侧导航" : "折叠左侧导航"} onClick={onToggle}>{collapsed ? "展开" : "收起"}</button>
</aside>
);
}
function RailButton({ item, active, onRoute }: { item: { id: RouteId; label: string; title: string }; active: boolean; onRoute(route: RouteId): void }): ReactElement {
return <button className={`rail-button${active ? " active" : ""}`} type="button" data-route={item.id} title={item.title} aria-label={item.title} aria-current={active ? "page" : undefined} onClick={() => onRoute(item.id)}>{item.label}</button>;
}
@@ -1,14 +0,0 @@
export function AppShell({ left, center, right }) {
return (
<main
className="workbench-shell"
data-app-shell
data-help-route-policy="non-default-internal-help"
data-internal-help-path="/help.md"
>
{left}
{center}
{right}
</main>
);
}
@@ -1,18 +0,0 @@
import { useWorkbenchState } from "../../state/workbenchState.tsx";
import { WorkspaceView } from "./WorkspaceView.tsx";
import { HelpView } from "../help/HelpView.tsx";
import { SkillsView } from "../skills/SkillsView.tsx";
import { SettingsView } from "../settings/SettingsView.tsx";
export function CenterWorkspace({ auth }) {
const { state } = useWorkbenchState();
const route = state.route;
return (
<section className="center-workspace" data-center-workspace>
{route === "workspace" ? <WorkspaceView auth={auth} /> : null}
{route === "help" ? <HelpView /> : null}
{route === "skills" ? <SkillsView /> : null}
{route === "settings" ? <SettingsView auth={auth} /> : null}
</section>
);
}
@@ -1,30 +0,0 @@
import { useWorkbenchState } from "../../state/workbenchState.tsx";
import { classifyWorkbenchLiveStatus } from "../../logic/live-status.ts";
export function LiveStatusSummary() {
const { state } = useWorkbenchState();
const summary = state.devicePodSnapshot?.status ?? null;
const probes = Array.isArray(summary?.probes) ? summary.probes : [];
const status = classifyWorkbenchLiveStatus({
devicePodStatus: state.devicePodSnapshot?.status ?? null,
});
const tag = status?.overallTone ?? "unverified";
return (
<details className={`code-agent-summary tone-border-${tag}`} id="code-agent-summary">
<summary>
<span className={`code-agent-summary-icon tone-${tag}`} aria-hidden="true"></span>
<strong className={`code-agent-summary-label tone-${tag}`}>{status?.label ?? "探测中"}</strong>
<span className="code-agent-summary-capability">
{probes.find((probe) => probe.serviceId === "hwlab-device-pod")?.kind ?? "未观测"}
</span>
<span className="code-agent-summary-trace">
{state.devicePodSnapshot?.id ? `pod ${state.devicePodSnapshot.id}` : "trace 未观测"}
</span>
</summary>
<div className="code-agent-summary-detail">
{probes.length === 0 ? "等待运行面状态上报" : probes.map((probe) => probe.serviceId).join(" / ")}
</div>
</details>
);
}
@@ -1,15 +0,0 @@
import { DevicePodPanel } from "../device-pod/DevicePodPanel.tsx";
export function RightSidebar() {
return (
<aside className="right-sidebar" id="device-pod-sidebar" aria-label="Device Pod 面板">
<header className="right-sidebar-head">
<div>
<p className="eyebrow">Device Pod</p>
<h2></h2>
</div>
</header>
<DevicePodPanel />
</aside>
);
}
@@ -1,93 +0,0 @@
import { useWorkbenchState } from "../../state/workbenchState.tsx";
import { sessionTabsFromConversations } from "../../logic/app-session-tabs.ts";
export function SessionSidebar({ auth }) {
const { state, selectConversation } = useWorkbenchState();
const view = sessionTabsFromConversations(state.conversations ?? [], {
selectedConversationId: state.activeConversationId,
});
const tabs = view.tabs;
return (
<>
<aside className="session-sidebar" id="session-sidebar" aria-label="Code Agent sessions">
<header className="session-sidebar-head">
<div>
<p className="eyebrow">Sessions</p>
<h2>Agent Sessions</h2>
</div>
<button
className="session-icon-button"
id="session-create"
type="button"
aria-label="创建 session"
title="创建 session"
onClick={() => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("hwlab:create-session"));
}
}}
>+</button>
</header>
<div className="session-model-channel" id="session-model-channel" aria-live="polite">
</div>
<div className="session-tabs" id="session-tabs" role="tablist" aria-label="Code Agent sessions">
{tabs.length === 0 ? (
<p className="session-empty"> session + </p>
) : (
tabs.map((tab) => {
const active = tab.key === view.activeKey;
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={active}
className={`session-tab tone-${tab.status ?? "idle"}`}
onClick={() => selectConversation(tab.conversationId, tab.sessionId ?? null)}
>
<span className="session-tab-label">{tab.label}</span>
<span className="session-tab-subtitle">{tab.subtitle}</span>
</button>
);
})
)}
</div>
<div className="session-sidebar-foot">
<span className="session-status" id="session-status">
{tabs.length > 0 ? `${tabs.length} 个 session` : "等待 workspace"}
</span>
<button
className="session-delete-button"
id="session-delete"
type="button"
disabled={!view.activeConversation}
onClick={() => {
if (view.activeConversation?.sessionId) {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("hwlab:delete-session", { detail: view.activeConversation.sessionId })
);
}
}
}}
></button>
</div>
</aside>
<div
className="resize-handle session-sidebar-resize"
id="session-sidebar-resize"
role="separator"
tabIndex={0}
aria-label="拖拽调整 Session sidebar 宽度"
aria-controls="session-sidebar"
aria-orientation="vertical"
aria-valuemin={148}
aria-valuemax={320}
aria-valuenow={220}
title="拖拽调整 Session sidebar 宽度;用左右方向键微调,Home/End 到最小或最大。"
/>
</>
);
}
@@ -1,37 +0,0 @@
import { useEffect } from "react";
import { useDevicePod } from "../../hooks/useDevicePod.ts";
import { useSkills } from "../../hooks/useSkills.ts";
import { useTracePolling } from "../../hooks/useTracePolling.ts";
import { useWorkspace } from "../../hooks/useWorkspace.ts";
import { useWorkbenchState } from "../../state/workbenchState.tsx";
import { ActivityRail } from "./ActivityRail.tsx";
import { AppShell } from "./AppShell.tsx";
import { CenterWorkspace } from "./CenterWorkspace.tsx";
import { RightSidebar } from "./RightSidebar.tsx";
import { SessionSidebar } from "./SessionSidebar.tsx";
export function Workbench({ auth }) {
const { state, setAuth, setWorkspace, setConversations, setDevicePod, setError, clearError } = useWorkbenchState();
useWorkspace({ setWorkspace, setConversations, setError, clearError });
useDevicePod({ setDevicePod, setError, clearError });
useSkills({ enabled: state.route === "skills", setError, clearError });
useTracePolling({ setTrace: () => {}, setError, clearError });
useEffect(() => {
if (auth.session) setAuth(auth.session);
}, [auth.session, setAuth]);
return (
<AppShell
left={
<>
<ActivityRail />
<SessionSidebar auth={auth} />
</>
}
center={<CenterWorkspace auth={auth} />}
right={<RightSidebar auth={auth} />}
/>
);
}
@@ -1,93 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useCodeAgentSubmit } from "../../hooks/useCodeAgentSubmit.ts";
import { useTracePolling } from "../../hooks/useTracePolling.ts";
import { useWorkbenchState } from "../../state/workbenchState.tsx";
import { computeCodeAgentComposerState } from "../../logic/composer-policy.ts";
import { CommandBar } from "../command-bar/CommandBar.tsx";
import { ConversationView } from "../conversation/ConversationView.tsx";
import { LiveStatusSummary } from "./LiveStatusSummary.tsx";
export function WorkspaceView({ auth }) {
const { state, setTrace, setError, clearError } = useWorkbenchState();
const composer = useMemo(
() =>
computeCodeAgentComposerState({
messages: state.conversations.flatMap((conv) => conv.messages ?? []),
currentRequest: state.composer,
workspace: state.workspace,
conversationId: state.activeConversationId,
sessionId: state.activeSessionId,
threadId: state.workspace?.threadId,
}),
[state.conversations, state.composer, state.workspace, state.activeConversationId, state.activeSessionId]
);
const { submit, pending } = useCodeAgentSubmit({
workspace: state.workspace,
activeConversationId: state.activeConversationId,
activeSessionId: state.activeSessionId,
});
const [draft, setDraft] = useState("");
useTracePolling({
traceId: composer?.targetTraceId ?? null,
setTrace,
setError,
clearError,
});
useEffect(() => {
if (typeof document === "undefined") return;
if (composer) {
document.body.dataset.composerMode = composer.submitMode ?? "turn";
document.body.dataset.composerLocked = composer.locked ? "true" : "false";
}
}, [composer]);
return (
<section className="view workbench-view" id="workspace" data-view="workspace" aria-labelledby="workspace-title">
<div className="conversation-column">
<div className="workspace-panel conversation-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Agent </p>
<h2 id="workspace-title">Agent </h2>
</div>
<div className="panel-title-actions">
<button
className="icon-button"
id="copy-session-id"
type="button"
hidden={!state.activeSessionId}
aria-label="复制 session id"
title="复制 session id 到剪贴板,便于在 HWLAB CLI 复现。"
onClick={async () => {
if (!state.activeSessionId) return;
try {
await navigator.clipboard?.writeText(state.activeSessionId);
} catch (_) {
/* ignore */
}
}}
> session id</button>
<span className={`state-tag tone-${composer?.submitMode === "steer" ? "running" : "source"}`} id="agent-chat-status">
{composer?.submitMode === "steer" ? "运行中" : "等待输入"}
</span>
</div>
</div>
<LiveStatusSummary />
<ConversationView />
</div>
</div>
<CommandBar
draft={draft}
setDraft={setDraft}
composer={composer}
pending={pending}
onSubmit={async (message) => {
await submit({ message, mode: composer?.submitMode ?? "turn" });
setDraft("");
}}
/>
</section>
);
}
@@ -0,0 +1,45 @@
import type { ReactElement } from "react";
import type { SessionTab } from "../../types/domain";
import { shortToken, toneClass } from "../../utils";
interface SessionSidebarProps {
tabs: SessionTab[];
loading: boolean;
modelLabel: string;
onCreate(): void;
onDelete(): void;
onSelect(tab: SessionTab): void;
}
export function SessionSidebar({ tabs, loading, modelLabel, onCreate, onDelete, onSelect }: SessionSidebarProps): ReactElement {
const active = tabs.find((tab) => tab.active) ?? null;
return (
<aside className="session-sidebar" id="session-sidebar" aria-label="Code Agent sessions">
<header className="session-sidebar-head">
<div>
<p className="eyebrow">Sessions</p>
<h2>Agent Sessions</h2>
</div>
<button className="session-icon-button" id="session-create" type="button" aria-label="创建 session" title="创建 session" onClick={onCreate}>+</button>
</header>
<div className="session-model-channel" id="session-model-channel" aria-live="polite">{modelLabel}</div>
<div className="session-tabs" id="session-tabs" role="tablist" aria-label="Code Agent sessions">
{tabs.length === 0 ? <p className="session-empty"> session</p> : tabs.map((tab) => <SessionTabButton key={tab.key} tab={tab} onSelect={onSelect} />)}
</div>
<div className="session-sidebar-foot">
<span className="session-status" id="session-status">{loading ? "加载中" : active ? `当前 ${shortToken(active.sessionId ?? active.conversationId, 8)}` : "等待 workspace"}</span>
<button className="session-delete-button" id="session-delete" type="button" disabled={!active} onClick={onDelete}></button>
</div>
</aside>
);
}
function SessionTabButton({ tab, onSelect }: { tab: SessionTab; onSelect(tab: SessionTab): void }): ReactElement {
return (
<button type="button" className={`session-tab tone-border-${toneClass(tab.status)}`} role="tab" aria-selected={tab.active} title={`conversation=${tab.conversationId ?? "unknown"} / session=${tab.sessionId ?? "unknown"} / trace=${tab.lastTraceId ?? "unknown"}`} onClick={() => onSelect(tab)}>
<span className="session-tab-label">{tab.label}</span>
<span className="session-tab-subtitle">{tab.subtitle}</span>
<span className="session-tab-count">{tab.messageCount} </span>
</button>
);
}
@@ -1,29 +1,14 @@
export function SettingsView({ auth }) {
const session = auth && auth.session ? auth.session : null;
const username = session && session.user && session.user.username ? session.user.username : "—";
const mode = session ? session.mode : "—";
const expiresAt = session && session.expiresAt ? new Date(session.expiresAt).toLocaleString("zh-CN") : "—";
import type { ReactElement } from "react";
interface SettingsViewProps {
onLogout(): Promise<void>;
}
export function SettingsView({ onLogout }: SettingsViewProps): ReactElement {
return (
<section className="view settings-view" id="settings" data-view="settings" aria-labelledby="settings-title">
<div className="workspace-panel settings-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow"></p>
<h2 id="settings-title"></h2>
</div>
</div>
<div className="settings-body">
<dl className="settings-summary">
<dt></dt>
<dd id="settings-username">{username}</dd>
<dt></dt>
<dd id="settings-mode">{mode}</dd>
<dt></dt>
<dd id="settings-expires">{expiresAt}</dd>
</dl>
<button className="settings-logout" id="settings-logout" type="button" onClick={() => auth.signOut()}></button>
</div>
<div className="panel-title-row"><div><p className="eyebrow"></p><h2 id="settings-title"></h2></div></div>
<div className="settings-content"><button className="logout-button" id="logout-button" type="button" onClick={() => void onLogout()}></button></div>
</div>
</section>
);
@@ -0,0 +1,12 @@
import type { ReactElement } from "react";
import type { Tone } from "../../types/domain";
interface StateTagProps {
tone: Tone | string;
children: React.ReactNode;
id?: string;
}
export function StateTag({ tone, children, id }: StateTagProps): ReactElement {
return <span id={id} className={`state-tag tone-${tone}`}>{children}</span>;
}
@@ -1,88 +1,119 @@
import { useRef, useState } from "react";
import type { ReactElement } from "react";
import { FormEvent, useEffect, useState } from "react";
import { useSkills } from "../../hooks/useSkills.ts";
import { useWorkbenchState } from "../../state/workbenchState.tsx";
import { uploadSkillFiles } from "../../services/api/skills.ts";
import { api } from "../../services/api/client";
import type { SkillFileResponse, SkillItem, SkillsResponse, SkillTreeResponse } from "../../types/domain";
import { jsonPreview, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
export function SkillsView() {
const { setError } = useWorkbenchState();
const { skills, loading } = useSkills({ enabled: true, setError, clearError: () => {} });
const [selected, setSelected] = useState(null);
const [uploadStatus, setUploadStatus] = useState(null);
const fileInputRef = useRef(null);
async function handleUpload(event) {
const files = Array.from(event.target.files ?? []);
if (files.length === 0) return;
setUploadStatus({ kind: "uploading", count: files.length });
const form = new FormData();
for (const file of files) {
form.append("files", file, file.webkitRelativePath || file.name);
interface SkillsState {
loading: boolean;
uploadPending: boolean;
items: SkillItem[];
assembly: SkillsResponse["agentRunAssembly"];
selectedId: string | null;
tree: SkillTreeResponse | null;
preview: SkillFileResponse["file"] | null;
error: string | null;
}
const result = await uploadSkillFiles(form);
if (!result.ok) {
setUploadStatus({ kind: "error", message: `上传失败: ${result.status}` });
const initialState: SkillsState = { loading: false, uploadPending: false, items: [], assembly: null, selectedId: null, tree: null, preview: null, error: null };
export function SkillsView(): ReactElement {
const [state, setState] = useState<SkillsState>(initialState);
async function load(force = false): Promise<void> {
if (state.loading && !force) return;
setState((current) => ({ ...current, loading: true, error: null }));
const response = await api.skills();
if (!response.ok) {
setState((current) => ({ ...current, loading: false, items: [], assembly: null, error: response.error ?? "skills 列表不可用" }));
return;
}
setUploadStatus({ kind: "ok", count: files.length });
const items = response.data?.skills ?? [];
const selectedId = state.selectedId && items.some((item) => item.id === state.selectedId) ? state.selectedId : items[0]?.id ?? null;
setState((current) => ({ ...current, loading: false, items, assembly: response.data?.agentRunAssembly ?? null, selectedId }));
if (selectedId) await loadDetail(selectedId);
}
async function loadDetail(skillId: string): Promise<void> {
setState((current) => ({ ...current, selectedId: skillId, error: null }));
const [tree, preview] = await Promise.all([api.skillTree(skillId), api.skillFile(skillId, "SKILL.md")]);
setState((current) => ({
...current,
tree: tree.ok ? tree.data : null,
preview: preview.ok ? preview.data?.file ?? null : { path: "SKILL.md", content: preview.error ?? "文件不可预览", status: "failed" },
error: tree.ok ? current.error : tree.error
}));
}
async function upload(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
const input = event.currentTarget.querySelector<HTMLInputElement>("#skill-upload-input");
const files = Array.from(input?.files ?? []);
if (files.length === 0 || state.uploadPending) return;
setState((current) => ({ ...current, uploadPending: true, error: null }));
try {
const payload = await Promise.all(files.map(readUploadFile));
const response = await api.uploadSkills(payload);
if (!response.ok) {
setState((current) => ({ ...current, error: response.error ?? "skill 上传失败" }));
} else {
if (input) input.value = "";
await load(true);
}
} finally {
setState((current) => ({ ...current, uploadPending: false }));
}
}
useEffect(() => { void load(); }, []);
const count = state.items.length;
const statusText = state.uploadPending ? "上传中" : state.loading ? "加载中" : state.error ? "阻塞" : `${count} 个 skill`;
return (
<section className="view skills-view" id="skills" data-view="skills" aria-labelledby="skills-title">
<div className="workspace-panel skills-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow"></p>
<h2 id="skills-title">Skills</h2>
<div><p className="eyebrow">Skills</p><h2 id="skills-title">Skill </h2></div>
<div className="skills-actions"><StateTag id="skill-status" tone={toneClass(state.error ? "blocked" : state.loading ? "pending" : "source")}>{statusText}</StateTag><button className="command-button secondary" id="skill-refresh" type="button" onClick={() => void load(true)}></button></div>
</div>
<span className="state-tag tone-dry-run" id="skills-status">{loading ? "加载中" : `${skills.length}`}</span>
</div>
<div className="skills-toolbar">
<input
id="skill-upload-input"
type="file"
webkitDirectory=""
multiple
hidden
ref={fileInputRef}
onChange={handleUpload}
/>
<button className="icon-button" id="skill-upload-button" type="button" onClick={() => fileInputRef.current && fileInputRef.current.click()}></button>
<button className="icon-button" id="skill-refresh-button" type="button" onClick={() => window.location.reload()}></button>
</div>
{uploadStatus ? <p className="skill-upload-status">{JSON.stringify(uploadStatus)}</p> : null}
<div className="skills-body">
<ul className="skills-list" id="skills-list" aria-label="已上传技能包">
{skills.map((skill) => (
<li key={skill.id ?? skill.name}>
<button type="button" className="skill-item" onClick={() => setSelected(skill)}>
{skill.name ?? skill.id}
</button>
</li>
))}
</ul>
<section className="skill-detail" id="skill-detail" aria-live="polite">
{selected ? <SkillPreview skill={selected} /> : <p className="skill-empty"></p>}
<div className="skills-layout">
<section className="skill-upload-panel" aria-label="上传 skill">
<form id="skill-upload-form" className="skill-upload-form" onSubmit={upload}>
<label className="skill-upload-drop"><span></span><input id="skill-upload-input" type="file" multiple webkitdirectory="" /></label>
<button className="command-button" id="skill-upload-button" type="submit" disabled={state.uploadPending}></button>
</form>
<AssemblyPanel assembly={state.assembly} />
<div id="skill-list" className="skill-list" aria-label="Skill 列表" aria-live="polite">{state.items.map((item) => <button key={item.id} type="button" className={`skill-item${item.id === state.selectedId ? " active" : ""}`} data-skill-id={item.id} onClick={() => void loadDetail(item.id)}><strong>{item.name ?? item.title ?? item.id}</strong><span>{item.description ?? item.status ?? "source"}</span></button>)}</div>
</section>
<section className="skill-detail-panel" aria-label="Skill 文件"><div className="panel-title-row skill-detail-head"><div><p className="eyebrow">Skill Files</p><h3 id="skill-detail-title">{state.selectedId ?? "未选择 skill"}</h3></div></div><div id="skill-tree" className="skill-tree" aria-label="Skill 文件树">{(state.tree?.files ?? state.tree?.tree ?? []).map((file) => <button type="button" key={file.path}>{file.path}</button>)}</div></section>
<section className="skill-preview-panel" aria-label="SKILL.md 预览"><pre id="skill-preview" className="skill-preview" aria-live="polite">{state.preview?.content ?? "等待 SKILL.md。"}</pre></section>
</div>
</div>
</section>
);
}
function SkillPreview({ skill }) {
function AssemblyPanel({ assembly }: { assembly: SkillsResponse["agentRunAssembly"] }): ReactElement {
if (!assembly) return <div id="skill-assembly" className="skill-assembly" aria-live="polite">AgentRun skill </div>;
const promptRefs = assembly.promptAssembly?.refs ?? assembly.promptRefs ?? [];
return (
<div>
<h3>{skill.name ?? skill.id}</h3>
<p>{skill.description ?? ""}</p>
<h4> prompt </h4>
<ul className="skill-prompt-assembly">
{Array.isArray(skill.promptRefs)
? skill.promptRefs.map((ref) => (
<li key={ref} className="skill-prompt-row">{ref}</li>
))
: <li className="skill-prompt-row">ResourceBundleRef.promptRefs </li>}
</ul>
<div id="skill-assembly" className="skill-assembly" aria-live="polite">
<div className={`skill-assembly-body tone-border-${toneClass(assembly.status)}`}>
<div className="skill-assembly-head"><strong className="skill-name">AgentRun </strong><StateTag tone={toneClass(assembly.status)}>{assembly.status ?? "unknown"}</StateTag></div>
<div className="skill-meta"><span>skills={assembly.counts?.skillRefs ?? assembly.skillRefs?.length ?? 0}</span><span>prompts={assembly.counts?.promptRefs ?? promptRefs.length}</span><span>tools={assembly.counts?.toolAliases ?? assembly.toolAliases?.length ?? 0}</span></div>
<div className="skill-prompt-assembly"><div className="skill-prompt-head"><span className="skill-prompt-title"> prompt </span><StateTag tone={toneClass(assembly.promptAssembly?.status ?? assembly.status)}>{assembly.promptAssembly?.status ?? "source"}</StateTag></div><div className="skill-prompt-list">{promptRefs.map((item) => <div className="skill-prompt-row" key={item.name}><span className="skill-prompt-name mono wrap">{item.name}</span><span>{item.required ? "required" : "optional"}</span></div>)}</div></div>
<pre className="skill-meta mono wrap">ResourceBundleRef.promptRefs {jsonPreview(promptRefs, 600)}</pre>
</div>
</div>
);
}
async function readUploadFile(file: File): Promise<{ relativePath: string; sizeBytes: number; contentBase64: string }> {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
return { relativePath: file.webkitRelativePath || file.name, sizeBytes: file.size, contentBase64: btoa(binary) };
}
+80 -59
View File
@@ -1,77 +1,98 @@
import { useCallback, useEffect, useReducer, useRef } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { performSignOut, probeAuthSession, signInWithCredentials } from "../services/auth.ts";
import { api } from "../services/api/client";
import type { AuthSession, AuthState, AuthUser } from "../types/domain";
import { asRecord, nonEmptyString } from "../utils";
const STATUS = Object.freeze({
CHECKING: "checking",
AUTHENTICATING: "authenticating",
AUTHENTICATED: "authenticated",
UNAUTHENTICATED: "unauthenticated",
});
const DEFAULT_USERNAME = "admin";
const DEFAULT_PASSWORD = "hwlab2026";
const initial = Object.freeze({
status: STATUS.CHECKING,
session: null,
error: null,
});
function reducer(state, action) {
switch (action.type) {
case "checking":
return { ...initial, status: STATUS.CHECKING };
case "authenticating":
return { ...state, status: STATUS.AUTHENTICATING, error: null };
case "authenticated":
return { status: STATUS.AUTHENTICATED, session: action.session, error: null };
case "unauthenticated":
return { status: STATUS.UNAUTHENTICATED, session: null, error: action.error ?? null };
default:
return state;
}
export interface UseAuthResult {
authState: AuthState;
session: AuthSession | null;
error: string | null;
login(username: string, password: string): Promise<void>;
logout(): Promise<void>;
}
export function useAuthGate() {
const [state, dispatch] = useReducer(reducer, initial);
const probeStarted = useRef(false);
export function useAuth(): UseAuthResult {
const [authState, setAuthState] = useState<AuthState>("checking");
const [session, setSession] = useState<AuthSession | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (probeStarted.current) return;
probeStarted.current = true;
(async () => {
const result = await probeAuthSession();
if (result.authenticated) {
dispatch({ type: "authenticated", session: result });
} else {
dispatch({ type: "unauthenticated", error: result.error ?? null });
let canceled = false;
async function run(): Promise<void> {
const current = await api.session();
if (canceled) return;
if (current.ok && current.data?.authenticated === true) {
const next = sessionFromPayload(current.data);
setSession(next);
setAuthState("authenticated");
return;
}
})().catch((error) => {
dispatch({ type: "unauthenticated", error: String(error) });
});
const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
const username = nonEmptyString(config?.username) ?? DEFAULT_USERNAME;
const password = nonEmptyString(config?.password) ?? DEFAULT_PASSWORD;
const mode = config?.mode ?? "auto";
if (mode === "auto") {
const autoLogin = await api.login(username, password);
if (canceled) return;
if (autoLogin.ok && autoLogin.data?.authenticated === true) {
setSession(sessionFromPayload(autoLogin.data));
setAuthState("authenticated");
return;
}
}
setAuthState("login");
}
void run();
return () => {
canceled = true;
};
}, []);
const signIn = useCallback(async (username, password) => {
dispatch({ type: "authenticating" });
const result = await signInWithCredentials(username, password);
if (result.ok) {
dispatch({ type: "authenticated", session: result.session });
return { ok: true };
useEffect(() => {
document.body.dataset.authState = authState;
}, [authState]);
const login = useCallback(async (username: string, password: string) => {
setError(null);
const response = await api.login(username.trim(), password);
if (!response.ok || response.data?.authenticated !== true) {
setError("账号或密码不正确,请重新输入。");
setAuthState("login");
return;
}
dispatch({ type: "unauthenticated", error: result.error });
return { ok: false, error: result.error };
setSession(sessionFromPayload(response.data));
setAuthState("authenticated");
}, []);
const signOut = useCallback(async () => {
await performSignOut();
if (typeof window !== "undefined") {
window.location.reload();
}
const logout = useCallback(async () => {
await api.logout();
setSession(null);
setAuthState("login");
}, []);
return useMemo(() => ({ authState, session, error, login, logout }), [authState, session, error, login, logout]);
}
function sessionFromPayload(payload: { authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }): AuthSession {
return {
status: state.status,
session: state.session,
error: state.error,
signIn,
signOut,
authenticated: payload.authenticated === true,
mode: "server",
user: userFromUnknown(payload.user),
actor: userFromUnknown(payload.actor),
expiresAt: nonEmptyString(payload.expiresAt)
};
}
function userFromUnknown(value: unknown): AuthUser | undefined {
const record = asRecord(value);
if (!record) return undefined;
return {
id: nonEmptyString(record.id) ?? undefined,
username: nonEmptyString(record.username) ?? undefined,
name: nonEmptyString(record.name) ?? undefined
};
}
@@ -1,45 +0,0 @@
import { useCallback, useState } from "react";
import { cancelCodeAgentTurn, steerCodeAgentTurn, submitCodeAgentTurn } from "../services/api/codeAgent.ts";
export function useCodeAgentSubmit({ workspace, activeConversationId, activeSessionId }) {
const [pending, setPending] = useState(false);
const [lastError, setLastError] = useState(null);
const [lastTraceId, setLastTraceId] = useState(null);
const submit = useCallback(
async ({ message, mode = "turn" }) => {
setLastError(null);
setPending(true);
const payload = {
message,
conversationId: activeConversationId,
sessionId: activeSessionId,
workspaceId: workspace?.workspaceId ?? null,
workspaceRevision: workspace?.revision ?? null,
threadId: workspace?.threadId ?? null,
};
const action = mode === "steer" ? steerCodeAgentTurn : submitCodeAgentTurn;
const result = await action(payload);
setPending(false);
if (!result.ok) {
setLastError(`submit failed: ${result.status}`);
return { ok: false };
}
const traceId = result.data?.traceId ?? result.data?.targetTraceId ?? null;
if (traceId) setLastTraceId(traceId);
return { ok: true, traceId, data: result.data };
},
[activeConversationId, activeSessionId, workspace]
);
const cancel = useCallback(async () => {
if (!lastTraceId) return { ok: false, error: "no active trace" };
setPending(true);
const result = await cancelCodeAgentTurn({ traceId: lastTraceId });
setPending(false);
return { ok: result.ok, data: result.data };
}, [lastTraceId]);
return { submit, cancel, pending, lastError, lastTraceId };
}
@@ -1,67 +0,0 @@
import { useEffect, useState } from "react";
import { fetchDevicePodEvents, fetchDevicePodStatus, listDevicePods } from "../services/api/devicePods.ts";
const POLL_INTERVAL_MS = 4000;
const EVENT_LIMIT = 120;
export function useDevicePod({ setDevicePod, setError, clearError }) {
const [devicePodId, setDevicePodId] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
const list = await listDevicePods();
if (cancelled) return;
if (!list.ok) {
setError(`device-pod list failed: ${list.status}`);
return;
}
const pods = Array.isArray(list.data?.pods) ? list.data.pods : [];
if (pods.length > 0) {
setDevicePodId(pods[0].devicePodId ?? pods[0].id ?? null);
}
})().catch((error) => setError(String(error)));
return () => {
cancelled = true;
};
}, [setError]);
useEffect(() => {
if (!devicePodId) return;
let cancelled = false;
const poll = async () => {
const status = await fetchDevicePodStatus(devicePodId);
if (cancelled) return;
if (!status.ok) {
setError(`device-pod status failed: ${status.status}`);
return;
}
setDevicePod({ id: devicePodId, status: status.data, updatedAt: Date.now() });
};
poll().catch(() => {});
const timer = setInterval(() => poll().catch(() => {}), POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(timer);
clearError();
};
}, [devicePodId, setDevicePod, setError, clearError]);
useEffect(() => {
if (!devicePodId) return;
let cancelled = false;
const poll = async () => {
const events = await fetchDevicePodEvents(devicePodId, { limit: EVENT_LIMIT });
if (cancelled) return;
if (!events.ok) return;
setDevicePod({ id: devicePodId, events: events.data?.events ?? [], updatedAt: Date.now() });
};
poll().catch(() => {});
const timer = setInterval(() => poll().catch(() => {}), POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [devicePodId, setDevicePod]);
}
@@ -1,30 +0,0 @@
import { useEffect, useState } from "react";
import { listSkills } from "../services/api/skills.ts";
export function useSkills({ enabled, setError, clearError }) {
const [skills, setSkills] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setLoading(true);
(async () => {
const result = await listSkills();
if (cancelled) return;
setLoading(false);
if (!result.ok) {
setError(`skills list failed: ${result.status}`);
return;
}
setSkills(Array.isArray(result.data?.skills) ? result.data.skills : []);
})().catch((error) => setError(String(error)));
return () => {
cancelled = true;
clearError();
};
}, [enabled, setError, clearError]);
return { skills, loading };
}
@@ -1,30 +0,0 @@
import { useEffect } from "react";
import { fetchCodeAgentResult, fetchCodeAgentTrace } from "../services/api/codeAgent.ts";
const POLL_INTERVAL_MS = 2000;
export function useTracePolling({ traceId, setTrace, setError, clearError }) {
useEffect(() => {
if (!traceId) return;
let cancelled = false;
const poll = async () => {
const trace = await fetchCodeAgentTrace(traceId);
if (cancelled) return;
if (!trace.ok) {
setError?.(`trace fetch failed: ${trace.status}`);
return;
}
const result = await fetchCodeAgentResult(traceId);
if (cancelled) return;
setTrace?.({ trace: trace.data, result: result.ok ? result.data : null, polledAt: Date.now() });
};
poll().catch(() => {});
const timer = setInterval(() => poll().catch(() => {}), POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(timer);
clearError?.();
};
}, [traceId, setTrace, setError, clearError]);
}
@@ -1,32 +0,0 @@
import { useEffect } from "react";
import { fetchWorkbenchWorkspace } from "../services/api/workspace.ts";
import { listConversations } from "../services/api/conversations.ts";
export function useWorkspace({ setWorkspace, setConversations, setError, clearError }) {
useEffect(() => {
let cancelled = false;
(async () => {
const ws = await fetchWorkbenchWorkspace();
if (cancelled) return;
if (ws.ok) {
setWorkspace(ws.data ?? null);
} else {
setError(`workspace hydrate failed: ${ws.status}`);
}
})().catch((error) => setError(String(error)));
(async () => {
const conv = await listConversations();
if (cancelled) return;
if (conv.ok) {
setConversations(Array.isArray(conv.data?.conversations) ? conv.data.conversations : []);
}
})().catch(() => {});
return () => {
cancelled = true;
clearError();
};
}, [setWorkspace, setConversations, setError, clearError]);
}
@@ -1,270 +0,0 @@
const EMPTY_CONVERSATION_SESSION_LIST = Object.freeze({ tabs: [], activeKey: null, activeConversation: null });
export function sessionTabsFromMessages(messages = [], options = {}) {
const list = Array.isArray(messages) ? messages : [];
const aliases = sessionAliasesFromMessages(list);
const sessions = new Map();
for (const message of list) {
const key = sessionKeyFromMessage(message, aliases);
const existing = sessions.get(key) ?? createMessageSessionTab(key, message);
mergeMessageIntoSessionTab(existing, message);
sessions.set(key, existing);
}
const tabs = [...sessions.values()]
.sort((left, right) => right.lastTimestampMs - left.lastTimestampMs)
.map((tab, index) => ({
...tab,
ordinal: index + 1,
label: tab.sessionId ? `Session ${shortToken(tab.sessionId)}` : `会话 ${index + 1}`,
subtitle: tab.status === "running" ? "处理中" : tab.lastTraceId ? `trace ${shortToken(tab.lastTraceId)}` : "无 trace"
}));
const preferredKey = firstNonEmptyString(options.activeSessionKey, options.sessionId, options.conversationId, options.traceId);
const activeKey = resolveActiveSessionKey(tabs, preferredKey);
return {
activeKey,
tabs: tabs.map((tab) => ({ ...tab, active: tab.key === activeKey })),
messages: activeKey ? list.filter((message) => sessionKeyFromMessage(message, aliases) === activeKey) : list
};
}
export function sessionTabsFromConversations(conversations = [], options = {}) {
const selectedConversationId = firstNonEmptyString(options.selectedConversationId, options.conversationId);
const selectedSessionId = firstNonEmptyString(options.selectedSessionId, options.sessionId);
const selectedTraceId = firstNonEmptyString(options.activeTraceId, options.traceId);
const tabs = (Array.isArray(conversations) ? conversations : [])
.map((conversation) => sessionTabFromConversation(conversation))
.filter(Boolean)
.sort((left, right) => right.lastTimestampMs - left.lastTimestampMs)
.map((tab, index) => ({
...tab,
ordinal: index + 1,
label: truncateLabel(tab.userPreview) || tab.title || `Session ${index + 1}`,
subtitle: conversationSessionTabSubtitle(tab),
active: conversationSessionTabMatches(tab, selectedConversationId, selectedSessionId, selectedTraceId)
}));
if (tabs.length === 0) return { ...EMPTY_CONVERSATION_SESSION_LIST };
let active = tabs.find((tab) => tab.active) ?? tabs[0];
const normalized = tabs.map((tab) => ({ ...tab, active: tab.key === active.key }));
active = normalized.find((tab) => tab.key === active.key) ?? normalized[0];
return { tabs: normalized, activeKey: active?.key ?? null, activeConversation: active ?? null };
}
export function sessionKeyFromMessage(message = {}, aliases = {}) {
const traceId = firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId);
const conversationId = firstNonEmptyString(message?.conversationId, message?.runnerTrace?.conversationId);
return firstNonEmptyString(
message?.sessionId,
message?.session?.sessionId,
message?.sessionReuse?.sessionId,
message?.runner?.sessionId,
message?.runnerTrace?.sessionId,
traceId ? aliases.traceSessionIds?.get(traceId) : null,
conversationId ? aliases.conversationSessionIds?.get(conversationId) : null,
conversationId,
traceId
) ?? "session:unknown";
}
export function activeSessionKeyFromRuntime(input = {}) {
const current = input.currentRequest && typeof input.currentRequest === "object" ? input.currentRequest : null;
return firstNonEmptyString(
input.activeSessionKey,
current?.sessionId,
input.sessionId,
current?.conversationId,
input.conversationId,
current?.traceId,
input.activeTraceId,
input.traceId
);
}
export function sessionTabFromConversation(conversation = {}) {
const conversationId = firstNonEmptyString(conversation.conversationId);
if (!conversationId) return null;
const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId);
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
const lastMessage = newestMessage(messages);
const updatedAt = firstNonEmptyString(lastMessage?.updatedAt, conversation.updatedAt, conversation.snapshot?.updatedAt, lastMessage?.createdAt, conversation.startedAt);
const lastTimestampMs = timestampMs(updatedAt);
const status = normalizedStatus(firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, lastMessage?.status));
const lastTraceId = firstNonEmptyString(conversation.lastTraceId, lastMessage?.traceId, lastMessage?.runnerTrace?.traceId);
const reportedCount = Number(conversation.messageCount);
const messageCount = Number.isFinite(reportedCount) && reportedCount >= 0 ? reportedCount : messages.length;
const userPreviewSource = firstNonEmptyString(
conversation.firstUserMessagePreview,
conversation.snapshot?.firstUserMessagePreview
);
const userPreview = userPreviewSource || firstNonEmptyString(
messages.find((message) => String(message?.role ?? "").toLowerCase() === "user")?.text,
messages.find((message) => String(message?.role ?? "").toLowerCase() === "user")?.title
);
return {
key: sessionId || conversationId,
conversationId,
sessionId,
threadId: firstNonEmptyString(conversation.threadId, conversation.session?.threadId, lastMessage?.threadId, lastMessage?.session?.threadId),
lastTraceId,
status,
title: conversationSessionTitle(conversation, sessionId, conversationId),
messageCount,
userPreview: userPreview ? String(userPreview) : null,
lastTimestampMs,
updatedAt,
lastMessagePreview: firstNonEmptyString(lastMessage?.text, lastMessage?.title, conversation.snapshot?.source)
};
}
function createMessageSessionTab(key, message) {
const timestampMs = timestampFromMessage(message);
return {
key,
sessionId: firstNonEmptyString(message?.sessionId, message?.session?.sessionId, message?.sessionReuse?.sessionId, message?.runner?.sessionId, message?.runnerTrace?.sessionId),
conversationId: firstNonEmptyString(message?.conversationId, message?.runnerTrace?.conversationId),
threadId: firstNonEmptyString(message?.threadId, message?.session?.threadId, message?.sessionReuse?.threadId, message?.providerTrace?.threadId, message?.runnerTrace?.threadId),
firstTraceId: firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId),
lastTraceId: firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId),
status: normalizedStatus(messageSessionStatusFromMessage(message)),
messageCount: 0,
agentCount: 0,
userCount: 0,
running: false,
firstTimestampMs: timestampMs,
lastTimestampMs: timestampMs
};
}
function sessionAliasesFromMessages(messages) {
const traceSessionIds = new Map();
const conversationSessionIds = new Map();
for (const message of messages) {
const sessionId = firstNonEmptyString(message?.sessionId, message?.session?.sessionId, message?.sessionReuse?.sessionId, message?.runner?.sessionId, message?.runnerTrace?.sessionId);
if (!sessionId) continue;
const traceId = firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId);
const conversationId = firstNonEmptyString(message?.conversationId, message?.runnerTrace?.conversationId);
if (traceId) traceSessionIds.set(traceId, sessionId);
if (conversationId) conversationSessionIds.set(conversationId, sessionId);
}
return { traceSessionIds, conversationSessionIds };
}
function mergeMessageIntoSessionTab(tab, message) {
const timestampMs = timestampFromMessage(message);
tab.sessionId = tab.sessionId ?? firstNonEmptyString(message?.sessionId, message?.session?.sessionId, message?.sessionReuse?.sessionId, message?.runner?.sessionId, message?.runnerTrace?.sessionId);
tab.conversationId = tab.conversationId ?? firstNonEmptyString(message?.conversationId, message?.runnerTrace?.conversationId);
tab.threadId = tab.threadId ?? firstNonEmptyString(message?.threadId, message?.session?.threadId, message?.sessionReuse?.threadId, message?.providerTrace?.threadId, message?.runnerTrace?.threadId);
const traceId = firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId);
tab.firstTraceId = tab.firstTraceId ?? traceId;
if (traceId) tab.lastTraceId = traceId;
tab.messageCount += 1;
if (message?.role === "agent") tab.agentCount += 1;
if (message?.role === "user") tab.userCount += 1;
const messageStatus = normalizedStatus(messageSessionStatusFromMessage(message));
tab.running = tab.running || messageStatus === "running";
tab.status = tab.running ? "running" : messageStatus || tab.status;
tab.firstTimestampMs = Math.min(tab.firstTimestampMs, timestampMs);
tab.lastTimestampMs = Math.max(tab.lastTimestampMs, timestampMs);
}
function resolveActiveSessionKey(tabs, preferredKey) {
if (tabs.length === 0) return null;
if (preferredKey) {
const matched = tabs.find((tab) => [tab.key, tab.sessionId, tab.conversationId, tab.lastTraceId, tab.firstTraceId].includes(preferredKey));
if (matched) return matched.key;
}
return tabs[0].key;
}
function timestampFromMessage(message) {
return timestampMs(message?.updatedAt ?? message?.createdAt ?? message?.runnerTrace?.updatedAt);
}
function messageSessionStatusFromMessage(message) {
return firstNonEmptyString(
message?.sessionLifecycleStatus,
message?.sessionSummary?.status,
message?.sessionLifecycle?.status,
message?.session?.lifecycleStatus,
message?.session?.status,
message?.runnerTrace?.sessionLifecycleStatus,
message?.runnerTrace?.sessionStatus,
message?.status
);
}
function conversationSessionTitle(conversation, sessionId, conversationId) {
const explicit = firstNonEmptyString(conversation.title, conversation.name, conversation.snapshot?.title);
if (explicit) return explicit;
const token = shortToken(sessionId || conversationId);
return `Session ${token}`;
}
function truncateLabel(text) {
const value = String(text ?? "").replace(/\s+/gu, " ").trim();
if (!value) return "";
if (value.length <= 36) return value;
return `${value.slice(0, 36).trimEnd()}`;
}
function conversationSessionTabSubtitle(tab) {
const relative = relativeUpdatedLabel(tab.lastTimestampMs);
const status = tabSubtitleStatus(tab);
if (relative && status) return `${relative} · ${status}`;
return status || relative || "";
}
function tabSubtitleStatus(tab) {
if (["running", "busy", "pending", "active"].includes(tab.status)) return "处理中";
if (tab.lastTraceId) return `trace ${shortToken(tab.lastTraceId)}`;
if (tab.threadId) return `thread ${shortToken(tab.threadId)}`;
return "未开始";
}
function relativeUpdatedLabel(timestampMs) {
if (!Number.isFinite(timestampMs) || timestampMs <= 0) return "";
const delta = Date.now() - timestampMs;
if (delta < 0) return "刚刚";
if (delta < 60_000) return `${Math.max(1, Math.round(delta / 1000))}s 前`;
if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m 前`;
if (delta < 86_400_000) return `${Math.round(delta / 3_600_000)}h 前`;
if (delta < 30 * 86_400_000) return `${Math.round(delta / 86_400_000)}d 前`;
return formatDateLabel(timestampMs);
}
function formatDateLabel(timestampMs) {
try {
return new Date(timestampMs).toISOString().slice(0, 10);
} catch {
return "";
}
}
function conversationSessionTabMatches(tab, conversationId, sessionId, traceId) {
const values = [tab.key, tab.conversationId, tab.sessionId, tab.lastTraceId].filter(Boolean);
return Boolean(values.includes(conversationId) || values.includes(sessionId) || values.includes(traceId));
}
function newestMessage(messages) {
return [...messages].sort((left, right) => timestampMs(right?.updatedAt ?? right?.createdAt) - timestampMs(left?.updatedAt ?? left?.createdAt))[0] ?? null;
}
function timestampMs(value) {
const timestamp = Date.parse(String(value ?? ""));
return Number.isFinite(timestamp) ? timestamp : 0;
}
function normalizedStatus(value) {
return String(value ?? "source").trim().toLowerCase() || "source";
}
function shortToken(value) {
const text = String(value ?? "").trim();
if (!text) return "unknown";
const parts = text.split(/[-_:]/u).filter(Boolean);
const token = parts.at(-1) ?? text;
return token.length > 10 ? token.slice(0, 10) : token;
}
function firstNonEmptyString(...values) {
for (const value of values) {
const text = String(value ?? "").trim();
if (text) return text;
}
return null;
}
@@ -1,455 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { traceDisplayRows, traceNoiseEventCount } from "./app-trace.ts";
test("trace display rows render request, setup, command, assistant markdown, and completion path", () => {
const trace = {
traceId: "trc_trace_contract",
events: traceEvents(),
assistantStreams: [{ itemId: "assistant-final", text: "# 处理完成\n\n- trace markdown ok" }]
};
const rows = traceDisplayRows(trace, trace.events);
const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n");
assert.match(text, /.* trace/u);
assert.match(text, //u);
assert.match(text, //u);
assert.match(text, /hostJobId=job-trace-1/u);
assert.match(text, //u);
assert.match(text, /# /u);
assert.equal(rows.some((row) => row.bodyFormat === "markdown"), true);
});
test("trace display rows render completion when no terminal assistant event exists", () => {
const events = [
event(1, "request:accepted", { promptSummary: "只跑完成事件" }),
event(2, "turn:completed", { status: "completed" })
];
const rows = traceDisplayRows({ traceId: "trc_completion", events }, events);
assert.equal(rows.some((row) => //u.test(row.header)), true);
});
test("trace display rows render assistant item text when stream snapshot is absent", () => {
const events = [
event(1, "request:accepted", { promptSummary: "请列出 device-pod" }),
event(2, "assistant:item_completed", {
type: "assistant_message",
status: "completed",
itemId: "msg_item_0",
message: "当前有以下 **5 个 device-pod** 可用。"
}),
commandEvent(3, "item/commandExecution:completed", {
itemId: "cmd-trace-2",
command: "grep -rn device-pod /workspace/hwlab",
status: "completed",
exitCode: 0,
stdoutSummary: "success=true"
}),
event(4, "assistant:completed", {
type: "assistant_message",
terminal: true,
status: "completed",
itemId: "assistant-final",
message: "可以继续指定要操作的 device-pod。"
})
];
const rows = traceDisplayRows({ traceId: "trc_assistant_item_text", events }, events);
const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n");
assert.match(text, /🤖 /u);
assert.match(text, / \*\*5 device-pod\*\* /u);
assert.match(text, //u);
assert.match(text, //u);
assert.match(text, / device-pod/u);
});
test("trace display rows suppress low-value AgentRun backend noise", () => {
const events = [
event(1, "agentrun:request:accepted", { message: "accepted" }),
event(2, "agentrun:run:reused"),
event(3, "agentrun:command:created"),
event(4, "agentrun:runner-job:reused"),
event(5, "agentrun:backend:command-created"),
event(6, "agentrun:backend:thread/status/changed"),
event(7, "agentrun:backend:thread/tokenUsage/updated"),
event(8, "agentrun:backend:account/rateLimits/updated"),
event(9, "agentrun:backend:codex-app-server-starting"),
event(10, "agentrun:backend:thread/goal/cleared"),
event(11, "agentrun:backend:item/agentMessage:started"),
event(12, "agentrun:backend:item/agentMessage:completed"),
commandEvent(13, "item/commandExecution:completed", {
itemId: "call_1",
command: "/bin/sh -lc 'hwpod profile list'",
status: "completed",
exitCode: 0,
durationMs: 708,
stdoutSummary: '{"ok":true,"action":"profile.list","body":{"devicePods":[{"devicePodId":"D601-F103-V2"}]}}'
}),
event(14, "agentrun:output:stdout", {
type: "output",
status: "running",
message: '{"ok":true,"action":"profile.list"}'
}),
event(15, "agentrun:assistant:message", {
type: "assistant",
status: "completed",
replyAuthority: true,
final: true,
terminal: true,
message: "当前可见 1 个 device-pod。"
}),
event(16, "agentrun:terminal:completed", { type: "result", terminal: true, status: "completed" })
];
const rows = traceDisplayRows({ traceId: "trc_agentrun_noise", events }, events);
const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n");
assert.equal(traceNoiseEventCount(events), 12);
assert.equal(/agentrun:request:accepted/u.test(text), false);
assert.equal(/agentrun:run:reused/u.test(text), false);
assert.equal(/runner-job:reused/u.test(text), false);
assert.equal(/thread\/status\/changed/u.test(text), false);
assert.equal(/thread\/goal\/cleared/u.test(text), false);
assert.equal(/item\/agentMessage/u.test(text), false);
assert.equal(/terminal completed/u.test(text), false);
assert.equal(/tokenUsage/u.test(text), false);
assert.match(text, //u);
assert.match(text, /hwpod profile list/u);
assert.match(text, /profile\.list/u);
assert.match(text, //u);
assert.match(text, / 1 device-pod/u);
});
test("trace display rows collapse successful assistant-only AgentRun turns", () => {
const events = [
event(1, "agentrun:request:accepted", { message: "accepted" }),
event(2, "agentrun:run:reused"),
event(3, "agentrun:command:created"),
event(4, "agentrun:runner-job:reused"),
event(5, "agentrun:backend:codex-app-server:reused"),
event(6, "agentrun:backend:turn/start:completed"),
event(7, "agentrun:assistant:message", {
type: "assistant",
status: "completed",
terminal: true,
message: "HWLAB663_FINAL_T2_OK"
}),
event(8, "agentrun:terminal:completed", { type: "result", terminal: true, status: "completed" })
];
const rows = traceDisplayRows({ traceId: "trc_agentrun_assistant_only", events }, events);
assert.equal(rows.length, 1);
assert.equal(rows[0].tone, "ok");
assert.match(rows[0].header, //u);
assert.match(rows[0].body ?? "", /HWLAB663_FINAL_T2_OK/u);
});
test("trace display rows keep AgentRun middle assistant message before final authority", () => {
const events = [
event(1, "agentrun:request:accepted", { message: "accepted" }),
event(2, "agentrun:assistant:message", {
type: "assistant",
status: "running",
itemId: "msg_progress",
messageIndex: 1,
messageCount: 2,
replyAuthority: false,
final: false,
message: "我先检查当前 device-pod 列表。"
}),
event(3, "agentrun:assistant:message", {
type: "assistant",
status: "completed",
itemId: "msg_final",
messageIndex: 2,
messageCount: 2,
replyAuthority: true,
final: true,
terminal: true,
message: "当前有 2 个 device-pod 可用。"
}),
event(4, "agentrun:terminal:completed", { type: "result", terminal: true, status: "completed" })
];
const rows = traceDisplayRows({ traceId: "trc_agentrun_middle_message", events }, events);
const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n");
assert.equal(rows.length, 2);
assert.match(text, //u);
assert.match(text, / device-pod /u);
assert.match(text, //u);
assert.match(text, / 2 device-pod /u);
});
test("trace display rows collapse AgentRun reused backend lifecycle noise", () => {
const events = [
event(1, "agentrun:request:accepted", { message: "accepted" }),
event(2, "agentrun:run:reused"),
event(3, "agentrun:command:created"),
event(4, "agentrun:runner-job:reused"),
event(5, "agentrun:backend:codex-app-server:reused", { message: "codex-app-server:reused" }),
event(6, "agentrun:backend:thread/resume:completed"),
event(7, "agentrun:backend:turn/start:completed"),
event(8, "agentrun:backend:backend-turn-started", { message: "backend-turn-started", waitingFor: "agentrun-backend" }),
event(9, "agentrun:backend:backend-turn-running", { message: "backend-turn-running", waitingFor: "agentrun-backend" }),
event(10, "agentrun:tool:item/started", {
type: "tool_call",
status: "running",
toolName: "userMessage",
itemId: "user_msg_noise",
outputBytes: 128,
message: "tool call"
}),
event(11, "agentrun:tool:item/completed", {
type: "tool_call",
status: "completed",
toolName: "userMessage",
itemId: "user_msg_noise",
outputBytes: 256,
message: "tool call"
}),
event(12, "agentrun:assistant:message", {
type: "assistant",
status: "completed",
replyAuthority: true,
final: true,
terminal: true,
message: "HWLAB663_REUSE_T2_OK"
}),
event(13, "agentrun:backend:backend-turn-finished", { message: "backend-turn-finished", waitingFor: "agentrun-backend" }),
event(14, "agentrun:terminal:completed", { type: "result", terminal: true, status: "completed" })
];
const rows = traceDisplayRows({ traceId: "trc_agentrun_reused_lifecycle", events }, events);
const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n");
assert.equal(rows.length, 1);
assert.equal(rows[0].tone, "ok");
assert.equal(/codex-app-server:reused/u.test(text), false);
assert.equal(/backend-turn-/u.test(text), false);
assert.equal(/userMessage/u.test(text), false);
assert.match(rows[0].body ?? "", /HWLAB663_REUSE_T2_OK/u);
});
test("trace display rows render non-noise AgentRun tool item details", () => {
const events = [
event(1, "agentrun:request:accepted", { message: "accepted" }),
event(2, "agentrun:tool:item/started", {
type: "tool_call",
status: "running",
toolName: "devicePod.list",
itemId: "tool_device_pod_list",
outputBytes: 128,
message: "tool call"
}),
event(3, "agentrun:tool:item/completed", {
type: "tool_call",
status: "completed",
toolName: "devicePod.list",
itemId: "tool_device_pod_list",
outputBytes: 2048,
message: "tool call"
}),
event(4, "agentrun:tool:item/started", {
type: "tool_call",
status: "running",
toolName: "reasoning",
itemId: "rs_noise",
outputBytes: 32,
message: "tool call"
}),
event(5, "agentrun:tool:item/completed", {
type: "tool_call",
status: "completed",
toolName: "reasoning",
itemId: "rs_noise",
outputBytes: 64,
message: "tool call"
}),
event(6, "agentrun:assistant:message", {
type: "assistant",
status: "completed",
terminal: true,
message: "当前可见 2 个 device-pod。"
})
];
const rows = traceDisplayRows({ traceId: "trc_agentrun_tool_item", events }, events);
const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n");
assert.equal(rows.length, 2);
assert.match(text, /devicePod\.list/u);
assert.match(text, /toolName=devicePod\.list/u);
assert.match(text, /itemId=tool_device_pod_list/u);
assert.match(text, /outputBytes=2048/u);
assert.equal(/reasoning/u.test(text), false);
assert.match(text, / 2 device-pod/u);
});
test("trace display rows collapse repeated AgentRun assistant snapshots and notification replay", () => {
const events = [
event(1, "agentrun:request:accepted", { message: "accepted" }),
event(2, "agentrun:assistant:message", {
type: "assistant",
status: "running",
itemId: "msg_progress_1",
message: "我尝试拉取这个 issue 的内容。"
}),
commandEvent(3, "item/commandExecution:completed", {
itemId: "cmd_issue_read",
command: "gh api repos/pikasTech/agentrun/issues/42",
status: "completed",
exitCode: 0,
stdoutSummary: "success=true"
}),
event(4, "agentrun:assistant:message", {
type: "assistant",
status: "running",
itemId: "msg_summary",
message: "已经拉到完整内容。Issue 元信息。"
}),
event(5, "agentrun:assistant:message", {
type: "assistant",
status: "running",
itemId: "msg_summary",
message: "已经拉到完整内容。Issue 元信息。6 条评论时间线。"
}),
event(6, "agentrun:assistant:message", {
type: "assistant",
status: "running",
itemId: "msg_summary",
message: "已经拉到完整内容。Issue 元信息。6 条评论时间线。验收完成。"
}),
event(7, "agentrun:backend:turn/completed", { message: "turn/completed" }),
event(8, "agentrun:assistant:message", {
type: "assistant",
status: "running",
itemId: "msg_progress_1_replay",
message: "我尝试拉取这个 issue 的内容。"
}),
event(9, "agentrun:assistant:message", {
type: "assistant",
status: "running",
itemId: "msg_summary",
message: "已经拉到完整内容。Issue 元信息。6 条评论时间线。验收完成。"
}),
event(10, "agentrun:terminal:completed", { type: "result", terminal: true, status: "completed" })
];
const rows = traceDisplayRows({ traceId: "trc_issue_720_duplicate", events }, events);
const assistantRows = rows.filter((row) => //u.test(row.header));
const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n");
assert.equal(assistantRows.length, 2);
assert.deepEqual(assistantRows.map((row) => row.seq), [2, 6]);
assert.equal(matchCount(text, / issue /u), 1);
assert.equal(matchCount(text, //u), 1);
assert.match(text, //u);
});
function traceEvents() {
return [
event(1, "request:accepted", { promptSummary: "请检查 trace" }),
event(2, "session:reused"),
commandEvent(3, "item/commandExecution:started", {
itemId: "cmd-trace-1",
command: "bash -lc 'build start'"
}),
{
...event(4, "item/commandExecution/outputDelta", {
type: "tool_call",
status: "output_chunk",
itemId: "cmd-trace-1",
outputSummary: '{"hostJobId":"job-trace-1","success":true}'
})
},
commandEvent(5, "item/commandExecution:completed", {
itemId: "cmd-trace-1",
command: "bash -lc 'build status'",
status: "completed",
exitCode: 0,
durationMs: 1200
}),
event(6, "assistant:completed", {
type: "assistant_message",
terminal: true,
status: "completed",
itemId: "assistant-final"
})
];
}
function commandEvent(seq, label, fields) {
return event(seq, label, {
type: "tool_call",
toolName: "commandExecution",
...fields
});
}
function event(seq, label, fields = {}) {
return {
traceId: "trc_trace_contract",
seq,
label,
type: fields.type ?? "event",
status: fields.status ?? "observed",
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
...fields
};
}
function matchCount(text, pattern) {
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
return [...String(text ?? "").matchAll(new RegExp(pattern.source, flags))].length;
}
test("trace display rows keep full final-response text without truncating long agent summaries", () => {
const longSummary = "下面是详细总结。".repeat(2000);
const events = [
event(1, "request:accepted", { promptSummary: "请给出详细总结" }),
event(2, "assistant:completed", {
type: "assistant_message",
terminal: true,
status: "completed",
itemId: "assistant-final",
message: longSummary
})
];
const rows = traceDisplayRows({ traceId: "trc_final_long", events }, events);
const terminalRow = rows.find((row) => row.terminal === true);
assert.ok(terminalRow, "expected a terminal row to be rendered for the final response");
assert.equal(terminalRow.tone, "ok");
assert.equal(terminalRow.body?.length, longSummary.length);
assert.equal(terminalRow.body?.includes("内容已截断"), false);
});
test("trace display rows still cap non-terminal streaming assistant text at the 2200 char limit", () => {
const streamingText = "streaming ".repeat(2000);
const events = [
event(1, "request:accepted", { promptSummary: "stream" }),
event(2, "assistant:item_completed", {
type: "assistant_message",
status: "completed",
itemId: "msg_streaming",
message: streamingText
}),
event(3, "assistant:completed", {
type: "assistant_message",
terminal: true,
status: "completed",
itemId: "assistant-final",
message: "done"
})
];
const rows = traceDisplayRows({ traceId: "trc_streaming_long", events }, events);
const streamingRow = rows.find((row) => row.rowId === "event:2");
assert.ok(streamingRow, "expected a non-terminal streaming row to be rendered");
assert.equal(streamingRow.terminal, false);
assert.ok((streamingRow.body?.length ?? 0) <= 2200);
assert.match(streamingRow.body ?? "", //u);
});
File diff suppressed because it is too large Load Diff
@@ -1,360 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
codeAgentAttributionFromMessage,
codeAgentFactsFromMessage,
codeAgentRuntimePathFromMessage
} from "./code-agent-facts.ts";
test("codex-readonly-runner is shown as partial read-only session registry", () => {
const facts = codeAgentFactsFromMessage({
status: "source",
provider: "codex-readonly-runner",
model: "read-only-tools",
backend: "hwlab-cloud-api/codex-readonly-runner",
workspace: "/workspace/hwlab",
sandbox: "read-only",
capabilityLevel: "read-only-session-tools",
sessionMode: "controlled-readonly-session-registry",
implementationType: "controlled-readonly-session-registry",
session: {
sessionId: "ses_readonly",
status: "idle",
turn: 1,
lastTraceId: "trc_readonly"
},
sessionReuse: {
reused: false,
turn: 1,
status: "idle"
},
runner: {
kind: "hwlab-readonly-runner",
codexStdio: false,
writeCapable: false,
durableSession: false,
longLivedSession: true
},
longLivedSessionGate: {
status: "blocked",
pass: false,
blockers: [{ code: "codex_stdio_blocked_readonly_session_available" }]
},
toolCalls: [{ name: "pwd", status: "completed" }],
skills: { status: "not_requested", count: 0 },
runnerTrace: {
traceId: "trc_readonly",
runnerKind: "hwlab-readonly-runner",
sessionMode: "controlled-readonly-session-registry",
turn: 1
},
traceId: "trc_readonly"
});
assert.equal(facts.kind, "controlled-readonly-session-registry");
assert.equal(facts.fullCodeAgent, false);
assert.equal(facts.codexSessionGatePass, false);
assert.match(facts.statusLabel, //u);
assert.match(facts.summary, //u);
assert.match(facts.summary, /Codex stdio/u);
assert.match(facts.rows.find((row) => row.label === "runner/provider/backend").value, /runner=hwlab-readonly-runner/u);
assert.match(facts.rows.find((row) => row.label === "toolCalls").value, /1\/1 pwd\(completed\)/u);
});
test("long-lived Codex app-server stdio session fields are shown as full Code Agent session", () => {
const message = {
status: "completed",
provider: "codex-stdio",
model: "gpt-5.5",
backend: "hwlab-cloud-api/codex-app-server-stdio",
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
session: {
sessionId: "ses_stdio",
status: "idle",
turn: 3,
idleTimeoutMs: 1800000,
lastTraceId: "trc_stdio",
longLivedSession: true,
codexStdio: true
},
runner: {
kind: "codex-app-server-stdio-runner",
codexStdio: true,
writeCapable: true,
durableSession: true
},
longLivedSessionGate: {
status: "pass",
pass: true
},
toolCalls: [{ name: "codex-reply", status: "completed" }],
skills: { status: "not_requested", count: 0 },
runnerTrace: {
traceId: "trc_stdio",
runnerKind: "codex-app-server-stdio-runner",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
turn: 3
},
providerTrace: {
transport: "stdio",
protocol: "codex-app-server-jsonrpc-stdio",
wireApi: "responses",
command: "codex app-server --listen stdio://",
terminalStatus: "completed"
},
traceId: "trc_stdio"
};
const facts = codeAgentFactsFromMessage(message);
const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...message });
assert.equal(facts.kind, "codex-stdio-long-lived");
assert.equal(facts.statusLabel, "真实 runnerCodex app-server stdio 长会话");
assert.equal(facts.fullCodeAgent, true);
assert.equal(facts.codexSessionGatePass, true);
assert.match(facts.rows.find((row) => row.label === "sandbox").value, /workspace-write/u);
assert.equal(runtimePath.kind, "codex-app-server-stdio");
assert.equal(runtimePath.tone, "ok");
assert.equal(runtimePath.provider, "codex-stdio");
assert.equal(runtimePath.runnerKind, "codex-app-server-stdio-runner");
assert.equal(runtimePath.protocol, "codex-app-server-jsonrpc-stdio");
assert.equal(runtimePath.implementationType, "repo-owned-codex-app-server-stdio-session");
assert.equal(runtimePath.command, "codex app-server --listen stdio://");
assert.equal(runtimePath.terminalStatus, "completed");
assert.equal(runtimePath.wireApi, "responses");
assert.match(runtimePath.summary, /Responses wire API/u);
});
test("AgentRun carried Codex app-server session is shown as full Code Agent session", () => {
const message = {
status: "completed",
provider: "codex-stdio",
model: "deepseek-chat",
backend: "hwlab-cloud-api/codex-app-server-stdio",
infrastructureBackend: "agentrun-v01/deepseek",
workspace: "/home/agentrun/workspace",
sandbox: "danger-full-access",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
session: {
sessionId: "ses_web_agentrun",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
status: "idle",
lastTraceId: "trc_web_agentrun",
longLivedSession: true,
codexStdio: true,
writeCapable: true,
durable: true,
durableSession: true
},
sessionReuse: {
sessionId: "ses_web_agentrun",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
reused: true
},
runner: {
kind: "codex-app-server-stdio-runner",
codexStdio: true,
writeCapable: true,
durableSession: true,
longLivedSession: true
},
longLivedSessionGate: {
status: "pass",
pass: true
},
toolCalls: [{ name: "codex-app-server.turn", status: "completed" }],
skills: { status: "delegated", count: 0 },
runnerTrace: {
traceId: "trc_web_agentrun",
runnerKind: "codex-app-server-stdio-runner",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session"
},
providerTrace: {
transport: "stdio",
protocol: "codex-app-server-jsonrpc-stdio",
wireApi: "responses",
command: "codex app-server --listen stdio://",
terminalStatus: "completed",
source: "agentrun-v01"
},
traceId: "trc_web_agentrun"
};
const facts = codeAgentFactsFromMessage(message);
const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...message });
assert.equal(facts.kind, "codex-stdio-long-lived");
assert.equal(facts.fullCodeAgent, true);
assert.equal(runtimePath.kind, "codex-app-server-stdio");
assert.equal(runtimePath.tone, "ok");
assert.equal(runtimePath.providerTraceMissing, false);
assert.equal(runtimePath.criticalMissing, false);
assert.equal(runtimePath.terminalStatusOk, true);
});
test("Codex app-server without providerTrace is degraded with structured missing fields", () => {
const message = {
role: "agent",
status: "completed",
provider: "codex-stdio",
backend: "hwlab-cloud-api/codex-app-server-stdio",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
runner: { kind: "codex-app-server-stdio-runner" },
longLivedSessionGate: { status: "pass", pass: true },
traceId: "trc_missing_provider_trace"
};
const facts = codeAgentFactsFromMessage(message);
const runtimePath = codeAgentRuntimePathFromMessage(message);
assert.equal(facts.kind, "codex-stdio-provider-trace-missing");
assert.equal(facts.fullCodeAgent, false);
assert.equal(runtimePath.providerTraceMissing, true);
assert.equal(runtimePath.label, "DEGRADEDproviderTrace 缺失");
assert.deepEqual(runtimePath.missingFields, ["protocol", "providerTrace.command", "providerTrace.terminalStatus"]);
});
test("AgentRun invalid tool-call failure is attributed to Codex provider tool-call JSON", () => {
const message = {
role: "agent",
status: "failed",
provider: "codex-stdio",
backend: "hwlab-cloud-api/codex-app-server-stdio",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
runner: { kind: "codex-app-server-stdio-runner" },
providerTrace: {
transport: "stdio",
protocol: "codex-app-server-jsonrpc-stdio",
wireApi: "responses",
command: "codex app-server --listen stdio://",
terminalStatus: "failed",
failureKind: "provider-invalid-tool-call",
failureMessage: "invalid params, invalid function arguments json string, tool_call_id: call_function_selftest_2 (2013)"
},
error: {
code: "provider-invalid-tool-call",
category: "provider_invalid_tool_call"
},
traceId: "trc_invalid_tool_call"
};
const facts = codeAgentFactsFromMessage(message);
const runtimePath = codeAgentRuntimePathFromMessage(message);
assert.equal(facts.kind, "codex-stdio-blocked");
assert.equal(runtimePath.kind, "codex-app-server-stdio");
assert.equal(runtimePath.terminalStatusOk, false);
assert.equal(runtimePath.failureKind, "provider-invalid-tool-call");
assert.equal(runtimePath.label, "BLOCKEDCodex tool-call JSON 无效");
assert.match(runtimePath.summary, / HWLAB device-pod/u);
assert.match(runtimePath.summary, /tool-call arguments JSON/u);
});
test("openai-responses fallback is explicitly text-chat-only", () => {
const message = {
status: "source",
provider: "openai-responses",
model: "gpt-5.5",
backend: "hwlab-cloud-api/openai-responses",
capabilityLevel: "text-chat-only",
sessionMode: "provider-text-request",
runner: {
kind: "openai-responses-fallback",
codexStdio: false,
writeCapable: false,
durableSession: false
},
longLivedSessionGate: {
status: "blocked",
pass: false,
blockers: [{ code: "openai_responses_fallback_not_session" }]
},
toolCalls: [],
skills: { status: "not_requested", count: 0 },
runnerTrace: {
traceId: "trc_fallback",
runnerKind: "openai-responses-fallback",
events: ["fallback:text-chat-only"]
},
traceId: "trc_fallback"
};
const facts = codeAgentFactsFromMessage(message);
const attribution = codeAgentAttributionFromMessage(message);
assert.equal(facts.kind, "text-chat-only");
assert.equal(facts.statusLabel, "OpenAI fallback:只是文本回答 / text-chat-only");
assert.equal(facts.fullCodeAgent, false);
assert.equal(facts.codexSessionGatePass, false);
assert.match(facts.summary, //u);
assert.match(attribution.label, //u);
assert.match(attribution.summary, //u);
assert.equal(attribution.fields.find((field) => field.key === "runnerKind").value, "openai-responses-fallback / 不是 runner 控制");
assert.equal(attribution.fields.find((field) => field.key === "capabilityLevel").value, "text-chat-only / 只是文本回答");
assert.equal(attribution.fields.find((field) => field.key === "toolCalls").value, "0/0 / 无工具调用:只是文本回答");
});
test("stateless one-shot is not wrapped as a long-lived session", () => {
const facts = codeAgentFactsFromMessage({
status: "source",
provider: "codex-cli",
model: "gpt-5.5",
backend: "hwlab-cloud-api/codex-cli",
workspace: "/workspace/hwlab",
sandbox: "read-only",
capabilityLevel: "text-chat-only",
sessionMode: "ephemeral-one-shot",
implementationType: "codex-cli-one-shot-ephemeral",
session: {
sessionId: "cnv_one_shot",
status: "expired",
idleTimeoutMs: 0,
lastTraceId: "trc_one_shot"
},
runner: {
kind: "codex-cli-one-shot-ephemeral",
codexStdio: false,
writeCapable: false,
durableSession: false
},
longLivedSessionGate: {
status: "blocked",
pass: false,
blockers: [{ code: "one_shot_runner_not_long_lived" }]
},
traceId: "trc_one_shot"
});
assert.equal(facts.kind, "stateless-one-shot");
assert.equal(facts.fullCodeAgent, false);
assert.equal(facts.codexSessionGatePass, false);
assert.match(facts.summary, / runner/u);
});
test("missing attribution fields render structured evidence insufficiency", () => {
const attribution = codeAgentAttributionFromMessage({
role: "agent",
status: "failed",
traceId: "trc_missing_fields",
error: {
code: "provider_unavailable",
message: "blocked"
}
});
assert.equal(attribution.label, "BLOCKED:当前不能执行");
assert.equal(attribution.fields.find((field) => field.key === "traceId").value, "trc_missing_fields");
assert.equal(attribution.fields.find((field) => field.key === "provider").missing, true);
assert.equal(attribution.fields.find((field) => field.key === "backend").value, "字段缺失:证据不足");
assert.equal(attribution.fields.find((field) => field.key === "runnerKind").chip, "runnerKind=字段缺失:证据不足");
assert.equal(attribution.fields.find((field) => field.key === "providerTrace.command").missing, true);
assert.match(attribution.summary, //u);
});
@@ -1,548 +0,0 @@
const TEXT_FALLBACK_RUNNER = "openai-responses-fallback";
const CODEX_ONE_SHOT_RUNNER = "codex-cli-one-shot-ephemeral";
const READONLY_SESSION_MODE = "controlled-readonly-session-registry";
const CODEX_APP_SERVER_RUNNER = "codex-app-server-stdio-runner";
const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived";
const CODEX_APP_SERVER_IMPLEMENTATION = "repo-owned-codex-app-server-stdio-session";
const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio";
const MISSING_FIELD_VALUE = "字段缺失:证据不足";
const REQUIRED_ATTRIBUTION_KEYS = Object.freeze([
"provider",
"backend",
"runnerKind",
"protocol",
"implementationType",
"providerTrace.command",
"providerTrace.terminalStatus",
"sessionMode",
"capabilityLevel",
"toolCalls",
"traceId",
"operationId",
"audit",
"evidence"
]);
export function codeAgentFactsFromMessage(message) {
if (!hasCodeAgentFields(message)) return null;
const classification = classifyCodeAgentFacts(message);
const rows = [
{
label: "runner/provider/backend",
value: compactFields([
requiredField("runner", message.runner?.kind ?? runnerKindFromMessage(message)),
requiredField("provider", message.provider),
requiredField("backend", message.backend)
])
},
{
label: "session",
value: compactFields([
field("id", message.session?.sessionId ?? message.sessionId ?? message.runner?.sessionId),
field("status", message.session?.status),
requiredField("sessionMode", message.sessionMode ?? message.runner?.sessionMode ?? message.runner?.session)
])
},
{
label: "workspace",
value: valueOrMissing(message.workspace ?? message.runner?.workspace)
},
{
label: "sandbox",
value: valueOrMissing(message.sandbox ?? message.runner?.sandbox)
},
{
label: "capabilityLevel",
value: valueOrMissing(message.capabilityLevel ?? message.runner?.capabilityLevel)
},
{
label: "toolCalls",
value: toolCallsSummary(message.toolCalls)
},
{
label: "skills",
value: skillsSummary(message.skills)
},
{
label: "runnerTrace/traceId",
value: compactFields([
requiredField("traceId", message.traceId),
field("runnerTrace", runnerTraceSummary(message.runnerTrace))
])
}
];
return {
...classification,
rows
};
}
export function classifyCodeAgentFacts(message) {
const runnerKind = runnerKindFromMessage(message);
const provider = String(message?.provider ?? "").trim();
const sessionMode = String(message?.sessionMode ?? message?.runner?.sessionMode ?? message?.runner?.session ?? "").trim();
const implementationType = String(message?.implementationType ?? message?.runner?.implementationType ?? "").trim();
const capabilityLevel = String(message?.capabilityLevel ?? message?.runner?.capabilityLevel ?? "").trim();
const longLivedGate = message?.longLivedSessionGate;
const runtimePath = codeAgentRuntimePathFromMessage(message);
if (
provider === "codex-stdio" &&
longLivedGate?.status === "pass" &&
longLivedGate?.pass === true &&
runtimePath?.kind === "codex-app-server-stdio" &&
runtimePath?.criticalMissing === false &&
runtimePath?.terminalStatusOk === true
) {
return {
kind: "codex-stdio-long-lived",
statusLabel: "真实 runnerCodex app-server stdio 长会话",
tone: "dev-live",
summary: "repo-owned Codex app-server stdio runner 已通过长会话 gateproviderTrace 明确显示 Responses wire API、stdio command 和 terminalStatus。",
fullCodeAgent: true,
codexSessionGatePass: true
};
}
if (runnerKind === CODEX_ONE_SHOT_RUNNER || sessionMode === "ephemeral-one-shot" || implementationType === "codex-cli-one-shot-ephemeral") {
return {
kind: "stateless-one-shot",
statusLabel: "一次性 runner:非长会话",
tone: "source",
summary: "回复来自一次性 runner;不可当作可复用或长会话 Code Agent。",
fullCodeAgent: false,
codexSessionGatePass: false
};
}
if (runnerKind === TEXT_FALLBACK_RUNNER || provider === "openai-responses" || capabilityLevel === "text-chat-only") {
return {
kind: "text-chat-only",
statusLabel: "OpenAI fallback:只是文本回答 / text-chat-only",
tone: "source",
summary: "只是文本回答;当前不能执行 workspace、tools、skills 或 Codex session 控制。",
fullCodeAgent: false,
codexSessionGatePass: false
};
}
if (provider === "codex-readonly-runner" || runnerKind === "hwlab-readonly-runner" || sessionMode === READONLY_SESSION_MODE) {
return {
kind: READONLY_SESSION_MODE,
statusLabel: "只读长会话:controlled-readonly-session-registry",
tone: "source",
summary: "只读 session registry 可复用 conversation/session;只能使用 read-only-session-tools,不能写工作区,也不能冒充完整 Codex stdio Code Agent。",
fullCodeAgent: false,
codexSessionGatePass: false
};
}
if (provider === "codex-stdio" || sessionMode === CODEX_APP_SERVER_SESSION_MODE) {
return {
kind: runtimePath?.providerTraceMissing ? "codex-stdio-provider-trace-missing" : "codex-stdio-blocked",
statusLabel: runtimePath?.providerTraceMissing ? "Codex stdio 降级:providerTrace 缺失" : "Codex stdio 受阻:当前不能执行",
tone: "blocked",
summary: runtimePath?.providerTraceMissing
? "Codex stdio 字段存在,但 providerTrace 缺失;界面不会猜测它是完整 app-server runner。"
: "Codex stdio 字段存在,但长会话 gate 或 providerTrace 终态未通过;当前不能标记为完整 runner 控制。",
fullCodeAgent: false,
codexSessionGatePass: false
};
}
return {
kind: "unknown-code-agent-path",
statusLabel: message?.status === "failed" ? "BLOCKED:当前不能执行" : "证据不足:字段缺失",
tone: message?.status === "failed" ? "blocked" : "source",
summary: "payload 缺少足够 provider/backend/runner/session 证据;只展示结构化字段缺失,不假定具备执行能力。",
fullCodeAgent: false,
codexSessionGatePass: false
};
}
export function codeAgentAttributionFromMessage(message) {
if (!message || typeof message !== "object") return null;
if (message.role && message.role !== "agent") return null;
if (!hasCodeAgentFields(message)) return null;
const classification = classifyCodeAgentFacts(message);
const ids = operationAuditEvidenceIds(message);
const runtimePath = codeAgentRuntimePathFromMessage(message);
const runnerKind = message.runner?.kind ?? runnerKindFromMessage(message);
const sessionMode = message.sessionMode ?? message.runner?.sessionMode ?? message.runner?.session;
const capabilityLevel = message.capabilityLevel ?? message.runner?.capabilityLevel;
const fields = [
attributionField("provider", message.provider, { message }),
attributionField("backend", message.backend, { message }),
attributionField("runnerKind", runnerKind, { message }),
attributionField("protocol", runtimePath?.protocol, { message }),
attributionField("implementationType", runtimePath?.implementationType, { message }),
attributionField("providerTrace.command", runtimePath?.command, { message }),
attributionField("providerTrace.terminalStatus", runtimePath?.terminalStatus, { message }),
attributionField("sessionMode", sessionMode, { message }),
attributionField("capabilityLevel", capabilityLevel, { message }),
attributionField("toolCalls", toolCallsAttributionSummary(message.toolCalls), { message, missingWhenNone: false }),
attributionField("traceId", message.traceId ?? message.runnerTrace?.traceId ?? message.providerTrace?.traceId, { message }),
attributionField("operationId", ids.operationId, { message }),
attributionField("audit", ids.auditId, { message }),
attributionField("evidence", ids.evidenceId, { message })
];
const missingFields = fields.filter((field) => field.missing).map((field) => field.key);
return {
kind: classification.kind,
tone: classification.tone,
label: messageAttributionLabel(message, classification),
summary: messageAttributionSummary(message, classification, missingFields, runtimePath),
fields,
missingFields
};
}
export function codeAgentRuntimePathFromMessage(message) {
if (!message || typeof message !== "object") return null;
if (message.role && message.role !== "agent") return null;
if (!hasCodeAgentFields(message)) return null;
const providerTrace = objectOrNull(message.providerTrace);
const runnerTrace = objectOrNull(message.runnerTrace);
const runnerKind = firstText(message.runner?.kind, runnerTrace?.runnerKind, runnerKindFromMessage(message));
const provider = nullableText(message.provider);
const protocol = firstText(providerTrace?.protocol, runnerTrace?.protocol);
const implementationType = firstText(message.implementationType, message.runner?.implementationType, runnerTrace?.implementationType);
const command = firstText(providerTrace?.command);
const terminalStatus = firstText(providerTrace?.terminalStatus);
const failureKind = firstText(providerTrace?.failureKind, message.error?.code, message.blocker?.code);
const failureMessage = firstText(providerTrace?.failureMessage, message.error?.message, message.blocker?.summary);
const wireApi = firstText(providerTrace?.wireApi, isCodexAppServerPath({ provider, runnerKind, protocol, implementationType, command }) ? "responses" : null);
const providerTraceMissing = !providerTrace;
const required = {
provider,
runnerKind,
protocol,
implementationType,
"providerTrace.command": command,
"providerTrace.terminalStatus": terminalStatus
};
const missingFields = Object.entries(required)
.filter(([, value]) => !nullableText(value))
.map(([key]) => key);
const isAppServer = isCodexAppServerPath({ provider, runnerKind, protocol, implementationType, command });
const isFallback = provider === "openai-responses" || runnerKind === TEXT_FALLBACK_RUNNER || message.capabilityLevel === "text-chat-only";
const isReadonly = provider === "codex-readonly-runner" || runnerKind === "hwlab-readonly-runner" || message.sessionMode === READONLY_SESSION_MODE;
const terminalStatusOk = terminalStatus === "completed" || terminalStatus === "succeeded";
const criticalMissing = missingFields.length > 0;
const blocked = ["failed", "blocked", "timeout", "error", "canceled"].includes(String(message.status ?? "").toLowerCase());
let kind = "unknown";
let label = "运行路径证据不足";
let summary = "payload 缺少 providerTrace 或 runner 关键字段;界面不会推断完整 Code Agent。";
let tone = blocked ? "blocked" : "warn";
if (isAppServer) {
kind = "codex-app-server-stdio";
if (!providerTrace) {
label = "DEGRADEDproviderTrace 缺失";
summary = "检测到 Codex app-server 形态字段,但 providerTrace 缺失;不能确认 command、protocol 或 terminalStatus。";
} else if (criticalMissing) {
label = "DEGRADED:运行路径字段不完整";
summary = `repo-owned Codex app-server stdio 已暴露部分字段,但仍缺少 ${missingFields.join(", ")}`;
} else if (!terminalStatusOk) {
if (failureKind === "provider-invalid-tool-call") {
label = "BLOCKEDCodex tool-call JSON 无效";
summary = `Codex app-server/provider 返回 failureKind=provider-invalid-tool-call,表示上游 tool-call arguments JSON 无效;不是 HWLAB device-pod 或 Cloud API 端点失败。${failureMessage ? ` 原始摘要:${failureMessage}` : ""}`;
} else if (failureKind === "thread-resume-failed") {
label = "BLOCKEDCodex thread resume 失败";
summary = `AgentRun 复用的 Codex thread 已失效,当前标准是不启动新的 thread/start,也不拼接历史上下文;本轮以 thread-resume-failed 终止。${failureMessage ? ` 原始摘要:${failureMessage}` : ""}`;
} else {
label = "BLOCKEDCodex app-server 终态未完成";
summary = `repo-owned Codex app-server stdio 返回 terminalStatus=${terminalStatus};本次不标为完整完成。`;
}
tone = "blocked";
} else {
label = "repo-owned Codex app-server stdio";
summary = `Codex app-server 通过 stdio JSON-RPC 运行,使用 Responses wire APIwire_api=${wireApi ?? "未观测"}),不是 MCP、fallback 或 Cloud Web 直连 OpenAI。`;
tone = "ok";
}
} else if (isFallback) {
kind = "openai-text-fallback";
label = "DEGRADEDOpenAI text fallback";
summary = "这是 OpenAI text fallback/text-chat-only;没有 workspace runner、Codex app-server session 或工具控制能力。";
tone = blocked ? "blocked" : "warn";
} else if (isReadonly) {
kind = "readonly-runner";
label = "DEGRADEDread-only/source runner";
summary = "这是只读或 source runner;不能写 workspace,也不能冒充完整 Code Agent 完成。";
tone = blocked ? "blocked" : "warn";
} else if (providerTraceMissing) {
kind = "provider-trace-missing";
label = blocked ? "BLOCKEDproviderTrace 缺失" : "DEGRADEDproviderTrace 缺失";
summary = "providerTrace 缺失;不会把 fallback/read-only/source 或未知路径显示成完整 Code Agent。";
}
return {
kind,
label,
summary,
tone,
provider,
runnerKind,
protocol,
implementationType,
command,
terminalStatus,
failureKind,
failureMessage,
wireApi,
providerTraceMissing,
criticalMissing,
missingFields,
terminalStatusOk,
rows: [
runtimePathRow("provider", provider),
runtimePathRow("runnerKind", runnerKind),
runtimePathRow("protocol", protocol),
runtimePathRow("implementationType", implementationType),
runtimePathRow("providerTrace.command", command),
runtimePathRow("providerTrace.terminalStatus", terminalStatus),
runtimePathRow("providerTrace.failureKind", failureKind),
runtimePathRow("wireApi", wireApi)
]
};
}
export function toolCallsSummary(toolCalls) {
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none";
const completed = toolCalls.filter((tool) => tool?.status === "completed").length;
const names = toolCalls
.map((tool) => compactFields([
firstText(tool?.name, "tool"),
tool?.status ? `(${tool.status})` : null,
tool?.route ? `@${tool.route}` : null
], ""))
.filter(Boolean)
.slice(0, 4)
.join(", ");
return `${completed}/${toolCalls.length}${names ? ` ${names}` : ""}`;
}
export function skillsSummary(skills) {
if (!skills || typeof skills !== "object") return "none";
const count = skills.totalCount ?? skills.count ?? (Array.isArray(skills.items) ? skills.items.length : 0);
const names = Array.isArray(skills.items)
? skills.items.map((item) => item?.name).filter(Boolean).slice(0, 3).join(",")
: "";
const blockers = Array.isArray(skills.blockers)
? skills.blockers.map((blocker) => blocker?.code).filter(Boolean).slice(0, 2).join(",")
: "";
return compactFields([
skills.status ?? "unknown",
`count=${count}`,
names ? `items=${names}` : null,
blockers ? `blockers=${blockers}` : null
], ":");
}
export function runnerTraceSummary(runnerTrace) {
if (!runnerTrace || typeof runnerTrace !== "object") return "none";
return compactFields([
field("traceId", runnerTrace.traceId),
field("runnerKind", runnerTrace.runnerKind),
field("sessionMode", runnerTrace.sessionMode),
field("implementationType", runnerTrace.implementationType),
field("protocol", runnerTrace.protocol),
typeof runnerTrace.turn === "number" ? field("turn", runnerTrace.turn) : null,
field("route", runnerTrace.route),
field("status", runnerTrace.status)
]);
}
function hasCodeAgentFields(message) {
return Boolean(
message &&
typeof message === "object" &&
(
message.provider ||
message.backend ||
message.runner ||
message.session ||
message.sessionMode ||
message.capabilityLevel ||
Array.isArray(message.toolCalls) ||
message.runnerTrace ||
message.providerTrace ||
(message.role === "agent" && (message.traceId || message.error || message.status === "failed" || message.status === "running"))
)
);
}
function messageAttributionLabel(message, classification) {
if (message?.status === "failed" || message?.sourceKind === "BLOCKED") return "BLOCKED:当前不能执行";
if (classification.kind === "text-chat-only") return "OpenAI fallback:只是文本回答";
if (classification.kind === READONLY_SESSION_MODE) return "read-only-session-tools:只读长会话";
if (classification.kind === "codex-stdio-long-lived") return "真实 runnerCodex app-server stdio 长会话";
if (classification.kind === "stateless-one-shot") return "一次性 runner:非长会话";
if (classification.kind === "codex-stdio-provider-trace-missing") return "DEGRADEDproviderTrace 缺失";
if (classification.kind === "codex-stdio-blocked") return "Codex stdio 受阻:当前不能执行";
return classification.statusLabel;
}
function messageAttributionSummary(message, classification, missingFields, runtimePath = null) {
const prefix = messageAttributionLabel(message, classification);
const missing = missingFields.length > 0
? `证据不足:${missingFields.join(",")} 字段缺失。`
: "关键归因字段已观测。";
const runtime = runtimePath?.summary ? `运行路径:${runtimePath.summary}` : "";
if (classification.kind === "text-chat-only") {
return `${prefix};只是文本回答,当前不能执行工具或 runner 控制。${missing}${runtime}`;
}
if (message?.status === "failed" || message?.sourceKind === "BLOCKED") {
return `${prefix};本次回复没有执行能力。${missing}${runtime}`;
}
return `${prefix}${missing}${runtime}`;
}
function attributionField(key, rawValue, { message, missingWhenNone = true } = {}) {
const text = nullableText(rawValue);
const noneLike = !text || ["none", "unknown", "null", "undefined"].includes(text.toLowerCase());
const missing = missingWhenNone && noneLike;
const value = missing ? MISSING_FIELD_VALUE : localizedAttributionValue(key, text ?? "none", message);
return {
key,
label: key,
value,
missing,
chip: `${key}=${value}`
};
}
function localizedAttributionValue(key, value, message) {
const normalized = String(value ?? "").trim();
if (key === "provider" && normalized === "openai-responses") return `${normalized} / OpenAI fallback:只是文本回答`;
if (key === "runnerKind" && normalized === CODEX_APP_SERVER_RUNNER) return `${normalized} / repo-owned Codex app-server stdio`;
if (key === "runnerKind" && normalized === TEXT_FALLBACK_RUNNER) return `${normalized} / 不是 runner 控制`;
if (key === "runnerKind" && normalized === "hwlab-readonly-runner") return `${normalized} / 只读 runner`;
if (key === "protocol" && normalized === CODEX_APP_SERVER_PROTOCOL) return `${normalized} / Responses wire API`;
if (key === "implementationType" && normalized === CODEX_APP_SERVER_IMPLEMENTATION) return `${normalized} / repo-owned app-server stdio session`;
if (key === "sessionMode" && normalized === READONLY_SESSION_MODE) return `${normalized} / 只读 session registry`;
if (key === "sessionMode" && normalized === CODEX_APP_SERVER_SESSION_MODE) return `${normalized} / app-server stdio 长会话`;
if (key === "capabilityLevel" && normalized === "text-chat-only") return "text-chat-only / 只是文本回答";
if (key === "capabilityLevel" && normalized === "read-only-session-tools") return `${normalized} / 只读工具`;
if (key === "toolCalls" && normalized === "none") {
return message?.capabilityLevel === "text-chat-only" ? "0/0 / 无工具调用:只是文本回答" : "0/0 / 无工具调用";
}
return normalized;
}
function operationAuditEvidenceIds(message) {
const providerTrace = objectOrNull(message?.providerTrace);
const runnerTrace = objectOrNull(message?.runnerTrace);
const toolCalls = Array.isArray(message?.toolCalls) ? message.toolCalls : [];
const parsedToolPayloads = toolCalls.map((toolCall) => parseToolStdout(toolCall?.stdout)).filter(Boolean);
return {
operationId: firstText(
message?.operationId,
message?.operation?.operationId,
providerTrace?.operationId,
runnerTrace?.operationId,
...toolCalls.map((toolCall) => toolCall?.operationId ?? toolCall?.hwlabApi?.operationId),
...parsedToolPayloads.map((payload) => payload?.operationId ?? payload?.operation?.operationId)
),
auditId: firstText(
message?.auditId,
message?.audit?.auditId,
providerTrace?.auditId,
runnerTrace?.auditId,
...toolCalls.map((toolCall) => toolCall?.auditId ?? toolCall?.audit?.auditId ?? toolCall?.hwlabApi?.auditId),
...parsedToolPayloads.map((payload) => payload?.auditId ?? payload?.audit?.auditId)
),
evidenceId: firstText(
message?.evidenceId,
message?.evidence?.evidenceId,
providerTrace?.evidenceId,
runnerTrace?.evidenceId,
...toolCalls.map((toolCall) => toolCall?.evidenceId ?? toolCall?.evidence?.evidenceId ?? toolCall?.hwlabApi?.evidenceId),
...parsedToolPayloads.map((payload) => payload?.evidenceId ?? payload?.evidence?.evidenceId)
)
};
}
function toolCallsAttributionSummary(toolCalls) {
const summary = toolCallsSummary(toolCalls);
return summary === "none" ? "none" : summary;
}
function objectOrNull(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function runnerKindFromMessage(message) {
return firstText(
message?.runner?.kind,
message?.runnerTrace?.runnerKind,
message?.provider === "openai-responses" ? TEXT_FALLBACK_RUNNER : null
);
}
function runtimePathRow(label, value) {
const text = nullableText(value);
return {
label,
value: text ?? MISSING_FIELD_VALUE,
missing: !text
};
}
function isCodexAppServerPath({ provider, runnerKind, protocol, implementationType, command }) {
return provider === "codex-stdio" && (
runnerKind === CODEX_APP_SERVER_RUNNER ||
protocol === CODEX_APP_SERVER_PROTOCOL ||
implementationType === CODEX_APP_SERVER_IMPLEMENTATION ||
/\bcodex\s+app-server\s+--listen\s+stdio:\/\//u.test(String(command ?? ""))
);
}
function parseToolStdout(stdout) {
if (typeof stdout !== "string" || !stdout.trim()) return null;
const text = stdout.trim();
const direct = parseJson(text);
if (direct) return direct;
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end <= start) return null;
return parseJson(text.slice(start, end + 1));
}
function parseJson(text) {
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function compactFields(fields, separator = " / ") {
return fields.filter((item) => item !== null && item !== undefined && String(item).trim()).map((item) => String(item).trim()).join(separator);
}
function field(label, value) {
const normalized = nullableText(value);
return normalized ? `${label}=${normalized}` : null;
}
function requiredField(label, value) {
const normalized = nullableText(value);
return `${label}=${normalized ?? MISSING_FIELD_VALUE}`;
}
function valueOrMissing(value) {
return nullableText(value) ?? MISSING_FIELD_VALUE;
}
function firstText(...values) {
return values.map(nullableText).find(Boolean) ?? null;
}
function nullableText(value) {
if (value === undefined || value === null || value === "") return null;
return String(value).replace(/\s+/gu, " ").trim();
}
@@ -1,603 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyCodeAgentStatusSummary } from "./code-agent-status.ts";
const liveRevision = Object.freeze({
healthLive: {
ok: true,
status: 200,
data: {
serviceId: "hwlab-cloud-api",
status: "ok",
commitId: "7de6edd2c41f54e4265e822d2593fa67057fa41a"
}
}
});
test("summarizes long-lived Code Agent as green with current deployment revision only", () => {
const summary = classifyCodeAgentStatusSummary({
live: liveRevision,
availability: {
status: "codex-stdio-feasible",
ready: true,
provider: "codex-stdio",
mode: "codex-stdio",
backend: "hwlab-cloud-api/codex-stdio",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
session: {
status: "idle",
lastTraceId: "trc_ready"
},
runner: {
kind: "codex-app-server-stdio-runner",
codexStdio: true
},
longLivedSessionGate: {
status: "pass"
},
codexStdioFeasibility: {
canStartLongLivedCodexStdio: true
},
providerTrace: {
protocol: "codex-app-server-jsonrpc-stdio",
command: "codex app-server --listen stdio://",
terminalStatus: "completed",
wireApi: "responses"
}
}
});
assert.equal(summary.kind, "long-lived-session");
assert.equal(summary.label, "长会话可用");
assert.equal(summary.tone, "ok");
assert.equal(summary.codeAgentStatus, "codex-stdio-feasible");
assert.equal(summary.providerModeBackend, "codex-stdio / codex-stdio / hwlab-cloud-api/codex-stdio");
assert.equal(summary.capabilityLevel, "long-lived-codex-stdio-session");
assert.equal(summary.sessionSummary, "codex-app-server-stdio-long-lived / idle / idle");
assert.equal(summary.sessionLifecycleStatus, "idle");
assert.match(summary.sessionLifecycleHint, //u);
assert.equal(summary.runnerKind, "codex-app-server-stdio-runner");
assert.equal(summary.protocol, "codex-app-server-jsonrpc-stdio");
assert.equal(summary.implementationType, "repo-owned-codex-app-server-stdio-session");
assert.equal(summary.providerTraceCommand, "codex app-server --listen stdio://");
assert.equal(summary.providerTraceTerminalStatus, "completed");
assert.match(summary.runtimePathLabel, /repo-owned Codex app-server stdio/u);
assert.equal(summary.readinessBlockers.length, 0);
assert.equal(summary.lastTraceId, "trc_ready");
assert.equal(summary.deploymentRevision, "7de6edd2c41f");
assert.equal(Object.keys(summary.fields).includes("sourceMain"), false);
});
test("summarizes Codex app-server stdio thread as reusable long-lived session", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
status: "completed",
provider: "codex-stdio",
mode: "codex-stdio",
backend: "hwlab-cloud-api/codex-app-server-stdio",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
session: {
sessionId: "ses_app_server",
threadId: "thread_app_server",
status: "idle",
lastTraceId: "trc_app_server",
codexStdio: true
},
sessionReuse: {
sessionId: "ses_app_server",
threadId: "thread_app_server",
reused: true,
turn: 2
},
runner: {
kind: "codex-app-server-stdio-runner",
codexStdio: true,
writeCapable: true,
durableSession: true
},
longLivedSessionGate: {
status: "pass"
},
providerTrace: {
protocol: "codex-app-server-jsonrpc-stdio",
threadId: "thread_app_server",
terminalStatus: "completed"
}
}
});
assert.equal(summary.kind, "long-lived-session");
assert.equal(summary.tone, "ok");
assert.equal(summary.sessionIdStatus, "ses_app_server / thread_app_server / idle / idle");
assert.equal(summary.sessionLifecycleLabel, "继续当前会话");
assert.match(summary.sessionLifecycleHint, //u);
assert.equal(summary.fields.threadId, "thread_app_server");
});
test("summarizes AgentRun invalid tool-call JSON as provider-side blocked status", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
status: "failed",
provider: "codex-stdio",
backend: "hwlab-cloud-api/codex-app-server-stdio",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
runner: { kind: "codex-app-server-stdio-runner" },
providerTrace: {
protocol: "codex-app-server-jsonrpc-stdio",
command: "codex app-server --listen stdio://",
terminalStatus: "failed",
failureKind: "provider-invalid-tool-call",
failureMessage: "invalid params, invalid function arguments json string, tool_call_id: call_function_selftest_2 (2013)"
},
error: {
code: "provider-invalid-tool-call",
category: "provider_invalid_tool_call"
},
traceId: "trc_status_invalid_tool_call"
}
});
assert.equal(summary.kind, "blocked");
assert.equal(summary.tone, "blocked");
assert.equal(summary.providerTraceFailureKind, "provider-invalid-tool-call");
assert.equal(summary.runtimePathLabel, "BLOCKEDCodex tool-call JSON 无效");
assert.match(summary.runtimePathSummary, / HWLAB device-pod/u);
});
test("maps degraded readonly session tools to warning, not green", () => {
const summary = classifyCodeAgentStatusSummary({
availability: {
status: "partial",
ready: false,
provider: "codex-readonly-runner",
backend: "hwlab-cloud-api/controlled-readonly-session-registry",
capabilityLevel: "read-only-session-tools",
sessionMode: "controlled-readonly-session-registry",
workspace: "/workspace/hwlab",
sandbox: "read-only",
session: {
sessionId: "ses_readonly",
status: "idle",
lastTraceId: "trc_readonly",
workspace: "/workspace/hwlab",
sandbox: "read-only",
runnerKind: "hwlab-readonly-runner",
sessionMode: "controlled-readonly-session-registry"
},
runner: {
kind: "hwlab-readonly-runner",
writeCapable: false
},
toolCalls: [{ name: "pwd", status: "completed" }],
skills: { status: "not_requested", count: 0 },
runnerTrace: {
traceId: "trc_readonly",
runnerKind: "hwlab-readonly-runner",
sessionMode: "controlled-readonly-session-registry",
turn: 1
},
blockers: [
{ code: "stdio_protocol_not_wired" }
]
}
});
assert.equal(summary.kind, "read-only-session-tools");
assert.equal(summary.label, "只读长会话工具");
assert.equal(summary.tone, "warn");
assert.equal(summary.sessionId, "ses_readonly");
assert.equal(summary.sessionIdStatus, "ses_readonly / idle / idle");
assert.equal(summary.workspace, "/workspace/hwlab");
assert.equal(summary.sandbox, "read-only");
assert.equal(summary.runnerKind, "hwlab-readonly-runner");
assert.equal(summary.toolCalls, "1/1 pwd(completed)");
assert.equal(summary.skills, "not_requested:count=0");
assert.match(summary.runnerTrace, /runnerKind=hwlab-readonly-runner/u);
assert.equal(summary.readinessBlockers.includes("stdio_protocol_not_wired"), true);
assert.equal(summary.lastTraceId, "trc_readonly");
});
test("summarizes Codex runtimeContract blockers from health availability", () => {
const summary = classifyCodeAgentStatusSummary({
availability: {
status: "partial",
ready: false,
provider: "codex-readonly-runner",
backend: "hwlab-cloud-api/codex-readonly-runner",
capabilityLevel: "read-only-session-tools",
sessionMode: "controlled-readonly-session-registry",
runner: {
kind: "hwlab-readonly-runner",
writeCapable: false
},
runtimeContract: {
binary: { status: "missing" },
lifecycleSupervisor: { status: "blocked" },
stdioProtocol: { status: "blocked" }
}
}
});
assert.equal(summary.kind, "read-only-session-tools");
assert.equal(summary.tone, "warn");
assert.equal(summary.readinessBlockers.includes("codex_cli_binary_missing"), true);
assert.equal(summary.readinessBlockers.includes("runner_lifecycle_missing"), true);
assert.equal(summary.readinessBlockers.includes("stdio_protocol_not_wired"), true);
});
test("maps text fallback to warning with safe labels", () => {
const summary = classifyCodeAgentStatusSummary({
availability: {
status: "available",
ready: true,
provider: "openai-responses",
mode: "openai",
backend: "hwlab-cloud-api/openai-responses",
capabilityLevel: "text-chat-only",
runner: {
kind: "openai-responses-fallback"
}
}
});
assert.equal(summary.kind, "fallback-text-chat-only");
assert.equal(summary.label, "会话降级");
assert.equal(summary.tone, "warn");
assert.equal(summary.provider, "openai-responses");
assert.equal(summary.capabilityLevel, "text-chat-only");
assert.equal(summary.sessionLifecycleStatus, "failed");
assert.match(summary.sessionLifecycleHint, /fallback\/text-only\/read-only/u);
});
test("maps stateless one-shot runner separately from long-lived sessions", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
status: "completed",
provider: "codex-cli",
backend: "hwlab-cloud-api/codex-cli",
capabilityLevel: "text-chat-only",
sessionMode: "ephemeral-one-shot",
runner: {
kind: "codex-cli-one-shot-ephemeral"
},
runnerLimitations: ["one-shot", "not-durable-session"],
traceId: "trc_one_shot"
}
});
assert.equal(summary.kind, "stateless-one-shot");
assert.equal(summary.label, "一次性执行");
assert.equal(summary.tone, "warn");
assert.equal(summary.sessionMode, "ephemeral-one-shot");
assert.equal(summary.lastTraceId, "trc_one_shot");
});
test("summarizes active Code Agent running request as pending with trace and session context", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
role: "agent",
status: "running",
sourceKind: "PENDING",
traceId: "trc_pending_heavy_skills",
conversationId: "cnv_pending_heavy_skills",
sessionId: "cnv_pending_heavy_skills"
}
});
assert.equal(summary.kind, "busy");
assert.equal(summary.label, "会话忙/请等待");
assert.equal(summary.tone, "pending");
assert.equal(summary.codeAgentStatus, "running");
assert.equal(summary.sessionId, "cnv_pending_heavy_skills");
assert.equal(summary.sessionLifecycleStatus, "busy");
assert.match(summary.sessionLifecycleHint, /\//u);
assert.equal(summary.lastTraceId, "trc_pending_heavy_skills");
assert.equal(summary.readinessBlockers.length, 0);
});
test("summarizes canceled Code Agent request with preserved retry context", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
role: "agent",
status: "canceled",
traceId: "trc_cancel_keep_context",
conversationId: "cnv_cancel_keep_context",
sessionId: "ses_cancel_keep_context",
session: {
sessionId: "ses_cancel_keep_context",
status: "canceled"
},
runnerTrace: {
traceId: "trc_cancel_keep_context",
sessionStatus: "canceled",
lastEvent: {
type: "cancel",
label: "cancel:canceled",
status: "canceled"
}
},
error: {
code: "codex_stdio_canceled",
message: "请求已取消"
}
}
});
assert.equal(summary.kind, "canceled");
assert.equal(summary.label, "请求已取消");
assert.equal(summary.tone, "blocked");
assert.equal(summary.codeAgentStatus, "canceled");
assert.equal(summary.sessionId, "ses_cancel_keep_context");
assert.equal(summary.sessionStatus, "canceled");
assert.equal(summary.sessionLifecycleStatus, "interrupted");
assert.match(summary.sessionLifecycleHint, //u);
assert.equal(summary.lastTraceId, "trc_cancel_keep_context");
});
test("summarizes timed out Code Agent request with preserved trace context", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
role: "agent",
status: "timeout",
traceId: "trc_timeout_keep_context",
conversationId: "cnv_timeout_keep_context",
sessionId: "ses_timeout_keep_context",
session: {
sessionId: "ses_timeout_keep_context",
status: "timeout"
},
runnerTrace: {
traceId: "trc_timeout_keep_context",
sessionStatus: "timeout",
lastEvent: {
type: "session",
label: "session:timeout",
status: "timeout"
}
},
error: {
code: "codex_stdio_timeout",
message: "请求超时"
}
}
});
assert.equal(summary.kind, "timeout");
assert.equal(summary.label, "等待超时");
assert.equal(summary.tone, "blocked");
assert.equal(summary.codeAgentStatus, "timeout");
assert.equal(summary.sessionId, "ses_timeout_keep_context");
assert.equal(summary.sessionStatus, "timeout");
assert.equal(summary.sessionLifecycleStatus, "failed");
assert.equal(summary.lastTraceId, "trc_timeout_keep_context");
});
test("surfaces expired and failed Code Agent session lifecycle hints", () => {
const expired = classifyCodeAgentStatusSummary({
latestMessage: {
status: "failed",
provider: "codex-stdio",
session: {
sessionId: "ses_expired_ui",
status: "expired",
codexStdio: true
},
error: {
code: "session_expired"
}
}
});
assert.equal(expired.kind, "session-expired");
assert.equal(expired.label, "会话已过期");
assert.equal(expired.sessionLifecycleStatus, "expired");
assert.match(expired.sessionLifecycleHint, / Code Agent session/u);
const failed = classifyCodeAgentStatusSummary({
latestMessage: {
status: "failed",
provider: "codex-stdio",
session: {
sessionId: "ses_failed_ui",
status: "failed",
codexStdio: true
},
error: {
code: "session_failed"
}
}
});
assert.equal(failed.kind, "turn-failed-session-continuable");
assert.equal(failed.label, "当前轮次失败");
assert.equal(failed.sessionLifecycleStatus, "failed");
assert.match(failed.sessionLifecycleHint, /session\/thread /u);
});
test("missing Code Agent session evidence is shown as lifecycle degradation", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
status: "completed",
provider: "codex-stdio",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
runner: {
kind: "codex-app-server-stdio-runner",
codexStdio: true,
writeCapable: true
},
blocker: {
code: "code_agent_session_evidence_missing"
}
}
});
assert.notEqual(summary.tone, "ok");
assert.equal(summary.sessionLifecycleLabel, "会话降级");
assert.match(summary.sessionLifecycleHint, //u);
});
test("maps blockers to blocked tone and redacts secret-like fields", () => {
const summary = classifyCodeAgentStatusSummary({
availability: {
status: "blocked",
ready: false,
provider: "openai-responses",
backend: "Secret hwlab-code-agent-provider should not show",
capabilityLevel: "blocked",
reason: "OPENAI_API_KEY_missing",
error: {
code: "provider_unavailable",
providerStatus: 503
},
missingEnv: ["OPENAI_API_KEY"],
traceId: "trc_blocked"
}
});
assert.equal(summary.kind, "blocked");
assert.equal(summary.label, "服务受阻");
assert.equal(summary.tone, "blocked");
assert.equal(summary.backend, "已隐藏");
assert.equal(summary.readinessBlockers.includes("provider_config_blocked"), true);
assert.equal(summary.readinessBlockers.includes("provider_unavailable"), true);
assert.equal(summary.readinessBlockers.includes("provider_http_503"), true);
assert.equal(summary.lastTraceId, "trc_blocked");
});
test("uses structured readiness current blockers and ignores stale provider blockers", () => {
const summary = classifyCodeAgentStatusSummary({
live: {
restIndex: {
ok: true,
status: 200,
data: {
readiness: {
codeAgent: {
status: "blocked",
ready: false,
providerReady: true,
durableDbReady: true,
sessionRunnerReady: true,
codexStdioFeasible: false,
currentBlocker: "codex_stdio_supervisor_disabled",
currentBlockers: ["codex_stdio_supervisor_disabled"],
provider: {
status: "ready",
ready: true,
provider: "openai-responses",
backend: "hwlab-cloud-api/openai-responses",
mode: "openai",
blocker: null
},
dbDurable: {
status: "ready",
ready: true,
adapter: "postgres",
blocker: null,
queryResult: "durable_readiness_ready"
},
sessionRunner: {
status: "read_only_long_lived_ready",
ready: true,
kind: "hwlab-readonly-runner",
mode: "controlled-readonly-session-registry",
capabilityLevel: "read-only-session-tools",
longLivedSession: true,
durableSession: false,
codexStdio: false,
writeCapable: false,
blocker: null
},
codexStdio: {
status: "blocked",
ready: false,
feasible: false,
blocker: "codex_stdio_supervisor_disabled",
blockerCodes: ["codex_stdio_supervisor_disabled"]
}
}
},
codeAgent: {
status: "partial",
ready: false,
provider: "openai-responses",
backend: "hwlab-cloud-api/openai-responses",
capabilityLevel: "read-only-session-tools",
blockerCodes: [
"provider_unavailable",
"runtime_durable_adapter_auth_blocked"
]
}
}
}
}
});
assert.equal(summary.kind, "read-only-session-tools");
assert.equal(summary.tone, "warn");
assert.equal(summary.provider, "openai-responses");
assert.equal(summary.capabilityLevel, "read-only-session-tools");
assert.deepEqual(summary.readinessBlockers, ["codex_stdio_supervisor_disabled"]);
assert.equal(summary.readinessBlockers.includes("provider_unavailable"), false);
assert.equal(summary.readinessBlockers.includes("runtime_durable_adapter_auth_blocked"), false);
});
test("structured readiness without codeAgent payload degrades into blocked status", () => {
const summary = classifyCodeAgentStatusSummary({
live: {
healthLive: {
ok: true,
status: 200,
data: {
readiness: {
codeAgent: {
status: "blocked",
ready: false,
providerReady: false,
durableDbReady: true,
sessionRunnerReady: false,
codexStdioFeasible: false,
currentBlocker: "provider_config_blocked",
currentBlockers: ["provider_config_blocked"],
provider: {
status: "blocked",
ready: false,
provider: "openai-responses",
backend: "hwlab-cloud-api/openai-responses",
mode: "openai",
blocker: "provider_config_blocked"
},
sessionRunner: {
status: "blocked",
ready: false,
kind: "unknown",
mode: "unknown",
capabilityLevel: "blocked",
blocker: "session_runner_blocked"
},
codexStdio: {
status: "blocked",
ready: false,
blocker: "codex_stdio_supervisor_disabled",
blockerCodes: ["codex_stdio_supervisor_disabled"]
}
}
}
}
}
}
});
assert.equal(summary.kind, "blocked");
assert.equal(summary.label, "服务受阻");
assert.equal(summary.tone, "blocked");
assert.equal(summary.readinessBlockers.includes("provider_config_blocked"), true);
assert.equal(summary.provider, "openai-responses");
});
@@ -1,572 +0,0 @@
import {
codeAgentRuntimePathFromMessage,
runnerTraceSummary as compactRunnerTraceSummary,
skillsSummary as compactSkillsSummary,
toolCallsSummary as compactToolCallsSummary
} from "./code-agent-facts.ts";
const GREEN_TONES = new Set(["ok", "available", "ready", "dev-live", "live", "pass"]);
const SECRET_MARKER = /\b(?:OPENAI_API_KEY|DATABASE_URL|secret|secretref|token|password|passwd|credential|private key|kubeconfig|||)\b/iu;
const NON_LIVE_VALUES = new Set(["", "n/a", "none", "null", "undefined", "unknown", "not_observed", "not-observed"]);
const MAX_FIELD_LENGTH = 96;
const CODEX_APP_SERVER_RUNNER_KIND = "codex-app-server-stdio-runner";
const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived";
const CODEX_APP_SERVER_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-stdio-session";
export function classifyCodeAgentStatusSummary({ availability = null, latestMessage = null, live = null } = {}) {
const observedAvailability = availability ?? codeAgentAvailabilityFromLive(live);
const payload = safeMerge(observedAvailability, latestMessage);
const blockers = readinessBlockers(payload);
const classification = classifyPayload(payload, blockers);
const safeTone = unsafeGreenForNonReady(classification, blockers) ? "warn" : classification.tone;
const provider = safeField(firstString(payload.provider, observedAvailability?.provider));
const mode = safeField(firstString(payload.mode, payload.providerTrace?.mode, observedAvailability?.mode));
const backend = safeField(firstString(payload.backend, observedAvailability?.backend));
const codeAgentStatus = safeField(firstString(payload.status, payload.readinessStatus, observedAvailability?.status));
const capabilityLevel = safeField(firstString(payload.capabilityLevel, payload.runner?.capabilityLevel, observedAvailability?.capabilityLevel));
const sessionMode = safeField(firstString(payload.sessionMode, payload.session?.sessionMode, payload.runnerTrace?.sessionMode, payload.runner?.sessionMode));
const sessionId = safeField(firstString(payload.session?.sessionId, payload.sessionId, payload.runner?.sessionId, payload.sessionReuse?.sessionId));
const threadId = safeField(firstString(payload.threadId, payload.session?.threadId, payload.sessionReuse?.threadId, payload.providerTrace?.threadId, payload.runnerTrace?.threadId));
const sessionStatus = safeField(firstString(payload.session?.status, payload.sessionStatus, payload.runnerTrace?.sessionStatus, payload.sessionReuse?.status));
const lifecycle = sessionLifecycleSummary(payload);
const sessionLifecycleStatus = safeField(lifecycle.status);
const sessionLifecycleHint = safeField(lifecycle.hint);
const sessionLifecycleLabel = safeField(lifecycle.label);
const workspace = safeField(firstString(payload.workspace, payload.session?.workspace, payload.runner?.workspace, payload.sessionReuse?.workspace));
const sandbox = safeField(firstString(payload.sandbox, payload.session?.sandbox, payload.runner?.sandbox));
const runnerKind = safeField(firstString(payload.runner?.kind, payload.session?.runnerKind, payload.runnerTrace?.runnerKind));
const toolCalls = safeField(compactToolCallsSummary(payload.toolCalls));
const skills = safeField(compactSkillsSummary(payload.skills));
const runnerTrace = safeField(compactRunnerTraceSummary(payload.runnerTrace));
const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...payload });
const protocol = safeField(runtimePath?.protocol);
const implementationType = safeField(runtimePath?.implementationType);
const providerTraceCommand = safeField(runtimePath?.command);
const providerTraceTerminalStatus = safeField(runtimePath?.terminalStatus);
const providerTraceFailureKind = safeField(runtimePath?.failureKind);
const runtimePathLabel = runtimePath?.label ?? "运行路径证据不足";
const runtimePathSummary = runtimePath?.summary ?? "providerTrace 未观测;不会推断完整 Code Agent。";
const runtimePathTone = runtimePath?.tone ?? "warn";
const lastTraceId = safeTraceId(firstString(
latestMessage?.traceId,
payload.traceId,
payload.session?.lastTraceId,
payload.runnerTrace?.lastTraceId,
payload.runnerTrace?.traceId,
payload.providerTrace?.traceId,
observedAvailability?.traceId
));
const deploymentRevision = currentDeploymentRevision(live);
return {
...classification,
tone: safeTone,
provider,
mode,
backend,
providerModeBackend: compactJoin([provider, mode, backend], " / "),
codeAgentStatus,
capabilityLevel,
sessionMode,
sessionId,
threadId,
sessionStatus,
sessionLifecycleStatus,
sessionLifecycleHint,
sessionLifecycleLabel,
sessionSummary: compactJoin([sessionMode, sessionStatus, sessionLifecycleStatus], " / "),
sessionIdStatus: compactJoin([sessionId, threadId, sessionStatus, sessionLifecycleStatus], " / "),
workspace,
sandbox,
runnerKind,
protocol,
implementationType,
providerTraceCommand,
providerTraceTerminalStatus,
providerTraceFailureKind,
runtimePathLabel,
runtimePathSummary,
runtimePathTone,
toolCalls,
skills,
runnerTrace,
readinessBlockers: blockers,
blockersLabel: blockers.length > 0 ? blockers.join(", ") : "无",
lastTraceId: lastTraceId || "未观测",
deploymentRevision,
fields: {
provider,
mode,
backend,
codeAgentStatus,
capabilityLevel,
sessionMode,
sessionId,
threadId,
sessionStatus,
sessionLifecycleStatus,
sessionLifecycleHint,
sessionLifecycleLabel,
workspace,
sandbox,
runnerKind,
protocol,
implementationType,
providerTraceCommand,
providerTraceTerminalStatus,
providerTraceFailureKind,
runtimePathLabel,
runtimePathSummary,
runtimePathTone,
toolCalls,
skills,
runnerTrace,
readinessBlockers: blockers,
lastTraceId: lastTraceId || null,
deploymentRevision
}
};
}
export function codeAgentAvailabilityFromLive(live = {}) {
return normalizedCodeAgentAvailability(
live?.restIndex?.data?.codeAgent ?? live?.healthLive?.data?.codeAgent ?? live?.health?.data?.codeAgent ?? null,
live?.restIndex?.data?.readiness?.codeAgent ?? live?.healthLive?.data?.readiness?.codeAgent ?? live?.health?.data?.readiness?.codeAgent ?? null
);
}
export function currentDeploymentRevision(live = {}) {
const payloads = [
live?.healthLive?.data,
live?.restIndex?.data,
live?.health?.data,
live?.adapter?.data
].filter(Boolean);
for (const payload of payloads) {
const revision = firstString(
payload?.commit?.id,
payload?.commitId,
payload?.revision,
payload?.runtime?.commitId,
payload?.image?.tag,
payload?.imageTag,
payload?.runtime?.imageTag
);
if (revision && !NON_LIVE_VALUES.has(revision.toLowerCase())) {
return shortRevision(revision);
}
}
return "未观测";
}
function classifyPayload(payload, blockers) {
if (!payload || Object.keys(payload).length === 0) {
return status("unverified", "探测中", "pending", "○", "等待同源接口返回 Code Agent 状态。");
}
const lifecycle = sessionLifecycleSummary(payload);
if (payload.status === "running" || lifecycle.status === "busy" || payload.session?.status === "busy" || payload.runnerTrace?.sessionStatus === "busy") {
return status("busy", "会话忙/请等待", "pending", "●", lifecycle.hint || "当前 Codex stdio 请求仍在 in-flight 状态,trace 会继续更新。");
}
if (lifecycle.status === "expired") {
return status("session-expired", "会话已过期", "blocked", "×", lifecycle.hint || "会话已过期,可重新发送建立新的 Code Agent session。");
}
if (lifecycle.status === "failed" && (payload.session?.status === "failed" || payload.sessionLifecycle?.status === "failed" || payload.sessionSummary?.status === "failed") && !isFallbackTextChatOnly(payload)) {
return status("turn-failed-session-continuable", "当前轮次失败", "warn", "!", lifecycle.hint || "当前 Code Agent turn 已失败;session/thread 已保留,可继续发送下一条消息。");
}
if (lifecycle.status === "interrupted" && !["canceled", "timeout", "error"].includes(payload.status)) {
return status("session-interrupted", lifecycle.label || "会话已中断", "blocked", "×", lifecycle.hint || "当前 Code Agent session 已中断,可重试或重新发送。");
}
if (payload.status === "canceled" || payload.session?.status === "canceled" || payload.error?.code === "codex_stdio_canceled") {
return status("canceled", "请求已取消", "blocked", "×", "当前请求已取消,输入、sessionId 和 traceId 已保留,可重试。");
}
if (payload.status === "timeout" || payload.session?.status === "timeout") {
return status("timeout", "等待超时", "blocked", "×", "当前请求超时,输入、sessionId 和 traceId 已保留,可重试。");
}
if (payload.status === "error" || payload.session?.status === "error") {
return status("error", "请求错误", "blocked", "×", "当前请求返回错误,输入、sessionId 和 traceId 已保留,可重试。");
}
if (isLongLivedReady(payload)) {
return status("long-lived-session", "长会话可用", "ok", "●", "repo-owned 长会话通道可复用。");
}
if (payload.provider === "codex-stdio") {
const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...payload });
if (runtimePath?.kind !== "codex-app-server-stdio" || runtimePath?.criticalMissing === true || runtimePath?.terminalStatusOk === false) {
return isBlocked(payload)
? status("blocked", "服务受阻", "blocked", "×", runtimePath?.summary ?? "Codex stdio 缺少 app-server providerTrace 终态证据。")
: status("degraded", "运行路径降级", "warn", "!", runtimePath?.summary ?? "Codex stdio 缺少 app-server providerTrace 终态证据。");
}
}
if (isReadOnlySessionTools(payload)) {
return status("read-only-session-tools", "只读长会话工具", "warn", "◇", "只读 runner 会话可复用,不能写工作区或冒充完整 Codex stdio。");
}
if (isStatelessOneShot(payload)) {
return status("stateless-one-shot", "一次性执行", "warn", "△", "当前回复来自一次性执行,不是可复用长会话。");
}
if (isBlocked(payload)) {
return status("blocked", "服务受阻", "blocked", "×", "Code Agent 通道暂不可用或 readiness 未通过。");
}
if (isFallbackTextChatOnly(payload)) {
return status("fallback-text-chat-only", "会话降级", "warn", "◌", lifecycle.hint || "只有文本对话能力,没有 workspace tools 或 session 控制。");
}
if (isDegraded(payload, blockers)) {
return status("degraded", "降级可用", "warn", "!", "Code Agent 有部分能力,但 readiness 仍有阻塞项。");
}
if (payload.status === "available" || payload.ready === true || payload.status === "completed") {
return status("available", "通道可用", "ok", "●", "受控对话通道已返回可用状态。");
}
return status("unverified", "探测中", "pending", "○", "等待同源接口返回 Code Agent 状态。");
}
function normalizedCodeAgentAvailability(availability, structuredReadiness) {
const base = availability && typeof availability === "object" ? { ...availability } : {};
const structured = structuredReadiness && typeof structuredReadiness === "object" ? structuredReadiness : null;
if (!structured) return Object.keys(base).length > 0 ? base : null;
const provider = structured.provider ?? {};
const sessionRunner = structured.sessionRunner ?? {};
const codexStdio = structured.codexStdio ?? {};
const merged = {
...base,
structuredReadiness: structured,
status: firstString(base.status, structured.status),
ready: base.ready === true || structured.ready === true,
readinessStatus: firstString(base.readinessStatus, structured.status),
providerReady: structured.providerReady === true,
durableDbReady: structured.durableDbReady === true,
sessionRunnerReady: structured.sessionRunnerReady === true,
codexStdioFeasible: structured.codexStdioFeasible === true,
provider: firstString(base.provider, provider.provider),
mode: firstString(base.mode, provider.mode, sessionRunner.mode),
backend: firstString(base.backend, provider.backend),
capabilityLevel: firstString(base.capabilityLevel, sessionRunner.capabilityLevel),
sessionMode: firstString(base.sessionMode, sessionRunner.mode),
currentBlockers: Array.isArray(structured.currentBlockers) ? [...structured.currentBlockers] : [],
blockerCodes: [
...(Array.isArray(structured.currentBlockers) ? structured.currentBlockers : []),
...(Array.isArray(codexStdio.blockerCodes) ? codexStdio.blockerCodes : [])
],
blockers: [
...(Array.isArray(structured.currentBlockers)
? structured.currentBlockers.map((code) => ({ code }))
: [])
],
runner: {
...(base.runner ?? {}),
kind: firstString(base.runner?.kind, sessionRunner.kind),
provider: firstString(base.runner?.provider, provider.provider),
backend: firstString(base.runner?.backend, provider.backend),
sessionMode: firstString(base.runner?.sessionMode, sessionRunner.mode),
capabilityLevel: firstString(base.runner?.capabilityLevel, sessionRunner.capabilityLevel),
longLivedSession: base.runner?.longLivedSession === true || sessionRunner.longLivedSession === true,
durableSession: base.runner?.durableSession === true || sessionRunner.durableSession === true,
codexStdio: base.runner?.codexStdio === true || sessionRunner.codexStdio === true,
writeCapable: base.runner?.writeCapable === true || sessionRunner.writeCapable === true
},
codexStdioFeasibility: {
...(base.codexStdioFeasibility ?? base.codexStdio ?? {}),
ready: codexStdio.ready === true,
canStartLongLivedCodexStdio: codexStdio.feasible === true || codexStdio.ready === true,
status: codexStdio.status,
blockerCodes: Array.isArray(codexStdio.blockerCodes) ? [...codexStdio.blockerCodes] : [],
blockers: codexStdio.blocker ? [{ code: codexStdio.blocker }] : []
}
};
if (structured.currentBlocker) merged.reason = firstString(base.reason, structured.currentBlocker);
return merged;
}
function status(kind, label, tone, icon, summary) {
return { kind, label, tone, icon, summary };
}
function safeMerge(availability, latestMessage) {
const base = availability && typeof availability === "object" ? availability : {};
const message = latestMessage && typeof latestMessage === "object" ? latestMessage : {};
const merged = {
...base,
...message,
runner: { ...(base.runner ?? {}), ...(message.runner ?? {}) },
session: mergeSession(base.session, message.session),
runnerTrace: { ...(base.runnerTrace ?? {}), ...(message.runnerTrace ?? {}) },
providerTrace: { ...(base.providerTrace ?? {}), ...(message.providerTrace ?? {}) },
longLivedSessionGate: { ...(base.longLivedSessionGate ?? {}), ...(message.longLivedSessionGate ?? {}) },
codexStdioFeasibility: { ...(base.codexStdioFeasibility ?? base.codexStdio ?? {}), ...(message.codexStdioFeasibility ?? {}) }
};
if (base.blockers || message.blockers) merged.blockers = [...(base.blockers ?? []), ...(message.blockers ?? [])];
if (base.blockerCodes || message.blockerCodes) merged.blockerCodes = [...(base.blockerCodes ?? []), ...(message.blockerCodes ?? [])];
if (base.runnerLimitations || message.runnerLimitations) merged.runnerLimitations = [...(base.runnerLimitations ?? []), ...(message.runnerLimitations ?? [])];
if (message.toolCalls !== undefined) merged.toolCalls = message.toolCalls;
if (message.skills !== undefined) merged.skills = message.skills;
return merged;
}
function mergeSession(baseSession, messageSession) {
const base = typeof baseSession === "string" ? { status: baseSession } : (baseSession ?? {});
const message = typeof messageSession === "string" ? { status: messageSession } : (messageSession ?? {});
return { ...base, ...message };
}
function isLongLivedReady(payload) {
return (
(
payload.longLivedSessionGate?.status === "pass" ||
payload.longLivedSessionGate?.pass === true ||
payload.codexStdioFeasibility?.canStartLongLivedCodexStdio === true
) &&
(
payload.provider === "codex-stdio" ||
payload.runner?.kind === CODEX_APP_SERVER_RUNNER_KIND ||
payload.runner?.codexStdio === true ||
payload.session?.codexStdio === true
) &&
payload.capabilityLevel === "long-lived-codex-stdio-session" &&
payload.sessionMode === CODEX_APP_SERVER_SESSION_MODE &&
(
!payload.implementationType ||
payload.implementationType === CODEX_APP_SERVER_IMPLEMENTATION_TYPE
)
) && payload.status !== "failed" && payload.status !== "blocked" && payload.ready !== false;
}
function isReadOnlySessionTools(payload) {
return (
payload.capabilityLevel === "read-only-session-tools" ||
payload.capabilityLevel === "read-only-tools" ||
payload.agentKind === "controlled-readonly-session-registry" ||
payload.sessionMode === "controlled-readonly-session-registry" ||
payload.implementationType === "controlled-readonly-session-registry" ||
payload.runner?.kind === "hwlab-readonly-runner" ||
(payload.runner?.writeCapable === false &&
(payload.runner?.ready === true || payload.partialReady === true || payload.sessionRunnerReady === true) &&
payload.capabilityLevel !== "text-chat-only" &&
payload.runner?.kind !== "openai-responses-fallback") ||
payload.partialReady === true
);
}
function isStatelessOneShot(payload) {
return (
payload.sessionMode === "ephemeral-one-shot" ||
payload.runner?.kind === "codex-cli-one-shot-ephemeral" ||
payload.implementationType === "codex-cli-one-shot-ephemeral" ||
array(payload.runnerLimitations).includes("one-shot")
);
}
function isFallbackTextChatOnly(payload) {
return (
payload.capabilityLevel === "text-chat-only" ||
payload.runner?.kind === "openai-responses-fallback" ||
payload.implementationType === "openai-responses-fallback" ||
payload.agentKind === "openai-fallback" ||
payload.provider === "openai-responses"
);
}
function isBlocked(payload) {
return payload.status === "blocked" ||
payload.status === "failed" ||
payload.ready === false ||
payload.capabilityLevel === "blocked" ||
Boolean(payload.error);
}
function sessionLifecycleSummary(payload = {}) {
let status = normalizeSessionLifecycleStatus(firstString(
payload?.sessionLifecycleStatus,
payload?.sessionLifecycle?.status,
payload?.sessionSummary?.status,
payload?.session?.lifecycleStatus,
payload?.session?.lifecycle?.status,
payload?.runnerTrace?.sessionLifecycleStatus,
payload?.session?.status,
payload?.sessionStatus,
payload?.runnerTrace?.sessionStatus,
payload?.sessionReuse?.status,
payload?.status
), {
fallback: fallbackLifecycleStatus(payload)
});
const fallback = isFallbackTextChatOnly(payload) ||
payload?.fallbackUsed === true ||
payload?.sessionLifecycle?.fallback === true ||
payload?.sessionSummary?.fallback === true;
const degraded = fallback ||
payload?.degraded === true ||
payload?.sessionLifecycle?.degraded === true ||
payload?.sessionSummary?.degraded === true ||
payload?.longLivedSessionGate?.status === "blocked" ||
payload?.blocker?.code === "code_agent_session_evidence_missing" ||
payload?.error?.code === "code_agent_session_evidence_missing";
const evidenceSessionId = firstString(
payload?.session?.sessionId,
payload?.sessionReuse?.sessionId,
payload?.runnerTrace?.sessionId
);
const topLevelSessionId = firstString(payload?.sessionId);
const hasSession = Boolean(evidenceSessionId || (topLevelSessionId && topLevelSessionId !== firstString(payload?.conversationId)));
if ((fallback || degraded) && !hasSession) status = "failed";
const concreteLifecycleBlock = ["busy", "expired", "failed", "interrupted"].includes(status) && hasSession;
const evidenceMissing = payload?.blocker?.code === "code_agent_session_evidence_missing" ||
payload?.error?.code === "code_agent_session_evidence_missing";
const reused = payload?.sessionSummary?.reused ??
payload?.sessionLifecycle?.reused ??
payload?.sessionReuse?.reused ??
payload?.session?.reused ??
payload?.runnerTrace?.sessionReused ??
null;
if (fallback) {
return {
status: status ?? "failed",
label: "会话降级",
hint: "会话降级:当前响应来自 fallback/text-only/read-only 路径,不按完整 Code Agent 长会话成功展示。"
};
}
if (degraded && (evidenceMissing || !concreteLifecycleBlock)) {
return {
status: status ?? "failed",
label: "会话降级",
hint: "会话降级:会话证据不完整或运行路径降级,输入已保留,可重新发送建立新会话。"
};
}
if (status === "busy") {
return { status, label: "会话忙", hint: "会话忙/请等待:上一轮仍在处理,稍后重试或取消当前请求。" };
}
if (status === "expired") {
return { status, label: "会话已过期", hint: "会话已过期,可重新发送建立新的 Code Agent session。" };
}
if (status === "failed") {
return { status, label: "会话失败", hint: "当前 Code Agent turn 已失败;session/thread 已保留,可继续发送下一条消息。" };
}
if (status === "interrupted") {
return { status, label: "会话已中断", hint: "当前 Code Agent session 已中断;输入和 trace 已保留,可重试或重新发送。" };
}
if (status === "creating") {
return { status, label: "正在建立会话", hint: "正在建立 Code Agent session,请等待后端返回 session/thread。" };
}
if (status === "ready" || status === "idle") {
return reused === true
? { status, label: "继续当前会话", hint: "可继续当前会话:后端已确认复用现有 conversation/session/thread。" }
: { status, label: "新会话已建立", hint: "新会话已建立:后端返回了可复用的 session/thread。" };
}
return { status, label: "会话未确认", hint: "尚未观测到可复用的 Code Agent session 状态。" };
}
function normalizeSessionLifecycleStatus(value, { fallback = null } = {}) {
const text = firstString(value);
const status = String(text ?? "").toLowerCase();
if (["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"].includes(status)) return status;
if (status === "canceled" || status === "cancelled") return "interrupted";
if (status === "timeout" || status === "error" || status === "blocked" || status === "degraded") return "failed";
if (status === "running") return "busy";
if (status === "completed" || status === "available" || status === "feasible" || status === "pass") return "idle";
return fallback;
}
function fallbackLifecycleStatus(payload) {
if (isFallbackTextChatOnly(payload) || payload?.degraded === true) return "failed";
if (payload?.status === "running") return "busy";
if (["failed", "timeout", "error"].includes(payload?.status)) return "failed";
if (payload?.status === "canceled") return "interrupted";
return null;
}
function isDegraded(payload, blockers) {
return payload.status === "partial" ||
payload.status === "degraded" ||
payload.readinessStatus === "partial" ||
payload.capabilityStatus === "controlled-readonly-session-registry" ||
blockers.length > 0;
}
function readinessBlockers(payload) {
const values = [
...array(payload?.blockerCodes),
...array(payload?.blockers).map((item) => item?.code ?? item),
...array(payload?.longLivedSessionGate?.blockers).map((item) => item?.code ?? item),
...array(payload?.codexStdioFeasibility?.blockers).map((item) => item?.code ?? item),
payload?.runtimeContract?.binary?.status === "missing" ? "codex_cli_binary_missing" : null,
payload?.runtimeContract?.binary?.present === true && payload?.runtimeContract?.binary?.executable === false ? "codex_cli_not_executable" : null,
payload?.runtimeContract?.binary?.present === true && payload?.runtimeContract?.binary?.nativeDependencyPresent === false ? "codex_cli_native_dependency_missing" : null,
payload?.runtimeContract?.lifecycleSupervisor?.status === "blocked" ? "runner_lifecycle_missing" : null,
payload?.runtimeContract?.stdioProtocol?.status === "blocked" ? "stdio_protocol_not_wired" : null,
payload?.reason,
payload?.error?.code,
payload?.providerStatus ? `provider_http_${payload.providerStatus}` : null,
payload?.error?.providerStatus ? `provider_http_${payload.error.providerStatus}` : null,
...array(payload?.missingEnv).map(() => "provider_config_blocked")
];
return [...new Set(values.map(safeBlocker).filter(Boolean))].slice(0, 4);
}
function safeBlocker(value) {
if (value && typeof value === "object" && !Array.isArray(value) && !value.code && !value.reason && !value.message) return null;
const text = safeField(typeof value === "object" ? value?.code ?? value?.reason ?? value?.message : value);
if (!text || text === "未观测" || /^_+$/u.test(text)) return null;
if (SECRET_MARKER.test(text)) return "provider_config_blocked";
return text.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 72);
}
function unsafeGreenForNonReady(classification, blockers) {
return GREEN_TONES.has(classification.tone) &&
(classification.kind.includes("blocked") || classification.kind === "degraded" || blockers.some((blocker) => /blocked|unavailable|failed|degraded/iu.test(blocker)));
}
function currentString(value) {
const text = String(value ?? "").trim();
if (!text || NON_LIVE_VALUES.has(text.toLowerCase())) return null;
return text;
}
function firstString(...values) {
for (const value of values) {
const text = currentString(value);
if (text) return text;
}
return null;
}
function safeField(value) {
const text = currentString(value);
if (!text) return "未观测";
if (SECRET_MARKER.test(text)) return "已隐藏";
return text.replace(/\s+/gu, " ").slice(0, MAX_FIELD_LENGTH);
}
function safeTraceId(value) {
const text = currentString(value);
if (!text || SECRET_MARKER.test(text)) return null;
return text.replace(/\s+/gu, "").slice(0, MAX_FIELD_LENGTH);
}
function shortRevision(value) {
const text = safeTraceId(value);
if (!text) return "未观测";
return /^[a-f0-9]{12,40}$/iu.test(text) ? text.slice(0, 12) : text.slice(0, 24);
}
function compactJoin(values, separator) {
const parts = values.map((value) => value === "未观测" ? null : value).filter(Boolean);
return parts.length > 0 ? parts.join(separator) : "未观测";
}
function array(value) {
return Array.isArray(value) ? value : [];
}
@@ -1,93 +0,0 @@
import { expect, test } from "bun:test";
import { computeCodeAgentComposerState } from "./composer-policy.ts";
test("composer stays unlocked and routes active turns to steer", () => {
const state = computeCodeAgentComposerState({
chatPending: true,
currentRequest: {
traceId: "trc_active",
conversationId: "cnv_active",
sessionId: "ses_active",
threadId: "thread-active"
},
messages: [{ role: "agent", status: "running", traceId: "trc_active" }]
});
expect(state.locked).toBe(false);
expect(state.disabled).toBe(false);
expect(state.submitMode).toBe("steer");
expect(state.route).toBe("/v1/agent/chat/steer");
expect(state.targetTraceId).toBe("trc_active");
expect(state.conversationId).toBe("cnv_active");
});
test("composer routes idle or terminal state to a normal turn", () => {
const state = computeCodeAgentComposerState({
sessionStatus: "completed",
workspace: {
workspaceId: "wsp_1",
revision: 3,
selectedConversationId: "cnv_1",
selectedAgentSessionId: "ses_1"
}
});
expect(state.locked).toBe(false);
expect(state.disabled).toBe(false);
expect(state.submitMode).toBe("turn");
expect(state.route).toBe("/v1/agent/chat");
expect(state.targetTraceId).toBeNull();
expect(state.workspaceId).toBe("wsp_1");
expect(state.workspaceRevision).toBe(3);
});
test("composer treats workspace activeTraceId as a steerable Web turn", () => {
const state = computeCodeAgentComposerState({
workspace: {
activeTraceId: "trc_shared_active",
selectedConversationId: "cnv_shared",
selectedAgentSessionId: "ses_shared",
threadId: "thread-shared"
}
});
expect(state.locked).toBe(false);
expect(state.disabled).toBe(false);
expect(state.submitMode).toBe("steer");
expect(state.targetTraceId).toBe("trc_shared_active");
expect(state.route).toBe("/v1/agent/chat/steer");
});
test("composer does not steer from stale terminal activeTraceId", () => {
const state = computeCodeAgentComposerState({
sessionStatus: "completed",
workspace: {
activeTraceId: "trc_stale_active",
sessionStatus: "completed",
selectedConversationId: "cnv_stale"
}
});
expect(state.locked).toBe(false);
expect(state.disabled).toBe(true);
expect(state.disabledReason).toBe("session_required");
expect(state.submitMode).toBe("turn");
expect(state.targetTraceId).toBeNull();
expect(state.route).toBe("/v1/agent/chat");
});
test("composer requires a new manual session after failed session", () => {
const state = computeCodeAgentComposerState({
sessionStatus: "failed",
workspace: {
selectedConversationId: "cnv_failed",
selectedAgentSessionId: "ses_failed"
}
});
expect(state.submitMode).toBe("turn");
expect(state.disabled).toBe(false);
expect(state.disabledReason).toBeNull();
expect(state.sessionUsable).toBe(true);
});
@@ -1,33 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyWorkbenchLiveStatus } from "./live-status.ts";
test("Device Pod profile hash evidence is summarized without undefined helper crashes", () => {
const status = classifyWorkbenchLiveStatus({
healthLive: { ok: true, data: { serviceId: "hwlab-cloud-api", status: "ok" } },
restIndex: { ok: true, data: { serviceId: "hwlab-cloud-api", status: "ok" } },
health: { ok: true, data: { serviceId: "hwlab-cloud-api", status: "ok" } },
adapter: { ok: true, data: { serviceId: "hwlab-cloud-api", status: "ok" } },
devicePodStatus: {
ok: true,
data: {
status: "ready",
serviceId: "hwlab-device-pod",
summary: {
devicePodId: "device-pod-71-freq",
targetId: "71-FREQ",
profileHash: "abcdef0123456789fedcba9876543210",
latestEvent: { refs: { traceId: "trc_live_status", operationId: "op_live_status" } }
}
}
}
});
const devicePodProbe = status.probes.find((probe) => probe.serviceId === "hwlab-device-pod");
assert.ok(devicePodProbe, JSON.stringify(status.probes));
assert.equal(devicePodProbe.kind, "pass");
assert.equal(devicePodProbe.reasonCode, "device_pod_ready");
assert.match(devicePodProbe.evidenceSummary, /profile=abcdef0123456789/u);
assert.doesNotMatch(devicePodProbe.evidenceSummary, /fedcba9876543210/u);
});
File diff suppressed because it is too large Load Diff
@@ -1,32 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderMessageMarkdown } from "./message-markdown.ts";
test("final response markdown supports common blocks and escapes raw html", () => {
const html = renderMessageMarkdown([
"# Gateway 控制 Windows CMD 的方法",
"",
"核心工具是 `/app/tools/hwlab-gateway-tran.mjs`。",
"",
"| 参数 | 作用 |",
"|---|---|",
"| `cmd` | 透传 Windows cmd |",
"",
"```bash",
"node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- pwd",
"```",
"",
"<span class=\"message-copy\">raw html should stay inert</span>",
"[blocked](javascript:alert(1))",
"![blocked-image](https://example.invalid/image.png)"
].join("\n"));
assert.match(html, /<h1>Gateway Windows CMD <\/h1>/u);
assert.match(html, /<table>/u);
assert.match(html, /<pre><code class="language-bash">/u);
assert.match(html, /&lt;span class=&quot;message-copy&quot;&gt;raw html should stay inert&lt;\/span&gt;/u);
assert.doesNotMatch(html, /<span class="message-copy">/u);
assert.doesNotMatch(html, /href="javascript:alert\(1\)"/u);
assert.doesNotMatch(html, /<img\b/u);
});
@@ -1,66 +0,0 @@
import { marked } from "../../third_party/marked/marked.esm.js";
export const messageMarkdownOptions = Object.freeze({
gfm: true,
breaks: false,
renderer: messageMarkdownRenderer()
});
export function renderMessageMarkdown(markdown) {
return marked.parse(String(markdown ?? ""), messageMarkdownOptions);
}
export function hardenRenderedMarkdown(root) {
for (const element of root.querySelectorAll("script, style, iframe, object, embed, form, input, button, textarea, select")) {
element.replaceWith(document.createTextNode(element.textContent ?? ""));
}
for (const element of root.querySelectorAll("*")) {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
if (name.startsWith("on") || name === "style") {
element.removeAttribute(attribute.name);
}
}
}
for (const anchor of root.querySelectorAll("a[href]")) {
const href = safeMarkdownHref(anchor.getAttribute("href"));
if (!href) {
anchor.removeAttribute("href");
} else {
anchor.setAttribute("href", href);
anchor.setAttribute("rel", "noreferrer");
}
}
}
function messageMarkdownRenderer() {
const renderer = new marked.Renderer();
renderer.html = ({ text }) => escapeHtml(text);
renderer.link = ({ href, title, tokens }) => {
const text = renderer.parser.parseInline(tokens);
const safeHref = safeMarkdownHref(href);
if (!safeHref) return text;
const safeTitle = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(safeHref)}"${safeTitle} rel="noreferrer">${text}</a>`;
};
renderer.image = ({ text }) => escapeHtml(text ?? "");
return renderer;
}
function safeMarkdownHref(value) {
const href = String(value ?? "").trim();
if (!href) return null;
if (/^(?:https?:|mailto:)/iu.test(href)) return href;
if (/^[#/?]/u.test(href)) return href;
return null;
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
})[char]);
}
-54
View File
@@ -1,54 +0,0 @@
export const runtime = {
endpoints: {
frontend: "http://74.48.78.17:16666",
api: "http://74.48.78.17:16667",
edge: "http://74.48.78.17:16667",
dev: "http://74.48.78.17:16667"
},
serviceRoute: [
"browser/CLI/gateway",
"master hwlab-edge-proxy",
"frp",
"D601 hwlab-dev/hwlab-router",
"cloud-web/cloud-api"
],
mvpSteps: [
"browser/CLI/gateway",
"master hwlab-edge-proxy",
"frp",
"D601 hwlab-dev/hwlab-router",
"cloud-web/cloud-api",
"hardware trusted closed loop",
"agent automation closed loop",
"evidence record",
"worker cleanup"
],
closedLoops: [
"hardware trusted closed loop",
"agent automation closed loop"
],
projects: [
{
projectId: "proj_mvp-l6",
name: "HWLAB L6 MVP",
status: "active",
environment: "dev",
updatedAt: "2026-05-21T00:00:00.000Z"
},
{
projectId: "proj_cloud-web",
name: "Cloud Web Skeleton",
status: "active",
environment: "dev",
updatedAt: "2026-05-20T12:00:00.000Z"
}
],
evidence: [
{
evidenceId: "evidence_mvp-runtime-plan",
kind: "report",
uri: "fixtures/mvp/evidence/mvp-runtime-plan.md",
serviceId: "hwlab-cli"
}
]
};
@@ -1,156 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { activeSessionKeyFromRuntime, sessionTabsFromConversations, sessionTabsFromMessages } from "./app-session-tabs.ts";
test("session tabs group messages and filter the active session", () => {
const messages = [
message("user", "ses_alpha", "cnv_alpha", "trc_alpha_1", "sent", "2026-06-03T00:00:01Z"),
message("agent", "ses_alpha", "cnv_alpha", "trc_alpha_1", "completed", "2026-06-03T00:00:02Z"),
message("user", "ses_beta", "cnv_beta", "trc_beta_1", "sent", "2026-06-03T00:00:03Z"),
message("agent", "ses_beta", "cnv_beta", "trc_beta_1", "running", "2026-06-03T00:00:04Z")
];
const view = sessionTabsFromMessages(messages, { activeSessionKey: "ses_alpha" });
assert.equal(view.tabs.length, 2);
assert.equal(view.activeKey, "ses_alpha");
assert.deepEqual(view.messages.map((item) => item.traceId), ["trc_alpha_1", "trc_alpha_1"]);
assert.equal(view.tabs.find((tab) => tab.key === "ses_beta")?.status, "running");
});
test("session tabs default to newest session when no preferred key is active", () => {
const messages = [
message("agent", "ses_old", "cnv_old", "trc_old", "completed", "2026-06-03T00:00:01Z"),
message("agent", "ses_new", "cnv_new", "trc_new", "completed", "2026-06-03T00:00:05Z")
];
const view = sessionTabsFromMessages(messages, { activeSessionKey: "missing" });
assert.equal(view.activeKey, "ses_new");
assert.deepEqual(view.messages.map((item) => item.sessionId), ["ses_new"]);
});
test("session tabs keep first-turn user and agent messages together when session id arrives later", () => {
const messages = [
{ ...message("user", null, "cnv_first", "trc_first", "sent", "2026-06-03T00:00:01Z"), sessionId: null },
message("agent", "ses_first", "cnv_first", "trc_first", "completed", "2026-06-03T00:00:02Z")
];
const view = sessionTabsFromMessages(messages, { activeSessionKey: "cnv_first" });
assert.equal(view.tabs.length, 1);
assert.equal(view.activeKey, "ses_first");
assert.equal(view.tabs[0].status, "completed");
assert.deepEqual(view.messages.map((item) => item.role), ["user", "agent"]);
});
test("runtime active session prefers current request session before global state", () => {
const key = activeSessionKeyFromRuntime({
currentRequest: { sessionId: "ses_current", conversationId: "cnv_current" },
sessionId: "ses_global",
conversationId: "cnv_global"
});
assert.equal(key, "ses_current");
});
test("session tabs use account conversations and keep the selected session active", () => {
const view = sessionTabsFromConversations([
conversation("cnv_old", "ses_old", "trc_old", "idle", "2026-06-03T00:00:01Z"),
conversation("cnv_new", "ses_new", "trc_new", "running", "2026-06-03T00:00:03Z")
], { selectedConversationId: "cnv_old" });
assert.equal(view.tabs.length, 2);
assert.equal(view.activeConversation?.conversationId, "cnv_old");
assert.equal(view.tabs[0].conversationId, "cnv_new");
assert.match(view.tabs.find((tab) => tab.conversationId === "cnv_new")?.subtitle ?? "", /处理中/);
});
test("session tabs default to newest visible conversation", () => {
const view = sessionTabsFromConversations([
conversation("cnv_a", "ses_a", "trc_a", "idle", "2026-06-03T00:00:01Z"),
conversation("cnv_b", "ses_b", "trc_b", "completed", "2026-06-03T00:00:05Z")
], { selectedConversationId: "missing" });
assert.equal(view.activeConversation?.conversationId, "cnv_b");
assert.equal(view.activeKey, "ses_b");
});
test("session tabs expose a stable empty conversation view", () => {
const view = sessionTabsFromConversations([]);
assert.deepEqual(view, { tabs: [], activeKey: null, activeConversation: null });
assert.equal(Array.isArray(view.tabs), true);
});
test("session tabs surface authoritative messageCount and fall back to message length", () => {
const explicit = sessionTabsFromConversations([
conversation("cnv_explicit", "ses_explicit", "trc_explicit", "completed", "2026-06-03T00:00:01Z", {
messageCount: 99,
firstUserMessagePreview: "记住暗号:33304"
})
]);
assert.equal(explicit.tabs[0].messageCount, 99);
assert.equal(explicit.tabs[0].userPreview, "记住暗号:33304");
assert.match(explicit.tabs[0].label, /记住暗号:33304/);
const fallback = sessionTabsFromConversations([
conversation("cnv_fb", "ses_fb", "trc_fb", "completed", "2026-06-03T00:00:01Z")
]);
assert.equal(fallback.tabs[0].messageCount, 1);
assert.equal(fallback.tabs[0].userPreview, null);
});
test("session tab subtitle prepends a relative updated-at label", () => {
const recent = new Date(Date.now() - 90_000).toISOString();
const view = sessionTabsFromConversations([
conversation("cnv_recent", "ses_recent", "trc_recent", "completed", recent)
]);
assert.match(view.tabs[0].subtitle, /^\d+m 前 · /);
assert.match(view.tabs[0].subtitle, /trace recent/);
const stale = new Date("2024-01-01T00:00:00Z").toISOString();
const oldView = sessionTabsFromConversations([
conversation("cnv_old", "ses_old", "trc_old", "completed", stale)
]);
assert.match(oldView.tabs[0].subtitle, /^2024-01-01 · /);
});
test("session tab label prefers user preview and truncates long input", () => {
const longPreview = "这是一段超过三十六字符的用户摘要需要被截断显示在 sidebar tab 上";
const view = sessionTabsFromConversations([
conversation("cnv_long", "ses_long", "trc_long", "completed", "2026-06-03T00:00:01Z", {
messageCount: 1,
firstUserMessagePreview: longPreview
})
]);
assert.ok(view.tabs[0].label.length <= 37, `label too long: ${view.tabs[0].label}`);
assert.ok(view.tabs[0].label.endsWith("…"));
});
function message(role, sessionId, conversationId, traceId, status, createdAt) {
return {
id: `${role}_${traceId}`,
role,
title: role,
text: traceId,
status,
sessionId,
conversationId,
traceId,
createdAt
};
}
function conversation(conversationId, sessionId, traceId, status, updatedAt, extras = {}) {
return {
conversationId,
sessionId,
status,
updatedAt,
lastTraceId: traceId,
...extras,
messages: [{ role: "agent", text: traceId, status, traceId, updatedAt }]
};
}
+8 -13
View File
@@ -1,19 +1,14 @@
import { StrictMode } from "react";
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";
import { WorkbenchStateProvider } from "./state/workbenchState.tsx";
import { App } from "./App";
import "./styles/workbench.css";
const mountNode = document.getElementById("root");
if (!mountNode) {
throw new Error("hwlab-cloud-web: missing #root mount node");
}
const root = document.getElementById("root");
if (!root) throw new Error("missing #root mount");
const root = createRoot(mountNode);
root.render(
<StrictMode>
<WorkbenchStateProvider>
createRoot(root).render(
<React.StrictMode>
<App />
</WorkbenchStateProvider>
</StrictMode>
</React.StrictMode>
);
@@ -1,19 +0,0 @@
import { apiGet, apiPost } from "./client.ts";
const AUTH_ROUTES = Object.freeze({
session: "/auth/session",
login: "/auth/login",
logout: "/auth/logout",
});
export async function loadAuthSession() {
return apiGet(AUTH_ROUTES.session);
}
export async function submitAuthCredentials(username, password) {
return apiPost(AUTH_ROUTES.login, { username, password });
}
export async function signOut() {
return apiPost(AUTH_ROUTES.logout, {});
}
+97 -45
View File
@@ -1,54 +1,106 @@
const API_BASE = "/v1";
import type {
AgentChatResponse,
ApiResult,
ConversationRecord,
DevicePodEventsResponse,
DevicePodListResponse,
DevicePodStatusResponse,
LiveProbePayload,
SkillsResponse,
SkillFileResponse,
SkillTreeResponse,
WorkspaceRecord
} from "../../types/domain";
function buildUrl(path) {
if (!path) return API_BASE;
if (path.startsWith("/")) return `${API_BASE}${path}`;
return `${API_BASE}/${path}`;
export interface ApiRequestOptions extends RequestInit {
timeoutMs?: number;
timeoutName?: string;
}
export async function apiGet(path, options = {}) {
return apiRequest("GET", path, options);
}
export async function apiPost(path, body, options = {}) {
return apiRequest("POST", path, { ...options, body });
}
export async function apiPatch(path, body, options = {}) {
return apiRequest("PATCH", path, { ...options, body });
}
export async function apiDelete(path, options = {}) {
return apiRequest("DELETE", path, options);
}
async function apiRequest(method, path, { body, headers, signal } = {}) {
const init = {
method,
credentials: "same-origin",
headers: {
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(headers ?? {}),
},
...(signal ? { signal } : {}),
};
if (body !== undefined) {
init.body = typeof body === "string" ? body : JSON.stringify(body);
}
const response = await fetch(buildUrl(path), init);
const text = await response.text();
let data = null;
if (text) {
export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<T>> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 4500);
try {
data = JSON.parse(text);
} catch (error) {
data = { raw: text, parseError: String(error) };
}
}
const response = await fetch(path, {
...options,
credentials: options.credentials ?? "same-origin",
headers: {
accept: "application/json",
...(options.body ? { "content-type": "application/json" } : {}),
...(options.headers ?? {})
},
signal: controller.signal
});
const payload = await response.json().catch(() => null) as T | null;
return {
ok: response.ok,
status: response.status,
data,
data: payload,
error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`)
};
} catch (error) {
return {
ok: false,
status: 0,
data: null,
error: error instanceof DOMException && error.name === "AbortError"
? `${options.timeoutName ?? path} 超时`
: error instanceof Error ? error.message : String(error)
};
} finally {
window.clearTimeout(timeout);
}
}
export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<string>> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 4500);
try {
const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin", signal: controller.signal });
const text = await response.text();
return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` };
} catch (error) {
return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
} finally {
window.clearTimeout(timeout);
}
}
function errorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object") {
const record = payload as Record<string, unknown>;
const nested = record.error && typeof record.error === "object" ? record.error as Record<string, unknown> : null;
const candidate = nested?.message ?? nested?.code ?? record.message ?? record.reason ?? record.error;
if (typeof candidate === "string" && candidate.trim()) return candidate;
}
return fallback;
}
export const api = {
session: (): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/session", { timeoutName: "auth session" }),
login: (username: string, password: string): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
timeoutName: "auth login"
}),
logout: (): Promise<ApiResult<{ ok?: boolean }>> => fetchJson("/auth/logout", { method: "POST", body: JSON.stringify({}) }),
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
adapter: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1/rpc/system.health", { timeoutMs: 12000, timeoutName: "adapter" }),
devicePods: (): Promise<ApiResult<DevicePodListResponse>> => fetchJson("/v1/device-pods", { timeoutMs: 12000, timeoutName: "Device Pods" }),
devicePodStatus: (devicePodId: string): Promise<ApiResult<DevicePodStatusResponse>> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/status`, { timeoutMs: 12000, timeoutName: "Device Pod status" }),
devicePodEvents: (devicePodId: string): Promise<ApiResult<DevicePodEventsResponse>> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/events?limit=120`, { timeoutMs: 12000, timeoutName: "Device Pod events" }),
workspace: (projectId: string): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }),
updateWorkspace: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
selectConversation: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
conversations: (projectId: string): Promise<ApiResult<{ conversations?: ConversationRecord[] }>> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }),
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }),
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent" }),
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer" }),
skills: (): Promise<ApiResult<SkillsResponse>> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }),
uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise<ApiResult<{ skill?: { id?: string } }>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }),
skillTree: (skillId: string): Promise<ApiResult<SkillTreeResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }),
skillFile: (skillId: string, relativePath: string): Promise<ApiResult<SkillFileResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`, { timeoutMs: 12000, timeoutName: "skill file" })
};
@@ -1,21 +0,0 @@
import { apiGet, apiPost } from "./client.ts";
export async function submitCodeAgentTurn(payload) {
return apiPost("/agent/chat", payload);
}
export async function steerCodeAgentTurn(payload) {
return apiPost("/agent/chat/steer", payload);
}
export async function cancelCodeAgentTurn(payload) {
return apiPost("/agent/chat/cancel", payload);
}
export async function fetchCodeAgentResult(traceId) {
return apiGet(`/agent/result/${encodeURIComponent(traceId)}`);
}
export async function fetchCodeAgentTrace(traceId) {
return apiGet(`/agent/trace/${encodeURIComponent(traceId)}`);
}
@@ -1,9 +0,0 @@
import { apiGet, apiPost } from "./client.ts";
export async function listConversations() {
return apiGet("/agent/conversations");
}
export async function loadConversationMessages(conversationId) {
return apiGet(`/agent/conversations/${encodeURIComponent(conversationId)}/messages`);
}
@@ -1,13 +0,0 @@
import { apiGet } from "./client.ts";
export async function listDevicePods() {
return apiGet("/device-pods");
}
export async function fetchDevicePodStatus(devicePodId) {
return apiGet(`/device-pods/${encodeURIComponent(devicePodId)}/status`);
}
export async function fetchDevicePodEvents(devicePodId, { limit = 120, signal } = {}) {
return apiGet(`/device-pods/${encodeURIComponent(devicePodId)}/events?limit=${limit}`, { signal });
}
@@ -1,17 +0,0 @@
import { apiGet, apiPost, apiDelete } from "./client.ts";
export async function listAgentSessions() {
return apiGet("/agent/sessions");
}
export async function createAgentSession(payload = {}) {
return apiPost("/agent/sessions", payload);
}
export async function deleteAgentSession(sessionId) {
return apiPost(`/agent/sessions/${encodeURIComponent(sessionId)}/delete`, {});
}
export async function selectAgentSession(payload) {
return apiPost("/agent/sessions/select", payload);
}
@@ -1,9 +0,0 @@
import { apiGet, apiPost } from "./client.ts";
export async function listSkills() {
return apiGet("/skills");
}
export async function uploadSkillFiles(formData) {
return apiPost("/skills/uploads", formData, { headers: {} });
}
@@ -1,9 +0,0 @@
import { apiGet, apiPatch } from "./client.ts";
export async function fetchWorkbenchWorkspace() {
return apiGet("/workbench/workspace");
}
export async function updateWorkbenchWorkspace(payload) {
return apiPatch("/workbench/workspace", payload);
}
-74
View File
@@ -1,74 +0,0 @@
import { loadAuthSession, submitAuthCredentials, signOut } from "./api/auth.ts";
const DEFAULT_USERNAME = "admin";
const DEFAULT_PASSWORD = "hwlab2026";
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
function resolveConfig() {
const configured = globalThis.HWLAB_CLOUD_WEB_CONFIG?.auth ?? {};
const sessionTtlMs = clampSessionTtl(configured.sessionTtlMs ?? configured.ttlMs ?? DEFAULT_SESSION_TTL_MS);
const mode = ["server", "local", "auto"].includes(configured.mode) ? configured.mode : "auto";
return {
mode,
username: nonEmptyString(configured.username, DEFAULT_USERNAME),
password: nonEmptyString(configured.password, DEFAULT_PASSWORD),
sessionTtlMs,
};
}
function nonEmptyString(value, fallback) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function clampSessionTtl(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_SESSION_TTL_MS;
return Math.min(Math.max(Math.trunc(parsed), 60_000), 7 * 24 * 60 * 60 * 1000);
}
export async function probeAuthSession() {
const config = resolveConfig();
const result = await loadAuthSession();
if (result.ok && result.data?.authenticated) {
return {
authenticated: true,
mode: "server",
user: result.data.user ?? { username: config.username },
expiresAt: result.data.expiresAt ?? null,
};
}
if (config.mode !== "auto" || !config.username || !config.password) {
return { authenticated: false, mode: "server" };
}
const auto = await submitAuthCredentials(config.username, config.password);
if (auto.ok && auto.data?.authenticated) {
return {
authenticated: true,
mode: "server",
user: auto.data.user ?? { username: config.username },
expiresAt: auto.data.expiresAt ?? null,
};
}
return { authenticated: false, mode: "server", error: auto.data?.error ?? null };
}
export async function signInWithCredentials(username, password) {
const result = await submitAuthCredentials(username, password);
if (!result.ok || !result.data?.authenticated) {
const message = result.data?.error ?? (result.status === 401 ? "账号或密码错误" : `登录失败 (${result.status})`);
return { ok: false, error: message };
}
return {
ok: true,
session: {
authenticated: true,
mode: "server",
user: result.data.user ?? { username },
expiresAt: result.data.expiresAt ?? null,
},
};
}
export async function performSignOut() {
await signOut();
}
@@ -0,0 +1,77 @@
const urlPattern = /^https?:\/\//iu;
export function renderMessageMarkdown(markdown: string): string {
const lines = markdown.replace(/\r\n/gu, "\n").split("\n");
const html: string[] = [];
let inCode = false;
let codeBuffer: string[] = [];
let listBuffer: string[] = [];
function flushList(): void {
if (listBuffer.length === 0) return;
html.push(`<ul>${listBuffer.map((item) => `<li>${inlineMarkdown(item)}</li>`).join("")}</ul>`);
listBuffer = [];
}
function flushCode(): void {
html.push(`<pre><code>${escapeHtml(codeBuffer.join("\n"))}</code></pre>`);
codeBuffer = [];
}
for (const line of lines) {
if (line.trim().startsWith("```")) {
if (inCode) {
flushCode();
inCode = false;
} else {
flushList();
inCode = true;
}
continue;
}
if (inCode) {
codeBuffer.push(line);
continue;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/u);
if (heading) {
flushList();
const level = heading[1]?.length ?? 1;
html.push(`<h${level}>${inlineMarkdown(heading[2] ?? "")}</h${level}>`);
continue;
}
const list = line.match(/^\s*[-*]\s+(.+)$/u);
if (list) {
listBuffer.push(list[1] ?? "");
continue;
}
if (!line.trim()) {
flushList();
continue;
}
flushList();
html.push(`<p>${inlineMarkdown(line)}</p>`);
}
if (inCode) flushCode();
flushList();
return html.join("\n");
}
function inlineMarkdown(value: string): string {
let escaped = escapeHtml(value);
escaped = escaped.replace(/`([^`]+)`/gu, "<code>$1</code>");
escaped = escaped.replace(/\*\*([^*]+)\*\*/gu, "<strong>$1</strong>");
escaped = escaped.replace(/\[([^\]]+)\]\(([^)]+)\)/gu, (_match: string, label: string, href: string) => {
const safeHref = urlPattern.test(href) ? href : "#";
return `<a href="${escapeAttribute(safeHref)}" rel="noreferrer">${escapeHtml(label)}</a>`;
});
return escaped;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"]/gu, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char] ?? char);
}
function escapeAttribute(value: string): string {
return escapeHtml(value).replace(/'/gu, "&#39;");
}
@@ -0,0 +1 @@
export const WORKBENCH_PROJECT_ID = "prj_device_pod_workbench";
@@ -0,0 +1,94 @@
import { api } from "../services/api/client";
import type { AgentChatResponse, ChatMessage, ConversationRecord, SessionTab, WorkspaceRecord } from "../types/domain";
import { firstNonEmptyString, nextProtocolId, nonEmptyString, relativeUpdatedLabel, shortToken } from "../utils";
import { WORKBENCH_PROJECT_ID } from "./constants";
export function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] {
const selected = workspace?.selectedConversation?.messages;
if (Array.isArray(selected)) return selected;
const workspaceMessages = workspace?.workspace?.messages;
return Array.isArray(workspaceMessages) ? workspaceMessages : [];
}
export function makeMessage(role: "user" | "agent", text: string, status: ChatMessage["status"], meta: { traceId: string; conversationId: string; sessionId: string | null; threadId: string | null; title?: string }): ChatMessage {
return { id: nextProtocolId("msg"), role, title: meta.title ?? (role === "user" ? "用户" : "Code Agent"), text, status, traceId: meta.traceId, conversationId: meta.conversationId, sessionId: meta.sessionId, threadId: meta.threadId, createdAt: new Date().toISOString() };
}
export function messageFromAgentResponse(messageId: string, pending: ChatMessage, response: AgentChatResponse): ChatMessage {
const errorObject = typeof response.error === "object" && response.error ? response.error : null;
const failed = Boolean(response.error) || ["failed", "blocked", "timeout"].includes(String(response.status ?? ""));
return {
...pending,
id: messageId,
title: failed ? "Code Agent 返回阻塞" : "Code Agent 回复",
text: firstNonEmptyString(response.reply, response.text, response.summary, errorObject?.message, response.error) ?? "后端没有返回可显示正文。",
status: failed ? "failed" : "completed",
updatedAt: new Date().toISOString(),
traceId: response.traceId ?? pending.traceId,
conversationId: response.conversationId ?? pending.conversationId,
sessionId: response.sessionId ?? pending.sessionId,
threadId: response.threadId ?? pending.threadId,
runnerTrace: response.runnerTrace ?? null,
traceEvents: response.traceEvents,
error: errorObject ? { ...errorObject, message: errorObject.message ?? "Code Agent failed" } : null
};
}
export async function ensureWorkspace(current: WorkspaceRecord | null): Promise<WorkspaceRecord | null> {
if (current?.workspaceId) return current;
const response = await api.workspace(WORKBENCH_PROJECT_ID);
return response.ok ? response.data?.workspace ?? null : null;
}
export async function persistConversation(input: { workspace: WorkspaceRecord | null; conversationId: string; sessionId: string | null; threadId: string | null; messages: ChatMessage[] }): Promise<void> {
await api.saveConversation(input.conversationId, {
projectId: WORKBENCH_PROJECT_ID,
conversationId: input.conversationId,
sessionId: input.sessionId,
threadId: input.threadId,
sessionStatus: input.messages.at(-1)?.status ?? "source",
lastTraceId: input.messages.at(-1)?.traceId,
messages: input.messages,
snapshot: { source: "cloud-web-react-account-sync", updatedAt: new Date().toISOString(), sessionStatus: input.messages.at(-1)?.status ?? "source" }
});
if (input.workspace?.workspaceId) {
await api.updateWorkspace(input.workspace.workspaceId, {
projectId: WORKBENCH_PROJECT_ID,
expectedRevision: input.workspace.revision,
selectedConversationId: input.conversationId,
selectedAgentSessionId: input.sessionId,
threadId: input.threadId,
activeTraceId: input.messages.findLast((message) => message.status === "running")?.traceId ?? null,
messages: input.messages,
workspace: { source: "cloud-web-react-workspace-sync", updatedAt: new Date().toISOString() },
updatedByClient: "cloud-web-react"
});
}
}
export function conversationsToTabs(conversations: ConversationRecord[], activeConversationId: string | null): SessionTab[] {
return conversations.map((conversation) => conversationToTab(conversation, activeConversationId)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt));
}
function conversationToTab(conversation: ConversationRecord, activeConversationId: string | null): SessionTab {
const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId);
const conversationId = conversation.conversationId;
const updatedAt = firstNonEmptyString(conversation.updatedAt, conversation.snapshot?.updatedAt, conversation.startedAt);
const status = firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, conversation.messages?.at(-1)?.status) ?? "source";
const trace = firstNonEmptyString(conversation.lastTraceId, conversation.messages?.at(-1)?.traceId);
const preview = firstNonEmptyString(conversation.firstUserMessagePreview, conversation.snapshot?.firstUserMessagePreview, conversation.messages?.find((message) => message.role === "user")?.text);
return { key: sessionId ?? conversationId, label: truncateLabel(preview) ?? `Session ${shortToken(sessionId ?? conversationId, 8)}`, subtitle: `${relativeUpdatedLabel(timestampMs(updatedAt))} · ${status === "running" ? "处理中" : trace ? `trace ${shortToken(trace, 8)}` : "未开始"}`, conversationId, sessionId, threadId: firstNonEmptyString(conversation.threadId, conversation.session?.threadId), lastTraceId: trace, status, messageCount: Number.isFinite(Number(conversation.messageCount)) ? Number(conversation.messageCount) : conversation.messages?.length ?? 0, active: conversationId === activeConversationId, updatedAt, userPreview: preview };
}
function truncateLabel(value: string | null): string | null {
if (!value) return null;
const text = value.replace(/\s+/gu, " ").trim();
return text.length <= 36 ? text : `${text.slice(0, 36).trimEnd()}...`;
}
function timestampMs(value: unknown): number {
const text = nonEmptyString(value);
if (!text) return 0;
const parsed = Date.parse(text);
return Number.isFinite(parsed) ? parsed : 0;
}
+320
View File
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useMemo, useReducer } from "react";
import { api } from "../services/api/client";
import type {
AgentChatResponse,
ChatMessage,
CodeAgentAvailability,
ConversationRecord,
DevicePodItem,
LiveSurface,
ProviderProfile,
SessionTab,
WorkspaceRecord
} from "../types/domain";
import { firstNonEmptyString, nextProtocolId, nonEmptyString } from "../utils";
import { WORKBENCH_PROJECT_ID } from "./constants";
import { conversationsToTabs, ensureWorkspace, makeMessage, messageFromAgentResponse, messagesFromWorkspace, persistConversation } from "./conversation";
const DEFAULT_DEVICE_POD_ID = "device-pod-71-freq";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
interface WorkbenchState {
workspace: WorkspaceRecord | null;
conversations: ConversationRecord[];
messages: ChatMessage[];
selectedDevicePodId: string;
providerProfile: ProviderProfile;
codeAgentTimeoutMs: number;
gatewayShellTimeoutMs: number;
live: LiveSurface | null;
codeAgentAvailability: CodeAgentAvailability | null;
loading: boolean;
chatPending: boolean;
currentRequest: { traceId: string; conversationId: string; sessionId: string | null; threadId: string | null } | null;
error: string | null;
}
interface ComposerState {
disabled: boolean;
disabledReason: string | null;
submitMode: "turn" | "steer";
route: "/v1/agent/chat" | "/v1/agent/chat/steer";
targetTraceId: string | null;
}
type Action =
| { type: "hydrate:start" }
| { type: "hydrate:done"; workspace: WorkspaceRecord | null; conversations: ConversationRecord[]; messages: ChatMessage[] }
| { type: "hydrate:error"; error: string }
| { type: "live:set"; live: LiveSurface; availability: CodeAgentAvailability | null; selectedDevicePodId: string }
| { type: "device:selected"; devicePodId: string }
| { type: "profile:set"; profile: ProviderProfile }
| { type: "timeout:set"; codeAgentTimeoutMs: number }
| { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number }
| { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: WorkbenchState["currentRequest"] }
| { type: "message:complete"; messageId: string; message: ChatMessage; availability: CodeAgentAvailability | null; workspace?: WorkspaceRecord | null }
| { type: "message:fail"; messageId: string; message: ChatMessage }
| { type: "chat:done" }
| { type: "conversation:select"; conversation: ConversationRecord | null; workspace?: WorkspaceRecord | null }
| { type: "conversation:list"; conversations: ConversationRecord[] }
| { type: "conversation:clear" };
const initialState: WorkbenchState = {
workspace: null,
conversations: [],
messages: [],
selectedDevicePodId: readStoredString("hwlab.workbench.devicePodId.v1") ?? DEFAULT_DEVICE_POD_ID,
providerProfile: readProviderProfile(),
codeAgentTimeoutMs: readStoredNumber("hwlab.workbench.codeAgentTimeoutMs.v1", DEFAULT_CODE_AGENT_TIMEOUT_MS, 30_000, 2_400_000),
gatewayShellTimeoutMs: readStoredNumber("hwlab.workbench.gatewayShellTimeoutMs.v1", DEFAULT_GATEWAY_TIMEOUT_MS, 30_000, 600_000),
live: null,
codeAgentAvailability: null,
loading: true,
chatPending: false,
currentRequest: null,
error: null
};
export interface WorkbenchStore {
state: WorkbenchState;
sessionTabs: SessionTab[];
activeConversationId: string | null;
composer: ComposerState;
hydrate(): Promise<void>;
refreshLive(devicePodId?: string): Promise<void>;
createSession(): Promise<void>;
selectConversation(tab: SessionTab): Promise<void>;
deleteCurrentSession(): Promise<void>;
submitMessage(text: string): Promise<void>;
clearConversation(): void;
setProviderProfile(profile: ProviderProfile): void;
setCodeAgentTimeoutMs(value: number): void;
setGatewayShellTimeoutMs(value: number): void;
selectDevicePod(devicePodId: string): void;
}
export function useWorkbenchStore(): WorkbenchStore {
const [state, dispatch] = useReducer(reducer, initialState);
const activeConversationId = firstNonEmptyString(
state.workspace?.selectedConversationId,
state.workspace?.workspace?.selectedConversationId,
state.messages.find((message) => message.conversationId)?.conversationId
);
const sessionTabs = useMemo(() => conversationsToTabs(state.conversations, activeConversationId), [state.conversations, activeConversationId]);
const composer = useMemo(() => composerFromState(state, activeConversationId), [activeConversationId, state]);
const hydrate = useCallback(async () => {
dispatch({ type: "hydrate:start" });
const [workspaceResult, conversationsResult] = await Promise.all([
api.workspace(WORKBENCH_PROJECT_ID),
api.conversations(WORKBENCH_PROJECT_ID)
]);
if (!workspaceResult.ok) {
dispatch({ type: "hydrate:error", error: workspaceResult.error ?? "workspace unavailable" });
return;
}
const workspace = workspaceResult.data?.workspace ?? null;
const conversations = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : [];
dispatch({
type: "hydrate:done",
workspace,
conversations,
messages: messagesFromWorkspace(workspace)
});
}, []);
const refreshLive = useCallback(async (devicePodId?: string) => {
const selected = devicePodId ?? state.selectedDevicePodId;
const [healthLive, health, restIndex, adapter, devicePods, devicePodStatus, devicePodEvents] = await Promise.all([
api.healthLive(),
api.health(),
api.restIndex(),
api.adapter(),
api.devicePods(),
api.devicePodStatus(selected),
api.devicePodEvents(selected)
]);
const live: LiveSurface = { healthLive, health, restIndex, adapter, devicePods, devicePodStatus, devicePodEvents, loadedAt: new Date().toISOString() };
dispatch({ type: "live:set", live, availability: availabilityFromLive(live), selectedDevicePodId: selected });
}, [state.selectedDevicePodId]);
useEffect(() => {
void hydrate();
}, [hydrate]);
useEffect(() => {
void refreshLive();
const timer = window.setInterval(() => void refreshLive(), 30_000);
return () => window.clearInterval(timer);
}, [refreshLive]);
const createSession = useCallback(async () => {
const workspace = await ensureWorkspace(state.workspace);
if (!workspace) return;
const response = await api.selectConversation(workspace.workspaceId, { projectId: WORKBENCH_PROJECT_ID, create: true, updatedByClient: "cloud-web-react" });
if (response.ok) {
const nextWorkspace = response.data?.workspace ?? workspace;
dispatch({ type: "conversation:select", conversation: nextWorkspace.selectedConversation ?? null, workspace: nextWorkspace });
const conversations = await api.conversations(WORKBENCH_PROJECT_ID);
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
}
}, [state.workspace]);
const selectConversation = useCallback(async (tab: SessionTab) => {
const workspace = await ensureWorkspace(state.workspace);
if (!workspace || !tab.conversationId) return;
const response = await api.selectConversation(workspace.workspaceId, {
projectId: WORKBENCH_PROJECT_ID,
conversationId: tab.conversationId,
sessionId: tab.sessionId,
threadId: tab.threadId,
updatedByClient: "cloud-web-react"
});
if (response.ok) {
const nextWorkspace = response.data?.workspace ?? workspace;
dispatch({ type: "conversation:select", conversation: nextWorkspace.selectedConversation ?? conversationFromTab(tab), workspace: nextWorkspace });
}
}, [state.workspace]);
const deleteCurrentSession = useCallback(async () => {
const conversationId = activeConversationId;
if (!conversationId) return;
const workspaceId = state.workspace?.workspaceId;
const response = await api.deleteConversation(conversationId, { projectId: WORKBENCH_PROJECT_ID, workspaceId, updatedByClient: "cloud-web-react" });
if (response.ok) {
dispatch({ type: "conversation:select", conversation: response.data?.workspace?.selectedConversation ?? null, workspace: response.data?.workspace ?? state.workspace });
const conversations = await api.conversations(WORKBENCH_PROJECT_ID);
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
}
}, [activeConversationId, state.workspace]);
const submitMessage = useCallback(async (text: string) => {
const value = text.trim();
if (!value) return;
const traceId = nextProtocolId("trc");
const conversationId = activeConversationId ?? nextProtocolId("cnv");
const sessionId = state.workspace?.selectedAgentSessionId ?? state.workspace?.workspace?.selectedAgentSessionId ?? null;
const threadId = state.workspace?.workspace?.threadId ?? null;
const user = makeMessage("user", value, "sent", { traceId, conversationId, sessionId, threadId });
const pending = makeMessage("agent", "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent 处理中" });
dispatch({ type: "message:pending", user, pending, request: { traceId, conversationId, sessionId, threadId } });
const route = composer.submitMode === "steer" ? api.steerAgentMessage : api.sendAgentMessage;
const response = await route({
projectId: WORKBENCH_PROJECT_ID,
message: value,
prompt: value,
conversationId,
sessionId,
threadId,
traceId,
providerProfile: state.providerProfile,
gatewayShellTimeoutMs: state.gatewayShellTimeoutMs,
workspaceId: state.workspace?.workspaceId,
workspaceRevision: state.workspace?.revision,
targetTraceId: composer.targetTraceId
}, state.codeAgentTimeoutMs);
if (response.ok && response.data) {
const completed = messageFromAgentResponse(pending.id, pending, response.data);
dispatch({ type: "message:complete", messageId: pending.id, message: completed, availability: response.data.availability ?? null });
await persistConversation({ workspace: state.workspace, conversationId, sessionId: completed.sessionId ?? sessionId, threadId: completed.threadId ?? threadId, messages: [...state.messages, user, completed] });
const conversations = await api.conversations(WORKBENCH_PROJECT_ID);
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
} else {
const failed = makeMessage("agent", response.error ?? "Code Agent 请求失败", "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 请求失败" });
dispatch({ type: "message:fail", messageId: pending.id, message: failed });
}
dispatch({ type: "chat:done" });
}, [activeConversationId, composer.submitMode, composer.targetTraceId, state.codeAgentTimeoutMs, state.gatewayShellTimeoutMs, state.messages, state.providerProfile, state.workspace]);
const clearConversation = useCallback(() => dispatch({ type: "conversation:clear" }), []);
const setProviderProfile = useCallback((profile: ProviderProfile) => {
window.localStorage.setItem("hwlab.workbench.codeAgentProviderProfile.v1", profile);
dispatch({ type: "profile:set", profile });
}, []);
const setCodeAgentTimeoutMs = useCallback((value: number) => {
window.localStorage.setItem("hwlab.workbench.codeAgentTimeoutMs.v1", String(value));
dispatch({ type: "timeout:set", codeAgentTimeoutMs: value });
}, []);
const setGatewayShellTimeoutMs = useCallback((value: number) => {
window.localStorage.setItem("hwlab.workbench.gatewayShellTimeoutMs.v1", String(value));
dispatch({ type: "gateway-timeout:set", gatewayShellTimeoutMs: value });
}, []);
const selectDevicePod = useCallback((devicePodId: string) => {
window.localStorage.setItem("hwlab.workbench.devicePodId.v1", devicePodId);
dispatch({ type: "device:selected", devicePodId });
void refreshLive(devicePodId);
}, [refreshLive]);
return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, clearConversation, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod };
}
function reducer(state: WorkbenchState, action: Action): WorkbenchState {
switch (action.type) {
case "hydrate:start": return { ...state, loading: true, error: null };
case "hydrate:done": return { ...state, loading: false, workspace: action.workspace, conversations: action.conversations, messages: action.messages, error: null };
case "hydrate:error": return { ...state, loading: false, error: action.error };
case "live:set": return { ...state, live: action.live, codeAgentAvailability: action.availability, selectedDevicePodId: action.selectedDevicePodId };
case "device:selected": return { ...state, selectedDevicePodId: action.devicePodId };
case "profile:set": return { ...state, providerProfile: action.profile };
case "timeout:set": return { ...state, codeAgentTimeoutMs: action.codeAgentTimeoutMs };
case "gateway-timeout:set": return { ...state, gatewayShellTimeoutMs: action.gatewayShellTimeoutMs };
case "message:pending": return { ...state, messages: [...state.messages, action.user, action.pending], chatPending: true, currentRequest: action.request };
case "message:complete": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message), codeAgentAvailability: action.availability ?? state.codeAgentAvailability, workspace: action.workspace ?? state.workspace };
case "message:fail": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) };
case "chat:done": return { ...state, chatPending: false, currentRequest: null };
case "conversation:select": return { ...state, workspace: action.workspace ?? state.workspace, messages: action.conversation?.messages ?? [], error: null };
case "conversation:list": return { ...state, conversations: action.conversations };
case "conversation:clear": return { ...state, messages: [], currentRequest: null, chatPending: false };
}
}
function replaceMessage(messages: ChatMessage[], messageId: string, replacement: ChatMessage): ChatMessage[] {
return messages.map((message) => message.id === messageId ? replacement : message);
}
function composerFromState(state: WorkbenchState, activeConversationId: string | null): ComposerState {
const activeTraceId = firstNonEmptyString(state.currentRequest?.traceId, state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId);
const sessionStatus = firstNonEmptyString(state.messages.at(-1)?.status, state.workspace?.workspace?.sessionStatus);
const canSteer = Boolean(activeTraceId && state.chatPending && sessionStatus !== "completed" && sessionStatus !== "failed");
if (canSteer) {
return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId };
}
if (!activeConversationId && !state.workspace?.workspaceId) {
return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null };
}
return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null };
}
function availabilityFromLive(live: LiveSurface): CodeAgentAvailability | null {
const candidates = [live.healthLive.data?.codeAgent, live.healthLive.data?.availability, live.health.data?.codeAgent, live.restIndex.data?.codeAgent];
return candidates.find((item): item is CodeAgentAvailability => Boolean(item && typeof item === "object")) ?? null;
}
function conversationFromTab(tab: SessionTab): ConversationRecord {
return { conversationId: tab.conversationId ?? tab.key, sessionId: tab.sessionId, threadId: tab.threadId, status: tab.status, messages: [] };
}
function readStoredString(key: string): string | null {
try { return nonEmptyString(window.localStorage.getItem(key)); } catch { return null; }
}
function readStoredNumber(key: string, fallback: number, min: number, max: number): number {
const value = Number(readStoredString(key));
if (!Number.isFinite(value)) return fallback;
return Math.min(Math.max(Math.trunc(value), min), max);
}
function readProviderProfile(): ProviderProfile {
const value = readStoredString("hwlab.workbench.codeAgentProviderProfile.v1");
return value === "codex-api" || value === "minimax-m3" || value === "deepseek" ? value : "deepseek";
}
export function devicePodsFromLive(live: LiveSurface | null): DevicePodItem[] {
const payload = live?.devicePods.data;
return payload?.devicePods ?? payload?.items ?? [];
}
@@ -1,138 +0,0 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useReducer,
} from "react";
const ROUTES = Object.freeze(["workspace", "skills", "help", "settings"]);
const DEFAULT_ROUTE = "workspace";
const initialState = Object.freeze({
ready: false,
auth: null,
route: DEFAULT_ROUTE,
workspace: null,
conversations: [],
activeConversationId: null,
activeSessionId: null,
devicePodSnapshot: null,
composer: null,
traceSnapshot: null,
lastError: null,
});
function reducer(state, action) {
switch (action.type) {
case "auth/set":
return { ...state, ready: true, auth: action.auth };
case "auth/clear":
return { ...initialState, ready: true, auth: null };
case "route/set":
return { ...state, route: action.route };
case "workspace/set":
return { ...state, workspace: action.workspace };
case "conversations/set":
return { ...state, conversations: action.conversations };
case "conversation/select":
return {
...state,
activeConversationId: action.conversationId ?? null,
activeSessionId: action.sessionId ?? null,
};
case "device-pod/set":
return { ...state, devicePodSnapshot: action.snapshot };
case "composer/set":
return { ...state, composer: action.composer };
case "trace/set":
return { ...state, traceSnapshot: action.snapshot };
case "error/set":
return { ...state, lastError: action.error };
case "error/clear":
return { ...state, lastError: null };
default:
return state;
}
}
const WorkbenchStateContext = createContext(null);
export function WorkbenchStateProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
const setAuth = useCallback((auth) => dispatch({ type: "auth/set", auth }), []);
const clearAuth = useCallback(() => dispatch({ type: "auth/clear" }), []);
const setRoute = useCallback(
(route) => {
if (!ROUTES.includes(route)) return;
dispatch({ type: "route/set", route });
},
[]
);
const setWorkspace = useCallback((workspace) => dispatch({ type: "workspace/set", workspace }), []);
const setConversations = useCallback(
(conversations) => dispatch({ type: "conversations/set", conversations }),
[]
);
const selectConversation = useCallback(
(conversationId, sessionId) =>
dispatch({ type: "conversation/select", conversationId, sessionId }),
[]
);
const setDevicePod = useCallback(
(snapshot) => dispatch({ type: "device-pod/set", snapshot }),
[]
);
const setComposer = useCallback((composer) => dispatch({ type: "composer/set", composer }), []);
const setTrace = useCallback((snapshot) => dispatch({ type: "trace/set", snapshot }), []);
const setError = useCallback((error) => dispatch({ type: "error/set", error }), []);
const clearError = useCallback(() => dispatch({ type: "error/clear" }), []);
const value = useMemo(
() => ({
state,
setAuth,
clearAuth,
setRoute,
setWorkspace,
setConversations,
selectConversation,
setDevicePod,
setComposer,
setTrace,
setError,
clearError,
}),
[
state,
setAuth,
clearAuth,
setRoute,
setWorkspace,
setConversations,
selectConversation,
setDevicePod,
setComposer,
setTrace,
setError,
clearError,
]
);
return (
<WorkbenchStateContext.Provider value={value}>
{children}
</WorkbenchStateContext.Provider>
);
}
export function useWorkbenchState() {
const ctx = useContext(WorkbenchStateContext);
if (!ctx) {
throw new Error("useWorkbenchState must be used within WorkbenchStateProvider");
}
return ctx;
}
export const workbenchRoutes = ROUTES;
@@ -0,0 +1,163 @@
:root {
color-scheme: light;
--ink: #1e2528;
--muted: #637076;
--line: #d8dedb;
--panel: #f7f8f4;
--surface: #ffffff;
--rail: #26302d;
--rail-active: #cce49a;
--accent: #1f7a6b;
--warn: #9a6200;
--bad: #b33131;
--ok: #237149;
--source: #496678;
--shadow: 0 12px 30px rgba(31, 42, 38, 0.12);
--rail-width: 92px;
--rail-collapsed-width: 44px;
--session-sidebar-width: 330px;
--right-sidebar-width: 728px;
--right-sidebar-min-width: 560px;
--right-sidebar-max-width: 740px;
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #edf1ec; letter-spacing: 0; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.login-shell { min-height: 100%; display: grid; place-items: center; padding: 24px; background: linear-gradient(135deg, #e7eee6 0%, #f7f8f4 62%, #dfe9e2 100%); }
.login-panel { width: min(420px, 100%); background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: 8px; padding: 28px; }
.login-heading h1 { margin: 0 0 8px; font-size: 28px; }
.eyebrow { margin: 0 0 6px; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
.login-form { display: grid; gap: 14px; margin-top: 18px; }
.login-form label { display: grid; gap: 6px; font-weight: 700; }
.login-form input { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; }
.login-submit, .logout-button, .command-button { border: 0; border-radius: 6px; padding: 10px 14px; color: #fff; background: var(--accent); font-weight: 800; }
.login-error { color: var(--bad); margin: 0; }
.workbench-shell { height: 100%; width: 100%; display: grid; grid-template-columns: var(--rail-width) var(--session-sidebar-width) 4px minmax(420px, 1fr) 4px var(--right-sidebar-width); grid-template-rows: minmax(0, 1fr); overflow: hidden; background: var(--panel); }
.workbench-shell.is-left-sidebar-collapsed { grid-template-columns: var(--rail-collapsed-width) 0 0 minmax(420px, 1fr) 4px var(--right-sidebar-width); }
.workbench-shell.is-right-sidebar-collapsed { grid-template-columns: var(--rail-width) var(--session-sidebar-width) 4px minmax(420px, 1fr) 0 0; }
.activity-rail { min-width: 0; display: grid; grid-template-rows: repeat(4, minmax(54px, auto)) 1fr minmax(54px, auto) minmax(54px, auto); gap: 6px; padding: 10px 8px; background: var(--rail); overflow-x: hidden; }
.rail-button { min-width: 0; min-height: 44px; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; padding: 8px 6px; background: transparent; color: #eef4ed; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rail-button.active { color: #1d2b23; background: var(--rail-active); border-color: var(--rail-active); }
.rail-spacer { min-height: 8px; }
.is-left-sidebar-collapsed .rail-button:not(.sidebar-toggle) { color: transparent; }
.is-left-sidebar-collapsed .session-sidebar { display: none; }
.session-sidebar { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 10px; padding: 14px; border-right: 1px solid var(--line); background: #f9faf6; overflow: hidden; }
.session-sidebar-head, .panel-title-row, .right-sidebar-head, .gate-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.session-sidebar h2, .panel-title-row h2, .right-sidebar h2 { margin: 0; font-size: 18px; }
.session-icon-button, .icon-button, .right-sidebar-toggle, .session-delete-button, .command-button.secondary, #device-event-follow, #device-event-jump { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 8px 10px; font-weight: 700; }
.session-tabs { min-height: 0; display: grid; align-content: start; gap: 8px; overflow-x: hidden; overflow-y: auto; }
.session-tab { display: grid; gap: 3px; text-align: left; border: 1px solid var(--line); border-radius: 6px; padding: 9px; background: var(--surface); }
.session-tab[aria-selected="true"] { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
.session-tab-label, .session-tab-subtitle { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.session-tab-subtitle, .session-tab-count, .session-status, .device-pod-meta, .message-meta { color: var(--muted); font-size: 12px; }
.session-sidebar-foot { display: grid; gap: 8px; }
.resize-handle { width: 4px; background: #d2dad5; }
.center-workspace { min-width: 0; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; }
.view { min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
.workspace-panel { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 1px 0 rgba(0,0,0,0.03); }
.conversation-panel { height: 100%; display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 12px; padding: 14px; overflow: hidden; }
.panel-title-actions { display: flex; align-items: center; gap: 8px; }
.state-tag { display: inline-flex; min-height: 28px; align-items: center; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 800; white-space: normal; }
.tone-ok, .tone-dev-live { color: var(--ok); background: #e4f4ea; }
.tone-source { color: var(--source); background: #e7eef2; }
.tone-pending { color: var(--warn); background: #fff3d8; }
.tone-blocked, .tone-error { color: var(--bad); background: #fee9e9; }
.tone-warn { color: var(--warn); background: #fff3d8; }
.tone-border-ok, .tone-border-dev-live { border-color: #9bd3b1 !important; }
.tone-border-pending { border-color: #e4bd68 !important; }
.tone-border-blocked, .tone-border-error { border-color: #e7a1a1 !important; }
.code-agent-summary { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; background: #fbfcf8; }
.code-agent-summary summary { display: flex; gap: 10px; align-items: center; cursor: pointer; min-width: 0; }
.code-agent-summary-detail pre, .message-trace pre, .skill-preview, .help-content, #device-detail-body { white-space: pre-wrap; overflow-wrap: anywhere; }
.conversation-list { min-height: 0; display: grid; align-content: start; gap: 12px; overflow: auto; padding-right: 4px; }
.message-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; }
.message-head { display: flex; justify-content: space-between; gap: 10px; }
.message-head h3 { margin: 0; font-size: 15px; }
.message-body { line-height: 1.58; }
.message-body pre { overflow: auto; background: #f3f5f1; padding: 10px; border-radius: 6px; }
.message-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.message-trace { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 8px; }
.message-trace-events { max-height: 260px; overflow: auto; display: grid; gap: 8px; }
.command-bar { display: grid; grid-template-columns: auto auto auto minmax(160px, 1fr) auto auto; gap: 8px; align-items: end; padding: 10px 14px; border-top: 1px solid var(--line); background: #f7f8f4; }
.agent-timeout-control { display: grid; gap: 4px; min-width: 126px; font-size: 12px; font-weight: 700; }
.agent-timeout-control select, .device-pod-picker select { border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: var(--surface); }
.input-shell { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; background: #fff; }
.prompt-mark { color: var(--accent); font-weight: 900; }
#command-input { min-width: 0; width: 100%; min-height: 40px; max-height: 120px; resize: none; border: 0; outline: none; }
.right-sidebar { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto auto auto minmax(0, 1fr); gap: 10px; padding: 14px; background: #fbfcf8; border-left: 1px solid var(--line); overflow-y: hidden; }
.is-right-sidebar-collapsed .right-sidebar, .is-right-sidebar-collapsed .right-sidebar-resize { display: none; }
.device-pod-picker { display: grid; gap: 6px; font-weight: 800; }
.device-pod-status { display: grid; gap: 10px; }
.device-pod-summary, .device-pod-interfaces { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.summary-tile { min-width: 0; min-height: 70px; display: grid; gap: 4px; text-align: left; border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 10px; }
.summary-tile span { color: var(--muted); font-size: 12px; }
.summary-tile strong { overflow-wrap: anywhere; }
.device-pod-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin: 0; }
.device-pod-meta div { border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; }
.device-pod-meta dt { font-weight: 800; }
.device-pod-meta dd { margin: 3px 0 0; overflow-wrap: anywhere; }
.device-pod-workspace { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 10px; overflow: hidden; }
.device-event-panel { min-height: 0; display: grid; grid-template-rows: auto minmax(132px, 1fr); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
.device-event-panel header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); font-weight: 800; }
.device-event-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 10px; background: #17201c; color: #e9f5e8; }
#device-event-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
.device-detail-dialog { max-width: min(720px, 92vw); border: 1px solid var(--line); border-radius: 8px; padding: 0; }
.device-detail-dialog form { display: grid; gap: 10px; padding: 16px; }
.skills-panel, .settings-panel, .help-panel { padding: 14px; }
.skills-layout { min-height: 0; display: grid; grid-template-columns: 280px minmax(220px, 1fr) minmax(260px, 1.2fr); gap: 12px; }
.skill-upload-panel, .skill-detail-panel, .skill-preview-panel { min-width: 0; display: grid; align-content: start; gap: 10px; }
.skill-upload-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.skill-upload-drop { border: 1px dashed var(--accent); border-radius: 8px; padding: 10px; background: #f4faf6; }
.skill-list, .skill-tree { display: grid; gap: 8px; max-height: 52vh; overflow: auto; }
.skill-item, .skill-tree button { text-align: left; border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 9px; }
.skill-item.active { border-color: var(--accent); }
.skill-assembly-body, .skill-prompt-assembly { display: grid; gap: 8px; border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: #fff; }
.skill-meta, .skill-prompt-list { display: flex; flex-wrap: wrap; gap: 8px; color: var(--muted); font-size: 12px; }
.skill-prompt-row { display: inline-flex; gap: 6px; border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.wrap { overflow-wrap: anywhere; }
.gate-view { display: grid; gap: 12px; }
.gate-controls { display: flex; flex-wrap: wrap; gap: 8px; }
.gate-table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
.gate-table { width: 100%; border-collapse: collapse; min-width: 920px; }
.gate-table th, .gate-table td { border-bottom: 1px solid var(--line); padding: 8px; text-align: left; }
@media (max-width: 1240px) {
.workbench-shell, .workbench-shell.is-right-sidebar-collapsed, .workbench-shell.is-left-sidebar-collapsed { grid-template-columns: 72px minmax(180px, 260px) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(280px, 38vh); }
.activity-rail { grid-column: 1; grid-row: 1 / span 2; }
.session-sidebar { grid-column: 2; grid-row: 1 / span 2; }
.session-sidebar-resize, .right-sidebar-resize { display: none; }
.center-workspace { grid-column: 3; grid-row: 1; }
.right-sidebar { grid-column: 3; grid-row: 2; border-left: 0; border-top: 1px solid var(--line); }
}
@media (max-width: 720px) {
html, body, #root { overflow: hidden; }
.workbench-shell, .workbench-shell.is-right-sidebar-collapsed, .workbench-shell.is-left-sidebar-collapsed { grid-template-columns: 56px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(260px, 34vh); }
.activity-rail { grid-column: 1; grid-row: 1 / span 2; padding: 6px 4px; }
.rail-button { font-size: 11px; min-height: 40px; }
.session-sidebar { display: none; }
.center-workspace { grid-column: 2; grid-row: 1; }
.right-sidebar { grid-column: 2; grid-row: 2; padding: 10px; }
.view { padding: 8px; }
.conversation-panel { padding: 10px; }
.command-bar { grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; }
.agent-timeout-control { display: none; }
.input-shell { grid-column: 1 / span 2; }
.command-button { min-height: 40px; }
.device-pod-summary, .device-pod-interfaces { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.device-pod-meta { display: none; }
.skills-layout { grid-template-columns: minmax(0, 1fr); }
}
+30
View File
@@ -0,0 +1,30 @@
export interface WorkbenchAuthConfig {
mode?: "server" | "auto";
username?: string;
password?: string;
sessionTtlMs?: number;
ttlMs?: number;
}
export interface WorkbenchTimeoutConfig {
apiTimeoutMs?: number;
liveSurfaceTimeoutMs?: number;
codeAgentTimeoutMs?: number;
codeAgentTimeoutMsMinMs?: number;
codeAgentSubmitTimeoutMs?: number;
codeAgentSubmitTimeoutMsMinMs?: number;
codeAgentCancelTimeoutMs?: number;
gatewayShellTimeoutMs?: number;
}
export interface WorkbenchConfig {
auth?: WorkbenchAuthConfig;
timeouts?: WorkbenchTimeoutConfig;
}
declare global {
interface Window {
HWLAB_CLOUD_WEB_CONFIG?: WorkbenchConfig;
HWLAB_CLOUD_WEB_TIMEOUTS?: WorkbenchTimeoutConfig;
}
}
+308
View File
@@ -0,0 +1,308 @@
export type Tone = "ok" | "source" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run";
export type RouteId = "workspace" | "skills" | "gate" | "help" | "settings";
export type AuthState = "checking" | "login" | "authenticated";
export type ProviderProfile = "deepseek" | "codex-api" | "minimax-m3";
export interface AuthUser {
id?: string;
username?: string;
name?: string;
}
export interface AuthSession {
authenticated: boolean;
mode: "server";
user?: AuthUser;
actor?: AuthUser;
expiresAt?: string | null;
}
export interface ApiResult<T> {
ok: boolean;
status: number;
data: T | null;
error: string | null;
}
export interface LiveProbePayload {
status?: string;
serviceId?: string;
commitId?: string;
revision?: string;
runtime?: Record<string, unknown>;
codeAgent?: CodeAgentAvailability;
availability?: CodeAgentAvailability;
[key: string]: unknown;
}
export interface LiveSurface {
healthLive: ApiResult<LiveProbePayload>;
health: ApiResult<LiveProbePayload>;
restIndex: ApiResult<LiveProbePayload>;
adapter: ApiResult<LiveProbePayload>;
devicePods: ApiResult<DevicePodListResponse>;
devicePodStatus: ApiResult<DevicePodStatusResponse> | null;
devicePodEvents: ApiResult<DevicePodEventsResponse> | null;
loadedAt: string;
}
export interface CodeAgentAvailability {
status?: string;
ready?: boolean;
provider?: string;
mode?: string;
backend?: string;
capabilityLevel?: string;
sessionMode?: string;
implementationType?: string;
runner?: Record<string, unknown>;
session?: Record<string, unknown>;
longLivedSessionGate?: Record<string, unknown>;
providerTrace?: Record<string, unknown>;
blockers?: Array<{ code?: string; message?: string }>;
[key: string]: unknown;
}
export interface WorkspaceRecord {
workspaceId: string;
revision?: number;
updatedAt?: string;
selectedConversationId?: string | null;
selectedAgentSessionId?: string | null;
selectedDevicePodId?: string | null;
activeTraceId?: string | null;
workspace?: {
selectedConversationId?: string | null;
selectedAgentSessionId?: string | null;
threadId?: string | null;
sessionStatus?: string | null;
messages?: ChatMessage[];
updatedAt?: string;
[key: string]: unknown;
};
selectedConversation?: ConversationRecord | null;
}
export interface ConversationRecord {
conversationId: string;
sessionId?: string | null;
threadId?: string | null;
status?: string | null;
updatedAt?: string | null;
startedAt?: string | null;
lastTraceId?: string | null;
messageCount?: number;
firstUserMessagePreview?: string | null;
title?: string | null;
name?: string | null;
snapshot?: {
title?: string | null;
sessionStatus?: string | null;
updatedAt?: string | null;
firstUserMessagePreview?: string | null;
source?: string | null;
};
session?: {
sessionId?: string | null;
threadId?: string | null;
};
messages?: ChatMessage[];
}
export type ChatRole = "user" | "agent" | "system";
export type ChatStatus = "source" | "sent" | "running" | "completed" | "failed" | "blocked" | "timeout" | "canceled";
export interface TraceEvent {
ts?: string;
kind?: string;
type?: string;
status?: string;
message?: string;
itemId?: string;
toolName?: string;
command?: string;
exitCode?: number;
stdoutSummary?: string;
stderrSummary?: string;
elapsedMs?: number;
[key: string]: unknown;
}
export interface RunnerTrace {
traceId?: string;
status?: string;
sessionId?: string;
threadId?: string;
runnerKind?: string;
sessionMode?: string;
events?: TraceEvent[];
eventCount?: number;
eventsCompacted?: boolean;
fullTraceLoaded?: boolean;
[key: string]: unknown;
}
export interface ChatMessage {
id: string;
role: ChatRole;
title: string;
text: string;
status: ChatStatus;
createdAt: string;
updatedAt?: string;
traceId?: string | null;
conversationId?: string | null;
sessionId?: string | null;
threadId?: string | null;
retryOf?: string | null;
runnerTrace?: RunnerTrace | null;
traceEvents?: TraceEvent[];
error?: {
code?: string;
message?: string;
category?: string;
providerStatus?: number;
[key: string]: unknown;
} | null;
[key: string]: unknown;
}
export interface AgentChatResponse {
status?: string;
reply?: string;
text?: string;
summary?: string;
traceId?: string;
conversationId?: string;
sessionId?: string;
threadId?: string;
workspaceId?: string;
workspaceRevision?: number;
sessionStatus?: string;
availability?: CodeAgentAvailability;
runnerTrace?: RunnerTrace;
traceEvents?: TraceEvent[];
error?: string | { code?: string; message?: string; providerStatus?: number; [key: string]: unknown };
[key: string]: unknown;
}
export interface SessionTab {
key: string;
label: string;
subtitle: string;
conversationId?: string | null;
sessionId?: string | null;
threadId?: string | null;
lastTraceId?: string | null;
status: string;
messageCount: number;
active: boolean;
updatedAt?: string | null;
userPreview?: string | null;
}
export interface DevicePodSummary {
devicePodId?: string;
targetId?: string;
profileId?: string;
profileHash?: string;
status?: string;
phase?: string;
updatedAt?: string;
latestEvent?: { refs?: { traceId?: string; operationId?: string }; [key: string]: unknown };
[key: string]: unknown;
}
export interface DevicePodItem {
devicePodId: string;
targetId?: string;
profileId?: string;
status?: string;
summary?: DevicePodSummary;
[key: string]: unknown;
}
export interface DevicePodListResponse {
devicePods?: DevicePodItem[];
items?: DevicePodItem[];
selectedDevicePodId?: string;
[key: string]: unknown;
}
export interface DevicePodStatusResponse {
status?: string;
serviceId?: string;
summary?: DevicePodSummary;
interfaces?: DevicePodInterface[];
chipId?: Record<string, unknown>;
uart?: Record<string, unknown>;
uartTail?: { text?: string; lines?: string[]; [key: string]: unknown };
[key: string]: unknown;
}
export interface DevicePodInterface {
name?: string;
id?: string;
type?: string;
status?: string;
detail?: string;
[key: string]: unknown;
}
export interface DevicePodEvent {
ts?: string;
time?: string;
level?: string;
message?: string;
text?: string;
refs?: { traceId?: string; operationId?: string };
[key: string]: unknown;
}
export interface DevicePodEventsResponse {
events?: DevicePodEvent[];
lines?: string[];
text?: string;
[key: string]: unknown;
}
export interface SkillRef {
name: string;
required?: boolean;
injectStages?: string[];
path?: string;
[key: string]: unknown;
}
export interface SkillItem {
id: string;
name?: string;
title?: string;
status?: string;
description?: string;
path?: string;
[key: string]: unknown;
}
export interface SkillsResponse {
skills?: SkillItem[];
roots?: Record<string, unknown> | null;
agentRunAssembly?: {
status?: string;
counts?: Record<string, number>;
skillRefs?: SkillRef[];
toolAliases?: SkillRef[];
promptRefs?: SkillRef[];
promptAssembly?: { refs?: SkillRef[]; status?: string; count?: number; requiredCount?: number; injectStages?: string[] };
resourceBundle?: { skillsDir?: string; commitId?: string };
} | null;
}
export interface SkillTreeResponse {
files?: Array<{ path: string; type?: string; sizeBytes?: number }>;
tree?: Array<{ path: string; type?: string; sizeBytes?: number }>;
}
export interface SkillFileResponse {
file?: { path: string; content?: string; status?: string };
}
-78
View File
@@ -1,78 +0,0 @@
// Shared type definitions for the React + TypeScript Cloud Web migration.
// Each domain (auth, session, conversation, device pod, code agent, trace)
// re-exports its public types from a single barrel so consumers can import
// from a stable path.
export type AuthMode = "server" | "local" | "auto";
export interface AuthUser {
username: string;
[k: string]: unknown;
}
export interface AuthSession {
authenticated: boolean;
mode: AuthMode | string;
user: AuthUser;
expiresAt: string | null;
}
export interface WorkbenchWorkspace {
workspaceId: string;
revision: number;
selectedConversationId: string | null;
selectedAgentSessionId: string | null;
threadId: string | null;
activeTraceId: string | null;
[k: string]: unknown;
}
export interface ConversationSummary {
conversationId: string;
sessionId: string | null;
status: string;
updatedAt: string;
lastTraceId: string | null;
messageCount?: number;
firstUserMessagePreview?: string | null;
messages: ConversationMessage[];
}
export interface ConversationMessage {
id: string;
role: "user" | "agent";
title?: string;
text: string;
status: string;
sessionId: string | null;
conversationId: string;
traceId: string | null;
createdAt?: string;
}
export interface DevicePodSummary_ {
devicePodId: string;
targetId: string;
profileHash: string;
latestEvent?: { refs?: { traceId?: string; operationId?: string } };
}
export interface DevicePodStatus {
status: string;
serviceId: string;
summary: DevicePodSummary_;
interfaces?: Array<{ name?: string; id?: string; status?: string }>;
}
export interface DevicePodEvent {
text?: string;
line?: string;
[k: string]: unknown;
}
export interface SkillSummary {
id?: string;
name?: string;
description?: string;
promptRefs?: string[];
}
+8
View File
@@ -0,0 +1,8 @@
import "react";
declare module "react" {
interface InputHTMLAttributes<T> {
webkitdirectory?: string;
directory?: string;
}
}
+81
View File
@@ -0,0 +1,81 @@
import type { Tone } from "./types/domain";
export function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function firstNonEmptyString(...values: unknown[]): string | null {
for (const value of values) {
const text = nonEmptyString(value);
if (text) return text;
}
return null;
}
export function toneClass(value: unknown): Tone {
const text = String(value ?? "source").toLowerCase();
if (["ok", "pass", "healthy", "completed", "ready", "available", "dev-live", "live"].includes(text)) return "ok";
if (["pending", "running", "checking", "loading", "busy", "active"].includes(text)) return "pending";
if (["blocked", "failed", "failure", "timeout", "error", "canceled", "unavailable"].includes(text)) return "blocked";
if (["warn", "warning", "degraded", "partial"].includes(text)) return "warn";
if (["dry-run", "source", "unverified", "recorded"].includes(text)) return text as Tone;
return "source";
}
export function shortToken(value: unknown, length = 12): string {
const text = nonEmptyString(value);
if (!text) return "unknown";
if (text.length <= length) return text;
return text.slice(0, length);
}
export function formatTimestamp(value: unknown): string {
const text = nonEmptyString(value);
if (!text) return "未知";
const date = new Date(text);
if (Number.isNaN(date.getTime())) return "未知";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
export function relativeUpdatedLabel(timestampMs: number): string {
if (!Number.isFinite(timestampMs) || timestampMs <= 0) return "未知";
const delta = Date.now() - timestampMs;
if (delta < 60_000) return "刚刚";
if (delta < 60 * 60_000) return `${Math.max(1, Math.round(delta / 60_000))}m 前`;
if (delta < 24 * 60 * 60_000) return `${Math.max(1, Math.round(delta / (60 * 60_000)))}h 前`;
return new Date(timestampMs).toISOString().slice(0, 10);
}
export function nextProtocolId(prefix: string): string {
const random = Math.random().toString(36).slice(2, 10);
return `${prefix}_${Date.now().toString(36)}_${random}`;
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : [];
}
export function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.min(Math.max(Math.trunc(numeric), min), max);
}
export function jsonPreview(value: unknown, limit = 900): string {
if (typeof value === "string") return value.slice(0, limit);
try {
return JSON.stringify(value, null, 2).slice(0, limit);
} catch {
return String(value).slice(0, limit);
}
}
File diff suppressed because it is too large Load Diff
-44
View File
@@ -1,44 +0,0 @@
# License information
## Contribution License Agreement
If you contribute code to this project, you are implicitly allowing your code
to be distributed under the MIT license. You are also implicitly verifying that
all code is your original work. `</legalese>`
## Marked
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## Markdown
Copyright © 2004, John Gruber
http://daringfireball.net/
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
File diff suppressed because one or more lines are too long
+12 -13
View File
@@ -15,22 +15,21 @@
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"strict": false,
"noImplicitAny": false,
"noImplicitThis": false,
"strictNullChecks": false,
"strictFunctionTypes": false,
"strictBindCallApply": false,
"strictPropertyInitialization": false,
"alwaysStrict": false,
"useUnknownInCatchVariables": false,
"noFallthroughCasesInSwitch": false
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"alwaysStrict": true,
"useUnknownInCatchVariables": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"scripts/*.ts",
"web-types.d.ts"
"src/**/*.tsx"
],
"exclude": [
"node_modules",
+2 -13
View File
@@ -1,27 +1,16 @@
import { defineConfig } from "vite";
import path from "node:path";
export default defineConfig({
root: path.resolve(import.meta.dirname),
base: "./",
build: {
outDir: "dist",
emptyOutDir: true,
target: "es2022",
sourcemap: false,
cssCodeSplit: false,
minify: false,
rollupOptions: {
output: {
entryFileNames: "app.js",
chunkFileNames: "app.js",
assetFileNames: "app-assets/[name]-[hash][extname]",
inlineDynamicImports: true
chunkFileNames: "assets/[name].js",
assetFileNames: "assets/[name][extname]"
}
}
},
server: {
port: 5183,
strictPort: false
}
});