mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.97.0
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULTS = {
|
||||
healthUrl: "http://127.0.0.1:3030/health",
|
||||
invokeUrl: "http://127.0.0.1:3030/invoke",
|
||||
timeoutMs: 90_000,
|
||||
intervalMs: 1_000,
|
||||
launchUrl: "about:blank",
|
||||
openWindow: false,
|
||||
streamMode: "both",
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Browser Runtime Smoke
|
||||
|
||||
用途:
|
||||
验证 browser runtime 最短主链可用:启动会话、读取状态、执行最小动作,并确认审计日志带出 session / target 关联键。
|
||||
|
||||
用法:
|
||||
node scripts/browser-runtime-smoke.mjs [选项]
|
||||
|
||||
选项:
|
||||
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
|
||||
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
|
||||
--timeout-ms <ms> 等待健康检查超时,默认 90000
|
||||
--interval-ms <ms> 健康检查轮询间隔,默认 1000
|
||||
--launch-url <url> 启动浏览器会话的 URL,默认 about:blank
|
||||
--open-window 显式打开浏览器窗口
|
||||
--stream-mode <mode> events | frames | both,默认 both
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--health-url" && argv[index + 1]) {
|
||||
options.healthUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--invoke-url" && argv[index + 1]) {
|
||||
options.invokeUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--timeout-ms" && argv[index + 1]) {
|
||||
options.timeoutMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--interval-ms" && argv[index + 1]) {
|
||||
options.intervalMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--launch-url" && argv[index + 1]) {
|
||||
options.launchUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--stream-mode" && argv[index + 1]) {
|
||||
options.streamMode = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--open-window") {
|
||||
options.openWindow = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) {
|
||||
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
|
||||
}
|
||||
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
|
||||
throw new Error("--interval-ms 必须是 >= 100 的数字");
|
||||
}
|
||||
if (!["events", "frames", "both"].includes(options.streamMode)) {
|
||||
throw new Error("--stream-mode 只支持 events / frames / both");
|
||||
}
|
||||
if (!options.launchUrl) {
|
||||
throw new Error("--launch-url 不能为空");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function invoke(invokeUrl, cmd, args) {
|
||||
const response = await fetch(invokeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cmd, args }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (payload?.error) {
|
||||
throw new Error(String(payload.error));
|
||||
}
|
||||
|
||||
return payload?.result;
|
||||
}
|
||||
|
||||
async function waitForHealth(options) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(options.healthUrl, { method: "GET" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
console.log(
|
||||
`[smoke:browser-runtime] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
|
||||
payload?.status ? ` status=${payload.status}` : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const detail =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: String(lastError || "unknown error");
|
||||
throw new Error(
|
||||
`[smoke:browser-runtime] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
function findLatestAudit(logs, matcher) {
|
||||
return (logs || []).find((item) => matcher(item));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await waitForHealth(options);
|
||||
|
||||
const profileKey = `smoke-browser-runtime-${Date.now()}`;
|
||||
let sessionId = null;
|
||||
|
||||
try {
|
||||
const launchResponse = await invoke(options.invokeUrl, "launch_browser_session", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
url: options.launchUrl,
|
||||
open_window: options.openWindow,
|
||||
stream_mode: options.streamMode,
|
||||
},
|
||||
});
|
||||
|
||||
sessionId = launchResponse?.session?.session_id ?? null;
|
||||
assert(
|
||||
typeof sessionId === "string" && sessionId.trim(),
|
||||
"launch_browser_session 未返回 session.session_id",
|
||||
);
|
||||
assert(
|
||||
launchResponse?.session?.profile_key === profileKey,
|
||||
"launch_browser_session 返回的 profile_key 与请求不一致",
|
||||
);
|
||||
|
||||
const sessionState = await invoke(
|
||||
options.invokeUrl,
|
||||
"get_browser_session_state",
|
||||
{
|
||||
request: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert(
|
||||
sessionState?.session_id === sessionId,
|
||||
"get_browser_session_state 返回的 session_id 不一致",
|
||||
);
|
||||
assert(
|
||||
sessionState?.profile_key === profileKey,
|
||||
"get_browser_session_state 返回的 profile_key 不一致",
|
||||
);
|
||||
assert(
|
||||
typeof sessionState?.target_id === "string" && sessionState.target_id.trim(),
|
||||
"get_browser_session_state 未返回 target_id",
|
||||
);
|
||||
|
||||
const actionResult = await invoke(options.invokeUrl, "browser_execute_action", {
|
||||
request: {
|
||||
profile_key: profileKey,
|
||||
action: "read_page",
|
||||
timeout_ms: 20_000,
|
||||
},
|
||||
});
|
||||
assert(actionResult?.success === true, "browser_execute_action(read_page) 未成功");
|
||||
assert(
|
||||
actionResult?.session_id === sessionId,
|
||||
"browser_execute_action 未返回对应的 session_id",
|
||||
);
|
||||
assert(
|
||||
actionResult?.target_id === sessionState.target_id,
|
||||
"browser_execute_action 未返回对应的 target_id",
|
||||
);
|
||||
|
||||
const auditLogs = await invoke(options.invokeUrl, "get_browser_action_audit_logs", {
|
||||
limit: 10,
|
||||
});
|
||||
const launchAudit = findLatestAudit(
|
||||
auditLogs,
|
||||
(item) =>
|
||||
item?.kind === "launch" &&
|
||||
item?.profile_key === profileKey &&
|
||||
item?.session_id === sessionId,
|
||||
);
|
||||
assert(launchAudit, "未找到对应的 launch audit 记录");
|
||||
assert(
|
||||
launchAudit?.target_id === sessionState.target_id,
|
||||
"launch audit 缺少 target_id 关联键",
|
||||
);
|
||||
|
||||
const actionAudit = findLatestAudit(
|
||||
auditLogs,
|
||||
(item) =>
|
||||
item?.kind === "action" &&
|
||||
item?.action === "read_page" &&
|
||||
item?.profile_key === profileKey,
|
||||
);
|
||||
assert(actionAudit, "未找到对应的 action audit 记录");
|
||||
assert(
|
||||
actionAudit?.session_id === sessionId,
|
||||
`action audit 缺少 session_id 关联键,record=${actionAudit?.id ?? "unknown"}`,
|
||||
);
|
||||
assert(
|
||||
actionAudit?.target_id === sessionState.target_id,
|
||||
`action audit 缺少 target_id 关联键,record=${actionAudit?.id ?? "unknown"}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[smoke:browser-runtime] 通过 session=${sessionId} target=${sessionState.target_id} profile=${profileKey}`,
|
||||
);
|
||||
} finally {
|
||||
if (sessionId) {
|
||||
try {
|
||||
await invoke(options.invokeUrl, "close_cdp_session", {
|
||||
request: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[smoke:browser-runtime] 清理会话失败: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function readSource(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertMatch(source, pattern, message, failures) {
|
||||
if (!pattern.test(source)) {
|
||||
failures.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIncludes(source, needle, message, failures) {
|
||||
if (!source.includes(needle)) {
|
||||
failures.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotMatch(source, pattern, message, failures) {
|
||||
if (pattern.test(source)) {
|
||||
failures.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function extractBalancedBlock(sourceCode, marker, openChar, closeChar) {
|
||||
const markerIndex = sourceCode.indexOf(marker);
|
||||
if (markerIndex < 0) {
|
||||
throw new Error(`未找到标记: ${marker}`);
|
||||
}
|
||||
|
||||
const openIndex = sourceCode.indexOf(openChar, markerIndex);
|
||||
if (openIndex < 0) {
|
||||
throw new Error(`标记后未找到 ${openChar}: ${marker}`);
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inTemplateString = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = openIndex; index < sourceCode.length; index += 1) {
|
||||
const currentChar = sourceCode[index];
|
||||
const nextChar = sourceCode[index + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (currentChar === "\n") {
|
||||
inLineComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (currentChar === "*" && nextChar === "/") {
|
||||
inBlockComment = false;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (!escaped && currentChar === "'") {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
escaped = !escaped && currentChar === "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (!escaped && currentChar === '"') {
|
||||
inDoubleQuote = false;
|
||||
}
|
||||
escaped = !escaped && currentChar === "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inTemplateString) {
|
||||
if (!escaped && currentChar === "`") {
|
||||
inTemplateString = false;
|
||||
}
|
||||
escaped = !escaped && currentChar === "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === "/" && nextChar === "/") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === "/" && nextChar === "*") {
|
||||
inBlockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === "'") {
|
||||
inSingleQuote = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === '"') {
|
||||
inDoubleQuote = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === "`") {
|
||||
inTemplateString = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === openChar) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === closeChar) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return sourceCode.slice(openIndex + 1, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法提取 ${marker} 的平衡块`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
const harnessMetadataPath =
|
||||
"src/components/agent/chat/utils/harnessRequestMetadata.ts";
|
||||
const executionRuntimePath =
|
||||
"src/components/agent/chat/utils/sessionExecutionRuntime.ts";
|
||||
const requestMetadataPath =
|
||||
"src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs";
|
||||
|
||||
const harnessMetadataSource = readSource(harnessMetadataPath);
|
||||
const executionRuntimeSource = readSource(executionRuntimePath);
|
||||
const requestMetadataSource = readSource(requestMetadataPath);
|
||||
|
||||
const legacyKeysBlock = extractBalancedBlock(
|
||||
harnessMetadataSource,
|
||||
"const LEGACY_HARNESS_STATE_KEYS = [",
|
||||
"[",
|
||||
"]",
|
||||
);
|
||||
const metadataBuilderBlock = extractBalancedBlock(
|
||||
harnessMetadataSource,
|
||||
"const metadata: Record<string, unknown> = {",
|
||||
"{",
|
||||
"}",
|
||||
);
|
||||
|
||||
const requiredMetadataKeys = [
|
||||
"preferences:",
|
||||
"preferred_team_preset_id:",
|
||||
"selected_team_id:",
|
||||
"selected_team_source:",
|
||||
"selected_team_label:",
|
||||
"selected_team_description:",
|
||||
"selected_team_summary:",
|
||||
"selected_team_roles:",
|
||||
"browser_requirement:",
|
||||
"browser_requirement_reason:",
|
||||
"browser_launch_url:",
|
||||
"browser_assist:",
|
||||
];
|
||||
|
||||
const forbiddenLegacyOutputKeys = [
|
||||
"creation_mode:",
|
||||
"creationMode:",
|
||||
"chat_mode:",
|
||||
"chatMode:",
|
||||
"web_search_enabled:",
|
||||
"webSearchEnabled:",
|
||||
"thinking_enabled:",
|
||||
"thinkingEnabled:",
|
||||
"task_mode_enabled:",
|
||||
"taskModeEnabled:",
|
||||
"subagent_mode_enabled:",
|
||||
"subagentModeEnabled:",
|
||||
"turn_team_decision:",
|
||||
"turnTeamDecision:",
|
||||
"turn_team_reason:",
|
||||
"turnTeamReason:",
|
||||
"turn_team_blueprint:",
|
||||
"turnTeamBlueprint:",
|
||||
];
|
||||
|
||||
const requiredLegacyCleanupKeys = [
|
||||
"creation_mode",
|
||||
"chat_mode",
|
||||
"web_search_enabled",
|
||||
"thinking_enabled",
|
||||
"task_mode_enabled",
|
||||
"subagent_mode_enabled",
|
||||
"turn_team_decision",
|
||||
"turn_team_reason",
|
||||
"turn_team_blueprint",
|
||||
];
|
||||
|
||||
const requiredBackendMappings = [
|
||||
'("preferred_team_preset_id", "preferred_team_preset_id")',
|
||||
'("preferredTeamPresetId", "preferred_team_preset_id")',
|
||||
'("selected_team_id", "selected_team_id")',
|
||||
'("selectedTeamId", "selected_team_id")',
|
||||
'("selected_team_source", "selected_team_source")',
|
||||
'("selectedTeamSource", "selected_team_source")',
|
||||
'("selected_team_label", "selected_team_label")',
|
||||
'("selectedTeamLabel", "selected_team_label")',
|
||||
'("selected_team_description", "selected_team_description")',
|
||||
'("selectedTeamDescription", "selected_team_description")',
|
||||
'("selected_team_summary", "selected_team_summary")',
|
||||
'("selectedTeamSummary", "selected_team_summary")',
|
||||
'("selected_team_roles", "selected_team_roles")',
|
||||
'("selectedTeamRoles", "selected_team_roles")',
|
||||
'("browser_requirement", "browser_requirement")',
|
||||
'("browserRequirement", "browser_requirement")',
|
||||
'("browser_requirement_reason", "browser_requirement_reason")',
|
||||
'("browserRequirementReason", "browser_requirement_reason")',
|
||||
'("browser_launch_url", "browser_launch_url")',
|
||||
'("browserLaunchUrl", "browser_launch_url")',
|
||||
];
|
||||
|
||||
const requiredRuntimeFields = [
|
||||
"session_id:",
|
||||
"execution_strategy:",
|
||||
"recent_preferences:",
|
||||
"recent_team_selection:",
|
||||
"recent_content_id:",
|
||||
];
|
||||
|
||||
requiredMetadataKeys.forEach((key) => {
|
||||
assertIncludes(
|
||||
metadataBuilderBlock,
|
||||
key,
|
||||
`[harness-contracts] 前端 metadata builder 缺少字段: ${key}`,
|
||||
failures,
|
||||
);
|
||||
});
|
||||
|
||||
forbiddenLegacyOutputKeys.forEach((key) => {
|
||||
assertNotMatch(
|
||||
metadataBuilderBlock,
|
||||
new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
|
||||
`[harness-contracts] 前端 metadata builder 仍在输出 legacy 字段: ${key}`,
|
||||
failures,
|
||||
);
|
||||
});
|
||||
|
||||
requiredLegacyCleanupKeys.forEach((key) => {
|
||||
assertIncludes(
|
||||
legacyKeysBlock,
|
||||
`"${key}"`,
|
||||
`[harness-contracts] LEGACY_HARNESS_STATE_KEYS 缺少清理项: ${key}`,
|
||||
failures,
|
||||
);
|
||||
});
|
||||
|
||||
assertMatch(
|
||||
harnessMetadataSource,
|
||||
/preferences:\s*\{\s*web_search:\s*preferences\.webSearch,\s*thinking:\s*preferences\.thinking,\s*task:\s*preferences\.task,\s*subagent:\s*preferences\.subagent,\s*\}/s,
|
||||
"[harness-contracts] 前端未按约定输出 preferences.web_search/thinking/task/subagent",
|
||||
failures,
|
||||
);
|
||||
|
||||
requiredBackendMappings.forEach((mapping) => {
|
||||
assertIncludes(
|
||||
requestMetadataSource,
|
||||
mapping,
|
||||
`[harness-contracts] 后端 request metadata 映射缺少字段: ${mapping}`,
|
||||
failures,
|
||||
);
|
||||
});
|
||||
|
||||
assertMatch(
|
||||
requestMetadataSource,
|
||||
/\("web_search_enabled",\s*&\["web_search", "webSearch"\]\[\.\.\]\)/,
|
||||
"[harness-contracts] 后端未从 preferences 回填 web_search_enabled",
|
||||
failures,
|
||||
);
|
||||
assertIncludes(
|
||||
requestMetadataSource,
|
||||
'&["thinking", "thinking_enabled", "thinkingEnabled"][..]',
|
||||
"[harness-contracts] 后端未从 preferences 回填 thinking_enabled",
|
||||
failures,
|
||||
);
|
||||
assertMatch(
|
||||
requestMetadataSource,
|
||||
/\("task_mode_enabled",\s*&\["task", "task_mode", "taskMode"\]\[\.\.\]\)/,
|
||||
"[harness-contracts] 后端未从 preferences 回填 task_mode_enabled",
|
||||
failures,
|
||||
);
|
||||
assertIncludes(
|
||||
requestMetadataSource,
|
||||
'&["subagent", "subagent_mode", "subagentMode"][..]',
|
||||
"[harness-contracts] 后端未从 preferences 回填 subagent_mode_enabled",
|
||||
failures,
|
||||
);
|
||||
|
||||
requiredRuntimeFields.forEach((field) => {
|
||||
assertIncludes(
|
||||
executionRuntimeSource,
|
||||
field,
|
||||
`[harness-contracts] execution runtime 缺少字段: ${field}`,
|
||||
failures,
|
||||
);
|
||||
});
|
||||
|
||||
assertIncludes(
|
||||
executionRuntimeSource,
|
||||
"createSessionRecentPreferencesFromChatToolPreferences",
|
||||
"[harness-contracts] execution runtime 缺少 recent preferences 适配函数",
|
||||
failures,
|
||||
);
|
||||
assertIncludes(
|
||||
executionRuntimeSource,
|
||||
"createTeamDefinitionFromExecutionRuntimeRecentTeamSelection",
|
||||
"[harness-contracts] execution runtime 缺少 recent team 反序列化函数",
|
||||
failures,
|
||||
);
|
||||
assertIncludes(
|
||||
executionRuntimeSource,
|
||||
"createSessionRecentTeamSelectionFromTeamDefinition",
|
||||
"[harness-contracts] execution runtime 缺少 recent team 序列化函数",
|
||||
failures,
|
||||
);
|
||||
|
||||
console.log("[harness-contracts] 检查文件:");
|
||||
console.log(`- ${harnessMetadataPath}`);
|
||||
console.log(`- ${executionRuntimePath}`);
|
||||
console.log(`- ${requestMetadataPath}`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("\n[harness-contracts] 发现契约漂移:");
|
||||
failures.forEach((failure) => {
|
||||
console.error(`- ${failure}`);
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n[harness-contracts] Harness 契约检查通过。");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,724 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULT_SANITIZED_WORKSPACE_ROOT = "/workspace/lime";
|
||||
const REQUIRED_REPLAY_ARTIFACTS = [
|
||||
"input.json",
|
||||
"expected.json",
|
||||
"grader.md",
|
||||
"evidence-links.json",
|
||||
];
|
||||
const HANDOFF_ARTIFACTS = [
|
||||
"plan.md",
|
||||
"progress.json",
|
||||
"handoff.md",
|
||||
"review-summary.md",
|
||||
];
|
||||
const EVIDENCE_ARTIFACTS = [
|
||||
"summary.md",
|
||||
"runtime.json",
|
||||
"timeline.json",
|
||||
"artifacts.json",
|
||||
];
|
||||
const ANALYSIS_BRIEF_FILE_NAME = "analysis-brief.md";
|
||||
const ANALYSIS_CONTEXT_FILE_NAME = "analysis-context.json";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
dryRun: false,
|
||||
format: "text",
|
||||
help: false,
|
||||
outputDir: "",
|
||||
replayDir: "",
|
||||
sanitizedWorkspaceRoot: DEFAULT_SANITIZED_WORKSPACE_ROOT,
|
||||
sessionId: "",
|
||||
title: "",
|
||||
workspaceRoot: process.cwd(),
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--session-id" && argv[index + 1]) {
|
||||
result.sessionId = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--replay-dir" && argv[index + 1]) {
|
||||
result.replayDir = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--workspace-root" && argv[index + 1]) {
|
||||
result.workspaceRoot = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--output-dir" && argv[index + 1]) {
|
||||
result.outputDir = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--title" && argv[index + 1]) {
|
||||
result.title = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--sanitized-workspace-root" && argv[index + 1]) {
|
||||
result.sanitizedWorkspaceRoot = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--format" && argv[index + 1]) {
|
||||
result.format = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--dry-run") {
|
||||
result.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Harness Analysis Brief Export
|
||||
|
||||
用法:
|
||||
node scripts/harness-analysis-brief.mjs --session-id "session-123"
|
||||
node scripts/harness-analysis-brief.mjs --replay-dir ".lime/harness/sessions/session-123/replay"
|
||||
|
||||
选项:
|
||||
--session-id ID 从 <workspace>/.lime/harness/sessions/<id>/replay 生成分析交接包
|
||||
--replay-dir PATH 直接指定 replay 目录;与 --session-id 二选一
|
||||
--workspace-root PATH 工作区根目录,默认当前目录
|
||||
--output-dir PATH 输出目录;默认 <session>/analysis
|
||||
--title TEXT 分析包标题;默认从 goal summary 推导
|
||||
--sanitized-workspace-root PATH 导出到外部 AI 时使用的工作区占位路径,默认 /workspace/lime
|
||||
--dry-run 只预览,不写文件
|
||||
--format FMT 标准输出格式:text | json
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function resolvePath(baseDir, targetPath) {
|
||||
return path.resolve(baseDir, targetPath);
|
||||
}
|
||||
|
||||
function toPortablePath(value) {
|
||||
return String(value).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function writeJsonFile(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function truncateText(value, maxLength = 800) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, maxLength)}…`;
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function replaceWorkspaceRootInString(value, workspaceRoot, placeholder) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let nextValue = value;
|
||||
const rawRoot = String(workspaceRoot);
|
||||
const portableRoot = toPortablePath(rawRoot);
|
||||
|
||||
if (rawRoot) {
|
||||
nextValue = nextValue.replaceAll(rawRoot, placeholder);
|
||||
}
|
||||
if (portableRoot && portableRoot !== rawRoot) {
|
||||
nextValue = nextValue.replaceAll(portableRoot, placeholder);
|
||||
}
|
||||
if (nextValue.includes(placeholder) && nextValue.includes("\\")) {
|
||||
nextValue = nextValue.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
function sanitizeValue(value, workspaceRoot, placeholder) {
|
||||
if (typeof value === "string") {
|
||||
return replaceWorkspaceRootInString(value, workspaceRoot, placeholder);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => sanitizeValue(entry, workspaceRoot, placeholder));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entryValue]) => [
|
||||
key,
|
||||
sanitizeValue(entryValue, workspaceRoot, placeholder),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveReplayDirectory(options, workspaceRoot) {
|
||||
if (options.replayDir) {
|
||||
return resolvePath(process.cwd(), options.replayDir);
|
||||
}
|
||||
|
||||
if (!options.sessionId) {
|
||||
throw new Error("必须提供 --session-id 或 --replay-dir。");
|
||||
}
|
||||
|
||||
return path.join(
|
||||
workspaceRoot,
|
||||
".lime",
|
||||
"harness",
|
||||
"sessions",
|
||||
options.sessionId,
|
||||
"replay",
|
||||
);
|
||||
}
|
||||
|
||||
function validateReplayDirectory(replayDir) {
|
||||
if (!fs.existsSync(replayDir) || !fs.statSync(replayDir).isDirectory()) {
|
||||
throw new Error(`replay 目录不存在: ${replayDir}`);
|
||||
}
|
||||
|
||||
const missing = REQUIRED_REPLAY_ARTIFACTS.filter(
|
||||
(artifact) => !fs.existsSync(path.join(replayDir, artifact)),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`replay 目录缺少文件: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function deriveSessionRootFromReplayDirectory(replayDir) {
|
||||
if (path.basename(replayDir) === "replay") {
|
||||
return path.dirname(replayDir);
|
||||
}
|
||||
return replayDir;
|
||||
}
|
||||
|
||||
function safeReadFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return readJsonFile(filePath);
|
||||
}
|
||||
|
||||
function sanitizeAbsolutePathForExternalUse(absolutePath, workspaceRoot, placeholder) {
|
||||
const relativePath = path.relative(workspaceRoot, absolutePath);
|
||||
if (
|
||||
!relativePath.startsWith("..") &&
|
||||
!path.isAbsolute(relativePath) &&
|
||||
relativePath !== ""
|
||||
) {
|
||||
return toPortablePath(path.join(placeholder, relativePath));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function listExistingArtifacts(rootPath, artifactNames, workspaceRoot, placeholder) {
|
||||
return artifactNames.map((fileName) => {
|
||||
const absolutePath = path.join(rootPath, fileName);
|
||||
const exists = fs.existsSync(absolutePath);
|
||||
return {
|
||||
fileName,
|
||||
exists,
|
||||
absolutePath: exists
|
||||
? sanitizeAbsolutePathForExternalUse(
|
||||
absolutePath,
|
||||
workspaceRoot,
|
||||
placeholder,
|
||||
)
|
||||
: "",
|
||||
relativePath: exists ? toPortablePath(path.relative(rootPath, absolutePath)) : "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function deriveTitle(options, inputPayload, replayDir) {
|
||||
if (options.title) {
|
||||
return options.title;
|
||||
}
|
||||
|
||||
const goalSummary = inputPayload?.task?.goalSummary;
|
||||
if (typeof goalSummary === "string" && goalSummary.trim().length > 0) {
|
||||
return goalSummary.trim();
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
inputPayload?.session?.sessionId ?? path.basename(path.dirname(replayDir));
|
||||
return `外部分析交接 / ${sessionId}`;
|
||||
}
|
||||
|
||||
function buildReadingOrder(handoffArtifacts, evidenceArtifacts) {
|
||||
const order = ["先读 replay/input.json 与 replay/expected.json,确认任务目标与判定标准。"];
|
||||
|
||||
if (handoffArtifacts.some((entry) => entry.fileName === "handoff.md" && entry.exists)) {
|
||||
order.push("再读 handoff/handoff.md 与 handoff/progress.json,确认当前状态、待继续事项与恢复顺序。");
|
||||
}
|
||||
|
||||
if (evidenceArtifacts.some((entry) => entry.fileName === "summary.md" && entry.exists)) {
|
||||
order.push("再读 evidence/summary.md 与 evidence/runtime.json,确认当前阻塞、pending request 与 diagnostics。");
|
||||
}
|
||||
|
||||
if (evidenceArtifacts.some((entry) => entry.fileName === "timeline.json" && entry.exists)) {
|
||||
order.push("如需复盘过程,再读 evidence/timeline.json。");
|
||||
}
|
||||
|
||||
order.push("最后回看 replay/grader.md,按约定输出根因、修复建议、回归建议与风险项。");
|
||||
return order;
|
||||
}
|
||||
|
||||
function buildExternalAnalysisPromptContract() {
|
||||
return {
|
||||
audience: "Claude Code / Codex",
|
||||
task: "基于 Lime 导出的结构化证据做问题分析与修复建议,不直接代替团队做最终决策。",
|
||||
requiredSections: [
|
||||
"结论",
|
||||
"根因判断",
|
||||
"关键证据",
|
||||
"修复建议",
|
||||
"回归建议",
|
||||
"风险与未知项",
|
||||
],
|
||||
rules: [
|
||||
"优先引用现有证据文件,不要求重建完整会话。",
|
||||
"如果证据不足,显式列出缺口,不要假装已经确认。",
|
||||
"只给分析与建议,不直接替团队批准或拒绝修复方案。",
|
||||
"如果怀疑路径、凭证或外部系统状态影响结论,先标注为待人工复核。",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildHumanReviewChecklist(inputPayload, expectedPayload) {
|
||||
const checklist = [
|
||||
"确认外部 AI 是否引用了现有证据,而不是凭空推断。",
|
||||
"确认修复建议是否直接服务当前失败模式,而不是顺手扩大范围。",
|
||||
"确认回归建议是否能沉淀为 replay / eval / smoke,而不是停留在口头建议。",
|
||||
];
|
||||
|
||||
if (expectedPayload?.graderSuggestion?.requiresHumanReview === true) {
|
||||
checklist.unshift("当前样本本来就要求人工复核,不应把外部 AI 结论当成最终裁决。");
|
||||
}
|
||||
|
||||
if (
|
||||
normalizeStringList(inputPayload?.classification?.failureModes).includes(
|
||||
"pending_request",
|
||||
)
|
||||
) {
|
||||
checklist.push("确认外部 AI 没有把 pending request 误判成已完成。");
|
||||
}
|
||||
|
||||
return checklist;
|
||||
}
|
||||
|
||||
function buildAnalysisContext({
|
||||
evidenceArtifacts,
|
||||
evidenceJson,
|
||||
evidenceRoot,
|
||||
expectedPayload,
|
||||
handoffArtifacts,
|
||||
handoffJson,
|
||||
inputPayload,
|
||||
options,
|
||||
replayDir,
|
||||
replayRootArtifacts,
|
||||
sessionRoot,
|
||||
title,
|
||||
workspaceRoot,
|
||||
}) {
|
||||
const sanitizedInput = sanitizeValue(
|
||||
{
|
||||
session: inputPayload?.session ?? {},
|
||||
task: inputPayload?.task ?? {},
|
||||
classification: inputPayload?.classification ?? {},
|
||||
runtimeContext: {
|
||||
pendingRequests: inputPayload?.runtimeContext?.pendingRequests ?? [],
|
||||
queuedTurns: inputPayload?.runtimeContext?.queuedTurns ?? [],
|
||||
todoItems: inputPayload?.runtimeContext?.todoItems ?? [],
|
||||
activeSubagents: inputPayload?.runtimeContext?.activeSubagents ?? [],
|
||||
},
|
||||
linkedArtifacts: inputPayload?.linkedArtifacts ?? {},
|
||||
},
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
);
|
||||
|
||||
const sanitizedExpected = sanitizeValue(
|
||||
{
|
||||
goalSummary: expectedPayload?.goalSummary ?? "",
|
||||
successCriteria: expectedPayload?.successCriteria ?? [],
|
||||
blockingChecks: expectedPayload?.blockingChecks ?? [],
|
||||
artifactChecks: expectedPayload?.artifactChecks ?? [],
|
||||
graderSuggestion: expectedPayload?.graderSuggestion ?? {},
|
||||
nonGoals: expectedPayload?.nonGoals ?? [],
|
||||
},
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
);
|
||||
|
||||
return {
|
||||
schemaVersion: "v1",
|
||||
source: {
|
||||
contractShape: "lime_external_analysis_handoff",
|
||||
derivedFrom: [
|
||||
"lime_workspace_handoff_bundle",
|
||||
"lime_workspace_evidence_pack",
|
||||
"lime_runtime_export_replay_case",
|
||||
],
|
||||
},
|
||||
title,
|
||||
exportedAt: new Date().toISOString(),
|
||||
sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot,
|
||||
replayRoot:
|
||||
sanitizeAbsolutePathForExternalUse(
|
||||
replayDir,
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
) || "",
|
||||
summary: {
|
||||
sessionId: sanitizedInput.session.sessionId ?? "",
|
||||
threadId: sanitizedInput.session.threadId ?? "",
|
||||
executionStrategy: sanitizedInput.session.executionStrategy ?? "",
|
||||
model: sanitizedInput.session.model ?? "",
|
||||
goalSummary: sanitizedInput.task.goalSummary ?? "",
|
||||
latestTurnStatus:
|
||||
sanitizedInput.task.latestTurnStatus ??
|
||||
handoffJson?.status?.latestTurnStatus ??
|
||||
evidenceJson?.thread?.latestTurnStatus ??
|
||||
"",
|
||||
threadStatus:
|
||||
sanitizedInput.task.threadStatus ??
|
||||
handoffJson?.status?.threadStatus ??
|
||||
evidenceJson?.thread?.status ??
|
||||
"",
|
||||
primaryBlockingKind:
|
||||
sanitizedInput.classification.primaryBlockingKind ??
|
||||
handoffJson?.diagnostics?.primaryBlockingKind ??
|
||||
evidenceJson?.thread?.diagnostics?.primaryBlockingKind ??
|
||||
"",
|
||||
primaryBlockingSummary:
|
||||
sanitizedInput.task.primaryBlockingSummary ??
|
||||
handoffJson?.diagnostics?.primaryBlockingSummary ??
|
||||
evidenceJson?.thread?.diagnostics?.primaryBlockingSummary ??
|
||||
"",
|
||||
failureModes: sanitizedInput.classification.failureModes ?? [],
|
||||
suiteTags: sanitizedInput.classification.suiteTags ?? [],
|
||||
pendingRequestCount:
|
||||
Array.isArray(sanitizedInput.runtimeContext.pendingRequests)
|
||||
? sanitizedInput.runtimeContext.pendingRequests.length
|
||||
: handoffJson?.status?.pendingRequestCount ??
|
||||
evidenceJson?.thread?.pendingRequestCount ??
|
||||
0,
|
||||
queuedTurnCount:
|
||||
Array.isArray(sanitizedInput.runtimeContext.queuedTurns)
|
||||
? sanitizedInput.runtimeContext.queuedTurns.length
|
||||
: handoffJson?.status?.queuedTurnCount ??
|
||||
evidenceJson?.thread?.queuedTurnCount ??
|
||||
0,
|
||||
},
|
||||
replay: {
|
||||
artifacts: replayRootArtifacts,
|
||||
graderExcerpt: truncateText(
|
||||
sanitizeValue(
|
||||
safeReadFile(path.join(replayDir, "grader.md")) ?? "",
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
),
|
||||
),
|
||||
input: sanitizedInput,
|
||||
expected: sanitizedExpected,
|
||||
},
|
||||
handoff: {
|
||||
artifacts: handoffArtifacts,
|
||||
progress: sanitizeValue(handoffJson ?? {}, workspaceRoot, options.sanitizedWorkspaceRoot),
|
||||
handoffExcerpt: truncateText(
|
||||
sanitizeValue(
|
||||
safeReadFile(path.join(sessionRoot, "handoff.md")) ?? "",
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
),
|
||||
),
|
||||
reviewSummaryExcerpt: truncateText(
|
||||
sanitizeValue(
|
||||
safeReadFile(path.join(sessionRoot, "review-summary.md")) ?? "",
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
),
|
||||
),
|
||||
},
|
||||
evidence: {
|
||||
artifacts: evidenceArtifacts,
|
||||
runtime: sanitizeValue(evidenceJson ?? {}, workspaceRoot, options.sanitizedWorkspaceRoot),
|
||||
summaryExcerpt: truncateText(
|
||||
sanitizeValue(
|
||||
safeReadFile(path.join(evidenceRoot, "summary.md")) ?? "",
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
),
|
||||
),
|
||||
},
|
||||
readingOrder: buildReadingOrder(handoffArtifacts, evidenceArtifacts),
|
||||
externalAnalysisContract: buildExternalAnalysisPromptContract(),
|
||||
humanReviewChecklist: buildHumanReviewChecklist(inputPayload, expectedPayload),
|
||||
};
|
||||
}
|
||||
|
||||
function renderArtifactList(artifacts, labelPrefix) {
|
||||
const available = artifacts.filter((entry) => entry.exists);
|
||||
if (available.length === 0) {
|
||||
return ["- 当前未检测到可用文件。"];
|
||||
}
|
||||
|
||||
return available.map(
|
||||
(entry) =>
|
||||
`- \`${labelPrefix}${entry.relativePath}\`${
|
||||
entry.absolutePath ? ` (${entry.absolutePath})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnalysisBrief(context) {
|
||||
const lines = [
|
||||
"# 外部分析交接简报",
|
||||
"",
|
||||
`- 标题:${context.title}`,
|
||||
`- 生成时间:${context.exportedAt}`,
|
||||
`- 会话:\`${context.summary.sessionId || "unknown"}\``,
|
||||
`- 线程:\`${context.summary.threadId || "unknown"}\``,
|
||||
`- 执行策略:${context.summary.executionStrategy || "unknown"}`,
|
||||
`- 模型:${context.summary.model || "unknown"}`,
|
||||
"",
|
||||
"## 当前问题",
|
||||
"",
|
||||
`- 目标摘要:${context.summary.goalSummary || "未知"}`,
|
||||
`- 线程状态:${context.summary.threadStatus || "未知"}`,
|
||||
`- 最新 turn 状态:${context.summary.latestTurnStatus || "未知"}`,
|
||||
`- 主要阻塞:${context.summary.primaryBlockingKind || "未知"}${context.summary.primaryBlockingSummary ? ` · ${context.summary.primaryBlockingSummary}` : ""}`,
|
||||
`- failure modes:${
|
||||
context.summary.failureModes.length > 0
|
||||
? context.summary.failureModes.join(", ")
|
||||
: "无"
|
||||
}`,
|
||||
`- suite tags:${
|
||||
context.summary.suiteTags.length > 0
|
||||
? context.summary.suiteTags.join(", ")
|
||||
: "无"
|
||||
}`,
|
||||
`- pending request:${context.summary.pendingRequestCount}`,
|
||||
`- queued turn:${context.summary.queuedTurnCount}`,
|
||||
"",
|
||||
"## 推荐读取顺序",
|
||||
"",
|
||||
...context.readingOrder.map((entry, index) => `${index + 1}. ${entry}`),
|
||||
"",
|
||||
"## Replay 文件",
|
||||
"",
|
||||
...renderArtifactList(context.replay.artifacts, "replay/"),
|
||||
"",
|
||||
"## Handoff 文件",
|
||||
"",
|
||||
...renderArtifactList(context.handoff.artifacts, ""),
|
||||
"",
|
||||
"## Evidence 文件",
|
||||
"",
|
||||
...renderArtifactList(context.evidence.artifacts, "evidence/"),
|
||||
"",
|
||||
"## 可直接给外部 AI 的任务说明",
|
||||
"",
|
||||
"```text",
|
||||
"你将收到一个由 Lime 导出的分析包。你的职责是做问题分析和修复建议,不直接替团队做最终决策。",
|
||||
"",
|
||||
"请优先读取 analysis-context.json 与 analysis-brief.md 中提到的 replay / handoff / evidence 文件。",
|
||||
"",
|
||||
"输出必须包含以下部分:",
|
||||
"- 结论",
|
||||
"- 根因判断",
|
||||
"- 关键证据",
|
||||
"- 修复建议",
|
||||
"- 回归建议",
|
||||
"- 风险与未知项",
|
||||
"",
|
||||
"约束:",
|
||||
"- 优先引用现有证据,不要假装看到不存在的信息。",
|
||||
"- 如果证据不足,明确写出缺口和需要人工确认的地方。",
|
||||
"- 不直接代表团队批准、拒绝或自动应用修复方案。",
|
||||
"```",
|
||||
"",
|
||||
"## 人工审核检查清单",
|
||||
"",
|
||||
...context.humanReviewChecklist.map((entry) => `- ${entry}`),
|
||||
"",
|
||||
"## 关键摘录",
|
||||
"",
|
||||
"### Replay Grader 摘录",
|
||||
"",
|
||||
context.replay.graderExcerpt || "当前无可用摘录。",
|
||||
"",
|
||||
"### Handoff 摘录",
|
||||
"",
|
||||
context.handoff.handoffExcerpt || "当前无可用摘录。",
|
||||
"",
|
||||
"### Evidence 摘录",
|
||||
"",
|
||||
context.evidence.summaryExcerpt || "当前无可用摘录。",
|
||||
"",
|
||||
"## 注意",
|
||||
"",
|
||||
`- 所有路径默认已按 \`${context.sanitizedWorkspaceRoot}\` 占位规则输出,便于外部 AI 消费。`,
|
||||
"- 这份简报只负责分析交接,不负责自动修复或自动回写 Lime。",
|
||||
"",
|
||||
];
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function renderText(result) {
|
||||
return [
|
||||
`[harness-analysis] title : ${result.title}`,
|
||||
`[harness-analysis] replay: ${result.replayDir}`,
|
||||
`[harness-analysis] output: ${result.outputDir}`,
|
||||
`[harness-analysis] brief : ${result.briefPath}`,
|
||||
`[harness-analysis] json : ${result.contextPath}`,
|
||||
`[harness-analysis] dry-run: ${result.dryRun ? "yes" : "no"}`,
|
||||
].join("\n").concat("\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = resolvePath(process.cwd(), options.workspaceRoot);
|
||||
const replayDir = resolveReplayDirectory(options, workspaceRoot);
|
||||
validateReplayDirectory(replayDir);
|
||||
|
||||
const inputPayload = readJsonFile(path.join(replayDir, "input.json"));
|
||||
const expectedPayload = readJsonFile(path.join(replayDir, "expected.json"));
|
||||
const sessionRoot = deriveSessionRootFromReplayDirectory(replayDir);
|
||||
const evidenceRoot = path.join(sessionRoot, "evidence");
|
||||
const outputDir = options.outputDir
|
||||
? resolvePath(process.cwd(), options.outputDir)
|
||||
: path.join(sessionRoot, "analysis");
|
||||
|
||||
const workspaceRootFromInput =
|
||||
inputPayload?.session?.workspaceRoot && typeof inputPayload.session.workspaceRoot === "string"
|
||||
? path.resolve(inputPayload.session.workspaceRoot)
|
||||
: workspaceRoot;
|
||||
|
||||
const replayRootArtifacts = listExistingArtifacts(
|
||||
replayDir,
|
||||
REQUIRED_REPLAY_ARTIFACTS,
|
||||
workspaceRootFromInput,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
);
|
||||
const handoffArtifacts = listExistingArtifacts(
|
||||
sessionRoot,
|
||||
HANDOFF_ARTIFACTS,
|
||||
workspaceRootFromInput,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
);
|
||||
const evidenceArtifacts = listExistingArtifacts(
|
||||
evidenceRoot,
|
||||
EVIDENCE_ARTIFACTS,
|
||||
workspaceRootFromInput,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
);
|
||||
|
||||
const handoffJson = safeReadJson(path.join(sessionRoot, "progress.json"));
|
||||
const evidenceJson = safeReadJson(path.join(evidenceRoot, "runtime.json"));
|
||||
|
||||
const title = deriveTitle(options, inputPayload, replayDir);
|
||||
const analysisContext = buildAnalysisContext({
|
||||
evidenceArtifacts,
|
||||
evidenceJson,
|
||||
evidenceRoot,
|
||||
expectedPayload,
|
||||
handoffArtifacts,
|
||||
handoffJson,
|
||||
inputPayload,
|
||||
options,
|
||||
replayDir,
|
||||
replayRootArtifacts,
|
||||
sessionRoot,
|
||||
title,
|
||||
workspaceRoot: workspaceRootFromInput,
|
||||
});
|
||||
const analysisBrief = buildAnalysisBrief(analysisContext);
|
||||
|
||||
const briefPath = path.join(outputDir, ANALYSIS_BRIEF_FILE_NAME);
|
||||
const contextPath = path.join(outputDir, ANALYSIS_CONTEXT_FILE_NAME);
|
||||
|
||||
if (!options.dryRun) {
|
||||
ensureDirectory(outputDir);
|
||||
fs.writeFileSync(briefPath, analysisBrief, "utf8");
|
||||
writeJsonFile(contextPath, analysisContext);
|
||||
}
|
||||
|
||||
const result = {
|
||||
briefPath: toPortablePath(briefPath),
|
||||
contextPath: toPortablePath(contextPath),
|
||||
dryRun: options.dryRun,
|
||||
outputDir: toPortablePath(outputDir),
|
||||
replayDir: toPortablePath(replayDir),
|
||||
title,
|
||||
};
|
||||
|
||||
if (options.format === "json") {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(renderText(result));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,700 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULT_MANIFEST_PATH = "docs/test/harness-evals.manifest.json";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
format: "text",
|
||||
help: false,
|
||||
manifest: DEFAULT_MANIFEST_PATH,
|
||||
outputJson: "",
|
||||
outputMarkdown: "",
|
||||
strict: true,
|
||||
workspaceRoot: process.cwd(),
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--manifest" && argv[index + 1]) {
|
||||
result.manifest = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--workspace-root" && argv[index + 1]) {
|
||||
result.workspaceRoot = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--format" && argv[index + 1]) {
|
||||
result.format = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--output-json" && argv[index + 1]) {
|
||||
result.outputJson = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--output-markdown" && argv[index + 1]) {
|
||||
result.outputMarkdown = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--no-strict") {
|
||||
result.strict = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--strict") {
|
||||
result.strict = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Harness Eval Runner
|
||||
|
||||
用法:
|
||||
node scripts/harness-eval-runner.mjs
|
||||
node scripts/harness-eval-runner.mjs --format json
|
||||
node scripts/harness-eval-runner.mjs --workspace-root "/path/to/workspace"
|
||||
node scripts/harness-eval-runner.mjs --output-json "./tmp/harness-eval-summary.json" --output-markdown "./tmp/harness-eval-summary.md"
|
||||
|
||||
选项:
|
||||
--manifest PATH 指定 manifest,默认 docs/test/harness-evals.manifest.json
|
||||
--workspace-root PATH 指定工作区根目录,默认当前目录
|
||||
--format FMT 控制标准输出格式:text | json | markdown
|
||||
--output-json PATH 将 JSON 摘要写入指定路径
|
||||
--output-markdown PATH 将 Markdown 摘要写入指定路径
|
||||
--strict 严格模式(默认),发现 invalid case 时返回非 0
|
||||
--no-strict 非严格模式,只输出摘要,不因 invalid case 退出失败
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function resolvePath(baseDir, relativePath) {
|
||||
return path.resolve(baseDir, relativePath);
|
||||
}
|
||||
|
||||
function ensureParentDirectory(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function mergeUniqueStrings(...groups) {
|
||||
return [...new Set(groups.flatMap((group) => normalizeStringList(group)))];
|
||||
}
|
||||
|
||||
function createBreakdownEntry(name) {
|
||||
return {
|
||||
name,
|
||||
caseCount: 0,
|
||||
readyCount: 0,
|
||||
invalidCount: 0,
|
||||
pendingRequestCaseCount: 0,
|
||||
needsHumanReviewCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateCaseBreakdown(cases, selector) {
|
||||
const breakdownMap = new Map();
|
||||
|
||||
for (const entry of cases) {
|
||||
const labels = mergeUniqueStrings(selector(entry));
|
||||
for (const label of labels) {
|
||||
const current = breakdownMap.get(label) ?? createBreakdownEntry(label);
|
||||
current.caseCount += 1;
|
||||
if (entry.status === "ready") {
|
||||
current.readyCount += 1;
|
||||
} else if (entry.status === "invalid") {
|
||||
current.invalidCount += 1;
|
||||
}
|
||||
if (entry.pendingRequestCount > 0) {
|
||||
current.pendingRequestCaseCount += 1;
|
||||
}
|
||||
if (entry.requiresHumanReview) {
|
||||
current.needsHumanReviewCount += 1;
|
||||
}
|
||||
breakdownMap.set(label, current);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(breakdownMap.values()).sort((left, right) => {
|
||||
if (right.caseCount !== left.caseCount) {
|
||||
return right.caseCount - left.caseCount;
|
||||
}
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}
|
||||
|
||||
function getValueByPath(target, dottedPath) {
|
||||
return dottedPath
|
||||
.split(".")
|
||||
.reduce(
|
||||
(current, segment) => (current == null ? undefined : current[segment]),
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
function isPresentValue(value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectFieldIssues(jsonPayload, fields, label) {
|
||||
const issues = [];
|
||||
for (const field of fields) {
|
||||
if (!isPresentValue(getValueByPath(jsonPayload, field))) {
|
||||
issues.push(`${label} 缺少字段: ${field}`);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function listReplayDirectories(rootPath) {
|
||||
if (!fs.existsSync(rootPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(rootPath, entry.name, "replay"))
|
||||
.filter((replayPath) => {
|
||||
try {
|
||||
return fs.statSync(replayPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function validateCaseDirectory(caseDir, caseConfig, defaults, context) {
|
||||
const requiredArtifacts = normalizeStringList(
|
||||
caseConfig.requiredArtifacts ?? defaults.requiredArtifacts,
|
||||
);
|
||||
const requiredInputFields = normalizeStringList(
|
||||
caseConfig.requiredInputFields ?? defaults.requiredInputFields,
|
||||
);
|
||||
const requiredExpectedFields = normalizeStringList(
|
||||
caseConfig.requiredExpectedFields ?? defaults.requiredExpectedFields,
|
||||
);
|
||||
const requiredEvidenceFields = normalizeStringList(
|
||||
caseConfig.requiredEvidenceFields ?? defaults.requiredEvidenceFields,
|
||||
);
|
||||
|
||||
const issues = [];
|
||||
const resolvedCaseDir = path.resolve(caseDir);
|
||||
const files = {};
|
||||
|
||||
for (const artifactName of requiredArtifacts) {
|
||||
const artifactPath = path.join(resolvedCaseDir, artifactName);
|
||||
files[artifactName] = artifactPath;
|
||||
if (!fs.existsSync(artifactPath)) {
|
||||
issues.push(`缺少文件: ${artifactName}`);
|
||||
}
|
||||
}
|
||||
|
||||
let inputPayload = null;
|
||||
let expectedPayload = null;
|
||||
let evidencePayload = null;
|
||||
|
||||
if (fs.existsSync(files["input.json"] ?? "")) {
|
||||
try {
|
||||
inputPayload = readJsonFile(files["input.json"]);
|
||||
issues.push(
|
||||
...collectFieldIssues(inputPayload, requiredInputFields, "input.json"),
|
||||
);
|
||||
} catch (error) {
|
||||
issues.push(`input.json 解析失败: ${String(error.message ?? error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(files["expected.json"] ?? "")) {
|
||||
try {
|
||||
expectedPayload = readJsonFile(files["expected.json"]);
|
||||
issues.push(
|
||||
...collectFieldIssues(
|
||||
expectedPayload,
|
||||
requiredExpectedFields,
|
||||
"expected.json",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
issues.push(`expected.json 解析失败: ${String(error.message ?? error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(files["evidence-links.json"] ?? "")) {
|
||||
try {
|
||||
evidencePayload = readJsonFile(files["evidence-links.json"]);
|
||||
issues.push(
|
||||
...collectFieldIssues(
|
||||
evidencePayload,
|
||||
requiredEvidenceFields,
|
||||
"evidence-links.json",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
issues.push(
|
||||
`evidence-links.json 解析失败: ${String(error.message ?? error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRequestCount = Array.isArray(
|
||||
inputPayload?.runtimeContext?.pendingRequests,
|
||||
)
|
||||
? inputPayload.runtimeContext.pendingRequests.length
|
||||
: 0;
|
||||
const classificationTags = mergeUniqueStrings(
|
||||
context.tags,
|
||||
inputPayload?.classification?.suiteTags,
|
||||
);
|
||||
const failureModes = normalizeStringList(
|
||||
inputPayload?.classification?.failureModes,
|
||||
);
|
||||
const primaryBlockingKind =
|
||||
typeof inputPayload?.classification?.primaryBlockingKind === "string"
|
||||
? inputPayload.classification.primaryBlockingKind.trim()
|
||||
: "";
|
||||
const requiresHumanReview =
|
||||
expectedPayload?.graderSuggestion?.requiresHumanReview === true;
|
||||
const preferredMode =
|
||||
typeof expectedPayload?.graderSuggestion?.preferredMode === "string"
|
||||
? expectedPayload.graderSuggestion.preferredMode
|
||||
: "";
|
||||
|
||||
return {
|
||||
caseId: context.caseId,
|
||||
title: context.title,
|
||||
suiteId: context.suiteId,
|
||||
suiteTitle: context.suiteTitle,
|
||||
source: context.source,
|
||||
priority: context.priority ?? "",
|
||||
tags: classificationTags,
|
||||
failureModes,
|
||||
primaryBlockingKind,
|
||||
caseDir: resolvedCaseDir,
|
||||
relativeCaseDir: path.relative(context.repoRoot, resolvedCaseDir) || ".",
|
||||
sessionId:
|
||||
inputPayload?.session?.sessionId ??
|
||||
expectedPayload?.sessionId ??
|
||||
path.basename(path.dirname(resolvedCaseDir)),
|
||||
threadId:
|
||||
inputPayload?.session?.threadId ?? expectedPayload?.threadId ?? "",
|
||||
goalSummary:
|
||||
inputPayload?.task?.goalSummary ?? expectedPayload?.goalSummary ?? "",
|
||||
pendingRequestCount,
|
||||
requiresHumanReview,
|
||||
preferredMode,
|
||||
status: issues.length === 0 ? "ready" : "invalid",
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
function expandSuiteCases(suiteConfig, defaults, repoRoot, workspaceRoot) {
|
||||
const suiteCases = [];
|
||||
const configuredCases = Array.isArray(suiteConfig.cases)
|
||||
? suiteConfig.cases
|
||||
: [];
|
||||
|
||||
for (const caseConfig of configuredCases) {
|
||||
const source = String(caseConfig.source ?? "").trim();
|
||||
if (source === "repo_fixture") {
|
||||
const caseDir = resolvePath(repoRoot, String(caseConfig.caseDir ?? ""));
|
||||
suiteCases.push(
|
||||
validateCaseDirectory(caseDir, caseConfig, defaults, {
|
||||
caseId: String(caseConfig.id ?? "unnamed-case"),
|
||||
priority: suiteConfig.priority,
|
||||
repoRoot,
|
||||
source,
|
||||
suiteId: String(suiteConfig.id ?? "unnamed-suite"),
|
||||
suiteTitle: String(suiteConfig.title ?? "未命名 Suite"),
|
||||
tags: caseConfig.tags,
|
||||
title: String(caseConfig.title ?? caseConfig.id ?? "未命名 Case"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source === "workspace_replay_discovery") {
|
||||
const discoveryRoot = resolvePath(
|
||||
workspaceRoot,
|
||||
String(caseConfig.root ?? ".lime/harness/sessions"),
|
||||
);
|
||||
const replayDirectories = listReplayDirectories(discoveryRoot);
|
||||
|
||||
if (
|
||||
replayDirectories.length === 0 &&
|
||||
caseConfig.allowZeroMatches !== true
|
||||
) {
|
||||
suiteCases.push({
|
||||
caseId: String(caseConfig.id ?? "workspace-discovery"),
|
||||
title: String(caseConfig.title ?? "工作区 Replay 自动发现"),
|
||||
suiteId: String(suiteConfig.id ?? "unnamed-suite"),
|
||||
suiteTitle: String(suiteConfig.title ?? "未命名 Suite"),
|
||||
source,
|
||||
priority: suiteConfig.priority ?? "",
|
||||
tags: normalizeStringList(caseConfig.tags),
|
||||
failureModes: [],
|
||||
primaryBlockingKind: "",
|
||||
caseDir: discoveryRoot,
|
||||
relativeCaseDir: path.relative(repoRoot, discoveryRoot) || ".",
|
||||
sessionId: "",
|
||||
threadId: "",
|
||||
goalSummary: "",
|
||||
pendingRequestCount: 0,
|
||||
requiresHumanReview: false,
|
||||
preferredMode: "",
|
||||
status: "invalid",
|
||||
issues: [
|
||||
`未发现 replay case 目录: ${path.relative(workspaceRoot, discoveryRoot) || "."}`,
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const replayDir of replayDirectories) {
|
||||
const sessionId = path.basename(path.dirname(replayDir));
|
||||
suiteCases.push(
|
||||
validateCaseDirectory(replayDir, caseConfig, defaults, {
|
||||
caseId: `${String(caseConfig.id ?? "workspace-case")}:${sessionId}`,
|
||||
priority: suiteConfig.priority,
|
||||
repoRoot,
|
||||
source,
|
||||
suiteId: String(suiteConfig.id ?? "unnamed-suite"),
|
||||
suiteTitle: String(suiteConfig.title ?? "未命名 Suite"),
|
||||
tags: caseConfig.tags,
|
||||
title: `${String(caseConfig.title ?? "工作区 Replay 样本")} / ${sessionId}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
suiteCases.push({
|
||||
caseId: String(caseConfig.id ?? "unknown-case"),
|
||||
title: String(caseConfig.title ?? "未命名 Case"),
|
||||
suiteId: String(suiteConfig.id ?? "unnamed-suite"),
|
||||
suiteTitle: String(suiteConfig.title ?? "未命名 Suite"),
|
||||
source,
|
||||
priority: suiteConfig.priority ?? "",
|
||||
tags: normalizeStringList(caseConfig.tags),
|
||||
failureModes: [],
|
||||
primaryBlockingKind: "",
|
||||
caseDir: "",
|
||||
relativeCaseDir: "",
|
||||
sessionId: "",
|
||||
threadId: "",
|
||||
goalSummary: "",
|
||||
pendingRequestCount: 0,
|
||||
requiresHumanReview: false,
|
||||
preferredMode: "",
|
||||
status: "invalid",
|
||||
issues: [`不支持的 case source: ${source || "(empty)"}`],
|
||||
});
|
||||
}
|
||||
|
||||
const readyCount = suiteCases.filter(
|
||||
(entry) => entry.status === "ready",
|
||||
).length;
|
||||
const invalidCount = suiteCases.length - readyCount;
|
||||
const discoveredCount = suiteCases.filter(
|
||||
(entry) => entry.source === "workspace_replay_discovery",
|
||||
).length;
|
||||
|
||||
return {
|
||||
id: String(suiteConfig.id ?? "unnamed-suite"),
|
||||
title: String(suiteConfig.title ?? "未命名 Suite"),
|
||||
priority: String(suiteConfig.priority ?? ""),
|
||||
roadmap: String(suiteConfig.roadmap ?? ""),
|
||||
description: String(suiteConfig.description ?? ""),
|
||||
upstream: suiteConfig.upstream ?? {},
|
||||
cases: suiteCases,
|
||||
stats: {
|
||||
configuredCaseCount: configuredCases.length,
|
||||
discoveredCaseCount: discoveredCount,
|
||||
caseCount: suiteCases.length,
|
||||
readyCount,
|
||||
invalidCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSummary(manifest, suites, options) {
|
||||
const allCases = suites.flatMap((suite) => suite.cases);
|
||||
const readyCases = allCases.filter((entry) => entry.status === "ready");
|
||||
const invalidCases = allCases.filter((entry) => entry.status === "invalid");
|
||||
const reviewCases = allCases.filter((entry) => entry.requiresHumanReview);
|
||||
const pendingCases = allCases.filter(
|
||||
(entry) => entry.pendingRequestCount > 0,
|
||||
);
|
||||
|
||||
return {
|
||||
manifestVersion: String(manifest.manifestVersion ?? "unknown"),
|
||||
title: String(manifest.title ?? "Lime Harness Eval Summary"),
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoRoot: process.cwd(),
|
||||
workspaceRoot: path.resolve(options.workspaceRoot),
|
||||
strict: options.strict,
|
||||
totals: {
|
||||
suiteCount: suites.length,
|
||||
caseCount: allCases.length,
|
||||
readyCount: readyCases.length,
|
||||
invalidCount: invalidCases.length,
|
||||
needsHumanReviewCount: reviewCases.length,
|
||||
pendingRequestCaseCount: pendingCases.length,
|
||||
},
|
||||
breakdowns: {
|
||||
suiteTags: aggregateCaseBreakdown(allCases, (entry) => entry.tags),
|
||||
failureModes: aggregateCaseBreakdown(
|
||||
allCases,
|
||||
(entry) => entry.failureModes,
|
||||
),
|
||||
},
|
||||
suites,
|
||||
};
|
||||
}
|
||||
|
||||
function renderText(summary) {
|
||||
const lines = [
|
||||
`[harness-eval] manifest: ${summary.title} (${summary.manifestVersion})`,
|
||||
`[harness-eval] workspace: ${summary.workspaceRoot}`,
|
||||
`[harness-eval] suites: ${summary.totals.suiteCount}`,
|
||||
`[harness-eval] cases : ${summary.totals.caseCount}`,
|
||||
`[harness-eval] ready : ${summary.totals.readyCount}`,
|
||||
`[harness-eval] invalid: ${summary.totals.invalidCount}`,
|
||||
`[harness-eval] pending-request cases: ${summary.totals.pendingRequestCaseCount}`,
|
||||
`[harness-eval] needs-review cases : ${summary.totals.needsHumanReviewCount}`,
|
||||
];
|
||||
|
||||
const topFailureModes = summary.breakdowns.failureModes.slice(0, 5);
|
||||
if (topFailureModes.length > 0) {
|
||||
lines.push("[harness-eval] top failure modes:");
|
||||
for (const entry of topFailureModes) {
|
||||
lines.push(
|
||||
` - ${entry.name}: case=${entry.caseCount}, invalid=${entry.invalidCount}, pending=${entry.pendingRequestCaseCount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const topSuiteTags = summary.breakdowns.suiteTags.slice(0, 5);
|
||||
if (topSuiteTags.length > 0) {
|
||||
lines.push("[harness-eval] top suite tags:");
|
||||
for (const entry of topSuiteTags) {
|
||||
lines.push(
|
||||
` - ${entry.name}: case=${entry.caseCount}, ready=${entry.readyCount}, invalid=${entry.invalidCount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const suite of summary.suites) {
|
||||
lines.push(
|
||||
`[harness-eval] suite ${suite.id}: ready ${suite.stats.readyCount} / ${suite.stats.caseCount}`,
|
||||
);
|
||||
for (const entry of suite.cases) {
|
||||
lines.push(
|
||||
` - ${entry.caseId} [${entry.status}] (${entry.source}) ${entry.relativeCaseDir}`,
|
||||
);
|
||||
if (entry.tags.length > 0) {
|
||||
lines.push(` tags: ${entry.tags.join(", ")}`);
|
||||
}
|
||||
if (entry.failureModes.length > 0) {
|
||||
lines.push(` failure_modes: ${entry.failureModes.join(", ")}`);
|
||||
}
|
||||
for (const issue of entry.issues) {
|
||||
lines.push(` * ${issue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function renderMarkdown(summary) {
|
||||
const lines = [
|
||||
"# Lime Harness Eval Summary",
|
||||
"",
|
||||
`- 生成时间:${summary.generatedAt}`,
|
||||
`- manifest:${summary.title} (${summary.manifestVersion})`,
|
||||
`- 工作区:\`${summary.workspaceRoot}\``,
|
||||
`- suite 数:${summary.totals.suiteCount}`,
|
||||
`- case 数:${summary.totals.caseCount}`,
|
||||
`- ready:${summary.totals.readyCount}`,
|
||||
`- invalid:${summary.totals.invalidCount}`,
|
||||
`- pending request case:${summary.totals.pendingRequestCaseCount}`,
|
||||
`- needs review case:${summary.totals.needsHumanReviewCount}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (summary.breakdowns.failureModes.length > 0) {
|
||||
lines.push("## Failure Mode 分布");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"| Failure Mode | case | invalid | pending_request | needs_review |",
|
||||
);
|
||||
lines.push("| --- | --- | --- | --- | --- |");
|
||||
for (const entry of summary.breakdowns.failureModes) {
|
||||
lines.push(
|
||||
`| ${entry.name} | ${entry.caseCount} | ${entry.invalidCount} | ${entry.pendingRequestCaseCount} | ${entry.needsHumanReviewCount} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (summary.breakdowns.suiteTags.length > 0) {
|
||||
lines.push("## Suite Tag 分布");
|
||||
lines.push("");
|
||||
lines.push("| Suite Tag | case | ready | invalid |");
|
||||
lines.push("| --- | --- | --- | --- |");
|
||||
for (const entry of summary.breakdowns.suiteTags) {
|
||||
lines.push(
|
||||
`| ${entry.name} | ${entry.caseCount} | ${entry.readyCount} | ${entry.invalidCount} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
for (const suite of summary.suites) {
|
||||
lines.push(`## ${suite.title}`);
|
||||
lines.push("");
|
||||
if (suite.description) {
|
||||
lines.push(suite.description);
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(`- ` + `suite_id:\`${suite.id}\``);
|
||||
if (suite.priority) {
|
||||
lines.push(`- 优先级:${suite.priority}`);
|
||||
}
|
||||
if (suite.roadmap) {
|
||||
lines.push(`- 路线图:${suite.roadmap}`);
|
||||
}
|
||||
lines.push(
|
||||
`- ready / total:${suite.stats.readyCount} / ${suite.stats.caseCount}`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("| Case | 状态 | 来源 | 分类 | 目录 | 问题 |");
|
||||
lines.push("| --- | --- | --- | --- | --- | --- |");
|
||||
for (const entry of suite.cases) {
|
||||
const issueText =
|
||||
entry.issues.length === 0 ? "无" : entry.issues.join("<br>");
|
||||
const classificationText = [];
|
||||
if (entry.tags.length > 0) {
|
||||
classificationText.push(`tags: ${entry.tags.join(", ")}`);
|
||||
}
|
||||
if (entry.failureModes.length > 0) {
|
||||
classificationText.push(`failure: ${entry.failureModes.join(", ")}`);
|
||||
}
|
||||
if (entry.primaryBlockingKind) {
|
||||
classificationText.push(`blocking: ${entry.primaryBlockingKind}`);
|
||||
}
|
||||
lines.push(
|
||||
`| ${entry.caseId} | ${entry.status} | ${entry.source} | ${classificationText.join("<br>") || "无"} | \`${entry.relativeCaseDir || "."}\` | ${issueText} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function determineExitCode(summary, options) {
|
||||
if (!options.strict) {
|
||||
return 0;
|
||||
}
|
||||
return summary.totals.invalidCount > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const manifestPath = resolvePath(repoRoot, options.manifest);
|
||||
const manifest = readJsonFile(manifestPath);
|
||||
const defaults = manifest.defaults ?? {};
|
||||
const suiteConfigs = Array.isArray(manifest.suites) ? manifest.suites : [];
|
||||
const suites = suiteConfigs.map((suiteConfig) =>
|
||||
expandSuiteCases(
|
||||
suiteConfig,
|
||||
defaults,
|
||||
repoRoot,
|
||||
path.resolve(options.workspaceRoot),
|
||||
),
|
||||
);
|
||||
|
||||
const summary = buildSummary(manifest, suites, options);
|
||||
const jsonOutput = `${JSON.stringify(summary, null, 2)}\n`;
|
||||
const markdownOutput = renderMarkdown(summary);
|
||||
const textOutput = renderText(summary);
|
||||
|
||||
if (options.outputJson) {
|
||||
const outputPath = resolvePath(repoRoot, options.outputJson);
|
||||
ensureParentDirectory(outputPath);
|
||||
fs.writeFileSync(outputPath, jsonOutput, "utf8");
|
||||
}
|
||||
|
||||
if (options.outputMarkdown) {
|
||||
const outputPath = resolvePath(repoRoot, options.outputMarkdown);
|
||||
ensureParentDirectory(outputPath);
|
||||
fs.writeFileSync(outputPath, markdownOutput, "utf8");
|
||||
}
|
||||
|
||||
if (options.format === "json") {
|
||||
process.stdout.write(jsonOutput);
|
||||
} else if (options.format === "markdown") {
|
||||
process.stdout.write(markdownOutput);
|
||||
} else {
|
||||
process.stdout.write(textOutput);
|
||||
}
|
||||
|
||||
const exitCode = determineExitCode(summary, options);
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const RUNNER_PATH = "scripts/harness-eval-runner.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
format: "text",
|
||||
help: false,
|
||||
historyDir: "",
|
||||
inputs: [],
|
||||
outputJson: "",
|
||||
outputMarkdown: "",
|
||||
workspaceRoot: process.cwd(),
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--input" && argv[index + 1]) {
|
||||
result.inputs.push(String(argv[index + 1]).trim());
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--history-dir" && argv[index + 1]) {
|
||||
result.historyDir = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--workspace-root" && argv[index + 1]) {
|
||||
result.workspaceRoot = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--format" && argv[index + 1]) {
|
||||
result.format = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--output-json" && argv[index + 1]) {
|
||||
result.outputJson = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--output-markdown" && argv[index + 1]) {
|
||||
result.outputMarkdown = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Harness Eval Trend Report
|
||||
|
||||
用法:
|
||||
node scripts/harness-eval-trend-report.mjs
|
||||
node scripts/harness-eval-trend-report.mjs --input "./tmp/harness-eval-summary.json"
|
||||
node scripts/harness-eval-trend-report.mjs --history-dir "./artifacts/history"
|
||||
node scripts/harness-eval-trend-report.mjs --output-json "./tmp/harness-eval-trend.json" --output-markdown "./tmp/harness-eval-trend.md"
|
||||
|
||||
选项:
|
||||
--input PATH 显式加入一个或多个 harness eval summary JSON
|
||||
--history-dir PATH 扫描目录下的历史 summary JSON
|
||||
--workspace-root PATH 未提供输入时,用该工作区生成当前 summary
|
||||
--format FMT 标准输出格式:text | json | markdown
|
||||
--output-json PATH 将 JSON 趋势报告写入指定路径
|
||||
--output-markdown PATH 将 Markdown 趋势报告写入指定路径
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function resolvePath(baseDir, relativePath) {
|
||||
return path.resolve(baseDir, relativePath);
|
||||
}
|
||||
|
||||
function ensureParentDirectory(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function collectJsonFiles(rootPath) {
|
||||
if (!rootPath || !fs.existsSync(rootPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const pending = [rootPath];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
const stat = fs.statSync(current);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
pending.push(path.join(current, entry.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isFile() && current.endsWith(".json")) {
|
||||
files.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function isHarnessEvalSummary(candidate) {
|
||||
return (
|
||||
candidate != null &&
|
||||
typeof candidate === "object" &&
|
||||
typeof candidate.generatedAt === "string" &&
|
||||
candidate.totals != null &&
|
||||
typeof candidate.totals.caseCount === "number" &&
|
||||
typeof candidate.totals.readyCount === "number" &&
|
||||
typeof candidate.totals.invalidCount === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function buildCurrentSummary(repoRoot, workspaceRoot) {
|
||||
const nodeCommand = process.execPath;
|
||||
const runnerPath = resolvePath(repoRoot, RUNNER_PATH);
|
||||
const output = execFileSync(
|
||||
nodeCommand,
|
||||
[runnerPath, "--format", "json", "--workspace-root", workspaceRoot],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
},
|
||||
);
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function computeReadyRate(summary) {
|
||||
const caseCount = normalizeNumber(summary?.totals?.caseCount);
|
||||
if (caseCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return normalizeNumber(summary?.totals?.readyCount) / caseCount;
|
||||
}
|
||||
|
||||
function getSuiteMap(summary) {
|
||||
const suites = Array.isArray(summary?.suites) ? summary.suites : [];
|
||||
return new Map(
|
||||
suites.map((suite) => [
|
||||
String(suite.id ?? ""),
|
||||
{
|
||||
id: String(suite.id ?? ""),
|
||||
title: String(suite.title ?? ""),
|
||||
caseCount: normalizeNumber(suite?.stats?.caseCount),
|
||||
readyCount: normalizeNumber(suite?.stats?.readyCount),
|
||||
invalidCount: normalizeNumber(suite?.stats?.invalidCount),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function getBreakdownMap(summary, key) {
|
||||
const entries = Array.isArray(summary?.breakdowns?.[key])
|
||||
? summary.breakdowns[key]
|
||||
: [];
|
||||
return new Map(
|
||||
entries.map((entry) => [
|
||||
String(entry.name ?? ""),
|
||||
{
|
||||
name: String(entry.name ?? ""),
|
||||
caseCount: normalizeNumber(entry.caseCount),
|
||||
readyCount: normalizeNumber(entry.readyCount),
|
||||
invalidCount: normalizeNumber(entry.invalidCount),
|
||||
pendingRequestCaseCount: normalizeNumber(entry.pendingRequestCaseCount),
|
||||
needsHumanReviewCount: normalizeNumber(entry.needsHumanReviewCount),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSuiteDeltas(baseline, latest) {
|
||||
const baselineSuites = getSuiteMap(baseline);
|
||||
const latestSuites = getSuiteMap(latest);
|
||||
const suiteIds = new Set([...baselineSuites.keys(), ...latestSuites.keys()]);
|
||||
|
||||
return Array.from(suiteIds)
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((suiteId) => {
|
||||
const baselineSuite = baselineSuites.get(suiteId) ?? {
|
||||
id: suiteId,
|
||||
title: suiteId,
|
||||
caseCount: 0,
|
||||
readyCount: 0,
|
||||
invalidCount: 0,
|
||||
};
|
||||
const latestSuite = latestSuites.get(suiteId) ?? {
|
||||
id: suiteId,
|
||||
title: baselineSuite.title,
|
||||
caseCount: 0,
|
||||
readyCount: 0,
|
||||
invalidCount: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
id: suiteId,
|
||||
title: latestSuite.title || baselineSuite.title || suiteId,
|
||||
baseline: baselineSuite,
|
||||
latest: latestSuite,
|
||||
delta: {
|
||||
caseCount: latestSuite.caseCount - baselineSuite.caseCount,
|
||||
readyCount: latestSuite.readyCount - baselineSuite.readyCount,
|
||||
invalidCount: latestSuite.invalidCount - baselineSuite.invalidCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildBreakdownDeltas(baseline, latest, key) {
|
||||
const baselineMap = getBreakdownMap(baseline, key);
|
||||
const latestMap = getBreakdownMap(latest, key);
|
||||
const names = new Set([...baselineMap.keys(), ...latestMap.keys()]);
|
||||
|
||||
return Array.from(names)
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((name) => {
|
||||
const baselineEntry = baselineMap.get(name) ?? {
|
||||
name,
|
||||
caseCount: 0,
|
||||
readyCount: 0,
|
||||
invalidCount: 0,
|
||||
pendingRequestCaseCount: 0,
|
||||
needsHumanReviewCount: 0,
|
||||
};
|
||||
const latestEntry = latestMap.get(name) ?? {
|
||||
name,
|
||||
caseCount: 0,
|
||||
readyCount: 0,
|
||||
invalidCount: 0,
|
||||
pendingRequestCaseCount: 0,
|
||||
needsHumanReviewCount: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
baseline: baselineEntry,
|
||||
latest: latestEntry,
|
||||
delta: {
|
||||
caseCount: latestEntry.caseCount - baselineEntry.caseCount,
|
||||
readyCount: latestEntry.readyCount - baselineEntry.readyCount,
|
||||
invalidCount: latestEntry.invalidCount - baselineEntry.invalidCount,
|
||||
pendingRequestCaseCount:
|
||||
latestEntry.pendingRequestCaseCount -
|
||||
baselineEntry.pendingRequestCaseCount,
|
||||
needsHumanReviewCount:
|
||||
latestEntry.needsHumanReviewCount -
|
||||
baselineEntry.needsHumanReviewCount,
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const invalidDeltaDiff =
|
||||
Math.abs(right.delta.invalidCount) - Math.abs(left.delta.invalidCount);
|
||||
if (invalidDeltaDiff !== 0) {
|
||||
return invalidDeltaDiff;
|
||||
}
|
||||
const caseDeltaDiff =
|
||||
Math.abs(right.delta.caseCount) - Math.abs(left.delta.caseCount);
|
||||
if (caseDeltaDiff !== 0) {
|
||||
return caseDeltaDiff;
|
||||
}
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}
|
||||
|
||||
function buildStatusSignals(baseline, latest, sampleCount) {
|
||||
const signals = [];
|
||||
|
||||
if (sampleCount < 2) {
|
||||
signals.push("样本数不足 2,当前仅形成 trend seed,还不能判断长期退化。");
|
||||
return signals;
|
||||
}
|
||||
|
||||
const readyRateDelta = computeReadyRate(latest) - computeReadyRate(baseline);
|
||||
const invalidDelta =
|
||||
normalizeNumber(latest?.totals?.invalidCount) -
|
||||
normalizeNumber(baseline?.totals?.invalidCount);
|
||||
const pendingDelta =
|
||||
normalizeNumber(latest?.totals?.pendingRequestCaseCount) -
|
||||
normalizeNumber(baseline?.totals?.pendingRequestCaseCount);
|
||||
|
||||
if (invalidDelta > 0) {
|
||||
signals.push(`invalid case 增加 ${invalidDelta},存在回归候选。`);
|
||||
}
|
||||
|
||||
if (readyRateDelta < 0) {
|
||||
signals.push(
|
||||
`ready rate 下降 ${(Math.abs(readyRateDelta) * 100).toFixed(1)}%,需检查最近样本或字段漂移。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingDelta > 0) {
|
||||
signals.push(
|
||||
`pending request case 增加 ${pendingDelta},需确认是否属于真实阻塞还是样本结构变化。`,
|
||||
);
|
||||
}
|
||||
|
||||
const failureModeDeltas = buildBreakdownDeltas(
|
||||
baseline,
|
||||
latest,
|
||||
"failureModes",
|
||||
);
|
||||
const increasedInvalidFailureMode = failureModeDeltas.find(
|
||||
(entry) => entry.delta.invalidCount > 0,
|
||||
);
|
||||
if (increasedInvalidFailureMode) {
|
||||
signals.push(
|
||||
`failure mode \`${increasedInvalidFailureMode.name}\` 的 invalid case 增加 ${increasedInvalidFailureMode.delta.invalidCount}。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (signals.length === 0) {
|
||||
signals.push("当前没有检测到明显退化信号。");
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
function buildTrendReport(samples, repoRoot) {
|
||||
const sortedSamples = [...samples].sort((left, right) => {
|
||||
const leftTime = Date.parse(left.summary.generatedAt);
|
||||
const rightTime = Date.parse(right.summary.generatedAt);
|
||||
if (
|
||||
Number.isFinite(leftTime) &&
|
||||
Number.isFinite(rightTime) &&
|
||||
leftTime !== rightTime
|
||||
) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return left.summary.generatedAt.localeCompare(right.summary.generatedAt);
|
||||
});
|
||||
|
||||
const baselineEntry = sortedSamples[0];
|
||||
const latestEntry = sortedSamples[sortedSamples.length - 1];
|
||||
const baseline = baselineEntry.summary;
|
||||
const latest = latestEntry.summary;
|
||||
const readyRateDelta = computeReadyRate(latest) - computeReadyRate(baseline);
|
||||
|
||||
return {
|
||||
reportVersion: "v1",
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoRoot,
|
||||
sampleCount: sortedSamples.length,
|
||||
baseline: {
|
||||
generatedAt: baseline.generatedAt,
|
||||
sourcePath: baselineEntry.sourcePath,
|
||||
totals: baseline.totals,
|
||||
},
|
||||
latest: {
|
||||
generatedAt: latest.generatedAt,
|
||||
sourcePath: latestEntry.sourcePath,
|
||||
totals: latest.totals,
|
||||
},
|
||||
delta: {
|
||||
suiteCount:
|
||||
normalizeNumber(latest?.totals?.suiteCount) -
|
||||
normalizeNumber(baseline?.totals?.suiteCount),
|
||||
caseCount:
|
||||
normalizeNumber(latest?.totals?.caseCount) -
|
||||
normalizeNumber(baseline?.totals?.caseCount),
|
||||
readyCount:
|
||||
normalizeNumber(latest?.totals?.readyCount) -
|
||||
normalizeNumber(baseline?.totals?.readyCount),
|
||||
invalidCount:
|
||||
normalizeNumber(latest?.totals?.invalidCount) -
|
||||
normalizeNumber(baseline?.totals?.invalidCount),
|
||||
pendingRequestCaseCount:
|
||||
normalizeNumber(latest?.totals?.pendingRequestCaseCount) -
|
||||
normalizeNumber(baseline?.totals?.pendingRequestCaseCount),
|
||||
needsHumanReviewCount:
|
||||
normalizeNumber(latest?.totals?.needsHumanReviewCount) -
|
||||
normalizeNumber(baseline?.totals?.needsHumanReviewCount),
|
||||
readyRate: readyRateDelta,
|
||||
},
|
||||
signals: buildStatusSignals(baseline, latest, sortedSamples.length),
|
||||
samples: sortedSamples.map((entry) => ({
|
||||
generatedAt: entry.summary.generatedAt,
|
||||
sourcePath: entry.sourcePath,
|
||||
totals: entry.summary.totals,
|
||||
})),
|
||||
suiteDeltas: buildSuiteDeltas(baseline, latest),
|
||||
classificationDeltas: {
|
||||
suiteTags: buildBreakdownDeltas(baseline, latest, "suiteTags"),
|
||||
failureModes: buildBreakdownDeltas(baseline, latest, "failureModes"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderText(report) {
|
||||
const lines = [
|
||||
`[harness-eval-trend] samples: ${report.sampleCount}`,
|
||||
`[harness-eval-trend] baseline: ${report.baseline.generatedAt}`,
|
||||
`[harness-eval-trend] latest : ${report.latest.generatedAt}`,
|
||||
`[harness-eval-trend] delta caseCount: ${report.delta.caseCount}`,
|
||||
`[harness-eval-trend] delta readyCount: ${report.delta.readyCount}`,
|
||||
`[harness-eval-trend] delta invalidCount: ${report.delta.invalidCount}`,
|
||||
`[harness-eval-trend] delta pendingRequestCaseCount: ${report.delta.pendingRequestCaseCount}`,
|
||||
`[harness-eval-trend] delta readyRate: ${(report.delta.readyRate * 100).toFixed(1)}%`,
|
||||
];
|
||||
|
||||
for (const signal of report.signals) {
|
||||
lines.push(`[harness-eval-trend] signal: ${signal}`);
|
||||
}
|
||||
|
||||
const topFailureModeDeltas = report.classificationDeltas.failureModes.slice(
|
||||
0,
|
||||
5,
|
||||
);
|
||||
if (topFailureModeDeltas.length > 0) {
|
||||
lines.push("[harness-eval-trend] top failure mode deltas:");
|
||||
for (const entry of topFailureModeDeltas) {
|
||||
lines.push(
|
||||
` - ${entry.name}: delta_case=${entry.delta.caseCount}, delta_invalid=${entry.delta.invalidCount}, delta_pending=${entry.delta.pendingRequestCaseCount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const lines = [
|
||||
"# Lime Harness Eval Trend",
|
||||
"",
|
||||
`- 生成时间:${report.generatedAt}`,
|
||||
`- 样本数:${report.sampleCount}`,
|
||||
`- baseline:${report.baseline.generatedAt}`,
|
||||
`- latest:${report.latest.generatedAt}`,
|
||||
"",
|
||||
"## 核心变化",
|
||||
"",
|
||||
`- suite 数变化:${report.delta.suiteCount}`,
|
||||
`- case 数变化:${report.delta.caseCount}`,
|
||||
`- ready 数变化:${report.delta.readyCount}`,
|
||||
`- invalid 数变化:${report.delta.invalidCount}`,
|
||||
`- pending request case 变化:${report.delta.pendingRequestCaseCount}`,
|
||||
`- needs review case 变化:${report.delta.needsHumanReviewCount}`,
|
||||
`- ready rate 变化:${(report.delta.readyRate * 100).toFixed(1)}%`,
|
||||
"",
|
||||
"## 信号",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const signal of report.signals) {
|
||||
lines.push(`- ${signal}`);
|
||||
}
|
||||
|
||||
if (report.classificationDeltas.failureModes.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("## Failure Mode 变化");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"| Failure Mode | baseline case | latest case | delta case | delta invalid | delta pending_request |",
|
||||
);
|
||||
lines.push("| --- | --- | --- | --- | --- | --- |");
|
||||
for (const entry of report.classificationDeltas.failureModes) {
|
||||
lines.push(
|
||||
`| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.delta.invalidCount} | ${entry.delta.pendingRequestCaseCount} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.classificationDeltas.suiteTags.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("## Suite Tag 变化");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"| Suite Tag | baseline case | latest case | delta case | delta invalid |",
|
||||
);
|
||||
lines.push("| --- | --- | --- | --- | --- |");
|
||||
for (const entry of report.classificationDeltas.suiteTags) {
|
||||
lines.push(
|
||||
`| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.delta.invalidCount} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## 时间线样本");
|
||||
lines.push("");
|
||||
lines.push("| 时间 | 来源 | case | ready | invalid | pending_request |");
|
||||
lines.push("| --- | --- | --- | --- | --- | --- |");
|
||||
for (const sample of report.samples) {
|
||||
lines.push(
|
||||
`| ${sample.generatedAt} | \`${sample.sourcePath}\` | ${sample.totals.caseCount} | ${sample.totals.readyCount} | ${sample.totals.invalidCount} | ${sample.totals.pendingRequestCaseCount} |`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Suite 变化");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"| Suite | baseline ready/total | latest ready/total | invalid delta |",
|
||||
);
|
||||
lines.push("| --- | --- | --- | --- |");
|
||||
for (const suite of report.suiteDeltas) {
|
||||
lines.push(
|
||||
`| ${suite.title} | ${suite.baseline.readyCount}/${suite.baseline.caseCount} | ${suite.latest.readyCount}/${suite.latest.caseCount} | ${suite.delta.invalidCount} |`,
|
||||
);
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function loadSamples(options, repoRoot) {
|
||||
const sampleEntries = [];
|
||||
const seenFingerprints = new Set();
|
||||
|
||||
const candidateFiles = [];
|
||||
for (const input of options.inputs) {
|
||||
candidateFiles.push(resolvePath(repoRoot, input));
|
||||
}
|
||||
if (options.historyDir) {
|
||||
candidateFiles.push(
|
||||
...collectJsonFiles(resolvePath(repoRoot, options.historyDir)),
|
||||
);
|
||||
}
|
||||
|
||||
for (const filePath of candidateFiles) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = readJsonFile(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isHarnessEvalSummary(parsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fingerprint = JSON.stringify([
|
||||
parsed.generatedAt,
|
||||
parsed.totals.caseCount,
|
||||
parsed.totals.readyCount,
|
||||
parsed.totals.invalidCount,
|
||||
parsed.totals.pendingRequestCaseCount,
|
||||
]);
|
||||
if (seenFingerprints.has(fingerprint)) {
|
||||
continue;
|
||||
}
|
||||
seenFingerprints.add(fingerprint);
|
||||
|
||||
sampleEntries.push({
|
||||
sourcePath: path.relative(repoRoot, filePath) || ".",
|
||||
summary: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
if (sampleEntries.length === 0) {
|
||||
const currentSummary = buildCurrentSummary(
|
||||
repoRoot,
|
||||
path.resolve(options.workspaceRoot),
|
||||
);
|
||||
sampleEntries.push({
|
||||
sourcePath: "(generated-current-summary)",
|
||||
summary: currentSummary,
|
||||
});
|
||||
}
|
||||
|
||||
return sampleEntries;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const samples = loadSamples(options, repoRoot);
|
||||
const report = buildTrendReport(samples, repoRoot);
|
||||
const jsonOutput = `${JSON.stringify(report, null, 2)}\n`;
|
||||
const markdownOutput = renderMarkdown(report);
|
||||
const textOutput = renderText(report);
|
||||
|
||||
if (options.outputJson) {
|
||||
const outputPath = resolvePath(repoRoot, options.outputJson);
|
||||
ensureParentDirectory(outputPath);
|
||||
fs.writeFileSync(outputPath, jsonOutput, "utf8");
|
||||
}
|
||||
|
||||
if (options.outputMarkdown) {
|
||||
const outputPath = resolvePath(repoRoot, options.outputMarkdown);
|
||||
ensureParentDirectory(outputPath);
|
||||
fs.writeFileSync(outputPath, markdownOutput, "utf8");
|
||||
}
|
||||
|
||||
if (options.format === "json") {
|
||||
process.stdout.write(jsonOutput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.format === "markdown") {
|
||||
process.stdout.write(markdownOutput);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(textOutput);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULT_MANIFEST_PATH = "docs/test/harness-evals.manifest.json";
|
||||
const DEFAULT_FIXTURES_ROOT = "docs/test/harness-fixtures/replay";
|
||||
const DEFAULT_SUITE_ID = "repo-promoted-replays";
|
||||
const DEFAULT_SANITIZED_WORKSPACE_ROOT = "/workspace/lime";
|
||||
const REQUIRED_ARTIFACTS = [
|
||||
"input.json",
|
||||
"expected.json",
|
||||
"grader.md",
|
||||
"evidence-links.json",
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
caseId: "",
|
||||
dryRun: false,
|
||||
fixturesRoot: DEFAULT_FIXTURES_ROOT,
|
||||
format: "text",
|
||||
help: false,
|
||||
manifest: DEFAULT_MANIFEST_PATH,
|
||||
replace: false,
|
||||
replayDir: "",
|
||||
sanitizedWorkspaceRoot: DEFAULT_SANITIZED_WORKSPACE_ROOT,
|
||||
sessionId: "",
|
||||
slug: "",
|
||||
suiteId: DEFAULT_SUITE_ID,
|
||||
title: "",
|
||||
workspaceRoot: process.cwd(),
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--session-id" && argv[index + 1]) {
|
||||
result.sessionId = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--replay-dir" && argv[index + 1]) {
|
||||
result.replayDir = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--workspace-root" && argv[index + 1]) {
|
||||
result.workspaceRoot = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--manifest" && argv[index + 1]) {
|
||||
result.manifest = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--fixtures-root" && argv[index + 1]) {
|
||||
result.fixturesRoot = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--suite-id" && argv[index + 1]) {
|
||||
result.suiteId = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--slug" && argv[index + 1]) {
|
||||
result.slug = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--case-id" && argv[index + 1]) {
|
||||
result.caseId = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--title" && argv[index + 1]) {
|
||||
result.title = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--sanitized-workspace-root" && argv[index + 1]) {
|
||||
result.sanitizedWorkspaceRoot = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--format" && argv[index + 1]) {
|
||||
result.format = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--replace") {
|
||||
result.replace = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--dry-run") {
|
||||
result.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Harness Replay Promote
|
||||
|
||||
用法:
|
||||
node scripts/harness-replay-promote.mjs --session-id "session-123" --slug "pending-request-runtime"
|
||||
node scripts/harness-replay-promote.mjs --replay-dir ".lime/harness/sessions/session-123/replay" --slug "pending-request-runtime"
|
||||
|
||||
选项:
|
||||
--session-id ID 从 <workspace>/.lime/harness/sessions/<id>/replay 提升
|
||||
--replay-dir PATH 直接指定 replay 目录;与 --session-id 二选一
|
||||
--workspace-root PATH 工作区根目录,默认当前目录
|
||||
--manifest PATH manifest 路径,默认 docs/test/harness-evals.manifest.json
|
||||
--fixtures-root PATH 目标 fixture 根目录,默认 docs/test/harness-fixtures/replay
|
||||
--suite-id ID 目标 suite,默认 repo-promoted-replays
|
||||
--slug NAME 目标目录名;未提供时会从 sessionId 推导
|
||||
--case-id ID manifest 中的 case id;默认 repo-promoted-<slug>
|
||||
--title TEXT manifest 中的 case 标题;默认用 goal summary 推导
|
||||
--sanitized-workspace-root PATH 写入仓库样本时替换绝对工作区路径,默认 /workspace/lime
|
||||
--replace 已存在同名 case / 目录时覆盖
|
||||
--dry-run 只预览,不写文件
|
||||
--format FMT 标准输出格式:text | json
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function writeJsonFile(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function resolvePath(baseDir, targetPath) {
|
||||
return path.resolve(baseDir, targetPath);
|
||||
}
|
||||
|
||||
function toPortablePath(value) {
|
||||
return String(value).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function mergeUniqueStrings(...groups) {
|
||||
return [...new Set(groups.flatMap((group) => normalizeStringList(group)))];
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function deriveReplayDirectory(options, workspaceRoot) {
|
||||
if (options.replayDir) {
|
||||
return resolvePath(process.cwd(), options.replayDir);
|
||||
}
|
||||
|
||||
if (!options.sessionId) {
|
||||
throw new Error("必须提供 --session-id 或 --replay-dir。");
|
||||
}
|
||||
|
||||
return path.join(
|
||||
workspaceRoot,
|
||||
".lime",
|
||||
"harness",
|
||||
"sessions",
|
||||
options.sessionId,
|
||||
"replay",
|
||||
);
|
||||
}
|
||||
|
||||
function validateReplayDirectory(replayDir) {
|
||||
if (!fs.existsSync(replayDir) || !fs.statSync(replayDir).isDirectory()) {
|
||||
throw new Error(`replay 目录不存在: ${replayDir}`);
|
||||
}
|
||||
|
||||
const missing = REQUIRED_ARTIFACTS.filter(
|
||||
(artifact) => !fs.existsSync(path.join(replayDir, artifact)),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`replay 目录缺少文件: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function deriveSlug(options, inputPayload, fallbackSessionId) {
|
||||
if (options.slug) {
|
||||
return slugify(options.slug);
|
||||
}
|
||||
|
||||
const derivedFromGoal = slugify(
|
||||
inputPayload?.task?.goalSummary ??
|
||||
inputPayload?.classification?.primaryBlockingKind ??
|
||||
"",
|
||||
);
|
||||
if (derivedFromGoal) {
|
||||
return derivedFromGoal;
|
||||
}
|
||||
|
||||
const derivedFromSession = slugify(fallbackSessionId);
|
||||
if (derivedFromSession) {
|
||||
return derivedFromSession;
|
||||
}
|
||||
|
||||
return "promoted-replay-case";
|
||||
}
|
||||
|
||||
function deriveCaseId(options, slug) {
|
||||
return options.caseId || `repo-promoted-${slug}`;
|
||||
}
|
||||
|
||||
function deriveTitle(options, inputPayload, expectedPayload, sessionId) {
|
||||
if (options.title) {
|
||||
return options.title;
|
||||
}
|
||||
|
||||
const goalSummary =
|
||||
inputPayload?.task?.goalSummary ?? expectedPayload?.goalSummary ?? "";
|
||||
if (typeof goalSummary === "string" && goalSummary.trim().length > 0) {
|
||||
return goalSummary.trim();
|
||||
}
|
||||
|
||||
return `工作区 Replay 沉淀 / ${sessionId}`;
|
||||
}
|
||||
|
||||
function replaceWorkspaceRootInString(value, workspaceRoot, placeholder) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let nextValue = value;
|
||||
const rawRoot = String(workspaceRoot);
|
||||
const portableRoot = toPortablePath(rawRoot);
|
||||
|
||||
if (rawRoot) {
|
||||
nextValue = nextValue.replaceAll(rawRoot, placeholder);
|
||||
}
|
||||
if (portableRoot && portableRoot !== rawRoot) {
|
||||
nextValue = nextValue.replaceAll(portableRoot, placeholder);
|
||||
}
|
||||
|
||||
if (nextValue.includes(placeholder) && nextValue.includes("\\")) {
|
||||
nextValue = nextValue.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
function sanitizePayload(value, workspaceRoot, placeholder) {
|
||||
if (typeof value === "string") {
|
||||
return replaceWorkspaceRootInString(value, workspaceRoot, placeholder);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) =>
|
||||
sanitizePayload(entry, workspaceRoot, placeholder),
|
||||
);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entryValue]) => [
|
||||
key,
|
||||
sanitizePayload(entryValue, workspaceRoot, placeholder),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function getRelativeIfInside(rootPath, absolutePath) {
|
||||
const relativePath = path.relative(rootPath, absolutePath);
|
||||
if (
|
||||
relativePath.startsWith("..") ||
|
||||
path.isAbsolute(relativePath) ||
|
||||
relativePath === ""
|
||||
) {
|
||||
return relativePath === "" ? "." : null;
|
||||
}
|
||||
return toPortablePath(relativePath);
|
||||
}
|
||||
|
||||
function buildPromotionMetadata({
|
||||
promotedAt,
|
||||
replayDir,
|
||||
sessionId,
|
||||
workspaceRoot,
|
||||
sanitizedWorkspaceRoot,
|
||||
}) {
|
||||
const replayRelativeDir = getRelativeIfInside(workspaceRoot, replayDir);
|
||||
const metadata = {
|
||||
promotedAt,
|
||||
promotedBy: "scripts/harness-replay-promote.mjs",
|
||||
sanitizedWorkspaceRoot,
|
||||
sourceSessionId: sessionId,
|
||||
};
|
||||
|
||||
if (replayRelativeDir && replayRelativeDir !== ".") {
|
||||
metadata.sourceReplayDir = replayRelativeDir;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function appendPromotionSection(graderMarkdown, promotionMetadata) {
|
||||
if (graderMarkdown.includes("## 仓库沉淀说明")) {
|
||||
return graderMarkdown;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
graderMarkdown.trimEnd(),
|
||||
"",
|
||||
"## 仓库沉淀说明",
|
||||
"",
|
||||
`- 提升时间:${promotionMetadata.promotedAt}`,
|
||||
`- 来源会话:\`${promotionMetadata.sourceSessionId}\``,
|
||||
`- 脱敏工作区根:\`${promotionMetadata.sanitizedWorkspaceRoot}\``,
|
||||
];
|
||||
|
||||
if (promotionMetadata.sourceReplayDir) {
|
||||
lines.push(`- 来源 replay 目录:\`${promotionMetadata.sourceReplayDir}\``);
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function loadSuite(manifestPayload, suiteId) {
|
||||
const suites = Array.isArray(manifestPayload.suites) ? manifestPayload.suites : [];
|
||||
const suiteIndex = suites.findIndex(
|
||||
(suite) => String(suite.id ?? "").trim() === suiteId,
|
||||
);
|
||||
if (suiteIndex === -1) {
|
||||
throw new Error(`manifest 中未找到目标 suite: ${suiteId}`);
|
||||
}
|
||||
return {
|
||||
suite: suites[suiteIndex],
|
||||
suiteIndex,
|
||||
suites,
|
||||
};
|
||||
}
|
||||
|
||||
function buildManifestCaseEntry({
|
||||
caseId,
|
||||
caseTitle,
|
||||
inputPayload,
|
||||
targetCaseDirValue,
|
||||
}) {
|
||||
return {
|
||||
id: caseId,
|
||||
title: caseTitle,
|
||||
source: "repo_fixture",
|
||||
caseDir: targetCaseDirValue,
|
||||
tags: mergeUniqueStrings(
|
||||
["repo-promoted"],
|
||||
inputPayload?.classification?.suiteTags,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function updateManifestCase({
|
||||
manifestPath,
|
||||
suiteId,
|
||||
caseEntry,
|
||||
replace,
|
||||
targetCaseDirValue,
|
||||
}) {
|
||||
const manifestPayload = readJsonFile(manifestPath);
|
||||
const { suite } = loadSuite(manifestPayload, suiteId);
|
||||
const cases = Array.isArray(suite.cases) ? [...suite.cases] : [];
|
||||
const normalizedTargetDir = toPortablePath(targetCaseDirValue);
|
||||
|
||||
const existingIndex = cases.findIndex((entry) => {
|
||||
const caseId = String(entry.id ?? "").trim();
|
||||
const caseDir = toPortablePath(String(entry.caseDir ?? "").trim());
|
||||
return caseId === caseEntry.id || caseDir === normalizedTargetDir;
|
||||
});
|
||||
|
||||
if (existingIndex >= 0 && !replace) {
|
||||
throw new Error(
|
||||
`manifest 已存在同名 case 或同目录 case,请使用 --replace 覆盖: ${caseEntry.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const existing = cases[existingIndex];
|
||||
cases[existingIndex] = {
|
||||
...existing,
|
||||
...caseEntry,
|
||||
tags: mergeUniqueStrings(existing.tags, caseEntry.tags),
|
||||
};
|
||||
} else {
|
||||
cases.push(caseEntry);
|
||||
}
|
||||
|
||||
cases.sort((left, right) =>
|
||||
String(left.id ?? "").localeCompare(String(right.id ?? "")),
|
||||
);
|
||||
suite.cases = cases;
|
||||
writeJsonFile(manifestPath, manifestPayload);
|
||||
|
||||
return {
|
||||
manifestPayload,
|
||||
replaced: existingIndex >= 0,
|
||||
};
|
||||
}
|
||||
|
||||
function writePromotedArtifacts({
|
||||
evidencePayload,
|
||||
expectedPayload,
|
||||
graderMarkdown,
|
||||
inputPayload,
|
||||
targetDir,
|
||||
}) {
|
||||
ensureDirectory(targetDir);
|
||||
writeJsonFile(path.join(targetDir, "input.json"), inputPayload);
|
||||
writeJsonFile(path.join(targetDir, "expected.json"), expectedPayload);
|
||||
writeJsonFile(path.join(targetDir, "evidence-links.json"), evidencePayload);
|
||||
fs.writeFileSync(path.join(targetDir, "grader.md"), graderMarkdown, "utf8");
|
||||
}
|
||||
|
||||
function toManifestCaseDirValue(repoRoot, targetDir) {
|
||||
const relativeToRepo = path.relative(repoRoot, targetDir);
|
||||
if (
|
||||
relativeToRepo &&
|
||||
!relativeToRepo.startsWith("..") &&
|
||||
!path.isAbsolute(relativeToRepo)
|
||||
) {
|
||||
return toPortablePath(relativeToRepo);
|
||||
}
|
||||
return toPortablePath(targetDir);
|
||||
}
|
||||
|
||||
function renderText(result) {
|
||||
const lines = [
|
||||
`[harness-replay-promote] suite: ${result.suiteId}`,
|
||||
`[harness-replay-promote] case : ${result.caseId}`,
|
||||
`[harness-replay-promote] title: ${result.title}`,
|
||||
`[harness-replay-promote] replay: ${result.sourceReplayDir}`,
|
||||
`[harness-replay-promote] target: ${result.targetCaseDir}`,
|
||||
`[harness-replay-promote] manifest target: ${result.manifestCaseDir}`,
|
||||
`[harness-replay-promote] dry-run: ${result.dryRun ? "yes" : "no"}`,
|
||||
`[harness-replay-promote] replaced: ${result.replaced ? "yes" : "no"}`,
|
||||
];
|
||||
|
||||
if (result.tags.length > 0) {
|
||||
lines.push(`[harness-replay-promote] tags: ${result.tags.join(", ")}`);
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const workspaceRoot = resolvePath(repoRoot, options.workspaceRoot);
|
||||
const replayDir = deriveReplayDirectory(options, workspaceRoot);
|
||||
validateReplayDirectory(replayDir);
|
||||
|
||||
const inputPath = path.join(replayDir, "input.json");
|
||||
const expectedPath = path.join(replayDir, "expected.json");
|
||||
const graderPath = path.join(replayDir, "grader.md");
|
||||
const evidencePath = path.join(replayDir, "evidence-links.json");
|
||||
|
||||
const originalInputPayload = readJsonFile(inputPath);
|
||||
const originalExpectedPayload = readJsonFile(expectedPath);
|
||||
const originalEvidencePayload = readJsonFile(evidencePath);
|
||||
const originalGraderMarkdown = fs.readFileSync(graderPath, "utf8");
|
||||
|
||||
const sessionId =
|
||||
String(
|
||||
originalInputPayload?.session?.sessionId ??
|
||||
path.basename(path.dirname(replayDir)),
|
||||
).trim() || "unknown-session";
|
||||
const slug = deriveSlug(options, originalInputPayload, sessionId);
|
||||
if (!slug) {
|
||||
throw new Error("无法推导目标 slug,请显式提供 --slug。");
|
||||
}
|
||||
|
||||
const caseId = deriveCaseId(options, slug);
|
||||
const title = deriveTitle(
|
||||
options,
|
||||
originalInputPayload,
|
||||
originalExpectedPayload,
|
||||
sessionId,
|
||||
);
|
||||
const promotedAt = new Date().toISOString();
|
||||
const promotionMetadata = buildPromotionMetadata({
|
||||
promotedAt,
|
||||
replayDir,
|
||||
sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot,
|
||||
sessionId,
|
||||
workspaceRoot,
|
||||
});
|
||||
|
||||
const inputPayload = sanitizePayload(
|
||||
originalInputPayload,
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
);
|
||||
inputPayload.source = "lime.repo_promoted.replay_case";
|
||||
inputPayload.classification = {
|
||||
...(inputPayload.classification ?? {}),
|
||||
sourceKind: "repo_promoted_fixture",
|
||||
};
|
||||
inputPayload.promotion = promotionMetadata;
|
||||
|
||||
const expectedPayload = sanitizePayload(
|
||||
originalExpectedPayload,
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
);
|
||||
expectedPayload.promotion = promotionMetadata;
|
||||
|
||||
const evidencePayload = sanitizePayload(
|
||||
originalEvidencePayload,
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
);
|
||||
evidencePayload.promotion = promotionMetadata;
|
||||
|
||||
const graderMarkdown = appendPromotionSection(
|
||||
replaceWorkspaceRootInString(
|
||||
originalGraderMarkdown,
|
||||
workspaceRoot,
|
||||
options.sanitizedWorkspaceRoot,
|
||||
),
|
||||
promotionMetadata,
|
||||
);
|
||||
|
||||
const fixturesRoot = resolvePath(repoRoot, options.fixturesRoot);
|
||||
const targetDir = path.join(fixturesRoot, slug);
|
||||
const targetExists = fs.existsSync(targetDir);
|
||||
if (targetExists && !options.replace) {
|
||||
throw new Error(`目标目录已存在,请使用 --replace 覆盖: ${targetDir}`);
|
||||
}
|
||||
|
||||
const manifestPath = resolvePath(repoRoot, options.manifest);
|
||||
const manifestCaseDir = toManifestCaseDirValue(repoRoot, targetDir);
|
||||
const caseEntry = buildManifestCaseEntry({
|
||||
caseId,
|
||||
caseTitle: title,
|
||||
inputPayload,
|
||||
targetCaseDirValue: manifestCaseDir,
|
||||
});
|
||||
|
||||
let replaced = false;
|
||||
if (!options.dryRun) {
|
||||
if (targetExists) {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
writePromotedArtifacts({
|
||||
evidencePayload,
|
||||
expectedPayload,
|
||||
graderMarkdown,
|
||||
inputPayload,
|
||||
targetDir,
|
||||
});
|
||||
const manifestUpdate = updateManifestCase({
|
||||
caseEntry,
|
||||
manifestPath,
|
||||
replace: options.replace,
|
||||
suiteId: options.suiteId,
|
||||
targetCaseDirValue: manifestCaseDir,
|
||||
});
|
||||
replaced = manifestUpdate.replaced;
|
||||
}
|
||||
|
||||
const result = {
|
||||
caseId,
|
||||
dryRun: options.dryRun,
|
||||
manifestCaseDir,
|
||||
manifestPath,
|
||||
replaced,
|
||||
sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot,
|
||||
slug,
|
||||
sourceReplayDir: toPortablePath(replayDir),
|
||||
suiteId: options.suiteId,
|
||||
tags: caseEntry.tags,
|
||||
targetCaseDir: toPortablePath(targetDir),
|
||||
title,
|
||||
};
|
||||
|
||||
if (options.format === "json") {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(renderText(result));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULTS = {
|
||||
healthUrl: "http://127.0.0.1:3030/health",
|
||||
invokeUrl: "http://127.0.0.1:3030/invoke",
|
||||
timeoutMs: 60_000,
|
||||
intervalMs: 1_000,
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Site Adapter Catalog Smoke
|
||||
|
||||
用途:
|
||||
验证站点适配器目录最小主链可用:目录状态、列表、推荐与检索结果可读。
|
||||
|
||||
用法:
|
||||
node scripts/site-adapter-catalog-smoke.mjs [选项]
|
||||
|
||||
选项:
|
||||
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
|
||||
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
|
||||
--timeout-ms <ms> 等待健康检查超时,默认 60000
|
||||
--interval-ms <ms> 健康检查轮询间隔,默认 1000
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--health-url" && argv[index + 1]) {
|
||||
options.healthUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--invoke-url" && argv[index + 1]) {
|
||||
options.invokeUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--timeout-ms" && argv[index + 1]) {
|
||||
options.timeoutMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--interval-ms" && argv[index + 1]) {
|
||||
options.intervalMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) {
|
||||
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
|
||||
}
|
||||
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
|
||||
throw new Error("--interval-ms 必须是 >= 100 的数字");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(options) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(options.healthUrl, { method: "GET" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
console.log(
|
||||
`[smoke:site-adapters] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
|
||||
payload?.status ? ` status=${payload.status}` : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const detail =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: String(lastError || "unknown error");
|
||||
throw new Error(
|
||||
`[smoke:site-adapters] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function invoke(invokeUrl, cmd, args) {
|
||||
const response = await fetch(invokeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cmd, args }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (payload?.error) {
|
||||
throw new Error(String(payload.error));
|
||||
}
|
||||
|
||||
return payload?.result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await waitForHealth(options);
|
||||
|
||||
const status = await invoke(options.invokeUrl, "site_get_adapter_catalog_status");
|
||||
assert(status && typeof status === "object", "site_get_adapter_catalog_status 返回为空");
|
||||
assert(
|
||||
typeof status.adapter_count === "number" && status.adapter_count >= 0,
|
||||
"site_get_adapter_catalog_status 缺少 adapter_count",
|
||||
);
|
||||
assert(
|
||||
status.source_kind === "bundled" || status.source_kind === "server_synced",
|
||||
"site_get_adapter_catalog_status 返回了未知 source_kind",
|
||||
);
|
||||
|
||||
const adapters = await invoke(options.invokeUrl, "site_list_adapters");
|
||||
assert(Array.isArray(adapters), "site_list_adapters 返回不是数组");
|
||||
assert(adapters.length > 0, "site_list_adapters 返回为空");
|
||||
|
||||
const adapter = adapters[0];
|
||||
assert(
|
||||
typeof adapter?.name === "string" && adapter.name.trim(),
|
||||
"site_list_adapters 首项缺少 name",
|
||||
);
|
||||
assert(
|
||||
typeof adapter?.domain === "string" && adapter.domain.trim(),
|
||||
"site_list_adapters 首项缺少 domain",
|
||||
);
|
||||
|
||||
const recommendations = await invoke(options.invokeUrl, "site_recommend_adapters", {
|
||||
request: {
|
||||
limit: 3,
|
||||
},
|
||||
});
|
||||
assert(Array.isArray(recommendations), "site_recommend_adapters 返回不是数组");
|
||||
if (recommendations.length > 0) {
|
||||
const recommendation = recommendations[0];
|
||||
assert(
|
||||
typeof recommendation?.adapter?.name === "string" &&
|
||||
recommendation.adapter.name.trim(),
|
||||
"site_recommend_adapters 首项缺少 adapter.name",
|
||||
);
|
||||
assert(
|
||||
typeof recommendation?.reason === "string" && recommendation.reason.trim(),
|
||||
"site_recommend_adapters 首项缺少 reason",
|
||||
);
|
||||
assert(
|
||||
typeof recommendation?.entry_url === "string" &&
|
||||
recommendation.entry_url.trim(),
|
||||
"site_recommend_adapters 首项缺少 entry_url",
|
||||
);
|
||||
}
|
||||
|
||||
const searchResults = await invoke(options.invokeUrl, "site_search_adapters", {
|
||||
request: {
|
||||
query: adapter.name,
|
||||
},
|
||||
});
|
||||
assert(Array.isArray(searchResults), "site_search_adapters 返回不是数组");
|
||||
assert(
|
||||
searchResults.some((item) => item?.name === adapter.name),
|
||||
"site_search_adapters 未返回刚刚列出的适配器",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[smoke:site-adapters] 通过 adapters=${adapters.length} source=${status.source_kind} recommended=${recommendations.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||
const DEFAULTS = {
|
||||
appUrl: "http://127.0.0.1:1420/",
|
||||
healthUrl: "http://127.0.0.1:3030/health",
|
||||
timeoutMs: 120_000,
|
||||
timeoutMs: 180_000,
|
||||
intervalMs: 1_000,
|
||||
reuseRunning: false,
|
||||
sampleProjectName: "Lime Smoke Workspace",
|
||||
@@ -39,7 +39,7 @@ Lime GUI 冒烟入口
|
||||
选项:
|
||||
--app-url <url> 前端地址,默认 http://127.0.0.1:1420/
|
||||
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
|
||||
--timeout-ms <ms> 等待 headless / bridge / smoke 的超时,默认 120000
|
||||
--timeout-ms <ms> 等待 headless / bridge / smoke 的超时,默认 180000
|
||||
--interval-ms <ms> 轮询间隔,默认 1000
|
||||
--sample-project-name <s> workspace 路径校验使用的示例项目名
|
||||
--reuse-running 复用已启动的 headless Tauri,不主动拉起
|
||||
@@ -357,6 +357,34 @@ async function main() {
|
||||
"smoke:workspace-ready",
|
||||
);
|
||||
|
||||
runCommand(
|
||||
npmCommand,
|
||||
[
|
||||
"run",
|
||||
"smoke:browser-runtime",
|
||||
"--",
|
||||
"--timeout-ms",
|
||||
String(options.timeoutMs),
|
||||
"--interval-ms",
|
||||
String(options.intervalMs),
|
||||
],
|
||||
"smoke:browser-runtime",
|
||||
);
|
||||
|
||||
runCommand(
|
||||
npmCommand,
|
||||
[
|
||||
"run",
|
||||
"smoke:site-adapters",
|
||||
"--",
|
||||
"--timeout-ms",
|
||||
String(options.timeoutMs),
|
||||
"--interval-ms",
|
||||
String(options.intervalMs),
|
||||
],
|
||||
"smoke:site-adapters",
|
||||
);
|
||||
|
||||
console.log("\n[verify:gui-smoke] 通过");
|
||||
} finally {
|
||||
if (startedByScript) {
|
||||
|
||||
Reference in New Issue
Block a user