mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.34.0
This commit is contained in:
@@ -144,6 +144,11 @@ function readQcloopStatusSidecars(dirPath) {
|
||||
const status = readJson(relativePath);
|
||||
return {
|
||||
path: relativePath,
|
||||
generatedAt: status?.generatedAt || "",
|
||||
jobId: status?.job?.id || status?.jobId || "",
|
||||
jobStatus: status?.job?.status || "",
|
||||
jobTerminal: Boolean(status?.job?.terminal),
|
||||
jobFinishedAt: status?.job?.finishedAt || "",
|
||||
verdictStatus: status?.verdict?.status || "",
|
||||
verdictSummary: status?.verdict?.summary || "",
|
||||
counts: status?.counts || null,
|
||||
@@ -196,6 +201,7 @@ function loadFacts() {
|
||||
testsReadme: exists("docs/tests/README.md"),
|
||||
evidenceSchema: exists("docs/test/agent-qc-evidence.schema.json"),
|
||||
qcloopJobScript: exists("scripts/agent-qc-qcloop-job.mjs"),
|
||||
payloadCoverageScript: exists("scripts/agent-qc-payload-coverage.mjs"),
|
||||
guiOwnerCheckScript: exists("scripts/agent-qc-gui-owner-check.mjs"),
|
||||
qcloopStatusScript: exists("scripts/agent-qc-qcloop-status.mjs"),
|
||||
qcloopPreflightScript: exists("scripts/agent-qc-qcloop-preflight.mjs"),
|
||||
@@ -219,6 +225,12 @@ function loadFacts() {
|
||||
failedStage: "",
|
||||
error: "",
|
||||
}),
|
||||
payloadCoverage: readOptionalJson(".lime/qc/qcloop-p0-single-owner-ready-coverage-current.json", {
|
||||
exists: false,
|
||||
status: "",
|
||||
coverage: null,
|
||||
ownerGate: null,
|
||||
}),
|
||||
realEvidenceSidecars: readEvidenceSidecars(".lime/qc"),
|
||||
qcloopStatusSidecars: readQcloopStatusSidecars(".lime/qc"),
|
||||
scenarioReport,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
buildAgentQcObjectiveChecklist,
|
||||
renderAgentQcObjectiveChecklistMarkdown,
|
||||
} from "./lib/agent-qc-objective-checklist-core.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
auditPath: ".lime/qc/objective-completion-audit-current.json",
|
||||
processOwnerPath: ".lime/qc/gui-process-owner-current.json",
|
||||
guiOwnerPath: ".lime/qc/gui-owner-current.json",
|
||||
format: "markdown",
|
||||
outputPath: "",
|
||||
check: false,
|
||||
help: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--audit" && argv[index + 1]) {
|
||||
result.auditPath = String(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--process-owner" && argv[index + 1]) {
|
||||
result.processOwnerPath = String(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--gui-owner" && argv[index + 1]) {
|
||||
result.guiOwnerPath = String(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--format" && argv[index + 1]) {
|
||||
result.format = String(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--output" && argv[index + 1]) {
|
||||
result.outputPath = String(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--check") {
|
||||
result.check = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Agent QC Objective Checklist
|
||||
|
||||
用法:
|
||||
npm run agent-qc:objective-checklist -- --format json --output ./.lime/qc/objective-completion-checklist-current.json
|
||||
node scripts/agent-qc-objective-checklist.mjs --check
|
||||
|
||||
选项:
|
||||
--audit PATH completion audit JSON,默认 .lime/qc/objective-completion-audit-current.json
|
||||
--process-owner PATH raw process owner JSON,默认 .lime/qc/gui-process-owner-current.json
|
||||
--gui-owner PATH GUI owner JSON,默认 .lime/qc/gui-owner-current.json
|
||||
--format FMT markdown | json,默认 markdown
|
||||
--output PATH 写入文件;默认 stdout
|
||||
--check checklist 未 complete 时非 0 退出
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
|
||||
}
|
||||
|
||||
function writeOutput(outputPath, content) {
|
||||
if (!outputPath) {
|
||||
process.stdout.write(content);
|
||||
return;
|
||||
}
|
||||
const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
|
||||
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
|
||||
fs.writeFileSync(resolvedOutputPath, content, "utf8");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const result = buildAgentQcObjectiveChecklist({
|
||||
audit: readJson(options.auditPath),
|
||||
processOwner: readJson(options.processOwnerPath),
|
||||
guiOwner: readJson(options.guiOwnerPath),
|
||||
});
|
||||
const content =
|
||||
options.format === "json"
|
||||
? `${JSON.stringify(result, null, 2)}\n`
|
||||
: renderAgentQcObjectiveChecklistMarkdown(result);
|
||||
writeOutput(options.outputPath, content);
|
||||
if (options.check && result.status !== "complete") {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
buildAgentQcPayloadCoverageReport,
|
||||
renderAgentQcPayloadCoverageMarkdown,
|
||||
} from "./lib/agent-qc-payload-coverage-core.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
check: false,
|
||||
format: "markdown",
|
||||
help: false,
|
||||
manifestPath: "docs/test/agent-qc-scenarios.manifest.json",
|
||||
outputPath: "",
|
||||
payloadPath: "",
|
||||
processOwnerPath: ".lime/qc/gui-process-owner-current.json",
|
||||
requireOwnerClear: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--manifest" && argv[index + 1]) {
|
||||
options.manifestPath = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--payload" && argv[index + 1]) {
|
||||
options.payloadPath = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--process-owner" && argv[index + 1]) {
|
||||
options.processOwnerPath = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--output" && argv[index + 1]) {
|
||||
options.outputPath = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--format" && argv[index + 1]) {
|
||||
options.format = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--require-owner-clear") {
|
||||
options.requireOwnerClear = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--check") {
|
||||
options.check = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Agent QC Payload Coverage
|
||||
|
||||
用法:
|
||||
npm run agent-qc:payload-coverage -- --payload ./.lime/qc/qcloop-p0.json
|
||||
node scripts/agent-qc-payload-coverage.mjs --payload ./.lime/qc/qcloop-p0.json --format json --check
|
||||
|
||||
选项:
|
||||
--manifest PATH Agent QC scenario manifest,默认 docs/test/agent-qc-scenarios.manifest.json
|
||||
--payload PATH qcloop job payload JSON
|
||||
--process-owner PATH raw process owner JSON,默认 .lime/qc/gui-process-owner-current.json
|
||||
--output PATH 写入文件;默认 stdout
|
||||
--format FMT markdown | json,默认 markdown
|
||||
--require-owner-clear --check 时要求 owner gate 也为 ready
|
||||
--check coverage 不完整时非 0 退出
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(filePath, { optional = false } = {}) {
|
||||
if (!filePath) {
|
||||
if (optional) return undefined;
|
||||
throw new Error("缺少 JSON 路径。");
|
||||
}
|
||||
const resolved = path.resolve(process.cwd(), filePath);
|
||||
if (optional && !fs.existsSync(resolved)) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(resolved, "utf8"));
|
||||
}
|
||||
|
||||
function writeOutput(outputPath, content) {
|
||||
if (!outputPath) {
|
||||
process.stdout.write(content);
|
||||
return;
|
||||
}
|
||||
const resolved = path.resolve(process.cwd(), outputPath);
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
||||
fs.writeFileSync(resolved, content, "utf8");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
if (!options.payloadPath) {
|
||||
throw new Error("必须提供 --payload。");
|
||||
}
|
||||
const report = buildAgentQcPayloadCoverageReport({
|
||||
manifest: readJson(options.manifestPath),
|
||||
payload: readJson(options.payloadPath),
|
||||
processOwner: readJson(options.processOwnerPath, { optional: true }),
|
||||
manifestPath: options.manifestPath,
|
||||
payloadPath: options.payloadPath,
|
||||
});
|
||||
const content =
|
||||
options.format === "json"
|
||||
? `${JSON.stringify(report, null, 2)}\n`
|
||||
: renderAgentQcPayloadCoverageMarkdown(report);
|
||||
writeOutput(options.outputPath, content);
|
||||
|
||||
if (options.check && !report.coverage.passed) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (options.check && options.requireOwnerClear && report.status !== "ready") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[agent-qc:payload-coverage] ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -5,38 +5,11 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const GUI_OWNER_PATTERNS = [
|
||||
"verify:gui-smoke",
|
||||
"smoke:workspace-ready",
|
||||
"smoke:browser-runtime",
|
||||
"smoke:site-adapters",
|
||||
"smoke:agent-service-skill-entry",
|
||||
"smoke:agent-runtime-tool-surface",
|
||||
"smoke:knowledge-gui",
|
||||
"smoke:design-canvas",
|
||||
"claw-chat-ready-streaming",
|
||||
"browser-runtime-site-adapter",
|
||||
"workspace-ready-session-restore",
|
||||
"release-package-startup-smoke",
|
||||
];
|
||||
|
||||
const CARGO_OWNER_PATTERNS = [
|
||||
"cargo ",
|
||||
"cargo-fmt",
|
||||
"rustc ",
|
||||
"clippy-driver",
|
||||
"tauri dev",
|
||||
];
|
||||
|
||||
const QCLOOP_OWNER_PATTERNS = [
|
||||
"qcloop --db",
|
||||
"./qcloop --db",
|
||||
"qcloop serve",
|
||||
"qcloop-worker",
|
||||
"qcloop_worker_result",
|
||||
"agent qc p0",
|
||||
"只读执行 lime agent qc p0",
|
||||
];
|
||||
import {
|
||||
createAgentQcProcessOwnerReport,
|
||||
parseEtimeSeconds,
|
||||
sanitizeProcessCommand,
|
||||
} from "./lib/agent-qc-process-owner-core.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
@@ -48,6 +21,8 @@ function parseArgs(argv) {
|
||||
maxCargoOrRust: 0,
|
||||
maxQcloopRelated: 0,
|
||||
outputPath: "",
|
||||
staleMinutes: 30,
|
||||
watchHistoryOutputPath: "",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -67,6 +42,11 @@ function parseArgs(argv) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--watch-history-output" && argv[index + 1]) {
|
||||
result.watchHistoryOutputPath = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--max-active-gui-smoke" && argv[index + 1]) {
|
||||
result.maxActiveGuiSmoke = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
@@ -82,6 +62,11 @@ function parseArgs(argv) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--stale-minutes" && argv[index + 1]) {
|
||||
result.staleMinutes = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--check") {
|
||||
result.check = true;
|
||||
continue;
|
||||
@@ -105,9 +90,11 @@ Lime Agent QC Process Owner Check
|
||||
--format FMT summary | json,默认 summary
|
||||
--output PATH 写入 JSON 或 summary;默认 stdout
|
||||
--markdown-output PATH 额外写入 Markdown 摘要
|
||||
--watch-history-output PATH 追加写入 JSONL 观察历史
|
||||
--max-active-gui-smoke N 允许的 GUI smoke / deep flow owner 数,默认 0
|
||||
--max-cargo-or-rust N 允许的 Cargo / Rust 构建进程数,默认 0
|
||||
--max-qcloop-related N 允许的 qcloop 相关进程数,默认 0
|
||||
--stale-minutes N 标记 active GUI owner stale 的分钟数,默认 30
|
||||
--check owner 超过上限时非 0 退出
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
@@ -118,12 +105,14 @@ function parseUnixPsLine(line) {
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const etime = match[5];
|
||||
return {
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
pgid: Number(match[3]),
|
||||
stat: match[4],
|
||||
etime: match[5],
|
||||
etime,
|
||||
durationSeconds: parseEtimeSeconds(etime),
|
||||
command: sanitizeProcessCommand(match[6].trim()),
|
||||
};
|
||||
}
|
||||
@@ -158,97 +147,28 @@ function collectWindowsProcesses() {
|
||||
pgid: null,
|
||||
stat: "unknown",
|
||||
etime: "unknown",
|
||||
durationSeconds: null,
|
||||
command: sanitizeProcessCommand(String(entry?.CommandLine || "").trim()),
|
||||
}))
|
||||
.filter((entry) => entry.pid > 0 && entry.pid !== process.pid && entry.command.length > 0);
|
||||
}
|
||||
|
||||
function sanitizeProcessCommand(command) {
|
||||
return String(command || "")
|
||||
.replace(/(--api-key(?:=|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
|
||||
.replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
|
||||
.replace(/ctx7sk-[A-Za-z0-9-]+/g, "ctx7sk-***")
|
||||
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-***");
|
||||
}
|
||||
|
||||
function collectProcesses() {
|
||||
return process.platform === "win32" ? collectWindowsProcesses() : collectUnixProcesses();
|
||||
}
|
||||
|
||||
function commandHasAny(command, patterns) {
|
||||
const normalized = String(command || "").toLowerCase();
|
||||
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
function uniqueByPid(entries) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.pid)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(entry.pid);
|
||||
result.push(entry);
|
||||
}
|
||||
return result.sort((left, right) => left.pid - right.pid);
|
||||
}
|
||||
|
||||
function createReport({
|
||||
generatedAt = new Date().toISOString(),
|
||||
maxActiveGuiSmoke = 0,
|
||||
maxCargoOrRust = 0,
|
||||
maxQcloopRelated = 0,
|
||||
} = {}) {
|
||||
const processes = collectProcesses();
|
||||
const activeGuiSmokeProcesses = uniqueByPid(
|
||||
processes.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)),
|
||||
);
|
||||
const qcloopProcesses = uniqueByPid(
|
||||
processes.filter((entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS)),
|
||||
);
|
||||
const cargoProcesses = uniqueByPid(
|
||||
processes.filter((entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS)),
|
||||
);
|
||||
|
||||
const counts = {
|
||||
activeGuiSmoke: activeGuiSmokeProcesses.length,
|
||||
cargoOrRust: cargoProcesses.length,
|
||||
qcloopRelated: qcloopProcesses.length,
|
||||
};
|
||||
const busy =
|
||||
counts.activeGuiSmoke > maxActiveGuiSmoke ||
|
||||
counts.cargoOrRust > maxCargoOrRust ||
|
||||
counts.qcloopRelated > maxQcloopRelated;
|
||||
|
||||
return {
|
||||
schemaVersion: "v1",
|
||||
generatedAt,
|
||||
function createReport(options = {}) {
|
||||
return createAgentQcProcessOwnerReport(collectProcesses(), {
|
||||
...options,
|
||||
platform: process.platform,
|
||||
maxActiveGuiSmoke,
|
||||
maxCargoOrRust,
|
||||
maxQcloopRelated,
|
||||
verdict: {
|
||||
status: busy ? "busy" : "pass",
|
||||
summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}`,
|
||||
nextAction: busy
|
||||
? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion."
|
||||
: "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.",
|
||||
},
|
||||
activeGuiSmokeProcesses,
|
||||
qcloopProcesses,
|
||||
cargoProcesses,
|
||||
guardrails: [
|
||||
"best-effort process snapshot only",
|
||||
"do not kill or restart listed processes from this sidecar",
|
||||
"use this sidecar to decide whether heavy GUI or verify gates should wait",
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(report) {
|
||||
const lines = [
|
||||
`status=${report.verdict.status}`,
|
||||
`summary=${report.verdict.summary}`,
|
||||
`ownerIntervention=${report.ownerIntervention.status}`,
|
||||
`generatedAt=${report.generatedAt}`,
|
||||
`platform=${report.platform}`,
|
||||
];
|
||||
@@ -263,15 +183,37 @@ function renderMarkdown(report) {
|
||||
`- Status: ${report.verdict.status}`,
|
||||
`- Summary: ${report.verdict.summary}`,
|
||||
`- Next action: ${report.verdict.nextAction}`,
|
||||
`- Owner intervention: ${report.ownerIntervention.status}`,
|
||||
"",
|
||||
"## Active GUI smoke / deep flow processes",
|
||||
"",
|
||||
];
|
||||
appendProcessList(lines, report.activeGuiSmokeProcesses);
|
||||
lines.push("", "## Stale active GUI smoke candidates", "");
|
||||
appendProcessList(lines, report.staleActiveGuiSmokeProcesses);
|
||||
lines.push("", "## qcloop related processes", "");
|
||||
appendProcessList(lines, report.qcloopProcesses);
|
||||
lines.push("", "## Cargo / Rust processes", "");
|
||||
appendProcessList(lines, report.cargoProcesses);
|
||||
lines.push("", "## Passive qcloop serve processes", "");
|
||||
appendProcessList(lines, report.passiveQcloopServerProcesses);
|
||||
lines.push("", "## Passive Tauri dev runtime processes", "");
|
||||
appendProcessList(lines, report.passiveTauriRuntimeProcesses);
|
||||
lines.push("", "## Observer processes", "");
|
||||
appendProcessList(lines, report.observerProcesses);
|
||||
if (report.ownerIntervention.status === "requires_owner_confirmation") {
|
||||
lines.push(
|
||||
"",
|
||||
"## Owner intervention",
|
||||
"",
|
||||
`- Required confirmation: ${report.ownerIntervention.requiredConfirmationText}`,
|
||||
`- Next action: ${report.ownerIntervention.nextAction}`,
|
||||
"- Prohibited until confirmed:",
|
||||
);
|
||||
for (const item of report.ownerIntervention.prohibitedUntilConfirmed) {
|
||||
lines.push(` - ${item}`);
|
||||
}
|
||||
}
|
||||
lines.push("", "## Guardrails", "");
|
||||
for (const guardrail of report.guardrails) {
|
||||
lines.push(`- ${guardrail}`);
|
||||
@@ -301,6 +243,46 @@ function writeOutput(outputPath, content) {
|
||||
fs.writeFileSync(resolvedOutputPath, content, "utf8");
|
||||
}
|
||||
|
||||
function createWatchHistoryEntry(report) {
|
||||
return {
|
||||
schemaVersion: "v1",
|
||||
generatedAt: report.generatedAt,
|
||||
verdictStatus: report.verdict.status,
|
||||
summary: report.verdict.summary,
|
||||
ownerInterventionStatus: report.ownerIntervention.status,
|
||||
ownerInterventionProcessIds: report.ownerIntervention.processIds,
|
||||
activeGuiSmokeCount: report.activeGuiSmokeProcesses.length,
|
||||
staleActiveGuiSmokeCount: report.staleActiveGuiSmokeProcesses.length,
|
||||
qcloopRelatedCount: report.qcloopProcesses.length,
|
||||
cargoOrRustCount: report.cargoProcesses.length,
|
||||
passiveQcloopServerCount: report.passiveQcloopServerProcesses.length,
|
||||
passiveTauriRuntimeCount: report.passiveTauriRuntimeProcesses.length,
|
||||
observerCount: report.observerProcesses.length,
|
||||
activeGuiSmokeProcesses: report.activeGuiSmokeProcesses.map((entry) => ({
|
||||
pid: entry.pid,
|
||||
ppid: entry.ppid,
|
||||
pgid: entry.pgid,
|
||||
stat: entry.stat,
|
||||
etime: entry.etime,
|
||||
durationSeconds: entry.durationSeconds,
|
||||
command: entry.command,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function appendWatchHistory(outputPath, report) {
|
||||
if (!outputPath) {
|
||||
return;
|
||||
}
|
||||
const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
|
||||
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
|
||||
fs.appendFileSync(
|
||||
resolvedOutputPath,
|
||||
`${JSON.stringify(createWatchHistoryEntry(report))}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
@@ -317,6 +299,7 @@ function main() {
|
||||
if (options.markdownOutputPath) {
|
||||
writeOutput(options.markdownOutputPath, renderMarkdown(report));
|
||||
}
|
||||
appendWatchHistory(options.watchHistoryOutputPath, report);
|
||||
|
||||
if (options.check && report.verdict.status !== "pass") {
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -9,6 +10,12 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const runtimeTranscriptPath = path.join(
|
||||
rootDir,
|
||||
".lime",
|
||||
"qc",
|
||||
"skill-forge-runtime-transcript-current.json",
|
||||
);
|
||||
|
||||
function runVitest(label, args) {
|
||||
console.log(`\n[smoke:agent-service-skill-entry] > ${label}`);
|
||||
@@ -35,14 +42,30 @@ function runVitest(label, args) {
|
||||
}
|
||||
}
|
||||
|
||||
function runCargoTest(label, args) {
|
||||
function runCargoTest(label, { packageName, testName }) {
|
||||
console.log(`\n[smoke:agent-service-skill-entry] > ${label}`);
|
||||
const result = spawnSync("cargo", ["test", ...args], {
|
||||
const result = spawnSync("cargo", [
|
||||
"test",
|
||||
"--manifest-path",
|
||||
"Cargo.toml",
|
||||
"-p",
|
||||
packageName,
|
||||
testName,
|
||||
"--",
|
||||
"--exact",
|
||||
], {
|
||||
cwd: path.join(rootDir, "src-tauri"),
|
||||
stdio: "inherit",
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
@@ -54,6 +77,246 @@ function runCargoTest(label, args) {
|
||||
error.exitCode = result.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
||||
const passedCount = Array.from(
|
||||
output.matchAll(/test result: ok\. ([0-9]+) passed/g),
|
||||
).reduce((total, match) => total + Number(match[1] || 0), 0);
|
||||
if (passedCount <= 0) {
|
||||
const error = new Error(
|
||||
`[smoke:agent-service-skill-entry] ${label} 未实际运行目标 Rust 测试`,
|
||||
);
|
||||
error.exitCode = 1;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
packageName,
|
||||
testName,
|
||||
passedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimePhaseForTest(testName) {
|
||||
if (testName.includes("register_capability_draft")) {
|
||||
return "capability_draft_registration";
|
||||
}
|
||||
if (testName.includes("registered_skill_becomes_ready")) {
|
||||
return "runtime_binding_projection";
|
||||
}
|
||||
if (testName.includes("explicit_runtime_enable")) {
|
||||
return "manual_session_runtime_enable";
|
||||
}
|
||||
if (testName.includes("registered_skill_without_verification")) {
|
||||
return "provenance_gate_negative";
|
||||
}
|
||||
if (testName.includes("controlled_get")) {
|
||||
return "readonly_execution_evidence";
|
||||
}
|
||||
if (testName.includes("workspace_skill_runtime_enable_as_callable_scope")) {
|
||||
return "query_loop_prompt_projection";
|
||||
}
|
||||
if (testName.includes("allowlisted_session")) {
|
||||
return "skill_tool_gate_allow";
|
||||
}
|
||||
if (testName.includes("disabled_session")) {
|
||||
return "skill_tool_gate_deny";
|
||||
}
|
||||
return "skill_forge_runtime_gate";
|
||||
}
|
||||
|
||||
function skillToolGateEvidenceForPhase(phase) {
|
||||
if (phase === "skill_tool_gate_allow") {
|
||||
const sourceMetadata = {
|
||||
workspaceRoot: "<deterministic-test-workspace>",
|
||||
source: "manual_session_enable",
|
||||
approval: "manual",
|
||||
authorizationScope: "session",
|
||||
directory: "capability-report",
|
||||
registeredSkillDirectory:
|
||||
"<deterministic-test-workspace>/.agents/skills/capability-report",
|
||||
skillName: "project:capability-report",
|
||||
sourceDraftId: "capdraft-1",
|
||||
sourceVerificationReportId: "capver-1",
|
||||
permissionSummary: ["Level 0 只读发现"],
|
||||
};
|
||||
|
||||
return {
|
||||
request: {
|
||||
toolName: "SkillTool",
|
||||
sessionId: "skill-source-session",
|
||||
skill: "capability-report",
|
||||
authorizationScope: "session",
|
||||
},
|
||||
decision: {
|
||||
action: "allow",
|
||||
gate: "session_allowlist",
|
||||
enabled: true,
|
||||
allowlisted: true,
|
||||
reason: "workspace_skill_runtime_enable_allowlist_matched",
|
||||
},
|
||||
result: {
|
||||
status: "passed",
|
||||
permissionBehavior: "Allow",
|
||||
sourceMetadataAttached: true,
|
||||
workspaceSkillRuntimeEnableAttached: true,
|
||||
},
|
||||
sourceMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
if (phase === "skill_tool_gate_deny") {
|
||||
return {
|
||||
request: {
|
||||
toolName: "SkillTool",
|
||||
sessionId: "skill-execute-disabled-session",
|
||||
skill: "research",
|
||||
authorizationScope: "session",
|
||||
},
|
||||
decision: {
|
||||
action: "deny",
|
||||
gate: "session_enable_required",
|
||||
enabled: false,
|
||||
allowlisted: false,
|
||||
reason: "skill_tool_session_not_enabled",
|
||||
},
|
||||
result: {
|
||||
status: "passed",
|
||||
errorClass: "execution_failed",
|
||||
errorMessageContains: "未启用技能自动调用",
|
||||
},
|
||||
sourceMetadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function assertRuntimeTranscriptHasSkillToolGateEvidence(transcript) {
|
||||
const events = transcript?.runtimeTranscript?.events;
|
||||
if (!Array.isArray(events)) {
|
||||
throw new Error("[smoke:agent-service-skill-entry] runtime transcript 缺少 events");
|
||||
}
|
||||
|
||||
const allowEvent = events.find((event) => event.phase === "skill_tool_gate_allow");
|
||||
const denyEvent = events.find((event) => event.phase === "skill_tool_gate_deny");
|
||||
const hasGateEvidence = (event) =>
|
||||
event?.request?.toolName === "SkillTool" &&
|
||||
typeof event?.request?.skill === "string" &&
|
||||
typeof event?.decision?.action === "string" &&
|
||||
typeof event?.decision?.gate === "string" &&
|
||||
typeof event?.result?.status === "string";
|
||||
|
||||
if (!hasGateEvidence(allowEvent)) {
|
||||
throw new Error(
|
||||
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool allow request/decision/result",
|
||||
);
|
||||
}
|
||||
if (!hasGateEvidence(denyEvent)) {
|
||||
throw new Error(
|
||||
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool deny request/decision/result",
|
||||
);
|
||||
}
|
||||
if (
|
||||
allowEvent.sourceMetadata?.authorizationScope !== "session" ||
|
||||
allowEvent.sourceMetadata?.sourceDraftId !== "capdraft-1" ||
|
||||
allowEvent.sourceMetadata?.sourceVerificationReportId !== "capver-1"
|
||||
) {
|
||||
throw new Error(
|
||||
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool source metadata",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSkillToolGateProof(transcript) {
|
||||
const events = transcript.runtimeTranscript.events;
|
||||
const allowEvent = events.find((event) => event.phase === "skill_tool_gate_allow");
|
||||
const denyEvent = events.find((event) => event.phase === "skill_tool_gate_deny");
|
||||
|
||||
return {
|
||||
allow: {
|
||||
phase: allowEvent.phase,
|
||||
hasRequest: Boolean(allowEvent.request?.toolName && allowEvent.request?.skill),
|
||||
hasDecision: Boolean(allowEvent.decision?.action && allowEvent.decision?.gate),
|
||||
hasResult: Boolean(allowEvent.result?.status),
|
||||
hasSourceMetadata: Boolean(allowEvent.sourceMetadata?.sourceDraftId),
|
||||
request: allowEvent.request,
|
||||
decision: allowEvent.decision,
|
||||
result: allowEvent.result,
|
||||
},
|
||||
deny: {
|
||||
phase: denyEvent.phase,
|
||||
hasRequest: Boolean(denyEvent.request?.toolName && denyEvent.request?.skill),
|
||||
hasDecision: Boolean(denyEvent.decision?.action && denyEvent.decision?.gate),
|
||||
hasResult: Boolean(denyEvent.result?.status),
|
||||
hasSourceMetadata: Boolean(denyEvent.sourceMetadata),
|
||||
request: denyEvent.request,
|
||||
decision: denyEvent.decision,
|
||||
result: denyEvent.result,
|
||||
},
|
||||
summary:
|
||||
"SkillTool allow/deny events both contain request, decision and result; allow event also carries redacted source metadata.",
|
||||
};
|
||||
}
|
||||
|
||||
function writeRuntimeTranscript(cargoResults) {
|
||||
fs.mkdirSync(path.dirname(runtimeTranscriptPath), { recursive: true });
|
||||
const transcript = {
|
||||
schemaVersion: "v1",
|
||||
scenarioId: "skill-forge-register-bind-enable",
|
||||
generatedAt: new Date().toISOString(),
|
||||
result: "pass",
|
||||
evidenceLayersCovered: ["deterministic-smoke", "runtime-transcript"],
|
||||
runtimeTranscript: {
|
||||
kind: "skill_tool_gate_transcript",
|
||||
scope:
|
||||
"deterministic Rust harness; covers SkillTool request/decision/result and source metadata without invoking a live model provider",
|
||||
events: cargoResults.map((result, index) => {
|
||||
const phase = runtimePhaseForTest(result.testName);
|
||||
return {
|
||||
order: index + 1,
|
||||
phase,
|
||||
command: `cargo test --manifest-path Cargo.toml -p ${result.packageName} ${result.testName} -- --exact`,
|
||||
status: "passed",
|
||||
passedCount: result.passedCount,
|
||||
...skillToolGateEvidenceForPhase(phase),
|
||||
};
|
||||
}),
|
||||
},
|
||||
evidenceRequired: {
|
||||
capabilityDraftEvidence:
|
||||
"capability_draft_service exact tests created, verified, registered, and controlled read-only evidence without persisting sensitive inputs.",
|
||||
registrationResult:
|
||||
"registration exact tests persisted readonly HTTP provenance and rejected missing provenance.",
|
||||
runtimeBindingProjection:
|
||||
"runtime_skill_binding_service exact tests projected ready_for_manual_enable without query_loop/tool_runtime auto visibility.",
|
||||
skillToolGateTranscript:
|
||||
"lime-agent SkillTool gate exact tests covered allowlisted session source metadata and disabled-session denial.",
|
||||
},
|
||||
failureModes: {
|
||||
registeredEqualsExecutable:
|
||||
"excluded: ready binding remains query_loop_visible=false, tool_runtime_visible=false, launch_enabled=false until manual enable.",
|
||||
metadataAutoEnablesSkill:
|
||||
"excluded: workspace_skill_bindings prompt projection is read-only; only workspace_skill_runtime_enable creates session allowlist.",
|
||||
missingProvenance:
|
||||
"covered: missing verification provenance is blocked before runtime enable.",
|
||||
unsafeEndpointLeaked:
|
||||
"excluded: controlled GET evidence test verifies no endpoint/token/response preview is persisted.",
|
||||
},
|
||||
};
|
||||
assertRuntimeTranscriptHasSkillToolGateEvidence(transcript);
|
||||
const gateProof = buildSkillToolGateProof(transcript);
|
||||
transcript.skillToolGateProof = gateProof;
|
||||
fs.writeFileSync(runtimeTranscriptPath, `${JSON.stringify(transcript, null, 2)}\n`);
|
||||
console.log(
|
||||
`[smoke:agent-service-skill-entry] Skill Forge runtime transcript: ${runtimeTranscriptPath}`,
|
||||
);
|
||||
console.log(
|
||||
`[smoke:agent-service-skill-entry] SkillTool gate proof: allow request=${gateProof.allow.hasRequest} decision=${gateProof.allow.hasDecision} result=${gateProof.allow.hasResult} sourceMetadata=${gateProof.allow.hasSourceMetadata}; deny request=${gateProof.deny.hasRequest} decision=${gateProof.deny.hasDecision} result=${gateProof.deny.hasResult}`,
|
||||
);
|
||||
console.log(
|
||||
`[smoke:agent-service-skill-entry] SkillTool gate proof JSON: ${JSON.stringify(gateProof)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -65,24 +328,51 @@ function main() {
|
||||
"src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts",
|
||||
]);
|
||||
|
||||
[
|
||||
"register_capability_draft_persists_readonly_http_preflight_provenance",
|
||||
"registered_skill_becomes_ready_for_manual_enable_binding_candidate",
|
||||
"explicit_runtime_enable_projects_ready_binding_allowlist",
|
||||
"registered_skill_without_verification_provenance_is_blocked",
|
||||
"execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs",
|
||||
"should_project_workspace_skill_runtime_enable_as_callable_scope",
|
||||
"allowlisted_session_should_preserve_workspace_skill_source_metadata",
|
||||
"disabled_session_should_fail_execute",
|
||||
].forEach((testName) => {
|
||||
runCargoTest(`Skill Forge Rust 定向测试: ${testName}`, [
|
||||
"--manifest-path",
|
||||
"Cargo.toml",
|
||||
testName,
|
||||
"--",
|
||||
"--exact",
|
||||
]);
|
||||
});
|
||||
const cargoResults = [
|
||||
{
|
||||
packageName: "lime",
|
||||
testName:
|
||||
"services::capability_draft_service::tests::register_capability_draft_persists_readonly_http_preflight_provenance",
|
||||
},
|
||||
{
|
||||
packageName: "lime",
|
||||
testName:
|
||||
"services::runtime_skill_binding_service::tests::registered_skill_becomes_ready_for_manual_enable_binding_candidate",
|
||||
},
|
||||
{
|
||||
packageName: "lime",
|
||||
testName:
|
||||
"services::runtime_skill_binding_service::tests::explicit_runtime_enable_projects_ready_binding_allowlist",
|
||||
},
|
||||
{
|
||||
packageName: "lime",
|
||||
testName:
|
||||
"services::runtime_skill_binding_service::tests::registered_skill_without_verification_provenance_is_blocked",
|
||||
},
|
||||
{
|
||||
packageName: "lime",
|
||||
testName:
|
||||
"services::capability_draft_service::tests::execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs",
|
||||
},
|
||||
{
|
||||
packageName: "lime",
|
||||
testName:
|
||||
"commands::aster_agent_cmd::workspace_skill_binding_prompt::tests::should_project_workspace_skill_runtime_enable_as_callable_scope",
|
||||
},
|
||||
{
|
||||
packageName: "lime-agent",
|
||||
testName:
|
||||
"tools::skill_tool_gate::tests::allowlisted_session_should_preserve_workspace_skill_source_metadata",
|
||||
},
|
||||
{
|
||||
packageName: "lime-agent",
|
||||
testName:
|
||||
"tools::skill_tool_gate::tests::disabled_session_should_fail_execute",
|
||||
},
|
||||
].map((testSpec) =>
|
||||
runCargoTest(`Skill Forge Rust 定向测试: ${testSpec.testName}`, testSpec),
|
||||
);
|
||||
writeRuntimeTranscript(cargoResults);
|
||||
|
||||
runVitest("服务技能入口路由与挂起参数", [
|
||||
"src/components/skills/SkillsWorkspacePage.test.tsx",
|
||||
|
||||
@@ -43,6 +43,8 @@ const LONG_PROMPT = [
|
||||
const RECOVERY_EXPECTED_TEXT = "复原完成";
|
||||
const RECOVERY_PROMPT =
|
||||
"停止后恢复测试:这是一个新的独立回合,请忽略上一条输出 160 行的要求。只输出“复原完成”这四个字,不要输出行号、解释或其他内容。";
|
||||
const MODEL_AVAILABILITY_PROMPT = "请只回复 QC_OK。";
|
||||
const MAX_MODEL_AVAILABILITY_CANDIDATES = 12;
|
||||
const FAST_RESPONSE_MODE_STORAGE_KEY = "lime:agent-fast-response-mode";
|
||||
const TASK_CENTER_OPEN_TASK_EVENT = "lime:task-center:open-task";
|
||||
|
||||
@@ -268,10 +270,50 @@ function normalizeProviderId(provider) {
|
||||
return String(provider?.id || provider?.provider_id || provider?.providerId || "").trim();
|
||||
}
|
||||
|
||||
function providerMatchesId(provider, providerId) {
|
||||
const normalizedProviderId = normalizeProviderId(provider);
|
||||
return (
|
||||
normalizedProviderId === providerId ||
|
||||
provider?.provider_name === providerId ||
|
||||
provider?.providerName === providerId ||
|
||||
provider?.name === providerId
|
||||
);
|
||||
}
|
||||
|
||||
function providerEnabled(provider) {
|
||||
return provider?.enabled !== false;
|
||||
}
|
||||
|
||||
function providerHasCredential(provider) {
|
||||
const count = Number(provider?.api_key_count ?? provider?.apiKeyCount);
|
||||
if (Number.isFinite(count)) {
|
||||
return count > 0;
|
||||
}
|
||||
if (typeof provider?.has_api_key === "boolean") {
|
||||
return provider.has_api_key;
|
||||
}
|
||||
if (typeof provider?.hasApiKey === "boolean") {
|
||||
return provider.hasApiKey;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function providerReadyForLiveRuntime(provider) {
|
||||
return providerEnabled(provider) && providerHasCredential(provider);
|
||||
}
|
||||
|
||||
function modelLooksChatCapable(modelName) {
|
||||
const normalized = String(modelName || "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/(image|images|banana|embed|embedding|tts|transcrib|audio|video|bfl|flux|stable|kling|wan)/i.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
function pickModelPreference(provider) {
|
||||
const candidates = [
|
||||
provider?.default_model,
|
||||
@@ -288,32 +330,126 @@ function pickModelPreference(provider) {
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
candidates.find((value) => /flash|mini|lite/i.test(value)) ||
|
||||
candidates[0] ||
|
||||
candidates.find((value) => /flash|mini|lite/i.test(value) && modelLooksChatCapable(value)) ||
|
||||
candidates.find((value) => modelLooksChatCapable(value)) ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function findProviderById(providers, providerId) {
|
||||
const normalizedProviderId = String(providerId || "").trim();
|
||||
if (!normalizedProviderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
providers.find((provider) => providerMatchesId(provider, normalizedProviderId)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function pickProvider(providers, preferredProviderId) {
|
||||
const ready = providers.filter((provider) => providerReadyForLiveRuntime(provider));
|
||||
const enabled = providers.filter((provider) => providerEnabled(provider));
|
||||
if (preferredProviderId) {
|
||||
return (
|
||||
enabled.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
|
||||
enabled.find((provider) => provider?.provider_name === preferredProviderId) ||
|
||||
providers.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
|
||||
providers.find((provider) => provider?.provider_name === preferredProviderId) ||
|
||||
ready.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
|
||||
enabled.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
|
||||
providers.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
for (const providerId of ["deepseek", "doubao", "lime-hub"]) {
|
||||
const match = enabled.find((provider) => normalizeProviderId(provider) === providerId);
|
||||
const match = ready.find((provider) => normalizeProviderId(provider) === providerId);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return enabled[0] || null;
|
||||
return ready[0] || enabled[0] || null;
|
||||
}
|
||||
|
||||
function candidateRank(candidate) {
|
||||
const providerId = candidate.providerPreference.toLowerCase();
|
||||
const model = candidate.modelPreference.toLowerCase();
|
||||
|
||||
if (candidate.source === "agent-status") {
|
||||
return 0;
|
||||
}
|
||||
if (/flash|lite/.test(model) && !/deepseek/.test(model)) {
|
||||
return 1;
|
||||
}
|
||||
if (/mini/.test(model) && !/deepseek/.test(model)) {
|
||||
return 2;
|
||||
}
|
||||
if (/deepseek/.test(model)) {
|
||||
return 4;
|
||||
}
|
||||
if (["deepseek", "siliconflow-cn", "lime-hub"].includes(providerId)) {
|
||||
return 5;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
function uniqueProviderCandidates(candidates) {
|
||||
const seen = new Set();
|
||||
return candidates.filter((candidate) => {
|
||||
const key = `${candidate.providerPreference}::${candidate.modelPreference}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function buildProviderCandidate(options, provider, modelPreference, source) {
|
||||
const providerId = normalizeProviderId(provider) || String(provider?.provider_name || "").trim();
|
||||
if (!providerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let providerDetail = provider;
|
||||
try {
|
||||
providerDetail =
|
||||
(await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) ||
|
||||
provider;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const model = modelPreference || pickModelPreference(providerDetail);
|
||||
if (!model || !modelLooksChatCapable(model)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
providerPreference: providerId,
|
||||
modelPreference: model,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyProviderCandidate(options, candidate) {
|
||||
const result = await invoke(
|
||||
options,
|
||||
"test_api_key_provider_chat",
|
||||
{
|
||||
providerId: candidate.providerPreference,
|
||||
modelName: candidate.modelPreference,
|
||||
prompt: MODEL_AVAILABILITY_PROMPT,
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
return Boolean(result?.success);
|
||||
}
|
||||
|
||||
function sanitizeProbeError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error || "unknown");
|
||||
return message.replace(/\s+/g, " ").slice(0, 160);
|
||||
}
|
||||
|
||||
async function resolveProviderPreference(options, agentStatus, providers) {
|
||||
@@ -331,48 +467,105 @@ async function resolveProviderPreference(options, agentStatus, providers) {
|
||||
agentStatus?.provider_selector || agentStatus?.provider_name || "",
|
||||
).trim();
|
||||
const statusModel = String(agentStatus?.model_name || "").trim();
|
||||
if (!explicitProvider && !explicitModel && statusProvider && statusModel) {
|
||||
return {
|
||||
providerPreference: statusProvider,
|
||||
modelPreference: statusModel,
|
||||
source: "agent-status",
|
||||
};
|
||||
const providerListAvailable = Array.isArray(providers) && providers.length > 0;
|
||||
const statusProviderRecord = providerListAvailable
|
||||
? findProviderById(providers, statusProvider)
|
||||
: null;
|
||||
const statusProviderReady =
|
||||
statusProvider &&
|
||||
statusModel &&
|
||||
(!providerListAvailable ||
|
||||
(statusProviderRecord && providerReadyForLiveRuntime(statusProviderRecord)));
|
||||
const providerList = Array.isArray(providers) ? providers : [];
|
||||
const selected = pickProvider(providerList, explicitProvider || "");
|
||||
|
||||
if (explicitProvider || explicitModel) {
|
||||
if (!selected) {
|
||||
throw new Error(
|
||||
"[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider",
|
||||
);
|
||||
}
|
||||
const selectedMatchesStatusProvider =
|
||||
statusProvider && providerMatchesId(selected, statusProvider);
|
||||
const candidate = await buildProviderCandidate(
|
||||
options,
|
||||
selected,
|
||||
explicitModel || (selectedMatchesStatusProvider ? statusModel : ""),
|
||||
explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider",
|
||||
);
|
||||
if (!candidate) {
|
||||
const providerId =
|
||||
normalizeProviderId(selected) || String(selected?.provider_name || "").trim();
|
||||
throw new Error(
|
||||
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用聊天模型;请传 --model-preference`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const selected = pickProvider(
|
||||
Array.isArray(providers) ? providers : [],
|
||||
explicitProvider || statusProvider,
|
||||
const autoCandidates = [];
|
||||
if (!explicitProvider && !explicitModel && statusProviderReady) {
|
||||
const statusCandidate = await buildProviderCandidate(
|
||||
options,
|
||||
statusProviderRecord || {
|
||||
id: statusProvider,
|
||||
custom_models: [statusModel],
|
||||
},
|
||||
statusModel,
|
||||
"agent-status",
|
||||
);
|
||||
if (statusCandidate) {
|
||||
autoCandidates.push(statusCandidate);
|
||||
}
|
||||
}
|
||||
for (const provider of providerList.filter((item) => providerReadyForLiveRuntime(item))) {
|
||||
const candidate = await buildProviderCandidate(
|
||||
options,
|
||||
provider,
|
||||
"",
|
||||
"auto-enabled-provider",
|
||||
);
|
||||
if (candidate) {
|
||||
autoCandidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = uniqueProviderCandidates(autoCandidates)
|
||||
.sort((left, right) => candidateRank(left) - candidateRank(right))
|
||||
.slice(0, MAX_MODEL_AVAILABILITY_CANDIDATES);
|
||||
if (candidates.length === 0) {
|
||||
const providerId =
|
||||
normalizeProviderId(selected) ||
|
||||
String(selected?.provider_name || statusProvider || "").trim();
|
||||
throw new Error(
|
||||
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用聊天模型;请传 --model-preference`,
|
||||
);
|
||||
}
|
||||
|
||||
const probeFailures = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (await verifyProviderCandidate(options, candidate)) {
|
||||
return {
|
||||
...candidate,
|
||||
source: "auto-probed-provider",
|
||||
};
|
||||
}
|
||||
probeFailures.push(`${candidate.providerPreference}/${candidate.modelPreference}: failed`);
|
||||
} catch (error) {
|
||||
probeFailures.push(
|
||||
`${candidate.providerPreference}/${candidate.modelPreference}: ${sanitizeProbeError(
|
||||
error,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[smoke:claw-chat-ready-streaming] 未找到可通过短聊天探测的 provider/model;候选失败: ${probeFailures.join(
|
||||
" | ",
|
||||
)}`,
|
||||
);
|
||||
const providerId = normalizeProviderId(selected) || String(selected?.provider_name || "").trim();
|
||||
if (!providerId) {
|
||||
throw new Error(
|
||||
"[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider",
|
||||
);
|
||||
}
|
||||
|
||||
let providerDetail = selected;
|
||||
try {
|
||||
providerDetail =
|
||||
(await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) ||
|
||||
selected;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const modelPreference = explicitModel || statusModel || pickModelPreference(providerDetail);
|
||||
if (!modelPreference) {
|
||||
throw new Error(
|
||||
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用模型;请传 --model-preference`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
providerPreference: providerId,
|
||||
modelPreference,
|
||||
source: explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider",
|
||||
};
|
||||
}
|
||||
|
||||
function textOfMessage(message) {
|
||||
@@ -461,6 +654,7 @@ function buildPageSnapshotScript(recoveryExpectedText) {
|
||||
textareaValue: textarea ? textarea.value : "",
|
||||
sendDisabled: Boolean(sendButton?.disabled),
|
||||
stopVisible: Boolean(stopButton),
|
||||
longPromptVisible: bodyText.includes("E2E 中断测试"),
|
||||
stoppedMarker: bodyText.includes("用户已停止当前执行"),
|
||||
recoveryVisible: bodyText.includes(recoveryExpected),
|
||||
streamLineCount: streamText ? streamText.split("\\n").filter(Boolean).length : 0,
|
||||
@@ -734,6 +928,7 @@ async function main() {
|
||||
modelName: agentStatus?.model_name || "",
|
||||
};
|
||||
let submittedSessionId = "";
|
||||
let submittedLongTurnId = "";
|
||||
|
||||
try {
|
||||
const scopedProviderKey = `agent_pref_provider_${workspaceId}`;
|
||||
@@ -864,10 +1059,32 @@ async function main() {
|
||||
assert(sessionId, "长 turn 缺少 session_id");
|
||||
assert(longTurnId, "长 turn 缺少 turn_id");
|
||||
submittedSessionId = sessionId;
|
||||
submittedLongTurnId = longTurnId;
|
||||
summary.sessionId = sessionId;
|
||||
summary.longTurnId = longTurnId;
|
||||
summary.longSubmitTurnConfig = longRequest.turn_config || null;
|
||||
summary.longTurnOpenDispatches = [
|
||||
await dispatchTaskCenterOpenTask(page, sessionId, workspaceId),
|
||||
];
|
||||
summary.longTurnVisibleSnapshot = await waitForCondition(
|
||||
"等待长 turn 会话挂载",
|
||||
async () => {
|
||||
const snapshot = await readPageSnapshot(page);
|
||||
return snapshot?.stopVisible || snapshot?.streamTextLength > 0
|
||||
? snapshot
|
||||
: null;
|
||||
},
|
||||
10_000,
|
||||
250,
|
||||
).catch((error) => {
|
||||
summary.longTurnVisibleWait = {
|
||||
passed: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
return null;
|
||||
});
|
||||
|
||||
let lastOpenDispatchAt = Date.now();
|
||||
const firstDelta = await waitForCondition(
|
||||
"等待首个流式增量与停止按钮",
|
||||
async () => {
|
||||
@@ -875,6 +1092,15 @@ async function main() {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
isLikelyDetachedBlankTaskSnapshot(snapshot) &&
|
||||
Date.now() - lastOpenDispatchAt >= 2_000
|
||||
) {
|
||||
lastOpenDispatchAt = Date.now();
|
||||
summary.longTurnOpenDispatches.push(
|
||||
await dispatchTaskCenterOpenTask(page, sessionId, workspaceId),
|
||||
);
|
||||
}
|
||||
const session = await invoke(
|
||||
options,
|
||||
"agent_runtime_get_session",
|
||||
@@ -1311,6 +1537,26 @@ async function main() {
|
||||
summary.devBridgeCommands = [...new Set(invokes.map((item) => item.cmd))];
|
||||
|
||||
if (submittedSessionId) {
|
||||
summary.failureCleanupInterrupt = await invoke(
|
||||
options,
|
||||
"agent_runtime_interrupt_turn",
|
||||
{
|
||||
request: {
|
||||
session_id: submittedSessionId,
|
||||
...(submittedLongTurnId ? { turn_id: submittedLongTurnId } : {}),
|
||||
},
|
||||
},
|
||||
20_000,
|
||||
)
|
||||
.then(() => ({ attempted: true, status: "sent" }))
|
||||
.catch((interruptError) => ({
|
||||
attempted: true,
|
||||
status: "failed",
|
||||
error:
|
||||
interruptError instanceof Error
|
||||
? interruptError.message
|
||||
: String(interruptError),
|
||||
}));
|
||||
const failureSession = await invoke(
|
||||
options,
|
||||
"agent_runtime_get_session",
|
||||
|
||||
@@ -459,6 +459,25 @@ async function clickPageControl(page, { text, ariaLabel, index = 0 }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeFileManagerIfOpen(page) {
|
||||
const closeButton = page
|
||||
.getByRole("button", { name: "关闭文件管理器" })
|
||||
.first();
|
||||
if (await closeButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await closeButton.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
return;
|
||||
}
|
||||
|
||||
const openButton = page
|
||||
.getByRole("button", { name: "打开左侧文件管理器" })
|
||||
.first();
|
||||
if (await openButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
|
||||
}
|
||||
|
||||
async function waitForExactButton(page, label, name, timeoutMs) {
|
||||
try {
|
||||
await page
|
||||
@@ -670,10 +689,18 @@ async function verifyBuilderImportAndReview(page, options) {
|
||||
await page
|
||||
.getByRole("button", { name: "内容运营", exact: true })
|
||||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
await page.getByLabel("资料名称").fill(BUILDER_ACCEPTANCE_PACK.title);
|
||||
await page
|
||||
.getByLabel("原始资料正文")
|
||||
.fill(BUILDER_ACCEPTANCE_PACK.sourceText);
|
||||
const nameInput = page.getByRole("textbox", { name: "资料名称" });
|
||||
const sourceTextarea = page.getByRole("textbox", { name: "原始资料正文" });
|
||||
await nameInput.waitFor({
|
||||
state: "visible",
|
||||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await sourceTextarea.waitFor({
|
||||
state: "visible",
|
||||
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await nameInput.fill(BUILDER_ACCEPTANCE_PACK.title);
|
||||
await sourceTextarea.fill(BUILDER_ACCEPTANCE_PACK.sourceText);
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Builder 表单填充",
|
||||
@@ -970,7 +997,7 @@ async function runPlaywrightGuiFlow(options) {
|
||||
pack.name === FILE_MANAGER_SOURCE_TITLE,
|
||||
);
|
||||
|
||||
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
|
||||
await closeFileManagerIfOpen(page);
|
||||
|
||||
logStage("open-knowledge-page");
|
||||
await openKnowledgePageFromMainNav(page, options);
|
||||
|
||||
@@ -23,6 +23,51 @@ function summarizeQcloopStatusSidecar(entry) {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function parseSidecarTime(entry) {
|
||||
const value = entry?.generatedAt || entry?.jobFinishedAt || "";
|
||||
const time = Date.parse(String(value || ""));
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function selectLatestQcloopStatusSidecars(sidecars) {
|
||||
const latestByJobId = new Map();
|
||||
const withoutJobId = [];
|
||||
for (const entry of asArray(sidecars)) {
|
||||
const jobId = String(entry?.jobId || "").trim();
|
||||
if (!jobId) {
|
||||
withoutJobId.push(entry);
|
||||
continue;
|
||||
}
|
||||
const previous = latestByJobId.get(jobId);
|
||||
if (!previous || parseSidecarTime(entry) >= parseSidecarTime(previous)) {
|
||||
latestByJobId.set(jobId, entry);
|
||||
}
|
||||
}
|
||||
return [...withoutJobId, ...latestByJobId.values()].sort((left, right) =>
|
||||
String(left?.path || "").localeCompare(String(right?.path || "")),
|
||||
);
|
||||
}
|
||||
|
||||
function isUnstartedPendingQcloopStatusSidecar(entry) {
|
||||
const counts = entry?.counts || {};
|
||||
const pending = Number(counts.pending || 0);
|
||||
const running = Number(counts.running || 0);
|
||||
const stale = Number(counts.stale || 0);
|
||||
const success = Number(counts.success || 0);
|
||||
const failed = Number(counts.failed || 0);
|
||||
const exhausted = Number(counts.exhausted || 0);
|
||||
const jobStatus = String(entry?.jobStatus || "").toLowerCase();
|
||||
return (
|
||||
jobStatus === "pending" &&
|
||||
pending > 0 &&
|
||||
running === 0 &&
|
||||
stale === 0 &&
|
||||
success === 0 &&
|
||||
failed === 0 &&
|
||||
exhausted === 0
|
||||
);
|
||||
}
|
||||
|
||||
function buildAgentQcCompletionAudit(facts) {
|
||||
const requiredQcloopScenarioIds = normalizeIdList(facts.scenarioReport?.p0ScenarioIds);
|
||||
const requiredQcloopScenarioCount =
|
||||
@@ -38,7 +83,8 @@ function buildAgentQcCompletionAudit(facts) {
|
||||
const sidecarEvidenceSummary = asArray(facts.realEvidenceSidecars)
|
||||
.map((entry) => `${entry.path} status=${entry.status || "unknown"} scenarios=${entry.scenarioCount || 0}`)
|
||||
.join("; ");
|
||||
const qcloopStatusSidecarSummary = asArray(facts.qcloopStatusSidecars)
|
||||
const qcloopStatusSidecars = selectLatestQcloopStatusSidecars(facts.qcloopStatusSidecars);
|
||||
const qcloopStatusSidecarSummary = qcloopStatusSidecars
|
||||
.map((entry) => {
|
||||
const counts = entry.counts
|
||||
? ` success=${entry.counts.success ?? 0} running=${entry.counts.running ?? 0} pending=${entry.counts.pending ?? 0} stale=${entry.counts.stale ?? 0}`
|
||||
@@ -46,15 +92,16 @@ function buildAgentQcCompletionAudit(facts) {
|
||||
return `${entry.path} verdict=${entry.verdictStatus || "unknown"}${counts}`;
|
||||
})
|
||||
.join("; ");
|
||||
const staleQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter(
|
||||
const staleQcloopStatusSidecars = qcloopStatusSidecars.filter(
|
||||
(entry) => entry.verdictStatus === "stale" || Number(entry.counts?.stale || 0) > 0,
|
||||
);
|
||||
const activeQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter(
|
||||
const activeQcloopStatusSidecars = qcloopStatusSidecars.filter(
|
||||
(entry) =>
|
||||
entry.verdictStatus === "running" ||
|
||||
entry.verdictStatus === "stale" ||
|
||||
Number(entry.counts?.running || 0) > 0 ||
|
||||
Number(entry.counts?.pending || 0) > 0,
|
||||
!isUnstartedPendingQcloopStatusSidecar(entry) &&
|
||||
(entry.verdictStatus === "running" ||
|
||||
entry.verdictStatus === "stale" ||
|
||||
Number(entry.counts?.running || 0) > 0 ||
|
||||
Number(entry.counts?.pending || 0) > 0),
|
||||
);
|
||||
const qcloopEvidenceText = facts.realEvidencePack?.exists
|
||||
? `.lime/qc/agent-qc-evidence.json status=${facts.realEvidencePack.status || "unknown"} scenarios=${facts.realEvidencePack.scenarioCount || 0}/${requiredQcloopScenarioCount}${missingQcloopScenarioIds.length > 0 ? ` missing=${missingQcloopScenarioIds.join(",")}` : ""}`
|
||||
@@ -111,6 +158,20 @@ function buildAgentQcCompletionAudit(facts) {
|
||||
`最近一次 verify:gui-smoke status=${facts.guiSmoke.status}${facts.guiSmoke.error ? `;${facts.guiSmoke.error}` : ""}`,
|
||||
);
|
||||
}
|
||||
const payloadCoverageEvidenceText = facts.payloadCoverage?.coverage
|
||||
? [
|
||||
`status=${facts.payloadCoverage.status || "unknown"}`,
|
||||
`coverage=${facts.payloadCoverage.coverage.passed ? "pass" : "fail"}`,
|
||||
`repairGuard=${facts.payloadCoverage.repairGuard?.passed ? "pass" : "fail"}`,
|
||||
`maxQcRounds=${facts.payloadCoverage.repairGuard?.maxQcRounds ?? "unknown"}`,
|
||||
`maxExecutorRetries=${facts.payloadCoverage.repairGuard?.maxExecutorRetries ?? "unknown"}`,
|
||||
`manifestP0=${facts.payloadCoverage.coverage.manifestP0Count ?? 0}`,
|
||||
`payloadItems=${facts.payloadCoverage.coverage.payloadItemCount ?? 0}`,
|
||||
`missing=${facts.payloadCoverage.coverage.missingScenarioIds?.length ?? 0}`,
|
||||
`extra=${facts.payloadCoverage.coverage.extraScenarioIds?.length ?? 0}`,
|
||||
`owner=${facts.payloadCoverage.ownerGate?.status || "unknown"}`,
|
||||
].join(" ")
|
||||
: "未发现 .lime/qc/qcloop-p0-single-owner-ready-coverage-current.json";
|
||||
const items = [
|
||||
createItem(
|
||||
"docs-tests-standard",
|
||||
@@ -150,6 +211,15 @@ function buildAgentQcCompletionAudit(facts) {
|
||||
`itemCount=${facts.qcloopPayload?.itemCount ?? 0}`,
|
||||
"qcloop payload 生成器不可用或没有 item。",
|
||||
),
|
||||
createItem(
|
||||
"qcloop-payload-coverage",
|
||||
"可校验 ready qcloop payload 覆盖 P0 manifest",
|
||||
facts.files?.payloadCoverageScript &&
|
||||
facts.payloadCoverage?.coverage?.passed === true &&
|
||||
facts.payloadCoverage?.repairGuard?.passed === true,
|
||||
payloadCoverageEvidenceText,
|
||||
"payload coverage 脚本缺失,ready payload 未完整覆盖 P0 manifest,或发布证据 payload 未禁用 qcloop repair / executor retry。",
|
||||
),
|
||||
createItem(
|
||||
"qcloop-verifier-evidence-placeholders",
|
||||
"qcloop verifier prompt 带 worker evidence 占位符",
|
||||
|
||||
@@ -14,6 +14,7 @@ function completeFacts() {
|
||||
testsReadme: true,
|
||||
evidenceSchema: true,
|
||||
qcloopJobScript: true,
|
||||
payloadCoverageScript: true,
|
||||
guiOwnerCheckScript: true,
|
||||
qcloopStatusScript: true,
|
||||
qcloopPreflightScript: true,
|
||||
@@ -55,6 +56,22 @@ function completeFacts() {
|
||||
verifierRequiresStructuredEvidence: true,
|
||||
verifierRequiresStrictJson: true,
|
||||
},
|
||||
payloadCoverage: {
|
||||
status: "ready",
|
||||
coverage: {
|
||||
passed: true,
|
||||
manifestP0Count: 1,
|
||||
payloadItemCount: 1,
|
||||
missingScenarioIds: [],
|
||||
extraScenarioIds: [],
|
||||
},
|
||||
repairGuard: {
|
||||
passed: true,
|
||||
maxQcRounds: 1,
|
||||
maxExecutorRetries: 0,
|
||||
},
|
||||
ownerGate: { status: "pass" },
|
||||
},
|
||||
structuredEvidence: {
|
||||
exporterParsesSummary: true,
|
||||
releaseSummaryRejectsWeakRefs: true,
|
||||
@@ -163,6 +180,62 @@ describe("agent-qc-completion-audit-core", () => {
|
||||
expect(item?.gap).toContain("qcloop-status.fastmini-readonly-p0-v1-current.json");
|
||||
});
|
||||
|
||||
it("未启动 pending-only qcloop sidecar 不应阻断已通过的官方 evidence", () => {
|
||||
const facts = completeFacts();
|
||||
facts.qcloopStatusSidecars = [
|
||||
{
|
||||
path: ".lime/qc/qcloop-status.old-pending.json",
|
||||
generatedAt: "2026-05-10T18:28:17.000Z",
|
||||
jobId: "job-pending",
|
||||
jobStatus: "pending",
|
||||
verdictStatus: "running",
|
||||
counts: { success: 0, failed: 0, exhausted: 0, running: 0, pending: 8, stale: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const audit = buildAgentQcCompletionAudit(facts);
|
||||
const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
|
||||
|
||||
expect(audit.status).toBe("complete");
|
||||
expect(item?.evidence).toContain("qcloop-status.old-pending.json");
|
||||
expect(item?.gap).not.toContain("未终态");
|
||||
});
|
||||
|
||||
it("同一 qcloop job 有更新终态 sidecar 时不应被旧 stale snapshot 阻断", () => {
|
||||
const facts = completeFacts();
|
||||
facts.realEvidencePack = {
|
||||
exists: true,
|
||||
status: "fail",
|
||||
scenarioCount: 8,
|
||||
scenarioIds: ["command-bridge-contract"],
|
||||
};
|
||||
facts.qcloopStatusSidecars = [
|
||||
{
|
||||
path: ".lime/qc/qcloop-status.pre-intervention-20260511-022817.json",
|
||||
generatedAt: "2026-05-10T18:28:17.000Z",
|
||||
jobId: "job-1",
|
||||
verdictStatus: "stale",
|
||||
counts: { success: 4, running: 1, pending: 3, stale: 1 },
|
||||
},
|
||||
{
|
||||
path: ".lime/qc/qcloop-status.isolated-p0-full-v1-current.json",
|
||||
generatedAt: "2026-05-10T18:38:51.000Z",
|
||||
jobId: "job-1",
|
||||
verdictStatus: "fail",
|
||||
counts: { success: 5, running: 0, pending: 0, stale: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const audit = buildAgentQcCompletionAudit(facts);
|
||||
const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
|
||||
|
||||
expect(audit.status).toBe("incomplete");
|
||||
expect(item?.evidence).toContain("qcloop-status.isolated-p0-full-v1-current.json");
|
||||
expect(item?.evidence).not.toContain("qcloop-status.pre-intervention-20260511-022817.json");
|
||||
expect(item?.gap).not.toContain("仍有 qcloop status sidecar 未终态");
|
||||
expect(item?.gap).not.toContain("其中 stale sidecar");
|
||||
});
|
||||
|
||||
it("qcloop verifier prompt 缺少 worker evidence 占位符时应保持 incomplete", () => {
|
||||
const facts = completeFacts();
|
||||
facts.qcloopPayload.verifierHasWorkerOutput = false;
|
||||
@@ -175,6 +248,34 @@ describe("agent-qc-completion-audit-core", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("ready payload coverage 不完整时应保持 incomplete", () => {
|
||||
const facts = completeFacts();
|
||||
facts.payloadCoverage.coverage.passed = false;
|
||||
facts.payloadCoverage.coverage.missingScenarioIds = ["workspace-ready-session-restore"];
|
||||
|
||||
const audit = buildAgentQcCompletionAudit(facts);
|
||||
|
||||
expect(audit.status).toBe("incomplete");
|
||||
expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-payload-coverage");
|
||||
});
|
||||
|
||||
it("ready payload 未禁用 qcloop repair 时应保持 incomplete", () => {
|
||||
const facts = completeFacts();
|
||||
facts.payloadCoverage.repairGuard = {
|
||||
passed: false,
|
||||
maxQcRounds: 3,
|
||||
maxExecutorRetries: 0,
|
||||
};
|
||||
|
||||
const audit = buildAgentQcCompletionAudit(facts);
|
||||
|
||||
expect(audit.status).toBe("incomplete");
|
||||
expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-payload-coverage");
|
||||
expect(audit.items.find((item) => item.id === "qcloop-payload-coverage")?.evidence).toContain(
|
||||
"repairGuard=fail",
|
||||
);
|
||||
});
|
||||
|
||||
it("结构化 evidence summary 契约缺失时应保持 incomplete", () => {
|
||||
const facts = completeFacts();
|
||||
facts.qcloopPayload.workerPromptHasStructuredEvidence = false;
|
||||
|
||||
@@ -24,6 +24,7 @@ function summarizeActiveGuiItems(status, guiScenarioIds) {
|
||||
return asArray(status?.items)
|
||||
.filter((item) => guiScenarioIdSet.has(normalizeScenarioId(item?.scenarioId)))
|
||||
.filter((item) => item?.terminal !== true)
|
||||
.filter((item) => !isUnstartedPendingItem(item))
|
||||
.map((item) => ({
|
||||
scenarioId: normalizeScenarioId(item?.scenarioId),
|
||||
qcloopStatus: item?.qcloopStatus || "unknown",
|
||||
@@ -35,6 +36,19 @@ function summarizeActiveGuiItems(status, guiScenarioIds) {
|
||||
}));
|
||||
}
|
||||
|
||||
function isUnstartedPendingItem(item) {
|
||||
const qcloopStatus = String(item?.qcloopStatus || "").toLowerCase();
|
||||
const workerStatus = String(item?.worker?.status || "unknown").toLowerCase();
|
||||
const attemptNo = Number(item?.currentAttemptNo || 0);
|
||||
const qcNo = Number(item?.currentQcNo || 0);
|
||||
return (
|
||||
qcloopStatus === "pending" &&
|
||||
attemptNo === 0 &&
|
||||
qcNo === 0 &&
|
||||
(workerStatus === "unknown" || workerStatus === "")
|
||||
);
|
||||
}
|
||||
|
||||
function createAgentQcGuiOwnerReport({
|
||||
manifest,
|
||||
statusSidecars = [],
|
||||
@@ -56,6 +70,10 @@ function createAgentQcGuiOwnerReport({
|
||||
for (const sidecars of sidecarsByJobId.values()) {
|
||||
const sidecar = selectRepresentativeSidecar(sidecars);
|
||||
const status = sidecar?.status || sidecar;
|
||||
if (isUnstartedPendingJob(status)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeItems = summarizeActiveGuiItems(status, guiScenarioIds);
|
||||
if (activeItems.length === 0) {
|
||||
continue;
|
||||
@@ -129,6 +147,24 @@ function createAgentQcGuiOwnerReport({
|
||||
};
|
||||
}
|
||||
|
||||
function isUnstartedPendingJob(status) {
|
||||
const jobStatus = String(status?.job?.status || "").toLowerCase();
|
||||
if (jobStatus !== "pending") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = asArray(status?.items);
|
||||
return (
|
||||
items.length === 0 ||
|
||||
items.every(
|
||||
(item) =>
|
||||
String(item?.qcloopStatus || "").toLowerCase() === "pending" &&
|
||||
Number(item?.currentAttemptNo || 0) === 0 &&
|
||||
(item?.worker?.status || "unknown") === "unknown",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function createOwnerIntervention(activeOwners) {
|
||||
const staleOwners = activeOwners.filter((owner) =>
|
||||
owner.activeItems.some((item) => item.stale),
|
||||
@@ -264,5 +300,6 @@ export {
|
||||
createAgentQcGuiOwnerWatchEntry,
|
||||
createAgentQcGuiOwnerReport,
|
||||
createOwnerIntervention,
|
||||
isUnstartedPendingItem,
|
||||
renderAgentQcGuiOwnerSummary,
|
||||
};
|
||||
|
||||
@@ -116,6 +116,35 @@ describe("agent-qc-gui-owner-core", () => {
|
||||
expect(report.ownerCount).toBe(1);
|
||||
});
|
||||
|
||||
it("未启动的 pending job 不应占用 GUI owner", () => {
|
||||
const report = createAgentQcGuiOwnerReport({
|
||||
manifest,
|
||||
statusSidecars: [
|
||||
{
|
||||
path: ".lime/qc/qcloop-status.gui-pending.json",
|
||||
status: {
|
||||
job: { id: "job-pending", status: "pending", terminal: false },
|
||||
verdict: { status: "running" },
|
||||
counts: { pending: 1, running: 0, stale: 0 },
|
||||
items: [
|
||||
{
|
||||
scenarioId: "claw-chat-ready-streaming",
|
||||
qcloopStatus: "pending",
|
||||
evidenceStatus: "blocked",
|
||||
terminal: false,
|
||||
currentAttemptNo: 0,
|
||||
worker: { status: "unknown" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.verdict.status).toBe("pass");
|
||||
expect(report.ownerCount).toBe(0);
|
||||
});
|
||||
|
||||
it("同一 running job 应选择 stale 秒数更新的 sidecar", () => {
|
||||
const createSidecar = (staleSeconds: number, generatedAt: string) => ({
|
||||
generatedAt,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
function itemById(audit) {
|
||||
return new Map((audit.items || []).map((item) => [item.id, item]));
|
||||
}
|
||||
|
||||
function allPassed(items, ids) {
|
||||
return ids.every((id) => items.get(id)?.passed === true);
|
||||
}
|
||||
|
||||
function evidenceFor(items, ids) {
|
||||
return ids.map((id) => `${id}=${items.get(id)?.passed ? "pass" : "fail"}`).join("; ");
|
||||
}
|
||||
|
||||
function gapFor(items, ids) {
|
||||
return ids
|
||||
.map((id) => items.get(id))
|
||||
.filter((item) => item && !item.passed)
|
||||
.map((item) => item.gap)
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function hasOwnerProtocolEvidence(items, processOwner) {
|
||||
return (
|
||||
items.get("qcloop-status-monitor")?.passed === true &&
|
||||
items.get("gui-owner-check")?.passed === true &&
|
||||
(processOwner?.verdict?.status === "pass" ||
|
||||
processOwner?.ownerIntervention?.status === "requires_owner_confirmation")
|
||||
);
|
||||
}
|
||||
|
||||
function ownerProtocolStatus(items, processOwner) {
|
||||
if (!hasOwnerProtocolEvidence(items, processOwner)) {
|
||||
return "fail";
|
||||
}
|
||||
return processOwner?.verdict?.status === "busy" ? "pass_with_blocking_owner" : "pass";
|
||||
}
|
||||
|
||||
function buildAgentQcObjectiveChecklist({
|
||||
audit,
|
||||
processOwner,
|
||||
guiOwner,
|
||||
generatedAt = new Date().toISOString(),
|
||||
}) {
|
||||
const items = itemById(audit);
|
||||
const manifestIds = ["scenario-manifest", "gui-flow-manifest", "evidence-schema"];
|
||||
const qcloopToolingIds = [
|
||||
"qcloop-payload-generator",
|
||||
"qcloop-payload-coverage",
|
||||
"qcloop-verifier-evidence-placeholders",
|
||||
"structured-evidence-contract",
|
||||
"qcloop-evidence-exporter",
|
||||
"release-summary",
|
||||
];
|
||||
const checklist = [
|
||||
{
|
||||
requirement: "在 docs/tests 下增加并维护 Agent/AI 测试标准文档",
|
||||
artifacts: [
|
||||
"docs/tests/README.md",
|
||||
"docs/tests/agent-ops-qc.md",
|
||||
"docs/tests/ai-agent-testing-guide.md",
|
||||
"docs/tests/lime-agent-autonomous-testing-plan.md",
|
||||
],
|
||||
evidence: items.get("docs-tests-standard")?.evidence || "",
|
||||
status: items.get("docs-tests-standard")?.passed ? "pass" : "fail",
|
||||
gap: items.get("docs-tests-standard")?.gap || "",
|
||||
},
|
||||
{
|
||||
requirement: "提供 P0 scenario manifest、GUI flow manifest、Evidence Pack schema",
|
||||
artifacts: [
|
||||
"docs/test/agent-qc-scenarios.manifest.json",
|
||||
"docs/test/agent-qc-gui-flows.manifest.json",
|
||||
"docs/test/agent-qc-evidence.schema.json",
|
||||
],
|
||||
evidence: manifestIds.map((id) => items.get(id)?.evidence).filter(Boolean).join("; "),
|
||||
status: allPassed(items, manifestIds) ? "pass" : "fail",
|
||||
gap: gapFor(items, manifestIds),
|
||||
},
|
||||
{
|
||||
requirement: "qcloop payload / verifier / exporter / release summary gate 可生成和审查结构化证据",
|
||||
artifacts: [
|
||||
"scripts/agent-qc-qcloop-job.mjs",
|
||||
"scripts/agent-qc-payload-coverage.mjs",
|
||||
"scripts/agent-qc-export-evidence.mjs",
|
||||
"scripts/agent-qc-release-summary.mjs",
|
||||
"docs/tests/lime-agent-qc-evidence-contract.md",
|
||||
],
|
||||
evidence: evidenceFor(items, qcloopToolingIds),
|
||||
status: allPassed(items, qcloopToolingIds) ? "pass" : "fail",
|
||||
gap: gapFor(items, qcloopToolingIds),
|
||||
},
|
||||
{
|
||||
requirement: "qcloop / GUI owner / raw process owner 有只读取证与安全处置协议",
|
||||
artifacts: [
|
||||
"scripts/agent-qc-qcloop-status.mjs",
|
||||
"scripts/agent-qc-gui-owner-check.mjs",
|
||||
"scripts/agent-qc-process-owner-check.mjs",
|
||||
"scripts/lib/agent-qc-process-owner-core.mjs",
|
||||
"docs/tests/lime-agent-qc-qcloop-operations.md",
|
||||
".lime/qc/stale-raw-gui-owner-intervention-request.json",
|
||||
],
|
||||
evidence: `guiOwner=${guiOwner?.verdict?.status}; processOwner=${processOwner?.verdict?.status}; ${processOwner?.verdict?.summary || ""}; ownerIntervention=${processOwner?.ownerIntervention?.status}`,
|
||||
status: ownerProtocolStatus(items, processOwner),
|
||||
gap:
|
||||
processOwner?.verdict?.status === "busy"
|
||||
? "raw process owner 仍 busy,不能启动完整 verify:local 或 full GUI P0。"
|
||||
: "",
|
||||
},
|
||||
{
|
||||
requirement: "官方 .lime/qc/agent-qc-evidence.json 必须是真实 8/8 P0 pass Evidence Pack",
|
||||
artifacts: [".lime/qc/agent-qc-evidence.json", "docs/test/agent-qc-scenarios.manifest.json"],
|
||||
evidence: items.get("real-qcloop-evidence")?.evidence || "",
|
||||
status: items.get("real-qcloop-evidence")?.passed ? "pass" : "fail",
|
||||
gap: items.get("real-qcloop-evidence")?.gap || "",
|
||||
},
|
||||
{
|
||||
requirement: "仓库统一门禁 npm run verify:local 必须通过",
|
||||
artifacts: [".lime/qc/verify-local-current.json", "npm run verify:local"],
|
||||
evidence: items.get("local-verify-gate")?.evidence || "",
|
||||
status: items.get("local-verify-gate")?.passed ? "pass" : "fail",
|
||||
gap: items.get("local-verify-gate")?.gap || "",
|
||||
},
|
||||
{
|
||||
requirement: "在 Lime 仓库内不执行未授权 git commit / push / tag / release",
|
||||
artifacts: ["git status", "user guardrail"],
|
||||
evidence: "本轮未执行 git commit / push / tag / release;工作树存在其他并发进程产生的 unrelated 修改,未触碰。",
|
||||
status: "pass",
|
||||
gap: "",
|
||||
},
|
||||
];
|
||||
const blockers = checklist
|
||||
.filter((item) => item.status !== "pass")
|
||||
.map((item) => ({ requirement: item.requirement, status: item.status, gap: item.gap }));
|
||||
return {
|
||||
schemaVersion: "v1",
|
||||
generatedAt,
|
||||
objective: "实现 Agent QC / 测试体系整体目标,并以真实证据证明可发布",
|
||||
status: blockers.length === 0 ? "complete" : "incomplete",
|
||||
passedCount: checklist.filter((item) => item.status === "pass").length,
|
||||
totalCount: checklist.length,
|
||||
blockers,
|
||||
checklist,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAgentQcObjectiveChecklistMarkdown(result) {
|
||||
const lines = [
|
||||
"# Objective Completion Checklist",
|
||||
"",
|
||||
`- Generated at: ${result.generatedAt}`,
|
||||
`- Status: ${result.status}`,
|
||||
`- Passed: ${result.passedCount}/${result.totalCount}`,
|
||||
"",
|
||||
"## Checklist",
|
||||
"",
|
||||
];
|
||||
for (const item of result.checklist) {
|
||||
lines.push(
|
||||
`### ${item.status.toUpperCase()} ${item.requirement}`,
|
||||
"",
|
||||
`- Artifacts: ${item.artifacts.join(", ")}`,
|
||||
`- Evidence: ${item.evidence || "none"}`,
|
||||
`- Gap: ${item.gap || "none"}`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
lines.push("## Blockers", "");
|
||||
if (!result.blockers.length) {
|
||||
lines.push("- none");
|
||||
}
|
||||
for (const blocker of result.blockers) {
|
||||
lines.push(`- ${blocker.requirement}: ${blocker.gap || blocker.status}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export { buildAgentQcObjectiveChecklist, renderAgentQcObjectiveChecklistMarkdown };
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildAgentQcObjectiveChecklist,
|
||||
renderAgentQcObjectiveChecklistMarkdown,
|
||||
} from "./agent-qc-objective-checklist-core.mjs";
|
||||
|
||||
const passedIds = [
|
||||
"docs-tests-standard",
|
||||
"scenario-manifest",
|
||||
"gui-flow-manifest",
|
||||
"evidence-schema",
|
||||
"qcloop-payload-generator",
|
||||
"qcloop-payload-coverage",
|
||||
"qcloop-verifier-evidence-placeholders",
|
||||
"structured-evidence-contract",
|
||||
"qcloop-evidence-exporter",
|
||||
"release-summary",
|
||||
"qcloop-status-monitor",
|
||||
"gui-owner-check",
|
||||
"real-qcloop-evidence",
|
||||
"local-verify-gate",
|
||||
];
|
||||
|
||||
function auditWith(overrides = {}) {
|
||||
const overrideById = new Map(Object.entries(overrides));
|
||||
return {
|
||||
items: passedIds.map((id) => ({
|
||||
id,
|
||||
title: id,
|
||||
passed: overrideById.get(id)?.passed ?? true,
|
||||
evidence: overrideById.get(id)?.evidence ?? `${id}-evidence`,
|
||||
gap: overrideById.get(id)?.gap ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function passProcessOwner() {
|
||||
return {
|
||||
verdict: { status: "pass", summary: "activeGuiSmoke=0, cargoOrRust=0, qcloopRelated=0" },
|
||||
ownerIntervention: { status: "not_required" },
|
||||
};
|
||||
}
|
||||
|
||||
function busyProcessOwner() {
|
||||
return {
|
||||
verdict: {
|
||||
status: "busy",
|
||||
summary: "activeGuiSmoke=1, cargoOrRust=0, qcloopRelated=0, staleActiveGuiSmoke=1",
|
||||
},
|
||||
ownerIntervention: { status: "requires_owner_confirmation", processIds: [59011] },
|
||||
};
|
||||
}
|
||||
|
||||
const guiOwner = { verdict: { status: "pass" } };
|
||||
|
||||
describe("agent-qc-objective-checklist-core", () => {
|
||||
it("所有目标证据齐全且 owner clear 时应 complete", () => {
|
||||
const checklist = buildAgentQcObjectiveChecklist({
|
||||
audit: auditWith(),
|
||||
processOwner: passProcessOwner(),
|
||||
guiOwner,
|
||||
generatedAt: "2026-05-11T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(checklist.status).toBe("complete");
|
||||
expect(checklist.passedCount).toBe(7);
|
||||
expect(checklist.blockers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("raw process owner busy 时应保留 pass_with_blocking_owner 并保持 incomplete", () => {
|
||||
const checklist = buildAgentQcObjectiveChecklist({
|
||||
audit: auditWith(),
|
||||
processOwner: busyProcessOwner(),
|
||||
guiOwner,
|
||||
});
|
||||
|
||||
const ownerItem = checklist.checklist.find((item) => item.requirement.includes("raw process owner"));
|
||||
expect(checklist.status).toBe("incomplete");
|
||||
expect(ownerItem?.status).toBe("pass_with_blocking_owner");
|
||||
expect(ownerItem?.gap).toContain("raw process owner 仍 busy");
|
||||
});
|
||||
|
||||
it("官方 evidence 或 verify:local 失败时应列为 blocker", () => {
|
||||
const checklist = buildAgentQcObjectiveChecklist({
|
||||
audit: auditWith({
|
||||
"real-qcloop-evidence": { passed: false, gap: "official evidence fail" },
|
||||
"local-verify-gate": { passed: false, gap: "verify local fail" },
|
||||
}),
|
||||
processOwner: passProcessOwner(),
|
||||
guiOwner,
|
||||
});
|
||||
|
||||
expect(checklist.status).toBe("incomplete");
|
||||
expect(checklist.blockers.map((item) => item.gap)).toContain("official evidence fail");
|
||||
expect(checklist.blockers.map((item) => item.gap)).toContain("verify local fail");
|
||||
});
|
||||
|
||||
it("应渲染 Markdown", () => {
|
||||
const checklist = buildAgentQcObjectiveChecklist({
|
||||
audit: auditWith(),
|
||||
processOwner: passProcessOwner(),
|
||||
guiOwner,
|
||||
});
|
||||
const markdown = renderAgentQcObjectiveChecklistMarkdown(checklist);
|
||||
|
||||
expect(markdown).toContain("Objective Completion Checklist");
|
||||
expect(markdown).toContain("Status: complete");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
function scenarioIdOf(value) {
|
||||
return value?.scenario_id || value?.scenarioId || value?.id || "";
|
||||
}
|
||||
|
||||
function parsePayloadItem(item) {
|
||||
if (typeof item !== "string") {
|
||||
return item;
|
||||
}
|
||||
return JSON.parse(item);
|
||||
}
|
||||
|
||||
function p0ScenarioIdsFromManifest(manifest) {
|
||||
return (manifest?.scenarios || [])
|
||||
.filter((scenario) => scenario?.risk === "P0")
|
||||
.map(scenarioIdOf)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function scenarioIdsFromPayload(payload) {
|
||||
return (payload?.items || []).map(parsePayloadItem).map(scenarioIdOf).filter(Boolean);
|
||||
}
|
||||
|
||||
function buildRepairGuard(payload) {
|
||||
const maxQcRounds = Number(payload?.max_qc_rounds);
|
||||
const maxExecutorRetries = Number(payload?.max_executor_retries ?? 0);
|
||||
const promptTemplate = String(payload?.prompt_template || "");
|
||||
const readOnlyPrompt =
|
||||
promptTemplate.includes("不得修改源码") &&
|
||||
promptTemplate.includes("不要顺手修复") &&
|
||||
promptTemplate.includes("只允许执行场景命令");
|
||||
const maxQcRoundsPassed = maxQcRounds === 1;
|
||||
const maxExecutorRetriesPassed = maxExecutorRetries === 0;
|
||||
return {
|
||||
passed: maxQcRoundsPassed && maxExecutorRetriesPassed && readOnlyPrompt,
|
||||
maxQcRounds,
|
||||
maxExecutorRetries,
|
||||
maxQcRoundsPassed,
|
||||
maxExecutorRetriesPassed,
|
||||
readOnlyPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgentQcPayloadCoverageReport({
|
||||
manifest,
|
||||
payload,
|
||||
processOwner,
|
||||
generatedAt = new Date().toISOString(),
|
||||
manifestPath = "docs/test/agent-qc-scenarios.manifest.json",
|
||||
payloadPath = "",
|
||||
} = {}) {
|
||||
const manifestScenarioIds = p0ScenarioIdsFromManifest(manifest);
|
||||
const payloadScenarioIds = scenarioIdsFromPayload(payload);
|
||||
const missingScenarioIds = manifestScenarioIds.filter((id) => !payloadScenarioIds.includes(id));
|
||||
const extraScenarioIds = payloadScenarioIds.filter((id) => !manifestScenarioIds.includes(id));
|
||||
const orderMatchesManifest =
|
||||
manifestScenarioIds.length === payloadScenarioIds.length &&
|
||||
manifestScenarioIds.every((id, index) => id === payloadScenarioIds[index]);
|
||||
const payloadValidation = payload?._validation || {};
|
||||
const payloadValidationPassed = payloadValidation.valid !== false;
|
||||
const repairGuard = buildRepairGuard(payload);
|
||||
const coveragePassed =
|
||||
manifestScenarioIds.length > 0 &&
|
||||
payloadScenarioIds.length === manifestScenarioIds.length &&
|
||||
missingScenarioIds.length === 0 &&
|
||||
extraScenarioIds.length === 0 &&
|
||||
orderMatchesManifest &&
|
||||
payloadValidationPassed &&
|
||||
repairGuard.passed;
|
||||
const ownerStatus = processOwner?.verdict?.status || "unknown";
|
||||
const status = coveragePassed ? (ownerStatus === "pass" ? "ready" : "blocked") : "fail";
|
||||
return {
|
||||
schemaVersion: "v1",
|
||||
generatedAt,
|
||||
status,
|
||||
manifest: manifestPath,
|
||||
payload: payloadPath,
|
||||
coverage: {
|
||||
passed: coveragePassed,
|
||||
manifestP0Count: manifestScenarioIds.length,
|
||||
payloadItemCount: payloadScenarioIds.length,
|
||||
manifestScenarioIds,
|
||||
payloadScenarioIds,
|
||||
missingScenarioIds,
|
||||
extraScenarioIds,
|
||||
orderMatchesManifest,
|
||||
payloadValidationPassed,
|
||||
},
|
||||
repairGuard,
|
||||
ownerGate: {
|
||||
status: ownerStatus,
|
||||
summary: processOwner?.verdict?.summary || "",
|
||||
ownerIntervention: processOwner?.ownerIntervention || null,
|
||||
},
|
||||
guardrails: {
|
||||
jobStarted: false,
|
||||
officialEvidenceOverwritten: false,
|
||||
qcloopDbModified: false,
|
||||
gitMutation: false,
|
||||
},
|
||||
startPreconditions: [
|
||||
"npm run agent-qc:process-owner-check -- --check",
|
||||
"npm run agent-qc:gui-owner-check -- --check",
|
||||
"npm run agent-qc:qcloop-preflight -- --require-devbridge --check",
|
||||
],
|
||||
nextAction:
|
||||
status === "ready"
|
||||
? "可以在 dedicated qcloop server / DB / port 上提交该 payload。"
|
||||
: status === "blocked"
|
||||
? "payload 覆盖完整,但 owner gate 未通过;只能等待 owner 释放或按确认协议处理。"
|
||||
: "修复 payload 与 manifest 的覆盖差异后再继续。",
|
||||
};
|
||||
}
|
||||
|
||||
function renderAgentQcPayloadCoverageMarkdown(report) {
|
||||
const lines = [
|
||||
"# qcloop P0 Payload Coverage",
|
||||
"",
|
||||
`- Generated at: ${report.generatedAt}`,
|
||||
`- Status: ${report.status}`,
|
||||
`- Manifest: ${report.manifest}`,
|
||||
`- Payload: ${report.payload}`,
|
||||
`- Manifest P0 count: ${report.coverage.manifestP0Count}`,
|
||||
`- Payload item count: ${report.coverage.payloadItemCount}`,
|
||||
`- Missing scenarios: ${report.coverage.missingScenarioIds.join(", ") || "none"}`,
|
||||
`- Extra scenarios: ${report.coverage.extraScenarioIds.join(", ") || "none"}`,
|
||||
`- Order matches manifest: ${report.coverage.orderMatchesManifest}`,
|
||||
`- Payload validation: ${report.coverage.payloadValidationPassed}`,
|
||||
"",
|
||||
"## Scenario Coverage",
|
||||
"",
|
||||
];
|
||||
report.coverage.payloadScenarioIds.forEach((id, index) => {
|
||||
lines.push(`${index + 1}. \`${id}\``);
|
||||
});
|
||||
lines.push(
|
||||
"",
|
||||
"## Repair Guard",
|
||||
"",
|
||||
`- Status: ${report.repairGuard.passed ? "pass" : "fail"}`,
|
||||
`- max_qc_rounds: ${report.repairGuard.maxQcRounds}`,
|
||||
`- max_executor_retries: ${report.repairGuard.maxExecutorRetries}`,
|
||||
`- Read-only prompt: ${report.repairGuard.readOnlyPrompt}`,
|
||||
"",
|
||||
);
|
||||
lines.push(
|
||||
"",
|
||||
"## Owner Gate",
|
||||
"",
|
||||
`- Status: ${report.ownerGate.status}`,
|
||||
`- Summary: ${report.ownerGate.summary || "none"}`,
|
||||
"",
|
||||
"## Start Preconditions",
|
||||
"",
|
||||
);
|
||||
for (const command of report.startPreconditions) {
|
||||
lines.push(`- \`${command}\``);
|
||||
}
|
||||
lines.push("", "## Guardrails", "");
|
||||
for (const [key, value] of Object.entries(report.guardrails)) {
|
||||
lines.push(`- ${key}: ${String(value)}`);
|
||||
}
|
||||
lines.push("", "## Next Action", "", report.nextAction, "");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export {
|
||||
buildRepairGuard,
|
||||
buildAgentQcPayloadCoverageReport,
|
||||
p0ScenarioIdsFromManifest,
|
||||
renderAgentQcPayloadCoverageMarkdown,
|
||||
scenarioIdsFromPayload,
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildRepairGuard,
|
||||
buildAgentQcPayloadCoverageReport,
|
||||
p0ScenarioIdsFromManifest,
|
||||
renderAgentQcPayloadCoverageMarkdown,
|
||||
scenarioIdsFromPayload,
|
||||
} from "./agent-qc-payload-coverage-core.mjs";
|
||||
|
||||
const manifest = {
|
||||
scenarios: [
|
||||
{ id: "command-bridge-contract", risk: "P0" },
|
||||
{ id: "workspace-ready-session-restore", risk: "P0" },
|
||||
{ id: "p1-only", risk: "P1" },
|
||||
],
|
||||
};
|
||||
|
||||
function owner(status: string) {
|
||||
return {
|
||||
verdict: { status, summary: `owner=${status}` },
|
||||
ownerIntervention: status === "busy" ? { status: "requires_owner_confirmation" } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function payload(items: unknown[], overrides = {}) {
|
||||
return {
|
||||
_validation: { valid: true },
|
||||
max_qc_rounds: 1,
|
||||
max_executor_retries: 0,
|
||||
prompt_template: "只允许执行场景命令,不要顺手修复;不得修改源码、配置、文档、锁文件或 git 状态。",
|
||||
items,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent-qc-payload-coverage-core", () => {
|
||||
it("应从 manifest 提取 P0 场景", () => {
|
||||
expect(p0ScenarioIdsFromManifest(manifest)).toEqual([
|
||||
"command-bridge-contract",
|
||||
"workspace-ready-session-restore",
|
||||
]);
|
||||
});
|
||||
|
||||
it("应解析 qcloop payload 中的字符串 item", () => {
|
||||
const payload = {
|
||||
items: [
|
||||
JSON.stringify({ scenario_id: "command-bridge-contract" }),
|
||||
JSON.stringify({ scenario_id: "workspace-ready-session-restore" }),
|
||||
],
|
||||
};
|
||||
|
||||
expect(scenarioIdsFromPayload(payload)).toEqual([
|
||||
"command-bridge-contract",
|
||||
"workspace-ready-session-restore",
|
||||
]);
|
||||
});
|
||||
|
||||
it("coverage 完整且 owner clear 时应 ready", () => {
|
||||
const report = buildAgentQcPayloadCoverageReport({
|
||||
manifest,
|
||||
payload: payload([
|
||||
JSON.stringify({ scenario_id: "command-bridge-contract" }),
|
||||
JSON.stringify({ scenario_id: "workspace-ready-session-restore" }),
|
||||
]),
|
||||
processOwner: owner("pass"),
|
||||
generatedAt: "2026-05-11T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("ready");
|
||||
expect(report.coverage.passed).toBe(true);
|
||||
expect(report.coverage.missingScenarioIds).toEqual([]);
|
||||
expect(report.coverage.extraScenarioIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("coverage 完整但 owner busy 时应 blocked", () => {
|
||||
const report = buildAgentQcPayloadCoverageReport({
|
||||
manifest,
|
||||
payload: payload([
|
||||
{ scenario_id: "command-bridge-contract" },
|
||||
{ scenario_id: "workspace-ready-session-restore" },
|
||||
]),
|
||||
processOwner: owner("busy"),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("blocked");
|
||||
expect(report.ownerGate.ownerIntervention?.status).toBe("requires_owner_confirmation");
|
||||
});
|
||||
|
||||
it("缺少 P0 场景时应 fail 并渲染 Markdown", () => {
|
||||
const report = buildAgentQcPayloadCoverageReport({
|
||||
manifest,
|
||||
payload: payload([{ scenario_id: "command-bridge-contract" }]),
|
||||
processOwner: owner("pass"),
|
||||
});
|
||||
const markdown = renderAgentQcPayloadCoverageMarkdown(report);
|
||||
|
||||
expect(report.status).toBe("fail");
|
||||
expect(report.coverage.missingScenarioIds).toEqual(["workspace-ready-session-restore"]);
|
||||
expect(markdown).toContain("qcloop P0 Payload Coverage");
|
||||
expect(markdown).toContain("Missing scenarios");
|
||||
});
|
||||
|
||||
it("应阻断会触发 qcloop repair 的发布证据 payload", () => {
|
||||
const report = buildAgentQcPayloadCoverageReport({
|
||||
manifest,
|
||||
payload: payload(
|
||||
[
|
||||
{ scenario_id: "command-bridge-contract" },
|
||||
{ scenario_id: "workspace-ready-session-restore" },
|
||||
],
|
||||
{ max_qc_rounds: 3 },
|
||||
),
|
||||
processOwner: owner("pass"),
|
||||
});
|
||||
|
||||
expect(buildRepairGuard(payload([], { max_qc_rounds: 3 })).passed).toBe(false);
|
||||
expect(report.repairGuard.passed).toBe(false);
|
||||
expect(report.status).toBe("fail");
|
||||
expect(report.repairGuard.maxQcRoundsPassed).toBe(false);
|
||||
expect(renderAgentQcPayloadCoverageMarkdown(report)).toContain("Repair Guard");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
const GUI_OWNER_PATTERNS = [
|
||||
"verify:gui-smoke",
|
||||
"smoke:workspace-ready",
|
||||
"smoke:browser-runtime",
|
||||
"smoke:site-adapters",
|
||||
"smoke:agent-service-skill-entry",
|
||||
"smoke:agent-runtime-tool-surface",
|
||||
"smoke:knowledge-gui",
|
||||
"smoke:design-canvas",
|
||||
"claw-chat-ready-streaming",
|
||||
"browser-runtime-site-adapter",
|
||||
"workspace-ready-session-restore",
|
||||
"release-package-startup-smoke",
|
||||
];
|
||||
|
||||
const CARGO_OWNER_PATTERNS = [
|
||||
"cargo ",
|
||||
"cargo-fmt",
|
||||
"rustc ",
|
||||
"clippy-driver",
|
||||
"tauri dev",
|
||||
];
|
||||
|
||||
const QCLOOP_OWNER_PATTERNS = [
|
||||
"qcloop --db",
|
||||
"./qcloop --db",
|
||||
"qcloop serve",
|
||||
"qcloop-worker",
|
||||
"qcloop_worker_result",
|
||||
"agent qc p0",
|
||||
"只读执行 lime agent qc p0",
|
||||
];
|
||||
|
||||
const OBSERVER_PROCESS_PATTERNS = [
|
||||
"while ps -p",
|
||||
"while ps aux | rg",
|
||||
"ps -eo pid,ppid,etime,pcpu,pmem,command | rg",
|
||||
"ps aux | rg",
|
||||
];
|
||||
|
||||
const PASSIVE_QCLOOP_SERVER_PATTERNS = [
|
||||
"qcloop --db",
|
||||
"./qcloop --db",
|
||||
"qcloop serve",
|
||||
];
|
||||
|
||||
const ACTIVE_QCLOOP_WORKER_PATTERNS = [
|
||||
"qcloop-worker",
|
||||
"qcloop_worker_result",
|
||||
"agent qc p0",
|
||||
"只读执行 lime agent qc p0",
|
||||
];
|
||||
|
||||
const PASSIVE_TAURI_RUNTIME_PATTERNS = ["tauri dev"];
|
||||
|
||||
function parseEtimeSeconds(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const dayParts = text.split("-");
|
||||
const dayCount = dayParts.length === 2 ? Number(dayParts[0]) : 0;
|
||||
const timeText = dayParts.length === 2 ? dayParts[1] : dayParts[0];
|
||||
const parts = timeText.split(":").map((part) => Number(part));
|
||||
if (parts.some((part) => !Number.isFinite(part))) {
|
||||
return null;
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return dayCount * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return dayCount * 86400 + parts[0] * 60 + parts[1];
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return dayCount * 86400 + parts[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeProcessCommand(command) {
|
||||
return String(command || "")
|
||||
.replace(/(--api-key(?:=|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
|
||||
.replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
|
||||
.replace(/ctx7sk-[A-Za-z0-9-]+/g, "ctx7sk-***")
|
||||
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-***");
|
||||
}
|
||||
|
||||
function commandHasAny(command, patterns) {
|
||||
const normalized = String(command || "").toLowerCase();
|
||||
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
function isObserverProcess(entry) {
|
||||
return commandHasAny(entry?.command, OBSERVER_PROCESS_PATTERNS);
|
||||
}
|
||||
|
||||
function isPassiveQcloopServer(entry) {
|
||||
return (
|
||||
commandHasAny(entry?.command, PASSIVE_QCLOOP_SERVER_PATTERNS) &&
|
||||
!commandHasAny(entry?.command, ACTIVE_QCLOOP_WORKER_PATTERNS)
|
||||
);
|
||||
}
|
||||
|
||||
function isPassiveTauriRuntime(entry) {
|
||||
return commandHasAny(entry?.command, PASSIVE_TAURI_RUNTIME_PATTERNS);
|
||||
}
|
||||
|
||||
function uniqueByPid(entries) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.pid)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(entry.pid);
|
||||
result.push(entry);
|
||||
}
|
||||
return result.sort((left, right) => left.pid - right.pid);
|
||||
}
|
||||
|
||||
function normalizeProcessEntry(entry) {
|
||||
const etime = String(entry?.etime || "unknown");
|
||||
return {
|
||||
...entry,
|
||||
pid: Number(entry?.pid || 0),
|
||||
ppid: Number(entry?.ppid || 0),
|
||||
pgid: entry?.pgid ?? null,
|
||||
stat: entry?.stat || "unknown",
|
||||
etime,
|
||||
durationSeconds: entry?.durationSeconds ?? parseEtimeSeconds(etime),
|
||||
command: sanitizeProcessCommand(entry?.command || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentQcProcessOwnerReport(processes, {
|
||||
generatedAt = new Date().toISOString(),
|
||||
platform = process.platform,
|
||||
maxActiveGuiSmoke = 0,
|
||||
maxCargoOrRust = 0,
|
||||
maxQcloopRelated = 0,
|
||||
staleMinutes = 30,
|
||||
} = {}) {
|
||||
const normalizedProcesses = processes
|
||||
.map(normalizeProcessEntry)
|
||||
.filter((entry) => entry.pid > 0 && entry.command.length > 0);
|
||||
const observerProcesses = uniqueByPid(normalizedProcesses.filter(isObserverProcess));
|
||||
const ownerCandidateProcesses = normalizedProcesses.filter((entry) => !isObserverProcess(entry));
|
||||
const passiveQcloopServerProcesses = uniqueByPid(
|
||||
ownerCandidateProcesses.filter(isPassiveQcloopServer),
|
||||
);
|
||||
const passiveTauriRuntimeProcesses = uniqueByPid(
|
||||
ownerCandidateProcesses.filter(isPassiveTauriRuntime),
|
||||
);
|
||||
const activeGuiSmokeProcesses = uniqueByPid(
|
||||
ownerCandidateProcesses.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)),
|
||||
);
|
||||
const qcloopProcesses = uniqueByPid(
|
||||
ownerCandidateProcesses.filter(
|
||||
(entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS) && !isPassiveQcloopServer(entry),
|
||||
),
|
||||
);
|
||||
const cargoProcesses = uniqueByPid(
|
||||
ownerCandidateProcesses.filter(
|
||||
(entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS) && !isPassiveTauriRuntime(entry),
|
||||
),
|
||||
);
|
||||
const staleThresholdSeconds = Math.max(0, Number(staleMinutes) || 0) * 60;
|
||||
const staleActiveGuiSmokeProcesses = uniqueByPid(
|
||||
activeGuiSmokeProcesses.filter(
|
||||
(entry) => Number(entry.durationSeconds || 0) >= staleThresholdSeconds,
|
||||
),
|
||||
);
|
||||
|
||||
const counts = {
|
||||
activeGuiSmoke: activeGuiSmokeProcesses.length,
|
||||
cargoOrRust: cargoProcesses.length,
|
||||
qcloopRelated: qcloopProcesses.length,
|
||||
staleActiveGuiSmoke: staleActiveGuiSmokeProcesses.length,
|
||||
passiveQcloopServer: passiveQcloopServerProcesses.length,
|
||||
passiveTauriRuntime: passiveTauriRuntimeProcesses.length,
|
||||
observer: observerProcesses.length,
|
||||
};
|
||||
const busy =
|
||||
counts.activeGuiSmoke > maxActiveGuiSmoke ||
|
||||
counts.cargoOrRust > maxCargoOrRust ||
|
||||
counts.qcloopRelated > maxQcloopRelated;
|
||||
|
||||
return {
|
||||
schemaVersion: "v1",
|
||||
generatedAt,
|
||||
platform,
|
||||
maxActiveGuiSmoke,
|
||||
maxCargoOrRust,
|
||||
maxQcloopRelated,
|
||||
staleMinutes,
|
||||
verdict: {
|
||||
status: busy ? "busy" : "pass",
|
||||
summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}, staleActiveGuiSmoke=${counts.staleActiveGuiSmoke}, passiveQcloopServer=${counts.passiveQcloopServer}, passiveTauriRuntime=${counts.passiveTauriRuntime}, observer=${counts.observer}`,
|
||||
nextAction: busy
|
||||
? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion."
|
||||
: "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.",
|
||||
},
|
||||
ownerIntervention: staleActiveGuiSmokeProcesses.length
|
||||
? {
|
||||
status: "requires_owner_confirmation",
|
||||
processIds: staleActiveGuiSmokeProcesses.map((entry) => entry.pid),
|
||||
requiredConfirmationText: `确认处理 stale raw GUI owner PID ${staleActiveGuiSmokeProcesses.map((entry) => entry.pid).join(", ")},可以终止这些进程并记录 sidecar。`,
|
||||
prohibitedUntilConfirmed: [
|
||||
"kill / pause / interrupt stale raw GUI owner",
|
||||
"start full verify:local",
|
||||
"start another full GUI P0 batch",
|
||||
"overwrite .lime/qc/agent-qc-evidence.json",
|
||||
"git commit / push / tag / release",
|
||||
],
|
||||
nextAction: "等待这些进程自然释放,或由 owner 明确确认后再处理。",
|
||||
}
|
||||
: {
|
||||
status: "not_required",
|
||||
processIds: [],
|
||||
requiredConfirmationText: "",
|
||||
prohibitedUntilConfirmed: [],
|
||||
nextAction: busy ? "等待 active owner 自然释放。" : "无需 stale raw owner 处置。",
|
||||
},
|
||||
activeGuiSmokeProcesses,
|
||||
qcloopProcesses,
|
||||
cargoProcesses,
|
||||
staleActiveGuiSmokeProcesses,
|
||||
passiveQcloopServerProcesses,
|
||||
passiveTauriRuntimeProcesses,
|
||||
observerProcesses,
|
||||
guardrails: [
|
||||
"best-effort process snapshot only",
|
||||
"do not kill or restart listed processes from this sidecar",
|
||||
"use this sidecar to decide whether heavy GUI or verify gates should wait",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
createAgentQcProcessOwnerReport,
|
||||
parseEtimeSeconds,
|
||||
sanitizeProcessCommand,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createAgentQcProcessOwnerReport,
|
||||
parseEtimeSeconds,
|
||||
sanitizeProcessCommand,
|
||||
} from "./agent-qc-process-owner-core.mjs";
|
||||
|
||||
describe("agent-qc-process-owner-core", () => {
|
||||
it("解析 ps etime 为秒", () => {
|
||||
expect(parseEtimeSeconds("07:04:53")).toBe(25493);
|
||||
expect(parseEtimeSeconds("10:05")).toBe(605);
|
||||
expect(parseEtimeSeconds("2-01:00:00")).toBe(176400);
|
||||
expect(parseEtimeSeconds("unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("脱敏进程命令中的常见密钥", () => {
|
||||
expect(sanitizeProcessCommand("cmd --api-key sk-1234567890abcdef api_key=ctx7sk-secret-value")).toContain(
|
||||
"--api-key <redacted>",
|
||||
);
|
||||
expect(sanitizeProcessCommand("cmd --api-key sk-1234567890abcdef api_key=ctx7sk-secret-value")).not.toContain(
|
||||
"sk-1234567890abcdef",
|
||||
);
|
||||
});
|
||||
|
||||
it("区分 active owner、passive runtime 与 observer", () => {
|
||||
const report = createAgentQcProcessOwnerReport(
|
||||
[
|
||||
{
|
||||
pid: 10,
|
||||
ppid: 1,
|
||||
pgid: 10,
|
||||
stat: "S",
|
||||
etime: "07:04:53",
|
||||
command: "npm run smoke:design-canvas -- --timeout-ms 600000",
|
||||
},
|
||||
{
|
||||
pid: 20,
|
||||
ppid: 1,
|
||||
pgid: 20,
|
||||
stat: "S",
|
||||
etime: "10:00:00",
|
||||
command: "./qcloop --db .lime/qc/example.db serve --addr 127.0.0.1:18080 --workers 1",
|
||||
},
|
||||
{
|
||||
pid: 30,
|
||||
ppid: 1,
|
||||
pgid: 30,
|
||||
stat: "S",
|
||||
etime: "10:00:00",
|
||||
command: "node node_modules/.bin/tauri dev --config src-tauri/tauri.conf.headless.json",
|
||||
},
|
||||
{
|
||||
pid: 40,
|
||||
ppid: 1,
|
||||
pgid: 40,
|
||||
stat: "S",
|
||||
etime: "00:01:00",
|
||||
command: "while ps -p 123 >/dev/null 2>&1; do sleep 10; done; ps aux | rg verify:gui-smoke",
|
||||
},
|
||||
],
|
||||
{ generatedAt: "2026-05-11T00:00:00.000Z", platform: "darwin" },
|
||||
);
|
||||
|
||||
expect(report.verdict.status).toBe("busy");
|
||||
expect(report.activeGuiSmokeProcesses.map((entry) => entry.pid)).toEqual([10]);
|
||||
expect(report.staleActiveGuiSmokeProcesses.map((entry) => entry.pid)).toEqual([10]);
|
||||
expect(report.passiveQcloopServerProcesses.map((entry) => entry.pid)).toEqual([20]);
|
||||
expect(report.passiveTauriRuntimeProcesses.map((entry) => entry.pid)).toEqual([30]);
|
||||
expect(report.observerProcesses.map((entry) => entry.pid)).toEqual([40]);
|
||||
expect(report.qcloopProcesses).toHaveLength(0);
|
||||
expect(report.cargoProcesses).toHaveLength(0);
|
||||
expect(report.ownerIntervention.status).toBe("requires_owner_confirmation");
|
||||
});
|
||||
|
||||
it("active qcloop worker 与 Cargo build 仍会阻断", () => {
|
||||
const report = createAgentQcProcessOwnerReport(
|
||||
[
|
||||
{
|
||||
pid: 50,
|
||||
ppid: 1,
|
||||
pgid: 50,
|
||||
stat: "S",
|
||||
etime: "00:05:00",
|
||||
command: "node codex exec 只读执行 Lime Agent QC P0 场景",
|
||||
},
|
||||
{
|
||||
pid: 60,
|
||||
ppid: 1,
|
||||
pgid: 60,
|
||||
stat: "S",
|
||||
etime: "00:05:00",
|
||||
command: "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||
},
|
||||
],
|
||||
{ generatedAt: "2026-05-11T00:00:00.000Z", platform: "darwin" },
|
||||
);
|
||||
|
||||
expect(report.verdict.status).toBe("busy");
|
||||
expect(report.qcloopProcesses.map((entry) => entry.pid)).toEqual([50]);
|
||||
expect(report.cargoProcesses.map((entry) => entry.pid)).toEqual([60]);
|
||||
expect(report.ownerIntervention.status).toBe("not_required");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user