@tea-agent/loop-agent 0.24.10 → 0.25.0
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 +35 -0
- package/dist/executors/dag-pi-executor.js +74 -13
- package/dist/executors/pi-executor.js +25 -7
- package/dist/executors/pi-prompt-transport.js +198 -0
- package/dist/executors/pi-sdk-executor.js +1 -1
- package/dist/executors/process-tree.js +33 -0
- package/dist/executors/shell-executor.js +239 -47
- package/dist/executors/shell-verification.js +4 -2
- package/dist/infrastructure/harness/task-store.js +31 -0
- package/dist/shared/operator/capabilities.js +6 -0
- package/dist/verification/maven/cache.js +142 -0
- package/dist/verification/maven/index.js +120 -0
- package/dist/verification/maven/plan-commands.js +421 -0
- package/dist/verification/maven/pom-static.js +136 -0
- package/dist/verification/maven/scope-resolve.js +153 -0
- package/dist/verification/maven/stale.js +130 -0
- package/dist/verification/maven/types.js +23 -0
- package/dist/verification/maven/workspace-graph.js +322 -0
- package/dist/worker/cli.js +177 -7
- package/dist/worker/delivery/final-verification.js +12 -0
- package/dist/worker/feature/advance.js +301 -0
- package/dist/worker/feature/doctor.js +223 -0
- package/dist/worker/feature/next-action.js +11 -3
- package/dist/worker/feature/scaffold.js +798 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +127 -0
- package/dist/workflows/dag/failure-category.js +5 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
- package/dist/workflows/dag/init-hybrid.js +208 -19
- package/dist/workflows/dag/reconcile-run.js +24 -0
- package/dist/workflows/dag/types.js +44 -0
- package/dist/workflows/dag/validate.js +16 -0
- package/docs/architecture/dag-execution.md +5 -1
- package/docs/architecture/runtime-boundaries.md +11 -1
- package/docs/architecture/worker-and-feature.md +13 -0
- package/docs/init-surface.manifest.json +3 -0
- package/docs/templates/agent-dag.schema.json +9 -0
- package/docs/templates/backend-test-dag.json +3 -3
- package/docs/templates/frontend-implementation-contract.schema.json +2 -2
- package/docs/templates/product-line/README.md +17 -1
- package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
- package/docs/templates/product-line/scaffold-samples/README.md +36 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
- package/examples/l5-report-coms-process-definition.html +322 -0
- package/package.json +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +84 -0
- package/skills/frontend-bounded-implement/SKILL.md +53 -0
- package/skills/frontend-implementation/SKILL.md +3 -7
- package/skills/frontend-implementation/references/node-contracts.md +3 -3
- package/skills/loop-agent/SKILL.md +8 -8
- package/skills/loop-agent/references/command-reference.md +14 -2
- package/skills/loop-agent/references/harness-policy.md +22 -0
- package/skills/loop-agent/references/task-workflow.md +5 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { writeDagNodeTextArtifact } from "../infrastructure/harness/artifact-store.js";
|
|
6
6
|
import { truncateOutput } from "../shared/output-truncation.js";
|
|
7
|
+
import { processTreeSpawnOptions, terminateProcessTree } from "./process-tree.js";
|
|
7
8
|
import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdictGateShellCommand, } from "./shell-presets.js";
|
|
8
9
|
import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
|
|
9
10
|
import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
|
|
@@ -18,13 +19,15 @@ import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } f
|
|
|
18
19
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
19
20
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
20
21
|
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
|
|
21
|
-
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
22
|
+
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
23
|
+
import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
22
24
|
import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
|
|
23
25
|
import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
24
26
|
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
25
27
|
import { buildShellProcessEnv } from "./shell-verification.js";
|
|
26
28
|
import { readRunState } from "../workflows/dag/run-store.js";
|
|
27
29
|
import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
|
|
30
|
+
import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
|
|
28
31
|
const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
|
|
29
32
|
const SUMMARY_STDOUT_MAX = 4_000;
|
|
30
33
|
const SUMMARY_STDERR_MAX = 2_000;
|
|
@@ -46,6 +49,54 @@ function resolveBashExecutable() {
|
|
|
46
49
|
envCandidate ??
|
|
47
50
|
"bash");
|
|
48
51
|
}
|
|
52
|
+
/** POSIX-style runDir for bash `${HARNESS_DAG_RUN_DIR}` expansions on Windows. */
|
|
53
|
+
function normalizeHarnessDagRunDir(runDir) {
|
|
54
|
+
return runDir.replaceAll(path.sep, "/");
|
|
55
|
+
}
|
|
56
|
+
const BACKEND_TEST_EXECUTE_DIAGNOSTICS_REL = "reports/backend-test-execute-diagnostics.md";
|
|
57
|
+
/**
|
|
58
|
+
* Write diagnostics *before* any bash spawn so Windows STATUS_DLL_INIT_FAILED
|
|
59
|
+
* (0xC0000142) / empty-shell-output failures still leave an auditable file.
|
|
60
|
+
* Shell steps append under `## shell-steps`; they must not overwrite this head.
|
|
61
|
+
*/
|
|
62
|
+
async function writeBackendTestExecuteDiagnosticsPreSpawn(input) {
|
|
63
|
+
const harnessDagRunDir = normalizeHarnessDagRunDir(input.runDir);
|
|
64
|
+
if (!harnessDagRunDir.trim()) {
|
|
65
|
+
throw new Error("HARNESS_DAG_RUN_DIR would be empty; refusing backend-test execute (would write under /reports)");
|
|
66
|
+
}
|
|
67
|
+
const reportsDir = path.join(input.runDir, "reports");
|
|
68
|
+
await mkdir(reportsDir, { recursive: true });
|
|
69
|
+
const diagnosticsPath = path.join(reportsDir, "backend-test-execute-diagnostics.md");
|
|
70
|
+
const lines = [
|
|
71
|
+
"# Backend Test Execute Diagnostics",
|
|
72
|
+
"",
|
|
73
|
+
"## pre-spawn",
|
|
74
|
+
"",
|
|
75
|
+
`- phase: pre-spawn`,
|
|
76
|
+
`- startedAt: ${new Date().toISOString()}`,
|
|
77
|
+
`- cwd: ${input.cwd}`,
|
|
78
|
+
`- runId: ${input.runId}`,
|
|
79
|
+
`- runDir: ${input.runDir}`,
|
|
80
|
+
`- HARNESS_DAG_RUN_DIR: ${harnessDagRunDir}`,
|
|
81
|
+
`- bashExecutable: ${resolveBashExecutable()}`,
|
|
82
|
+
`- platform: ${process.platform}`,
|
|
83
|
+
`- mappedScriptCount: ${input.mappedScripts.length}`,
|
|
84
|
+
"- mappedScripts:",
|
|
85
|
+
...(input.mappedScripts.length > 0
|
|
86
|
+
? input.mappedScripts.map((script) => ` - ${script}`)
|
|
87
|
+
: [" - <none>"]),
|
|
88
|
+
`- commandPlanCount: ${input.commandPlan.length}`,
|
|
89
|
+
"- commandPlan:",
|
|
90
|
+
...input.commandPlan.map((label, index) => ` - ${index + 1}. ${label}`),
|
|
91
|
+
"",
|
|
92
|
+
"## shell-steps",
|
|
93
|
+
"",
|
|
94
|
+
"_Shell commands append below. If this section stays empty, bash never ran successfully._",
|
|
95
|
+
"",
|
|
96
|
+
];
|
|
97
|
+
await writeFile(diagnosticsPath, `${lines.join("\n")}\n`, "utf8");
|
|
98
|
+
return { diagnosticsPath, harnessDagRunDir };
|
|
99
|
+
}
|
|
49
100
|
function isWithinRoot(root, candidate) {
|
|
50
101
|
const relative = path.relative(root, candidate);
|
|
51
102
|
return (relative === "" ||
|
|
@@ -114,6 +165,7 @@ export async function executeShellCommand(input) {
|
|
|
114
165
|
const child = spawn(resolveBashExecutable(), ["-c", input.command], {
|
|
115
166
|
cwd: input.cwd,
|
|
116
167
|
env: buildShellProcessEnv(input.envAllowlist, injectedEnv),
|
|
168
|
+
...processTreeSpawnOptions(),
|
|
117
169
|
stdio: ["ignore", "pipe", "pipe"],
|
|
118
170
|
});
|
|
119
171
|
let stdout = createBoundedOutput();
|
|
@@ -187,10 +239,10 @@ export async function executeShellCommand(input) {
|
|
|
187
239
|
if (input.timeoutMs > 0) {
|
|
188
240
|
timeoutHandle = setTimeout(() => {
|
|
189
241
|
timedOut = true;
|
|
190
|
-
child
|
|
242
|
+
terminateProcessTree(child, "SIGTERM");
|
|
191
243
|
sigkillHandle = setTimeout(() => {
|
|
192
244
|
if (child.exitCode === null) {
|
|
193
|
-
child
|
|
245
|
+
terminateProcessTree(child, "SIGKILL");
|
|
194
246
|
}
|
|
195
247
|
}, 5_000);
|
|
196
248
|
}, input.timeoutMs);
|
|
@@ -385,64 +437,104 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
385
437
|
const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
|
|
386
438
|
const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
387
439
|
const pytestTargets = mappedScripts.map(shellQuote).join(" ");
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
440
|
+
// Split into short bash -c commands (aligned with markdown-environment).
|
|
441
|
+
// A single ultra-long compound command has been observed on Windows to
|
|
442
|
+
// exit 0xC0000142 (STATUS_DLL_INIT_FAILED) in ~40ms with empty stdout/
|
|
443
|
+
// stderr and no diagnostics file. Keep pytest exactly once.
|
|
444
|
+
const commandPlan = [
|
|
445
|
+
"shell-bootstrap",
|
|
446
|
+
"python-identity",
|
|
447
|
+
"pytest-version",
|
|
448
|
+
"pytest-run",
|
|
449
|
+
];
|
|
450
|
+
let preSpawnOk = false;
|
|
451
|
+
try {
|
|
452
|
+
await writeBackendTestExecuteDiagnosticsPreSpawn({
|
|
453
|
+
runDir: meta.runDir,
|
|
454
|
+
runId: meta.runId,
|
|
455
|
+
cwd: input.cwd,
|
|
456
|
+
mappedScripts,
|
|
457
|
+
commandPlan: [...commandPlan],
|
|
458
|
+
});
|
|
459
|
+
preSpawnOk = true;
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
stdout: "",
|
|
466
|
+
stderr: [
|
|
467
|
+
"backend-test markdown-execute-html shell failed",
|
|
468
|
+
"failureCategory=invalid-output",
|
|
469
|
+
`mappedScripts=${mappedScripts.join(",") || "<none>"}`,
|
|
470
|
+
`preSpawnDiagnostics=failed`,
|
|
471
|
+
`diagnostics=${BACKEND_TEST_EXECUTE_DIAGNOSTICS_REL}`,
|
|
472
|
+
message,
|
|
473
|
+
].join("\n"),
|
|
474
|
+
failureCategory: "invalid-output",
|
|
475
|
+
durationMs: Date.now() - started,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
// Append-only shell snippets; never overwrite the JS pre-spawn head.
|
|
479
|
+
const shellBootstrapCommand = [
|
|
480
|
+
'if [ -z "${HARNESS_DAG_RUN_DIR:-}" ]; then echo "HARNESS_DAG_RUN_DIR is empty; refusing to write diagnostics under /reports" >&2; exit 2; fi',
|
|
395
481
|
'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
|
|
396
482
|
'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
].join("; "),
|
|
407
|
-
mappedScriptDiagLines || 'echo " - <none>" >> "${DIAG_FILE}"',
|
|
483
|
+
'echo "STEP=shell-bootstrap"',
|
|
484
|
+
'echo "- shellBootstrapAt: $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" >> "${DIAG_FILE}"',
|
|
485
|
+
'echo "- shellPwd: $(pwd)" >> "${DIAG_FILE}"',
|
|
486
|
+
'echo "- shellHARNESS_DAG_RUN_DIR: ${HARNESS_DAG_RUN_DIR}" >> "${DIAG_FILE}"',
|
|
487
|
+
'echo "- shell: ${BASH:-bash} (${BASH_VERSION:-unknown})" >> "${DIAG_FILE}"',
|
|
488
|
+
].join("; ");
|
|
489
|
+
const pythonIdentityCommand = [
|
|
490
|
+
'if [ -z "${HARNESS_DAG_RUN_DIR:-}" ]; then echo "HARNESS_DAG_RUN_DIR is empty" >&2; exit 2; fi',
|
|
491
|
+
'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
|
|
408
492
|
'echo "STEP=resolve-python"',
|
|
409
493
|
'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
'echo "- PYTHON_BIN: ${PYTHON_BIN:-<empty>}" >> "${DIAG_FILE}"',
|
|
414
|
-
].join("; "),
|
|
494
|
+
'echo "- command_v_python: $(command -v python || true)" >> "${DIAG_FILE}"',
|
|
495
|
+
'echo "- command_v_python3: $(command -v python3 || true)" >> "${DIAG_FILE}"',
|
|
496
|
+
'echo "- PYTHON_BIN: ${PYTHON_BIN:-<empty>}" >> "${DIAG_FILE}"',
|
|
415
497
|
'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
|
|
416
498
|
'echo "STEP=python-identity"',
|
|
417
499
|
'if ! PYTHONUNBUFFERED=1 "${PYTHON_BIN}" -c "import sys; print(sys.executable); print(sys.version.splitlines()[0])" >> "${DIAG_FILE}" 2>&1; then echo "STEP=python-identity-failed" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
|
|
500
|
+
].join("; ");
|
|
501
|
+
const pytestVersionCommand = [
|
|
502
|
+
'if [ -z "${HARNESS_DAG_RUN_DIR:-}" ]; then echo "HARNESS_DAG_RUN_DIR is empty" >&2; exit 2; fi',
|
|
503
|
+
'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
|
|
504
|
+
'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
|
|
505
|
+
'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
|
|
418
506
|
'echo "STEP=pytest-version"',
|
|
419
507
|
'if ! PYTHONUNBUFFERED=1 "${PYTHON_BIN}" -m pytest --version >> "${DIAG_FILE}" 2>&1; then echo "STEP=pytest-version-failed" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
|
|
508
|
+
].join("; ");
|
|
509
|
+
// Single pytest invocation; exit 0/1 + non-empty html are reportable.
|
|
510
|
+
const pytestRunCommand = [
|
|
511
|
+
'if [ -z "${HARNESS_DAG_RUN_DIR:-}" ]; then echo "HARNESS_DAG_RUN_DIR is empty" >&2; exit 2; fi',
|
|
512
|
+
'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
|
|
513
|
+
'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
|
|
514
|
+
'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
|
|
420
515
|
'echo "STEP=pytest-run"',
|
|
421
516
|
`echo "- pytestCommand: python -m pytest ${mappedScripts.join(" ")} -v -p no:cacheprovider --html=reports/backend-test.html --self-contained-html" >> "\${DIAG_FILE}"`,
|
|
422
|
-
// Run pytest exactly once and emit the native pytest-html
|
|
423
|
-
// self-contained report. The HTML/facts renderers read per-case
|
|
424
|
-
// captured stdout (HTTP_REQUEST/HTTP_RESPONSE) directly from the
|
|
425
|
-
// pytest-html data-jsonblob island, so a JUnit XML report is no
|
|
426
|
-
// longer generated.
|
|
427
517
|
`PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 "\${PYTHON_BIN}" -m pytest ${pytestTargets} -v -p no:cacheprovider --html="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" --self-contained-html`,
|
|
428
518
|
"STATUS=$?",
|
|
429
519
|
'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
'echo "- finishedAt: $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" >> "${DIAG_FILE}"',
|
|
434
|
-
].join("; "),
|
|
520
|
+
'echo "- pytestExitCode: ${STATUS}" >> "${DIAG_FILE}"',
|
|
521
|
+
'if [ -f "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" ]; then echo "- pytestHtml: present ($(wc -c < "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" | tr -d " ") bytes)" >> "${DIAG_FILE}"; else echo "- pytestHtml: missing" >> "${DIAG_FILE}"; fi',
|
|
522
|
+
'echo "- finishedAt: $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" >> "${DIAG_FILE}"',
|
|
435
523
|
'echo "STEP=pytest-finished status=${STATUS}"',
|
|
436
|
-
// exit 0 (all pass) or 1 (assertion failures) with a valid
|
|
437
|
-
// pytest-html report are reportable; collection errors / crashes
|
|
438
|
-
// (exit >= 2) or a missing report surface as a pipeline error below.
|
|
439
524
|
'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" ]; then exit 0; fi',
|
|
440
525
|
'echo "STEP=pipeline-failed status=${STATUS} html=$([ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" ] && echo present || echo missing)" >&2',
|
|
441
526
|
'exit "${STATUS}"',
|
|
442
527
|
].join("; ");
|
|
443
|
-
const
|
|
528
|
+
const pipelineCommands = [
|
|
529
|
+
shellBootstrapCommand,
|
|
530
|
+
pythonIdentityCommand,
|
|
531
|
+
pytestVersionCommand,
|
|
532
|
+
pytestRunCommand,
|
|
533
|
+
];
|
|
534
|
+
const results = await executePipelineCommands(input, meta, pipelineCommands);
|
|
444
535
|
if (!results.every((result) => result.ok)) {
|
|
445
|
-
const
|
|
536
|
+
const failureIndex = results.findIndex((result) => !result.ok);
|
|
537
|
+
const failure = results[failureIndex];
|
|
446
538
|
const combinedStdout = results.map((result) => result.stdout).join("\n");
|
|
447
539
|
let diagnosticsText = "";
|
|
448
540
|
try {
|
|
@@ -451,11 +543,18 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
451
543
|
catch {
|
|
452
544
|
diagnosticsText = "";
|
|
453
545
|
}
|
|
546
|
+
const failedStep = failureIndex >= 0 && failureIndex < commandPlan.length
|
|
547
|
+
? commandPlan[failureIndex]
|
|
548
|
+
: "unknown";
|
|
454
549
|
const stderrSummary = [
|
|
455
550
|
"backend-test markdown-execute-html shell failed",
|
|
456
551
|
`failureCategory=${failure.failureCategory}`,
|
|
457
552
|
`exitCode=${failure.exitCode ?? "null"}`,
|
|
458
553
|
`durationMs=${failure.durationMs}`,
|
|
554
|
+
`failedCommandIndex=${failureIndex + 1}`,
|
|
555
|
+
`failedStep=${failedStep}`,
|
|
556
|
+
`commandPlanCount=${pipelineCommands.length}`,
|
|
557
|
+
`preSpawnDiagnostics=${preSpawnOk ? "written" : "failed"}`,
|
|
459
558
|
`mappedScripts=${mappedScripts.join(",") || "<none>"}`,
|
|
460
559
|
`shellStdoutEmpty=${combinedStdout.trim().length === 0}`,
|
|
461
560
|
`shellStderrEmpty=${failure.stderr.trim().length === 0}`,
|
|
@@ -465,7 +564,7 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
465
564
|
failure.stderrArtifactPath
|
|
466
565
|
? `commandStderrArtifact=${failure.stderrArtifactPath}`
|
|
467
566
|
: undefined,
|
|
468
|
-
|
|
567
|
+
`diagnostics=${BACKEND_TEST_EXECUTE_DIAGNOSTICS_REL}`,
|
|
469
568
|
failure.stderr.trim() || "(shell stderr empty)",
|
|
470
569
|
diagnosticsText.trim()
|
|
471
570
|
? `--- diagnostics ---\n${diagnosticsText.trim()}`
|
|
@@ -484,8 +583,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
484
583
|
const reportsDir = path.join(meta.runDir, "reports");
|
|
485
584
|
const pytestHtmlContent = await readFile(path.join(reportsDir, "backend-test.html"), "utf8");
|
|
486
585
|
const pytestExitCode = Number.parseInt((await readFile(path.join(reportsDir, "backend-test-pytest-exit.txt"), "utf8")).trim(), 10);
|
|
487
|
-
if (![0, 1].includes(pytestExitCode))
|
|
586
|
+
if (![0, 1].includes(pytestExitCode)) {
|
|
488
587
|
throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
|
|
588
|
+
}
|
|
489
589
|
const parsed = parsePytestHtmlReport(pytestHtmlContent);
|
|
490
590
|
// Bind Result v1 from the native pytest-html report BEFORE overwriting with the
|
|
491
591
|
// styled renderer (which drops the data-jsonblob island).
|
|
@@ -507,11 +607,52 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
507
607
|
traceabilitySummary,
|
|
508
608
|
});
|
|
509
609
|
const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
|
|
510
|
-
const facts = renderBackendTestFacts({
|
|
610
|
+
const facts = renderBackendTestFacts({
|
|
611
|
+
parsed,
|
|
612
|
+
cases,
|
|
613
|
+
pytestExitCode,
|
|
614
|
+
htmlRelativePath: "reports/backend-test.html",
|
|
615
|
+
htmlContent,
|
|
616
|
+
caseValidationSummary,
|
|
617
|
+
traceabilitySummary,
|
|
618
|
+
});
|
|
511
619
|
const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
|
|
512
620
|
const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
|
|
621
|
+
// Deterministic L-5 dashboard: machine-computed metrics (not Pi-generated).
|
|
622
|
+
// manifest is produced by an upstream finalize node; tolerate its absence
|
|
623
|
+
// so a minimal DAG without manifest still gets a degraded dashboard.
|
|
624
|
+
let manifestForL5 = {};
|
|
625
|
+
try {
|
|
626
|
+
const manifestPath = path.join(meta.runDir, "contracts", "backend-test-case-manifest.json");
|
|
627
|
+
manifestForL5 = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
// manifest missing: L5 AC/automation metrics degrade to unavailable.
|
|
631
|
+
}
|
|
632
|
+
const l5Metrics = computeL5ReportMetrics({
|
|
633
|
+
result: { passed: parsed.passed, failed: parsed.failed, error: parsed.errors, skipped: parsed.skipped },
|
|
634
|
+
manifest: manifestForL5,
|
|
635
|
+
coverage: null,
|
|
636
|
+
criticalRiskCount: parsed.failed + parsed.errors > 0 ? 1 : 0,
|
|
637
|
+
});
|
|
638
|
+
const failureSummaries = parsed.cases
|
|
639
|
+
.filter((c) => c.status !== "passed")
|
|
640
|
+
.map((c) => {
|
|
641
|
+
const id = c.name.match(/BE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}/)?.[0] ?? c.name;
|
|
642
|
+
return { caseId: id, title: c.name, message: c.message ?? c.status, script: `${c.classname.split("::")[0] ?? c.classname}` };
|
|
643
|
+
});
|
|
644
|
+
const l5Html = renderBackendTestL5Dashboard({
|
|
645
|
+
title: meta.spec.title,
|
|
646
|
+
objective: meta.spec.objective,
|
|
647
|
+
parsed,
|
|
648
|
+
metrics: l5Metrics,
|
|
649
|
+
caseValidationSummary,
|
|
650
|
+
traceabilitySummary,
|
|
651
|
+
failures: failureSummaries.length > 0 ? failureSummaries : undefined,
|
|
652
|
+
});
|
|
653
|
+
const l5Path = await writeRunReport(meta.runDir, "backend-test-l5-dashboard.html", l5Html);
|
|
513
654
|
const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
|
|
514
|
-
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `result=${resultArtifact.path}`, facts);
|
|
655
|
+
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `l5-dashboard=${l5Path}`, `result=${resultArtifact.path}`, facts);
|
|
515
656
|
}
|
|
516
657
|
else if (pipeline === "contracts") {
|
|
517
658
|
const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
|
|
@@ -1376,10 +1517,61 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1376
1517
|
if (!shell || commands.length === 0) {
|
|
1377
1518
|
throw new Error(`shell task ${input.task.id} requires shell.preset, shell.verdictGate, and/or non-empty shell.commands`);
|
|
1378
1519
|
}
|
|
1520
|
+
const started = Date.now();
|
|
1521
|
+
if (shell.mavenVerificationPlan) {
|
|
1522
|
+
try {
|
|
1523
|
+
assertMavenPlanFresh(input.cwd, shell.mavenVerificationPlan, { shellCommands: commands });
|
|
1524
|
+
}
|
|
1525
|
+
catch (error) {
|
|
1526
|
+
const message = error instanceof MavenPlanStaleError
|
|
1527
|
+
? error.message
|
|
1528
|
+
: error instanceof Error
|
|
1529
|
+
? error.message
|
|
1530
|
+
: String(error);
|
|
1531
|
+
const staleMessage = message.includes("verification-plan-stale")
|
|
1532
|
+
? message
|
|
1533
|
+
: `verification-plan-stale: ${message}`;
|
|
1534
|
+
const staleResults = commands.map((command) => ({
|
|
1535
|
+
command,
|
|
1536
|
+
cwd: input.cwd,
|
|
1537
|
+
durationMs: 0,
|
|
1538
|
+
exitCode: null,
|
|
1539
|
+
failureCategory: "verification-plan-stale",
|
|
1540
|
+
ok: false,
|
|
1541
|
+
stderr: staleMessage,
|
|
1542
|
+
stderrBytes: Buffer.byteLength(staleMessage),
|
|
1543
|
+
stderrTruncated: false,
|
|
1544
|
+
stdout: "",
|
|
1545
|
+
stdoutBytes: 0,
|
|
1546
|
+
stdoutTruncated: false,
|
|
1547
|
+
timedOut: false,
|
|
1548
|
+
}));
|
|
1549
|
+
await writeDagNodeTextArtifact(meta.runDir, input.task.id, "result.summary.md", buildShellResultSummaryMarkdown({
|
|
1550
|
+
nodeId: input.task.id,
|
|
1551
|
+
runId: meta.runId,
|
|
1552
|
+
rootCwd: input.cwd,
|
|
1553
|
+
results: staleResults,
|
|
1554
|
+
}));
|
|
1555
|
+
return {
|
|
1556
|
+
ok: false,
|
|
1557
|
+
stdout: "",
|
|
1558
|
+
stderr: `${staleMessage}\nRegenerate/validate the DAG before running Maven verification.`,
|
|
1559
|
+
failureCategory: "verification-plan-stale",
|
|
1560
|
+
durationMs: Date.now() - started,
|
|
1561
|
+
...{
|
|
1562
|
+
commandResults: staleResults.map((result) => ({
|
|
1563
|
+
ok: result.ok,
|
|
1564
|
+
exitCode: result.exitCode,
|
|
1565
|
+
failureCategory: result.failureCategory,
|
|
1566
|
+
command: result.command,
|
|
1567
|
+
})),
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1379
1572
|
const cwd = resolveShellCwd(input.cwd, shell.cwd);
|
|
1380
1573
|
const timeoutMs = shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
|
|
1381
1574
|
const results = [];
|
|
1382
|
-
const started = Date.now();
|
|
1383
1575
|
let beforeStatus;
|
|
1384
1576
|
try {
|
|
1385
1577
|
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { processTreeSpawnOptions, terminateProcessTree } from "./process-tree.js";
|
|
2
3
|
export const DEFAULT_VERIFY_TIMEOUT_MS = 1_800_000;
|
|
3
4
|
const VERIFY_ENV_ALLOWLIST = [
|
|
4
5
|
"HOME",
|
|
@@ -142,6 +143,7 @@ export async function executeCommand(command, defaultTimeoutMs = DEFAULT_VERIFY_
|
|
|
142
143
|
const child = spawn(binary, args, {
|
|
143
144
|
cwd: command.cwd,
|
|
144
145
|
env: command.env ?? process.env,
|
|
146
|
+
...processTreeSpawnOptions(),
|
|
145
147
|
stdio: ["ignore", "pipe", "pipe"],
|
|
146
148
|
});
|
|
147
149
|
let stdout = "";
|
|
@@ -184,10 +186,10 @@ export async function executeCommand(command, defaultTimeoutMs = DEFAULT_VERIFY_
|
|
|
184
186
|
if (timeoutMs > 0) {
|
|
185
187
|
timeoutHandle = setTimeout(() => {
|
|
186
188
|
timedOut = true;
|
|
187
|
-
child
|
|
189
|
+
terminateProcessTree(child, "SIGTERM");
|
|
188
190
|
sigkillHandle = setTimeout(() => {
|
|
189
191
|
if (child.exitCode === null) {
|
|
190
|
-
child
|
|
192
|
+
terminateProcessTree(child, "SIGKILL");
|
|
191
193
|
}
|
|
192
194
|
}, 5_000);
|
|
193
195
|
}, timeoutMs);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { taskConfigSchema } from "../../shared/types.js";
|
|
3
4
|
import { appendJsonlLineAtomic, writeJsonAtomic, writeTextAtomic, } from "./atomic-write.js";
|
|
4
5
|
function taskDir(repoRoot, taskId) {
|
|
5
6
|
return path.join(repoRoot, ".harness", "tasks", taskId);
|
|
@@ -10,6 +11,27 @@ function taskFile(repoRoot, taskId, filename) {
|
|
|
10
11
|
function taskWriteOptions(repoRoot) {
|
|
11
12
|
return { repoRoot };
|
|
12
13
|
}
|
|
14
|
+
function describeTaskConfigValidationError(error) {
|
|
15
|
+
if (error &&
|
|
16
|
+
typeof error === "object" &&
|
|
17
|
+
"issues" in error &&
|
|
18
|
+
Array.isArray(error.issues)) {
|
|
19
|
+
return error.issues
|
|
20
|
+
.map((issue) => {
|
|
21
|
+
if (!issue || typeof issue !== "object")
|
|
22
|
+
return String(issue);
|
|
23
|
+
const pathValue = "path" in issue && Array.isArray(issue.path)
|
|
24
|
+
? issue.path.map(String).join(".")
|
|
25
|
+
: "";
|
|
26
|
+
const message = "message" in issue && typeof issue.message === "string"
|
|
27
|
+
? issue.message
|
|
28
|
+
: String(issue);
|
|
29
|
+
return pathValue ? `${pathValue}: ${message}` : message;
|
|
30
|
+
})
|
|
31
|
+
.join("; ");
|
|
32
|
+
}
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|
|
13
35
|
export function resolveTaskContextFromDir(taskDir) {
|
|
14
36
|
const normalized = path.resolve(taskDir);
|
|
15
37
|
const taskId = path.basename(normalized);
|
|
@@ -37,6 +59,15 @@ export function resolveTaskStateContext(statePath) {
|
|
|
37
59
|
return { repoRoot, taskId };
|
|
38
60
|
}
|
|
39
61
|
export async function writeTaskConfig(repoRoot, taskId, config) {
|
|
62
|
+
try {
|
|
63
|
+
taskConfigSchema.parse(config);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new Error([
|
|
67
|
+
`invalid task config for ${taskId}: ${describeTaskConfigValidationError(error)}`,
|
|
68
|
+
"`referenceDocs` must use `{ path, name? }[]`; `verifyCommands` must use `{ label, command, timeoutMs? }[]`.",
|
|
69
|
+
].join(" "));
|
|
70
|
+
}
|
|
40
71
|
await writeJsonAtomic(taskFile(repoRoot, taskId, "task.json"), config, taskWriteOptions(repoRoot));
|
|
41
72
|
}
|
|
42
73
|
export async function writeWorkflowState(repoRoot, taskId, state) {
|
|
@@ -61,7 +61,10 @@ const OFFICIAL_ACTIONS = [
|
|
|
61
61
|
...["init", "status", "run", "record-round", "add-signal", "closeout"].map((leaf) => [`loop${leaf.replace(/(^|-)(.)/g, (_m, _d, c) => c.toUpperCase())}`, `loop-agent loop ${leaf}`, leaf === "status" ? "read" : "mutation", "advanced"]),
|
|
62
62
|
...["list", "inspect", "save", "run", "diff", "replay"].map((leaf) => [`workflow${leaf[0].toUpperCase()}${leaf.slice(1)}`, `loop-agent workflow ${leaf}`, ["list", "inspect", "diff"].includes(leaf) ? "read" : "mutation", "advanced"]),
|
|
63
63
|
["workerFeatureReview", "agent-worker feature review", "read", "model-callable"],
|
|
64
|
+
["workerFeatureScaffold", "agent-worker feature scaffold", "mutation", "advanced"],
|
|
64
65
|
["workerFeatureRun", "agent-worker feature run", "long-running", "human-gated-required"],
|
|
66
|
+
["workerFeatureAdvance", "agent-worker feature advance", "long-running", "human-gated-required"],
|
|
67
|
+
["workerFeatureDoctor", "agent-worker feature doctor", "read", "model-callable"],
|
|
65
68
|
["workerFeatureVerifyFinal", "agent-worker feature verify-final", "long-running", "human-gated-required"],
|
|
66
69
|
["workerFeatureDelivery", "agent-worker feature delivery", "mutation", "human-gated-required"],
|
|
67
70
|
["workerFeatureCloseout", "agent-worker feature closeout", "mutation", "human-gated-required"],
|
|
@@ -734,7 +737,10 @@ export const OPERATOR_COMMAND_COVERAGE = Object.freeze([
|
|
|
734
737
|
{ command: "loop-agent stats", coverage: "model-callable", action: "stats", source: "loop-agent" },
|
|
735
738
|
{ command: "loop-agent stats context", coverage: "model-callable", action: "statsContext", source: "loop-agent" },
|
|
736
739
|
{ command: "agent-worker feature review", coverage: "model-callable", action: "workerFeatureReview", source: "agent-worker" },
|
|
740
|
+
{ command: "agent-worker feature scaffold", coverage: "advanced", action: "workerFeatureScaffold", source: "agent-worker" },
|
|
737
741
|
{ command: "agent-worker feature run", coverage: "human-gated-required", action: "workerFeatureRun", source: "agent-worker" },
|
|
742
|
+
{ command: "agent-worker feature advance", coverage: "human-gated-required", action: "workerFeatureAdvance", source: "agent-worker" },
|
|
743
|
+
{ command: "agent-worker feature doctor", coverage: "model-callable", action: "workerFeatureDoctor", source: "agent-worker" },
|
|
738
744
|
{ command: "agent-worker feature verify-final", coverage: "human-gated-required", action: "workerFeatureVerifyFinal", source: "agent-worker" },
|
|
739
745
|
{ command: "agent-worker feature delivery", coverage: "human-gated-required", action: "workerFeatureDelivery", source: "agent-worker" },
|
|
740
746
|
{ command: "agent-worker feature closeout", coverage: "human-gated-required", action: "workerFeatureCloseout", source: "agent-worker" },
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { MAVEN_CACHE_SCHEMA_VERSION, MAVEN_PLANNER_VERSION, } from "./types.js";
|
|
4
|
+
import { buildMavenWorkspaceGraph } from "./workspace-graph.js";
|
|
5
|
+
const CACHE_FILE_NAME = "maven-workspace-graph.json";
|
|
6
|
+
/** Directory names ignored during discovery and lightweight entry-list checks. */
|
|
7
|
+
const SKIP_DIR_NAMES = new Set([
|
|
8
|
+
".git",
|
|
9
|
+
".harness",
|
|
10
|
+
"node_modules",
|
|
11
|
+
"target",
|
|
12
|
+
"dist",
|
|
13
|
+
"build",
|
|
14
|
+
".idea",
|
|
15
|
+
".vscode",
|
|
16
|
+
"coverage",
|
|
17
|
+
".turbo",
|
|
18
|
+
".next",
|
|
19
|
+
]);
|
|
20
|
+
export function defaultMavenCacheDir(repoRoot) {
|
|
21
|
+
return path.join(repoRoot, ".harness", "cache");
|
|
22
|
+
}
|
|
23
|
+
export function mavenGraphCachePath(cacheDir) {
|
|
24
|
+
return path.join(cacheDir, CACHE_FILE_NAME);
|
|
25
|
+
}
|
|
26
|
+
function isValidCachedGraph(value) {
|
|
27
|
+
if (!value || typeof value !== "object")
|
|
28
|
+
return false;
|
|
29
|
+
const graph = value;
|
|
30
|
+
return (graph.schemaVersion === MAVEN_CACHE_SCHEMA_VERSION &&
|
|
31
|
+
graph.plannerVersion === MAVEN_PLANNER_VERSION &&
|
|
32
|
+
typeof graph.fingerprint === "string" &&
|
|
33
|
+
Array.isArray(graph.pomPaths) &&
|
|
34
|
+
Array.isArray(graph.nodes));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Lightweight freshness probe: only stat previously recorded directories and
|
|
38
|
+
* file metadata. Does NOT re-walk the whole tree or re-parse POMs.
|
|
39
|
+
* Returns true when the cached graph can be reused as-is.
|
|
40
|
+
*/
|
|
41
|
+
export function isCachedGraphStillFresh(repoRoot, graph) {
|
|
42
|
+
if (graph.schemaVersion !== MAVEN_CACHE_SCHEMA_VERSION ||
|
|
43
|
+
graph.plannerVersion !== MAVEN_PLANNER_VERSION) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const manifest = graph.manifest;
|
|
47
|
+
if (!manifest) {
|
|
48
|
+
// Old cache shape without manifest cannot prove lightweight freshness.
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
// Directory entry lists: any add/remove invalidates.
|
|
52
|
+
for (const dir of manifest.directories) {
|
|
53
|
+
const absDir = dir.path === "" ? repoRoot : path.join(repoRoot, ...dir.path.split("/"));
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(absDir, { withFileTypes: true })
|
|
57
|
+
.filter((e) => {
|
|
58
|
+
if (SKIP_DIR_NAMES.has(e.name))
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
return !e.isSymbolicLink();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.map((e) => e.name)
|
|
68
|
+
.sort();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (entries.length !== dir.entryNames.length)
|
|
74
|
+
return false;
|
|
75
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
76
|
+
if (entries[i] !== dir.entryNames[i])
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// File metadata: size/mtime change triggers rebuild (content re-hash on rebuild).
|
|
81
|
+
for (const file of manifest.files) {
|
|
82
|
+
const abs = path.join(repoRoot, ...file.path.split("/"));
|
|
83
|
+
try {
|
|
84
|
+
const lst = lstatSync(abs);
|
|
85
|
+
if (lst.isSymbolicLink())
|
|
86
|
+
return false;
|
|
87
|
+
const st = statSync(abs);
|
|
88
|
+
if (st.size !== file.size)
|
|
89
|
+
return false;
|
|
90
|
+
if (st.mtimeMs !== file.mtimeMs)
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Load cached graph when schema/planner versions match and lightweight
|
|
101
|
+
* directory/file metadata is unchanged. Full parse only on miss/stale.
|
|
102
|
+
*/
|
|
103
|
+
export function loadOrBuildMavenWorkspaceGraph(input) {
|
|
104
|
+
const cacheDir = input.cacheDir ?? defaultMavenCacheDir(input.repoRoot);
|
|
105
|
+
const cachePath = mavenGraphCachePath(cacheDir);
|
|
106
|
+
if (existsSync(cachePath)) {
|
|
107
|
+
try {
|
|
108
|
+
const raw = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
109
|
+
if (isValidCachedGraph(raw) && isCachedGraphStillFresh(input.repoRoot, raw)) {
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Fall through to rebuild.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const graph = buildMavenWorkspaceGraph(input.repoRoot);
|
|
118
|
+
writeGraphCache(cacheDir, graph);
|
|
119
|
+
return graph;
|
|
120
|
+
}
|
|
121
|
+
export function writeGraphCache(cacheDir, graph) {
|
|
122
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
123
|
+
const payload = `${JSON.stringify(graph, null, 2)}\n`;
|
|
124
|
+
// Guard: never persist absolute host paths.
|
|
125
|
+
if (/[A-Za-z]:[\\/]/.test(payload) || payload.includes("\\\\")) {
|
|
126
|
+
// Absolute Windows paths are forbidden; re-check using graph fields only.
|
|
127
|
+
const serialized = JSON.stringify({
|
|
128
|
+
schemaVersion: graph.schemaVersion,
|
|
129
|
+
plannerVersion: graph.plannerVersion,
|
|
130
|
+
fingerprint: graph.fingerprint,
|
|
131
|
+
pomPaths: graph.pomPaths,
|
|
132
|
+
wrapperPaths: graph.wrapperPaths,
|
|
133
|
+
nodes: graph.nodes,
|
|
134
|
+
reactors: graph.reactors,
|
|
135
|
+
reactorRootByPomPath: graph.reactorRootByPomPath,
|
|
136
|
+
manifest: graph.manifest,
|
|
137
|
+
});
|
|
138
|
+
writeFileSync(mavenGraphCachePath(cacheDir), `${serialized}\n`, "utf8");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
writeFileSync(mavenGraphCachePath(cacheDir), payload, "utf8");
|
|
142
|
+
}
|