@tea-agent/loop-agent 0.25.0 → 0.25.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +4 -4
- package/dist/application/dag/generate-task-dag.js +25 -8
- package/dist/commands/dag-init-hybrid.js +3 -2
- package/dist/commands/delegate.js +11 -11
- package/dist/commands/init.js +13 -12
- package/dist/commands/instructions.js +1 -2
- package/dist/executors/shell-executor.js +36 -4
- package/dist/task/read-model.js +3 -7
- package/dist/task/runtime.js +12 -3
- package/dist/worker/run-task/run-task.js +18 -2
- package/dist/workflows/dag/backend-test-markdown-workflow.js +240 -11
- package/dist/workflows/dag/backend-test-result-contract.js +46 -15
- package/dist/workflows/dag/init-hybrid.js +5 -8
- package/dist/workflows/dag/types.js +11 -0
- package/dist/workflows/loop/actions/dag-action.js +2 -1
- package/docs/init-surface.manifest.json +3 -0
- package/docs/templates/backend-test-dag.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +6 -8
- package/skills/loop-agent/references/command-reference.md +19 -10
- package/skills/loop-agent/references/harness-policy.md +6 -6
- package/skills/loop-agent/references/hybrid-dag.md +8 -7
- package/skills/loop-agent/references/post-implementation-and-patterns.md +19 -9
- package/skills/loop-agent/references/source-and-plan-practice.md +161 -0
- package/skills/loop-agent/references/task-workflow.md +8 -5
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { connect } from "node:net";
|
|
2
4
|
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createWriteStream } from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
4
10
|
const CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/g;
|
|
5
11
|
const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
6
12
|
const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
|
|
@@ -768,15 +774,57 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
|
768
774
|
function junitCaseId(name) {
|
|
769
775
|
return symbolCaseId(name) ?? name.match(CASE_ID)?.[0];
|
|
770
776
|
}
|
|
777
|
+
/** Prefer function-name Case ID; fall back to first BE-* in free text (docstring/log). */
|
|
778
|
+
function extractCaseIdFromText(value) {
|
|
779
|
+
if (!value?.trim())
|
|
780
|
+
return undefined;
|
|
781
|
+
return junitCaseId(value) ?? value.match(CASE_ID)?.[0];
|
|
782
|
+
}
|
|
783
|
+
function resolveReportCaseId(result, catalog) {
|
|
784
|
+
const fromName = extractCaseIdFromText(result.name);
|
|
785
|
+
if (fromName && catalog.has(fromName))
|
|
786
|
+
return fromName;
|
|
787
|
+
if (fromName)
|
|
788
|
+
return fromName;
|
|
789
|
+
// Match catalog by pytest function/method name when Case ID is only in docstring/md.
|
|
790
|
+
const byFunction = [...catalog.values()].find((item) => item.testFunctions.includes(result.name));
|
|
791
|
+
if (byFunction)
|
|
792
|
+
return byFunction.id;
|
|
793
|
+
const fromStdout = extractCaseIdFromText(result.stdout);
|
|
794
|
+
if (fromStdout && catalog.has(fromStdout))
|
|
795
|
+
return fromStdout;
|
|
796
|
+
if (fromStdout)
|
|
797
|
+
return fromStdout;
|
|
798
|
+
const fromDetails = extractCaseIdFromText(result.details);
|
|
799
|
+
if (fromDetails && catalog.has(fromDetails))
|
|
800
|
+
return fromDetails;
|
|
801
|
+
return "未关联";
|
|
802
|
+
}
|
|
771
803
|
function humanStatus(status) {
|
|
772
804
|
return status === "passed" ? "通过" : status === "failure" ? "失败" : status === "error" ? "错误" : "跳过";
|
|
773
805
|
}
|
|
774
806
|
function formatDuration(durationMs) {
|
|
775
807
|
return durationMs === undefined ? "未记录" : `${(durationMs / 1000).toFixed(3)} 秒`;
|
|
776
808
|
}
|
|
777
|
-
function inferredScriptPath(classname) {
|
|
778
|
-
|
|
779
|
-
|
|
809
|
+
function inferredScriptPath(classname, filePath) {
|
|
810
|
+
if (filePath?.trim()) {
|
|
811
|
+
return filePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
812
|
+
}
|
|
813
|
+
const moduleName = classname
|
|
814
|
+
.split(".")
|
|
815
|
+
.filter((part) => part && !/^Test[A-Z_]/.test(part) && part !== "Test")
|
|
816
|
+
.join("/");
|
|
817
|
+
if (!moduleName || moduleName === "unknown")
|
|
818
|
+
return "unknown.py";
|
|
819
|
+
return moduleName.endsWith(".py") ? moduleName : `${moduleName}.py`;
|
|
820
|
+
}
|
|
821
|
+
function resolveReportScriptPath(result, item) {
|
|
822
|
+
if (item?.scriptPath)
|
|
823
|
+
return item.scriptPath;
|
|
824
|
+
if (result.filePath?.trim()) {
|
|
825
|
+
return result.filePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
826
|
+
}
|
|
827
|
+
return inferredScriptPath(result.classname, result.filePath);
|
|
780
828
|
}
|
|
781
829
|
export function redactBackendTestOutput(value) {
|
|
782
830
|
return value
|
|
@@ -809,8 +857,97 @@ function reportOutputLines(value, prefix) {
|
|
|
809
857
|
return redactBackendTestOutput(trimmed.trim()).slice(0, 4000);
|
|
810
858
|
});
|
|
811
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* Normalize alternate HTTP log dialects into HTTP_REQUEST / HTTP_RESPONSE lines.
|
|
862
|
+
* Supports common project helpers that emit [REQ]/[RESP] block style instead of
|
|
863
|
+
* the contract prefixes (keeps original HTTP_* lines unchanged).
|
|
864
|
+
*/
|
|
865
|
+
export function normalizeBackendTestHttpLogText(value) {
|
|
866
|
+
if (!value.trim())
|
|
867
|
+
return value;
|
|
868
|
+
const lines = value.split(/\r?\n/);
|
|
869
|
+
const out = [];
|
|
870
|
+
let i = 0;
|
|
871
|
+
while (i < lines.length) {
|
|
872
|
+
const raw = lines[i];
|
|
873
|
+
const line = raw.trim();
|
|
874
|
+
// Already-contract lines: pass through.
|
|
875
|
+
if (/^(HTTP_REQUEST|HTTP REQUEST|HTTP_RESPONSE|HTTP RESPONSE)\b/i.test(line)) {
|
|
876
|
+
out.push(raw);
|
|
877
|
+
i += 1;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
// [REQ] METHOD path OR [REQ] METHOD url
|
|
881
|
+
const reqHead = line.match(/^\[REQ\]\s+([A-Z]+)\s+(\S+)\s*$/i);
|
|
882
|
+
if (reqHead) {
|
|
883
|
+
const method = reqHead[1].toUpperCase();
|
|
884
|
+
const url = reqHead[2];
|
|
885
|
+
let body = "";
|
|
886
|
+
let j = i + 1;
|
|
887
|
+
// Optional [REQ body] then JSON/text until separator or next tag.
|
|
888
|
+
if (j < lines.length && /^\[REQ\s*body\]/i.test(lines[j].trim())) {
|
|
889
|
+
j += 1;
|
|
890
|
+
const bodyLines = [];
|
|
891
|
+
while (j < lines.length &&
|
|
892
|
+
!/^[=-]{3,}\s*$/.test(lines[j].trim()) &&
|
|
893
|
+
!/^\[(?:REQ|RESP)/i.test(lines[j].trim()) &&
|
|
894
|
+
!/^(HTTP_REQUEST|HTTP_RESPONSE|HTTP REQUEST|HTTP RESPONSE)\b/i.test(lines[j].trim())) {
|
|
895
|
+
bodyLines.push(lines[j]);
|
|
896
|
+
j += 1;
|
|
897
|
+
}
|
|
898
|
+
body = bodyLines.join("\n").trim();
|
|
899
|
+
}
|
|
900
|
+
const payload = { method, url };
|
|
901
|
+
if (body) {
|
|
902
|
+
try {
|
|
903
|
+
payload.json = JSON.parse(body);
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
payload.body = body.slice(0, 4000);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
out.push(`HTTP_REQUEST ${JSON.stringify(payload)}`);
|
|
910
|
+
i = j;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
// [RESP status] 200 OR [RESP] 200
|
|
914
|
+
const respHead = line.match(/^\[RESP(?:\s+status)?\]\s+(\d{3})\b/i);
|
|
915
|
+
if (respHead) {
|
|
916
|
+
const status_code = Number(respHead[1]);
|
|
917
|
+
let body = "";
|
|
918
|
+
let j = i + 1;
|
|
919
|
+
if (j < lines.length && /^\[RESP\s*body\]/i.test(lines[j].trim())) {
|
|
920
|
+
j += 1;
|
|
921
|
+
const bodyLines = [];
|
|
922
|
+
while (j < lines.length &&
|
|
923
|
+
!/^[=-]{3,}\s*$/.test(lines[j].trim()) &&
|
|
924
|
+
!/^\[(?:REQ|RESP)/i.test(lines[j].trim()) &&
|
|
925
|
+
!/^(HTTP_REQUEST|HTTP_RESPONSE|HTTP REQUEST|HTTP RESPONSE)\b/i.test(lines[j].trim())) {
|
|
926
|
+
bodyLines.push(lines[j]);
|
|
927
|
+
j += 1;
|
|
928
|
+
}
|
|
929
|
+
body = bodyLines.join("\n").trim();
|
|
930
|
+
}
|
|
931
|
+
const payload = { status_code };
|
|
932
|
+
if (body) {
|
|
933
|
+
try {
|
|
934
|
+
payload.result = JSON.parse(body);
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
payload.result = body.slice(0, 4000);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
out.push(`HTTP_RESPONSE ${JSON.stringify(payload)}`);
|
|
941
|
+
i = j;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
out.push(raw);
|
|
945
|
+
i += 1;
|
|
946
|
+
}
|
|
947
|
+
return out.join("\n");
|
|
948
|
+
}
|
|
812
949
|
function requestResponseSummary(result) {
|
|
813
|
-
const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
950
|
+
const combined = normalizeBackendTestHttpLogText([result.stdout, result.stderr].filter(Boolean).join("\n"));
|
|
814
951
|
return {
|
|
815
952
|
requests: reportOutputLines(combined, "HTTP_REQUEST"),
|
|
816
953
|
responses: reportOutputLines(combined, "HTTP_RESPONSE"),
|
|
@@ -1047,8 +1184,8 @@ export function renderBackendTestHtml(input) {
|
|
|
1047
1184
|
const failures = input.parsed.cases.filter((result) => result.status !== "passed");
|
|
1048
1185
|
const headColor = failed ? { fg: "#b42318", bg: "linear-gradient(135deg,#fef3f2 0,#fee4e2 100%)", border: "#fda29b", icon: "✗" } : { fg: "#067647", bg: "linear-gradient(135deg,#ecfdf3 0,#d1fadf 100%)", border: "#abefc6", icon: "✓" };
|
|
1049
1186
|
const caseCards = input.parsed.cases.map((result) => {
|
|
1050
|
-
const caseId =
|
|
1051
|
-
const item = catalog.get(caseId);
|
|
1187
|
+
const caseId = resolveReportCaseId(result, catalog);
|
|
1188
|
+
const item = caseId === "未关联" ? undefined : catalog.get(caseId);
|
|
1052
1189
|
const io = requestResponseSummary(result);
|
|
1053
1190
|
const calls = pairHttpCalls(io.requests, io.responses);
|
|
1054
1191
|
const statusColors = statusColor(result.status);
|
|
@@ -1058,7 +1195,7 @@ export function renderBackendTestHtml(input) {
|
|
|
1058
1195
|
const failureBlock = result.status === "passed"
|
|
1059
1196
|
? ""
|
|
1060
1197
|
: `<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:#fef3f2;border:1px solid #fda29b;color:#b42318;font-size:0.84rem"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? `<details style="margin-top:6px"><summary style="cursor:pointer;color:#b42318;font-size:0.78rem">失败详情</summary><pre style="white-space:pre-wrap;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.78rem;margin-top:6px;border:1px solid #eceef2;line-height:1.6">${escapeHtml(result.details)}</pre></details>` : ""}</div>`;
|
|
1061
|
-
const scriptPath =
|
|
1198
|
+
const scriptPath = resolveReportScriptPath(result, item);
|
|
1062
1199
|
return `<details style="background:#fff;border:1px solid #e6eaf0;border-radius:12px;margin-top:10px"><summary style="display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;list-style:none"><span style="background:#e8edf5;color:#2b3a55;padding:2px 9px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.76rem;font-weight:600;white-space:nowrap">${escapeHtml(caseId)}</span><div style="flex:1;min-width:0"><div style="font-weight:600;color:#172033;font-size:0.92rem">${escapeHtml(item?.title ?? result.name)}</div><code style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.74rem;color:#98a2b3">${escapeHtml(result.name)}</code></div><span style="color:#98a2b3;font-size:0.8rem;white-space:nowrap">${formatDuration(result.durationMs)}</span><span style="display:inline-block;padding:2px 10px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${statusColors.fg};background:${statusColors.bg}">${humanStatus(result.status)}</span><span style="color:#aab2bf;font-size:0.8rem">▾</span></summary><div style="padding:4px 16px 14px 16px;border-top:1px solid #f0f3f7"><div style="margin-top:10px"><div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px">自动化脚本</div><code style="background:#eef2f7;color:#344054;padding:1px 6px;border-radius:5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.8rem">${escapeHtml(scriptPath)}</code></div>${item?.scenario ? `<div style="margin-top:12px"><div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px">验收场景</div><div style="color:#475467;font-size:0.86rem;line-height:1.75">${escapeHtml(item.scenario)}</div></div>` : ""}${logSection}${failureBlock}</div></details>`;
|
|
1063
1200
|
}).join("");
|
|
1064
1201
|
const metricCard = (label, value, valueColor = "#172033") => `<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">${escapeHtml(label)}</span><div style="font-size:22px;font-weight:700;color:${valueColor}">${value}</div></div>`;
|
|
@@ -1067,7 +1204,7 @@ export function renderBackendTestHtml(input) {
|
|
|
1067
1204
|
const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation)}${renderQualityCard("Markdown → pytest 追溯", traceability)}</div></div>`;
|
|
1068
1205
|
const failureOverview = failures.length > 0
|
|
1069
1206
|
? `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">失败概览</div><div style="display:grid;gap:10px;margin-top:8px">${failures.map((result) => {
|
|
1070
|
-
const caseId =
|
|
1207
|
+
const caseId = resolveReportCaseId(result, catalog);
|
|
1071
1208
|
const c = statusColor(result.status);
|
|
1072
1209
|
return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:2px">${escapeHtml(result.message || humanStatus(result.status))}</div></div>`;
|
|
1073
1210
|
}).join("")}</div></div>`
|
|
@@ -1079,6 +1216,98 @@ function renderQualityCard(title, summary) {
|
|
|
1079
1216
|
const label = summary.status === "Unavailable" ? "不可用" : summary.status;
|
|
1080
1217
|
return `<div style="padding:12px 14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfe"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"><strong style="color:#17365d;font-size:0.88rem">${escapeHtml(title)}</strong><span style="display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${colors.fg};background:${colors.bg}">${label}</span></div><div style="color:#667085;font-size:0.78rem">Findings:${summary.findings ?? "不可用"}</div><div style="color:#475467;font-size:0.8rem;margin-top:2px">${escapeHtml(summary.firstFinding)}</div></div>`;
|
|
1081
1218
|
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Dump JaCoCo execution data over TCP from a JaCoCo tcpserver agent, convert
|
|
1221
|
+
* .exec → jacoco.xml via jacococli.jar, then parse into a code-coverage-v1
|
|
1222
|
+
* contract. Designed for Java services started with
|
|
1223
|
+
* `-javaagent:jacocoagent.jar=output=tcpserver,port=6300,append=false`.
|
|
1224
|
+
*
|
|
1225
|
+
* Failure-safe: any transport/conversion/parse error returns null so the L-5
|
|
1226
|
+
* dashboard degrades coverage to unavailable instead of blocking the run.
|
|
1227
|
+
*/
|
|
1228
|
+
export async function collectJacocoCoverage(input) {
|
|
1229
|
+
const [host, portStr] = input.endpoint.split(":");
|
|
1230
|
+
const port = Number(portStr);
|
|
1231
|
+
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
const execPath = path.join(input.workDir, "jacoco.exec");
|
|
1235
|
+
const xmlPath = path.join(input.workDir, "jacoco.xml");
|
|
1236
|
+
// Step 1: TCP dump of JaCoCo execution data. The protocol is JaCoCo's
|
|
1237
|
+
// binary "dump" command: a magic header + command byte. We send the dump
|
|
1238
|
+
// request and stream the response into jacoco.exec.
|
|
1239
|
+
try {
|
|
1240
|
+
await dumpJacocoExec(host, port, execPath, input.connectTimeoutMs);
|
|
1241
|
+
}
|
|
1242
|
+
catch {
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|
|
1245
|
+
// Step 2: jacococli.jar report .exec → jacoco.xml
|
|
1246
|
+
try {
|
|
1247
|
+
await execFileAsync("java", [
|
|
1248
|
+
"-jar", input.cliJarPath,
|
|
1249
|
+
"report", execPath,
|
|
1250
|
+
"--xml", xmlPath,
|
|
1251
|
+
"--includes", input.includes,
|
|
1252
|
+
], { timeout: 60000, windowsHide: true });
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
// Step 3: parse jacoco.xml into a code-coverage-v1 contract.
|
|
1258
|
+
try {
|
|
1259
|
+
const xml = await readFile(xmlPath, "utf8");
|
|
1260
|
+
const artifactSha256 = createHash("sha256").update(xml).digest("hex");
|
|
1261
|
+
return parseJacocoXml(xml, {
|
|
1262
|
+
sourceScope: input.sourceScope,
|
|
1263
|
+
commitSha: input.commitSha,
|
|
1264
|
+
artifactPath: "reports/jacoco.xml",
|
|
1265
|
+
artifactSha256,
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Send a JaCoCo TCP dump command and stream the response into destPath.
|
|
1274
|
+
* JaCoCo tcpserver protocol: connect, send dump command header, read until close.
|
|
1275
|
+
*/
|
|
1276
|
+
function dumpJacocoExec(host, port, destPath, connectTimeoutMs) {
|
|
1277
|
+
return new Promise((resolve, reject) => {
|
|
1278
|
+
const socket = connect({ host, port });
|
|
1279
|
+
const writeStream = createWriteStream(destPath);
|
|
1280
|
+
const timer = setTimeout(() => {
|
|
1281
|
+
socket.destroy();
|
|
1282
|
+
reject(new Error("jacoco-tcp-connect-timeout"));
|
|
1283
|
+
}, connectTimeoutMs);
|
|
1284
|
+
socket.on("connect", () => {
|
|
1285
|
+
// JaCoCo TCP "dump" command: magic 0xC0FFEE + cmd=0x40 (dump)
|
|
1286
|
+
// Format: <magic 3 bytes><cmd id 1 byte>
|
|
1287
|
+
// The server responds with the exec data and closes.
|
|
1288
|
+
const magic = Buffer.from([0xC0, 0xFF, 0xEE]);
|
|
1289
|
+
const dumpCmd = Buffer.from([0x40]);
|
|
1290
|
+
socket.end(Buffer.concat([magic, dumpCmd]));
|
|
1291
|
+
});
|
|
1292
|
+
socket.on("error", (error) => {
|
|
1293
|
+
clearTimeout(timer);
|
|
1294
|
+
reject(error);
|
|
1295
|
+
});
|
|
1296
|
+
socket.on("close", () => {
|
|
1297
|
+
clearTimeout(timer);
|
|
1298
|
+
writeStream.end(() => {
|
|
1299
|
+
// Verify we got non-empty exec data.
|
|
1300
|
+
readFile(destPath).then((buf) => {
|
|
1301
|
+
if (buf.length === 0)
|
|
1302
|
+
reject(new Error("jacoco-dump-empty"));
|
|
1303
|
+
else
|
|
1304
|
+
resolve();
|
|
1305
|
+
}).catch(reject);
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
socket.pipe(writeStream);
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1082
1311
|
function inferModuleScript(classname) {
|
|
1083
1312
|
// classname like "test_process_definition_list.py::TestX::test_a" → take the file stem.
|
|
1084
1313
|
const first = classname.split("::")[0] ?? classname;
|
|
@@ -1244,9 +1473,9 @@ export function renderBackendTestFacts(input) {
|
|
|
1244
1473
|
"| 用例编号 | 用例名称 | 自动化脚本 | 测试函数 | 结果 | 耗时 | 失败原因 |",
|
|
1245
1474
|
"|---|---|---|---|---|---:|---|",
|
|
1246
1475
|
...input.parsed.cases.map((result) => {
|
|
1247
|
-
const caseId =
|
|
1248
|
-
const item = catalog.get(caseId);
|
|
1249
|
-
const script =
|
|
1476
|
+
const caseId = resolveReportCaseId(result, catalog);
|
|
1477
|
+
const item = caseId === "未关联" ? undefined : catalog.get(caseId);
|
|
1478
|
+
const script = resolveReportScriptPath(result, item);
|
|
1250
1479
|
return `| ${caseId} | ${item?.title ?? result.name} | \`${script}\` | \`${result.name}\` | ${humanStatus(result.status)} | ${formatDuration(result.durationMs)} | ${(result.message ?? "—").replaceAll("|", "\\|")} |`;
|
|
1251
1480
|
}),
|
|
1252
1481
|
"",
|
|
@@ -428,7 +428,8 @@ export function parsePytestHtmlReport(html) {
|
|
|
428
428
|
continue;
|
|
429
429
|
const rawResult = (record.result ?? "").toLowerCase();
|
|
430
430
|
const testId = record.testId ?? nodeId;
|
|
431
|
-
const { classname, name } = splitPytestNodeId(testId);
|
|
431
|
+
const { classname, name, filePath } = splitPytestNodeId(testId);
|
|
432
|
+
const filePathFields = filePath ? { filePath } : {};
|
|
432
433
|
const durationMs = parseDurationLabelMs(record.duration);
|
|
433
434
|
const capturedLog = record.log ?? "";
|
|
434
435
|
// pytest-html collapses captured stdout/stderr into a single `log` field
|
|
@@ -441,6 +442,8 @@ export function parsePytestHtmlReport(html) {
|
|
|
441
442
|
cases.push({
|
|
442
443
|
classname,
|
|
443
444
|
name,
|
|
445
|
+
...filePathFields,
|
|
446
|
+
...filePathFields,
|
|
444
447
|
durationMs,
|
|
445
448
|
status: "passed",
|
|
446
449
|
...(stdout ? { stdout } : {}),
|
|
@@ -455,6 +458,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
455
458
|
cases.push({
|
|
456
459
|
classname,
|
|
457
460
|
name,
|
|
461
|
+
...filePathFields,
|
|
458
462
|
durationMs,
|
|
459
463
|
status: "failure",
|
|
460
464
|
message: summary,
|
|
@@ -471,6 +475,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
471
475
|
cases.push({
|
|
472
476
|
classname,
|
|
473
477
|
name,
|
|
478
|
+
...filePathFields,
|
|
474
479
|
durationMs,
|
|
475
480
|
status: "error",
|
|
476
481
|
message: summary,
|
|
@@ -484,6 +489,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
484
489
|
cases.push({
|
|
485
490
|
classname,
|
|
486
491
|
name,
|
|
492
|
+
...filePathFields,
|
|
487
493
|
durationMs,
|
|
488
494
|
status: "skipped",
|
|
489
495
|
...(stdout ? { stdout } : {}),
|
|
@@ -499,6 +505,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
499
505
|
cases.push({
|
|
500
506
|
classname,
|
|
501
507
|
name,
|
|
508
|
+
...filePathFields,
|
|
502
509
|
durationMs,
|
|
503
510
|
status: "error",
|
|
504
511
|
message: summary,
|
|
@@ -520,20 +527,44 @@ export function parsePytestHtmlReport(html) {
|
|
|
520
527
|
failures: failures.slice(0, MAX_FAILURES),
|
|
521
528
|
};
|
|
522
529
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
530
|
+
/**
|
|
531
|
+
* Split a pytest node id into module classname, test name, and optional file path.
|
|
532
|
+
*
|
|
533
|
+
* Handles:
|
|
534
|
+
* - `testcase/test_x.py::test_name`
|
|
535
|
+
* - `testcase/test_x.py::TestClass::test_name` (class-based; previous code
|
|
536
|
+
* treated `TestClass` as the file segment and later filtered it to unknown.py)
|
|
537
|
+
* - Windows separators and parametrized names `test_name[param]`
|
|
538
|
+
*/
|
|
539
|
+
export function splitPytestNodeId(testId) {
|
|
540
|
+
const normalized = testId.replaceAll("\\", "/").trim();
|
|
541
|
+
if (!normalized) {
|
|
542
|
+
return { classname: "unknown", name: "unknown" };
|
|
543
|
+
}
|
|
544
|
+
const parts = normalized.split("::").filter(Boolean);
|
|
545
|
+
if (parts.length < 2) {
|
|
546
|
+
// Bare id without "::" — keep as name; path unknown.
|
|
547
|
+
return { classname: "unknown", name: normalized };
|
|
548
|
+
}
|
|
549
|
+
let rawName = parts[parts.length - 1];
|
|
550
|
+
// Drop call args if present; keep parametrize brackets in display name strip for id match.
|
|
551
|
+
const paren = rawName.indexOf("(");
|
|
552
|
+
if (paren >= 0)
|
|
553
|
+
rawName = rawName.slice(0, paren);
|
|
554
|
+
const cleanName = rawName.trim() || "unknown";
|
|
555
|
+
// Prefer the first segment that looks like a .py file path.
|
|
556
|
+
const filePart = parts.find((part) => /\.py$/i.test(part)) ??
|
|
557
|
+
(parts[0].includes("/") || parts[0].includes(".") ? parts[0] : undefined);
|
|
558
|
+
const filePath = filePart
|
|
559
|
+
? filePart.replace(/\\/g, "/")
|
|
560
|
+
: undefined;
|
|
561
|
+
const moduleWithoutExt = (filePath ?? "unknown").replace(/\.py$/i, "");
|
|
562
|
+
const classname = moduleWithoutExt.replaceAll("/", ".") || "unknown";
|
|
563
|
+
return {
|
|
564
|
+
classname,
|
|
565
|
+
name: cleanName,
|
|
566
|
+
...(filePath ? { filePath } : {}),
|
|
567
|
+
};
|
|
537
568
|
}
|
|
538
569
|
function splitPytestHtmlLogSections(log) {
|
|
539
570
|
if (!log)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { access, readdir, readFile, realpath,
|
|
2
|
+
import { access, readdir, readFile, realpath, } from "node:fs/promises";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
5
|
+
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
6
6
|
import { assertValidDagSpec } from "./validate.js";
|
|
7
7
|
import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1, DAG_RUNTIME_CONTRACT_SCHEMA_VERSION, DEFAULT_DAG_OUTPUT_LANGUAGE, DEFAULT_DAG_EXECUTOR_MODELS, parseDagSpec, } from "./types.js";
|
|
8
8
|
import { planMavenVerification, } from "../../verification/maven/index.js";
|
|
@@ -3515,7 +3515,7 @@ async function buildBackendTestHybridDag(sources) {
|
|
|
3515
3515
|
'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
|
|
3516
3516
|
'echo "pytest targets are resolved at runtime from final Markdown 自动化映射"',
|
|
3517
3517
|
].join("; ");
|
|
3518
|
-
const execute = shellNode("execute-backend-pytest-and-html-report-shell", [traceability.id], "markdown-execute-html", "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Prefer the deterministic module one-to-one path when a mapped script is missing but the module stem file exists. Generate a native pytest-html self-contained report, then render the primary self-contained Chinese HTML report from the same pytest-html plus final Markdown case metadata without rerun. Keep 测试结论, quality status, failure overview, and a polished per-case result card with concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.", "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md,
|
|
3518
|
+
const execute = shellNode("execute-backend-pytest-and-html-report-shell", [traceability.id], "markdown-execute-html", "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Prefer the deterministic module one-to-one path when a mapped script is missing but the module stem file exists. Generate a native pytest-html self-contained report, then render the primary self-contained Chinese HTML report from the same pytest-html plus final Markdown case metadata without rerun. Keep 测试结论, quality status, failure overview, and a polished per-case result card with concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.", "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md, a deterministic self-contained reports/backend-test-l5-dashboard.html (machine-computed L-5 metrics, no JSON), and an optional contracts/code-coverage-v1.json when jacocoCoverage is configured (JaCoCo TCP dump → jacoco.xml → parsed; failure-safe); exit 0/1 with valid evidence continues.", [pytestCommand], 300000);
|
|
3519
3519
|
if (execute.shell) {
|
|
3520
3520
|
execute.shell.envAllowlist = collectBackendTestShellEnvAllowlist(sources);
|
|
3521
3521
|
}
|
|
@@ -5741,9 +5741,6 @@ function buildSupervisedHybridDag(standard, sources) {
|
|
|
5741
5741
|
assertValidDagSpec(spec);
|
|
5742
5742
|
return spec;
|
|
5743
5743
|
}
|
|
5744
|
-
export function defaultHybridDagOutputPath(taskId) {
|
|
5745
|
-
return path.join(os.tmpdir(), `${taskId}-hybrid-dag.json`);
|
|
5746
|
-
}
|
|
5747
5744
|
export async function writeHybridDagDraft(sources, outputPath, options = {}) {
|
|
5748
5745
|
const routingProjectCapability = sources.taskConfig.taskKind === "standard" &&
|
|
5749
5746
|
(options.template === undefined || options.template === "standard-dag")
|
|
@@ -5774,7 +5771,7 @@ export async function writeHybridDagDraft(sources, outputPath, options = {}) {
|
|
|
5774
5771
|
taskId: preparedSources.taskId,
|
|
5775
5772
|
});
|
|
5776
5773
|
}
|
|
5777
|
-
await
|
|
5774
|
+
await writeJsonAtomic(outputPath, spec, { repoRoot: sources.repoRoot });
|
|
5778
5775
|
return {
|
|
5779
5776
|
taskId: sources.taskId,
|
|
5780
5777
|
outputPath,
|
|
@@ -5787,7 +5784,7 @@ export async function writeHybridDagDraft(sources, outputPath, options = {}) {
|
|
|
5787
5784
|
export async function initHybridDagFromTask(repoRoot, taskId, options = {}) {
|
|
5788
5785
|
const sources = await loadTaskHybridSources(repoRoot, taskId);
|
|
5789
5786
|
assertTaskAllowedPathsPreflight(sources.taskConfig);
|
|
5790
|
-
const outputPath = options.outputPath ??
|
|
5787
|
+
const outputPath = options.outputPath ?? getTaskPaths(repoRoot, taskId).dagDraftPath;
|
|
5791
5788
|
return writeHybridDagDraft(sources, outputPath, {
|
|
5792
5789
|
template: options.template,
|
|
5793
5790
|
});
|
|
@@ -326,6 +326,17 @@ export const dagShellConfigSchema = z.object({
|
|
|
326
326
|
frontendReviewContext: dagFrontendReviewContextSchema.optional(),
|
|
327
327
|
frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
|
|
328
328
|
backendTestPipeline: dagBackendTestPipelineSchema.optional(),
|
|
329
|
+
/** JaCoCo coverage collection for backend-test (Java services). When set, node 7 dumps coverage over TCP from a JaCoCo tcpserver agent and feeds it to the L-5 dashboard. */
|
|
330
|
+
jacocoCoverage: z.object({
|
|
331
|
+
/** JaCoCo tcpserver endpoint, e.g. "host:6300". */
|
|
332
|
+
endpoint: z.string().min(1),
|
|
333
|
+
/** Absolute path to jacococli.jar on this machine, used to convert .exec → jacoco.xml. */
|
|
334
|
+
cliJarPath: z.string().min(1),
|
|
335
|
+
/** Business package filter passed to JaCoCo includes, e.g. "com.example.*". Defaults to "*". */
|
|
336
|
+
includes: z.string().min(1).optional().default("*"),
|
|
337
|
+
/** TCP connect timeout in ms. Defaults to 5000. */
|
|
338
|
+
connectTimeoutMs: z.number().int().positive().optional().default(5000),
|
|
339
|
+
}).strict().optional(),
|
|
329
340
|
verifyEvidence: dagShellVerifyEvidenceSchema.optional(),
|
|
330
341
|
repairArtifactGate: dagRepairArtifactGateSchema.optional(),
|
|
331
342
|
/** fail (default): any nonzero command fails the node. record: finish node FINISHED with failure facts for downstream assess/repair. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import { writeLoopDagRecord } from "../../../infrastructure/harness/loop-action-store.js";
|
|
4
|
+
import { getTaskPaths } from "../../../task/runtime.js";
|
|
4
5
|
import { getLoopPaths } from "../paths.js";
|
|
5
6
|
import { appendLoopRound } from "../rounds.js";
|
|
6
7
|
import { rewriteLoopContext } from "../context.js";
|
|
@@ -26,7 +27,7 @@ export async function runLoopDagAction(repoRoot, taskId, options = {}) {
|
|
|
26
27
|
await mkdir(dagEvidenceDir, { recursive: true });
|
|
27
28
|
const dagPath = options.dagPath
|
|
28
29
|
? path.resolve(repoRoot, options.dagPath)
|
|
29
|
-
:
|
|
30
|
+
: getTaskPaths(repoRoot, taskId).dagDraftPath;
|
|
30
31
|
const runner = options.runner ?? resolveDefaultRunner(repoRoot);
|
|
31
32
|
let generated = {};
|
|
32
33
|
if (!options.dagPath) {
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"skills/agent-worker/references/agent-worker-operator.md",
|
|
56
56
|
"skills/loop-agent/SKILL.md",
|
|
57
57
|
"skills/loop-agent/references/command-reference.md",
|
|
58
|
+
"skills/loop-agent/references/source-and-plan-practice.md",
|
|
58
59
|
"skills/loop-agent/references/docs-converge.md",
|
|
59
60
|
"skills/ai-engineering-context/SKILL.md",
|
|
60
61
|
"skills/verification-before-completion/SKILL.md",
|
|
@@ -133,6 +134,7 @@
|
|
|
133
134
|
".agents/skills/agent-worker/references/agent-worker-operator.md",
|
|
134
135
|
".agents/skills/loop-agent/SKILL.md",
|
|
135
136
|
".agents/skills/loop-agent/references/command-reference.md",
|
|
137
|
+
".agents/skills/loop-agent/references/source-and-plan-practice.md",
|
|
136
138
|
".agents/skills/loop-agent/references/docs-converge.md",
|
|
137
139
|
".agents/skills/ai-engineering-context/SKILL.md",
|
|
138
140
|
".agents/skills/verification-before-completion/SKILL.md",
|
|
@@ -212,6 +214,7 @@
|
|
|
212
214
|
".agents/skills/agent-worker/references/agent-worker-operator.md": "copied",
|
|
213
215
|
".agents/skills/loop-agent/SKILL.md": "copied",
|
|
214
216
|
".agents/skills/loop-agent/references/command-reference.md": "copied",
|
|
217
|
+
".agents/skills/loop-agent/references/source-and-plan-practice.md": "copied",
|
|
215
218
|
".agents/skills/loop-agent/references/docs-converge.md": "copied",
|
|
216
219
|
".agents/skills/ai-engineering-context/SKILL.md": "copied",
|
|
217
220
|
".agents/skills/verification-before-completion/SKILL.md": "copied",
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
".harness/dag-runs/**",
|
|
251
251
|
"artifacts/**"
|
|
252
252
|
],
|
|
253
|
-
"outputContract": "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md,
|
|
253
|
+
"outputContract": "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md, a deterministic self-contained reports/backend-test-l5-dashboard.html (machine-computed L-5 metrics, no JSON), and an optional contracts/code-coverage-v1.json when jacocoCoverage is configured (JaCoCo TCP dump → jacoco.xml → parsed; failure-safe); exit 0/1 with valid evidence continues.",
|
|
254
254
|
"subtask_prompt": "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Prefer the deterministic module one-to-one path when a mapped script is missing but the module stem file exists. Generate a native pytest-html self-contained report, then render the primary self-contained Chinese HTML report from the same pytest-html plus final Markdown case metadata without rerun. Keep 测试结论, quality status, failure overview, and a polished per-case result card with concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.",
|
|
255
255
|
"shell": {
|
|
256
256
|
"commands": [
|
package/package.json
CHANGED
|
@@ -27,10 +27,12 @@ Entry: routing and hard rules. Required details come from frontmatter references
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
loop-agent new-task <task-id> "Title"
|
|
30
|
-
# Prefer
|
|
31
|
-
|
|
32
|
-
loop-agent dag
|
|
33
|
-
|
|
30
|
+
# Prefer import-prd when a PRD exists; plan create for non-trivial work
|
|
31
|
+
# (see references/source-and-plan-practice.md)
|
|
32
|
+
loop-agent dag run-task <task-id> --profile auto --strict-models
|
|
33
|
+
# default draft: .harness/tasks/<task-id>/dag.json
|
|
34
|
+
loop-agent dag validate --dag .harness/tasks/<task-id>/dag.json --strict-models --strict-governance
|
|
35
|
+
loop-agent run-dag --dag .harness/tasks/<task-id>/dag.json --cwd <repo-root>
|
|
34
36
|
```
|
|
35
37
|
|
|
36
38
|
执行前审阅 `profileRouting`、`governanceProfile`、writer `writeSet`、`forbiddenPaths`、decision gate mode。
|
|
@@ -44,10 +46,6 @@ loop-agent run-dag --dag <dag.json> --cwd <repo-root>
|
|
|
44
46
|
| Operator commands、`agent-worker` | `references/command-reference.md` |
|
|
45
47
|
| 独立验证、failure handling、closeout | `references/verification-and-failure-handling.md` |
|
|
46
48
|
|
|
47
|
-
## Source Layout
|
|
48
|
-
|
|
49
|
-
CLI `src/cli/`;DAG `src/workflows/dag/`;loop `src/workflows/loop/`;executors `src/executors/`;worker `src/worker/`;governance `src/governance/`。不新增平行兼容入口。
|
|
50
|
-
|
|
51
49
|
## Hard Rules
|
|
52
50
|
|
|
53
51
|
1. Use vertical tracer bullets across real integration layers;each needs independent acceptance and verification.
|