chore: release v0.97.0

This commit is contained in:
coso
2026-03-27 14:35:56 +08:00
parent e380c98649
commit 26e7c60fdd
234 changed files with 36543 additions and 3657 deletions
+293
View File
@@ -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);
});
+357
View File
@@ -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();
+724
View File
@@ -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();
+700
View File
@@ -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();
+639
View File
@@ -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();
+635
View File
@@ -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();
+211
View File
@@ -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);
});
+30 -2
View File
@@ -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) {