@tea-agent/loop-agent 0.28.13 → 0.29.1
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/AGENTS.md +1 -1
- package/CHANGELOG.md +73 -15
- package/dist/commands/client-recovery.js +56 -1
- package/dist/commands/init-upgrade.js +186 -21
- package/dist/commands/init.js +1 -1
- package/dist/executors/dag-pi-executor.js +52 -3
- package/dist/executors/pi-playwright-cli-tool.js +14 -8
- package/dist/executors/shell-executor.js +288 -0
- package/dist/task/config-types.js +21 -0
- package/dist/worker/console/app-data.js +132 -11
- package/dist/worker/console/chat/pi-runtime.js +24 -42
- package/dist/worker/console/chat/resource-loader.js +11 -20
- package/dist/worker/console/chat/routes.js +7 -8
- package/dist/worker/console/chat/runtime-context.js +1 -1
- package/dist/worker/console/chat/tools.js +67 -54
- package/dist/worker/console/operation-runner.js +15 -1
- package/dist/worker/console/operation-store.js +70 -49
- package/dist/worker/console/operator-actions.js +57 -1
- package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
- package/dist/worker/console/static/favicon.svg +37 -0
- package/dist/worker/console/static/index.html +2 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
- package/dist/workflows/dag/frontend-test-case-checklist.js +94 -15
- package/dist/workflows/dag/frontend-test-case-manifest.js +104 -0
- package/dist/workflows/dag/frontend-test-html-report.js +106 -24
- package/dist/workflows/dag/frontend-test-result-contract.js +3 -0
- package/dist/workflows/dag/init-hybrid.js +187 -108
- package/dist/workflows/dag/node-execution.js +31 -2
- package/dist/workflows/dag/retry-policy.js +55 -18
- package/dist/workflows/dag/types.js +41 -0
- package/dist/workflows/dag/validate.js +42 -4
- package/docs/operations/README.md +1 -1
- package/docs/templates/README.md +2 -1
- package/docs/templates/agent-dag.schema.json +9 -4
- package/docs/templates/backend-test-dag.json +36 -11
- package/docs/templates/frontend-test-case-checklist.md +1 -1
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +9 -1
- package/docs/templates/frontend-test-dag.json +125 -267
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-standard-scenarios.v1.json +114 -0
- package/docs/templates/init-managed-agents.md +1 -1
- package/harness.json +2 -2
- package/package.json +2 -1
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +1 -1
- package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
|
@@ -8,6 +8,8 @@ import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND
|
|
|
8
8
|
import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
|
|
9
9
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
10
10
|
import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
11
|
+
import { isWriterEmptyDiffRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
|
|
12
|
+
import { assessBackendTestMdWriterCompleteness, assessBackendTestPytestWriterCompleteness, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
|
|
11
13
|
import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
12
14
|
export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
13
15
|
/** Bounded writer tools: no unrestricted bash; edit/write are policy-wrapped via SDK customTools. */
|
|
@@ -338,7 +340,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
338
340
|
}
|
|
339
341
|
let beforeStatus;
|
|
340
342
|
let beforePathFingerprints;
|
|
341
|
-
|
|
343
|
+
/** tools-only: keep SDK path sandbox; skip git baseline + post-diff write-guard hard fail. */
|
|
344
|
+
const skipGitWriteGuard = isWriteTask && input.task.writeGuardPolicy === "tools-only";
|
|
345
|
+
if (isWriteTask && !skipGitWriteGuard) {
|
|
342
346
|
try {
|
|
343
347
|
beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
|
|
344
348
|
const beforeSnapshot = snapshotGitStatusPorcelain(beforeStatus);
|
|
@@ -680,7 +684,39 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
680
684
|
afterStatus: changeManifestAfterStatus ?? "",
|
|
681
685
|
});
|
|
682
686
|
}
|
|
683
|
-
|
|
687
|
+
let completenessFailure;
|
|
688
|
+
if (mapped.ok &&
|
|
689
|
+
writeGuardOk &&
|
|
690
|
+
!writerOutcomeViolation &&
|
|
691
|
+
isBackendTestCompletenessRetryCandidate(input.task)) {
|
|
692
|
+
try {
|
|
693
|
+
const progress = input.task.id === "generate-backend-pytest-pi"
|
|
694
|
+
? await assessBackendTestPytestWriterCompleteness(input.cwd)
|
|
695
|
+
: await assessBackendTestMdWriterCompleteness(input.cwd);
|
|
696
|
+
await writeBackendTestWriterProgressArtifacts({
|
|
697
|
+
runDir: meta.runDir,
|
|
698
|
+
progress,
|
|
699
|
+
attempt: input.attempt ?? 1,
|
|
700
|
+
maxAttempts: input.task.retryPolicy?.maxAttempts ?? 3,
|
|
701
|
+
});
|
|
702
|
+
if (progress.status !== "PASS") {
|
|
703
|
+
const classified = classifyBackendTestWriterCompletenessFailure(progress);
|
|
704
|
+
completenessFailure = {
|
|
705
|
+
failureCategory: classified.recoverable
|
|
706
|
+
? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
|
|
707
|
+
: "invalid-output",
|
|
708
|
+
detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
completenessFailure = {
|
|
714
|
+
failureCategory: "invalid-output",
|
|
715
|
+
detail: `backend-test completeness gate crashed: ${error instanceof Error ? error.message : String(error)}`,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (writeGuardOk && !writerOutcomeViolation && !completenessFailure) {
|
|
684
720
|
return mapped;
|
|
685
721
|
}
|
|
686
722
|
const stderrParts = [mapped.stderr];
|
|
@@ -690,6 +726,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
690
726
|
if (writerOutcomeViolation) {
|
|
691
727
|
stderrParts.push(writerOutcomeViolation);
|
|
692
728
|
}
|
|
729
|
+
if (completenessFailure) {
|
|
730
|
+
stderrParts.push(completenessFailure.detail);
|
|
731
|
+
}
|
|
693
732
|
if (meta.writeGuardAttribution === "best-effort") {
|
|
694
733
|
stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
|
|
695
734
|
}
|
|
@@ -699,7 +738,13 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
699
738
|
stderr: stderrParts.filter(Boolean).join("\n\n"),
|
|
700
739
|
failureCategory: mapped.ok
|
|
701
740
|
? writeGuardOk
|
|
702
|
-
?
|
|
741
|
+
? completenessFailure
|
|
742
|
+
? completenessFailure.failureCategory
|
|
743
|
+
: changeManifestChangedFiles?.length === 0 &&
|
|
744
|
+
isWriterEmptyDiffRetryCandidate(input.task) &&
|
|
745
|
+
isChangedWriterImplementationOutcome(mapped.assistantText || mapped.stdout)
|
|
746
|
+
? WRITER_EMPTY_DIFF_RETRY_CATEGORY
|
|
747
|
+
: "invalid-output"
|
|
703
748
|
: "write-guard"
|
|
704
749
|
: mapped.failureCategory,
|
|
705
750
|
durationMs: mapped.durationMs || Date.now() - started,
|
|
@@ -776,6 +821,10 @@ function parseWriterImplementationOutcome(text) {
|
|
|
776
821
|
function isWriterImplementationOutcome(value) {
|
|
777
822
|
return (value === "changed" || value === "already-satisfied" || value === "blocked");
|
|
778
823
|
}
|
|
824
|
+
function isChangedWriterImplementationOutcome(text) {
|
|
825
|
+
const parsed = parseWriterImplementationOutcome(text);
|
|
826
|
+
return parsed.kind === "valid" && parsed.outcome === "changed";
|
|
827
|
+
}
|
|
779
828
|
function writerOutcomeDiagnostics(text, parsed, changedFiles) {
|
|
780
829
|
const firstNonEmpty = text
|
|
781
830
|
.split(/\r?\n/)
|
|
@@ -197,15 +197,21 @@ function validateOpenArgs(args, baseUrl) {
|
|
|
197
197
|
for (const arg of args) {
|
|
198
198
|
assertNoControlMeta(arg);
|
|
199
199
|
assertNoSessionFlag(arg);
|
|
200
|
-
if (arg === "--browser=chrome" || arg === "--
|
|
200
|
+
if (arg === "--browser=chrome" || arg === "--headless") {
|
|
201
201
|
flags.add(arg);
|
|
202
202
|
normalized.push(arg);
|
|
203
203
|
continue;
|
|
204
204
|
}
|
|
205
|
+
// Accept legacy --headed but normalize to headless for frontend-test CI/dogfood.
|
|
206
|
+
if (arg === "--headed") {
|
|
207
|
+
flags.add("--headless");
|
|
208
|
+
if (!normalized.includes("--headless"))
|
|
209
|
+
normalized.push("--headless");
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
205
212
|
if (arg.startsWith("--browser=") ||
|
|
206
|
-
arg === "--browser"
|
|
207
|
-
|
|
208
|
-
throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headed only");
|
|
213
|
+
arg === "--browser") {
|
|
214
|
+
throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headless only");
|
|
209
215
|
}
|
|
210
216
|
if (/^[a-z][a-z0-9+.-]*:/i.test(arg) || arg.startsWith("http")) {
|
|
211
217
|
url = arg;
|
|
@@ -213,12 +219,12 @@ function validateOpenArgs(args, baseUrl) {
|
|
|
213
219
|
}
|
|
214
220
|
throw new PlaywrightCliPolicyError("open-args", `unsupported open argument: ${arg}`);
|
|
215
221
|
}
|
|
216
|
-
if (!flags.has("--browser=chrome") || !flags.has("--
|
|
222
|
+
if (!flags.has("--browser=chrome") || !flags.has("--headless")) {
|
|
217
223
|
// controller injects required flags when missing from model args
|
|
218
224
|
if (!flags.has("--browser=chrome"))
|
|
219
225
|
normalized.unshift("--browser=chrome");
|
|
220
|
-
if (!flags.has("--
|
|
221
|
-
normalized.push("--
|
|
226
|
+
if (!flags.has("--headless"))
|
|
227
|
+
normalized.push("--headless");
|
|
222
228
|
}
|
|
223
229
|
if (!url) {
|
|
224
230
|
throw new PlaywrightCliPolicyError("open-url-required", "open requires an absolute http(s) URL");
|
|
@@ -509,7 +515,7 @@ export function preparePlaywrightCliArgv(input, ctx) {
|
|
|
509
515
|
catch {
|
|
510
516
|
throw new PlaywrightCliPolicyError("goto-url-invalid", "goto requires a URL relative to the controller baseUrl");
|
|
511
517
|
}
|
|
512
|
-
const openLike = validateOpenArgs(["--browser=chrome", "--
|
|
518
|
+
const openLike = validateOpenArgs(["--browser=chrome", "--headless", target], ctx.baseUrl);
|
|
513
519
|
return openLike[openLike.length - 1];
|
|
514
520
|
});
|
|
515
521
|
if (args.length !== 1) {
|
|
@@ -18,6 +18,7 @@ import { materializeFrontendImplementationContract } from "../workflows/dag/fron
|
|
|
18
18
|
import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
|
|
19
19
|
import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
|
|
20
20
|
import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
|
|
21
|
+
import { materializeFrontendTestCaseManifest } from "../workflows/dag/frontend-test-case-manifest.js";
|
|
21
22
|
import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
|
|
22
23
|
import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
|
|
23
24
|
import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
|
|
@@ -30,6 +31,7 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
|
|
|
30
31
|
import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
|
|
31
32
|
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
|
|
32
33
|
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
34
|
+
import { applyDeterministicScenarioParamRepairs, assessBackendScenarioParamConsistency, classifyBackendTestFailureWithScenarioParam, readBackendScenarioParamFacts, renderBackendTestFailureAnalysis, writeBackendScenarioParamArtifacts, writeScenarioParamRepairAudit, } from "../workflows/dag/backend-test-scenario-param.js";
|
|
33
35
|
import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
|
|
34
36
|
import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
35
37
|
import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
|
|
@@ -768,6 +770,77 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
768
770
|
durationMs: Date.now() - started,
|
|
769
771
|
};
|
|
770
772
|
}
|
|
773
|
+
// Scenario-param consistency (P1): assess -> deterministic repair <=1 -> reassess final.
|
|
774
|
+
try {
|
|
775
|
+
const initialAssessment = await assessBackendScenarioParamConsistency({
|
|
776
|
+
workspaceRoot: input.cwd,
|
|
777
|
+
phase: "initial",
|
|
778
|
+
repairAttempt: 0,
|
|
779
|
+
strictScenarioParamGate: Boolean((meta.spec.globalConstraints ?? []).some((item) => /strictScenarioParamGate\s*=\s*true/i.test(item))),
|
|
780
|
+
});
|
|
781
|
+
const initialArtifacts = await writeBackendScenarioParamArtifacts({
|
|
782
|
+
runDir: meta.runDir,
|
|
783
|
+
facts: initialAssessment.facts,
|
|
784
|
+
markdown: initialAssessment.markdown,
|
|
785
|
+
});
|
|
786
|
+
outputs.push(`scenarioParamInitial=${initialArtifacts.reportPath}`, `scenarioParamInitialFacts=${initialArtifacts.factsPath}`);
|
|
787
|
+
let finalFacts = initialAssessment.facts;
|
|
788
|
+
let finalMarkdown = initialAssessment.markdown;
|
|
789
|
+
if (initialAssessment.facts.repairEligible) {
|
|
790
|
+
const repair = await applyDeterministicScenarioParamRepairs({
|
|
791
|
+
workspaceRoot: input.cwd,
|
|
792
|
+
facts: initialAssessment.facts,
|
|
793
|
+
});
|
|
794
|
+
const auditPath = await writeScenarioParamRepairAudit({
|
|
795
|
+
runDir: meta.runDir,
|
|
796
|
+
audit: repair.audit,
|
|
797
|
+
changedFiles: repair.changedFiles,
|
|
798
|
+
});
|
|
799
|
+
outputs.push(`scenarioParamRepairAudit=${auditPath}`, `scenarioParamRepaired=${repair.repaired.join(",") || "(none)"}`);
|
|
800
|
+
const reassessment = await assessBackendScenarioParamConsistency({
|
|
801
|
+
workspaceRoot: input.cwd,
|
|
802
|
+
phase: "final",
|
|
803
|
+
repairAttempt: 1,
|
|
804
|
+
strictScenarioParamGate: initialAssessment.facts.strictScenarioParamGate,
|
|
805
|
+
});
|
|
806
|
+
finalFacts = reassessment.facts;
|
|
807
|
+
finalMarkdown = reassessment.markdown;
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
finalFacts = {
|
|
811
|
+
...initialAssessment.facts,
|
|
812
|
+
phase: "final",
|
|
813
|
+
repairAttempt: 0,
|
|
814
|
+
repairEligible: false,
|
|
815
|
+
};
|
|
816
|
+
finalMarkdown = initialAssessment.markdown.replace("Phase: initial", "Phase: final");
|
|
817
|
+
}
|
|
818
|
+
const finalArtifacts = await writeBackendScenarioParamArtifacts({
|
|
819
|
+
runDir: meta.runDir,
|
|
820
|
+
facts: finalFacts,
|
|
821
|
+
markdown: finalMarkdown,
|
|
822
|
+
});
|
|
823
|
+
outputs.push(`scenarioParamFinal=${finalArtifacts.reportPath}`, `scenarioParamFinalFacts=${finalArtifacts.factsPath}`, `scenarioParamMismatch=${finalFacts.summary.mismatchCount}`, finalMarkdown);
|
|
824
|
+
if (finalFacts.strictScenarioParamGate &&
|
|
825
|
+
finalFacts.summary.mismatchCount > 0) {
|
|
826
|
+
return {
|
|
827
|
+
ok: false,
|
|
828
|
+
stdout: outputs.join("\n\n"),
|
|
829
|
+
stderr: "backend-test strict scenario-param gate blocked residual MISMATCH before execute",
|
|
830
|
+
failureCategory: "invalid-output",
|
|
831
|
+
durationMs: Date.now() - started,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
837
|
+
const report = "# Backend Test Scenario-Param Consistency\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Scenario-param analysis crashed: " +
|
|
838
|
+
message +
|
|
839
|
+
"\n";
|
|
840
|
+
const reportPath = await writeRunReport(meta.runDir, "backend-test-scenario-param-consistency.md", report);
|
|
841
|
+
outputs.push(`scenarioParam=${reportPath}`, report);
|
|
842
|
+
// Advisory by default: do not fail the node on scenario-param infrastructure errors.
|
|
843
|
+
}
|
|
771
844
|
}
|
|
772
845
|
else if (pipeline === "markdown-manifest") {
|
|
773
846
|
const sourceBinding = meta.spec.sourceBinding;
|
|
@@ -989,6 +1062,69 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
989
1062
|
});
|
|
990
1063
|
const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
|
|
991
1064
|
const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
|
|
1065
|
+
// Structured failure analysis report (P2); emitted even when there are zero failures.
|
|
1066
|
+
let scenarioParamFinalStatus = "UNAVAILABLE";
|
|
1067
|
+
let scenarioParamRepairAttempt = 0;
|
|
1068
|
+
const scenarioParamStatusByToken = new Map();
|
|
1069
|
+
try {
|
|
1070
|
+
const scenarioFacts = await readBackendScenarioParamFacts(path.join(meta.runDir, "contracts", "backend-test-scenario-param-consistency-facts.json"));
|
|
1071
|
+
scenarioParamFinalStatus =
|
|
1072
|
+
scenarioFacts.summary.mismatchCount > 0 ? "FAIL" : "PASS";
|
|
1073
|
+
scenarioParamRepairAttempt = scenarioFacts.repairAttempt;
|
|
1074
|
+
for (const entry of scenarioFacts.entries) {
|
|
1075
|
+
scenarioParamStatusByToken.set(entry.caseId, entry.status);
|
|
1076
|
+
scenarioParamStatusByToken.set(entry.tpId, entry.status);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
// optional facts
|
|
1081
|
+
}
|
|
1082
|
+
const failureCases = parsed.cases.filter((item) => item.status === "failure" || item.status === "error");
|
|
1083
|
+
const failureAnalysisMarkdown = renderBackendTestFailureAnalysis({
|
|
1084
|
+
generatedAt: new Date().toISOString(),
|
|
1085
|
+
htmlReportPath: "reports/backend-test.html",
|
|
1086
|
+
total: parsed.tests,
|
|
1087
|
+
passed: parsed.passed,
|
|
1088
|
+
failed: parsed.failed + parsed.errors,
|
|
1089
|
+
durationLabel: `${parsed.durationMs ?? 0}ms`,
|
|
1090
|
+
environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8").catch(() => "unavailable"),
|
|
1091
|
+
scenarioParamFinalStatus,
|
|
1092
|
+
repairAttempt: scenarioParamRepairAttempt,
|
|
1093
|
+
failures: failureCases.map((result) => {
|
|
1094
|
+
const caseMatch = result.name.match(/\bBE-[A-Z0-9_-]+-\d{2,3}\b/);
|
|
1095
|
+
const tpMatch = result.name.match(/\bTP-[A-Z0-9-]+\b/);
|
|
1096
|
+
const caseId = caseMatch?.[0] ?? result.name;
|
|
1097
|
+
const scenarioParamStatus = (tpMatch && scenarioParamStatusByToken.get(tpMatch[0])) ||
|
|
1098
|
+
scenarioParamStatusByToken.get(caseId);
|
|
1099
|
+
const evidence = [result.message, result.details]
|
|
1100
|
+
.filter(Boolean)
|
|
1101
|
+
.join("\n");
|
|
1102
|
+
const statusMatch = /assert\s+(\d{3})\s*==\s*(\d{3})/i.exec(evidence) ||
|
|
1103
|
+
/expected[^\d]*(\d{3})[\s\S]{0,40}actual[^\d]*(\d{3})/i.exec(evidence);
|
|
1104
|
+
return {
|
|
1105
|
+
name: result.name,
|
|
1106
|
+
caseId,
|
|
1107
|
+
scenario: cases.find((item) => item.id === caseId)?.scenario ??
|
|
1108
|
+
cases.find((item) => item.id === caseId)?.title ??
|
|
1109
|
+
result.name,
|
|
1110
|
+
expectedCode: statusMatch?.[2] ?? statusMatch?.[1],
|
|
1111
|
+
actualCode: statusMatch?.[1] ?? statusMatch?.[2],
|
|
1112
|
+
message: result.message || result.status,
|
|
1113
|
+
scriptPath: result.classname,
|
|
1114
|
+
durationLabel: result.durationMs !== undefined
|
|
1115
|
+
? `${result.durationMs}ms`
|
|
1116
|
+
: undefined,
|
|
1117
|
+
scenarioParamStatus,
|
|
1118
|
+
classification: classifyBackendTestFailureWithScenarioParam({
|
|
1119
|
+
message: result.message || "",
|
|
1120
|
+
details: result.details,
|
|
1121
|
+
scenarioParamStatus,
|
|
1122
|
+
}),
|
|
1123
|
+
};
|
|
1124
|
+
}),
|
|
1125
|
+
});
|
|
1126
|
+
const failureAnalysisPath = await writeRunReport(meta.runDir, "backend-test-failure-analysis.md", failureAnalysisMarkdown);
|
|
1127
|
+
outputs.push(`failureAnalysis=${failureAnalysisPath}`);
|
|
992
1128
|
// Deterministic L-5 dashboard: machine-computed metrics (not Pi-generated).
|
|
993
1129
|
// manifest is produced by an upstream finalize node; tolerate its absence
|
|
994
1130
|
// so a minimal DAG without manifest still gets a degraded dashboard.
|
|
@@ -1691,6 +1827,149 @@ async function executeFrontendTestEvidenceValidation(input) {
|
|
|
1691
1827
|
};
|
|
1692
1828
|
}
|
|
1693
1829
|
}
|
|
1830
|
+
async function executeFrontendTestCaseManifest(input, meta) {
|
|
1831
|
+
const started = Date.now();
|
|
1832
|
+
try {
|
|
1833
|
+
const gate = input.task.shell?.frontendTestCaseManifest ?? {};
|
|
1834
|
+
const declaredAcIds = gate.declaredAcIds ??
|
|
1835
|
+
(meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
|
|
1836
|
+
const checklist = await validateFrontendCaseChecklist({
|
|
1837
|
+
workspaceRoot: input.cwd,
|
|
1838
|
+
declaredAcIds,
|
|
1839
|
+
});
|
|
1840
|
+
if (checklist.issues.length) {
|
|
1841
|
+
return {
|
|
1842
|
+
ok: false,
|
|
1843
|
+
stdout: "",
|
|
1844
|
+
stderr: `frontend-test checklist blocked: ${JSON.stringify(checklist.issues)}`,
|
|
1845
|
+
failureCategory: "invalid-output",
|
|
1846
|
+
durationMs: Date.now() - started,
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
const materialized = await materializeFrontendTestCaseManifest({
|
|
1850
|
+
workspaceRoot: input.cwd,
|
|
1851
|
+
maxCases: gate.maxCases,
|
|
1852
|
+
declaredAcIds,
|
|
1853
|
+
});
|
|
1854
|
+
return {
|
|
1855
|
+
ok: true,
|
|
1856
|
+
stdout: `${JSON.stringify({ cases: materialized.cases })}
|
|
1857
|
+
`,
|
|
1858
|
+
stderr: "",
|
|
1859
|
+
failureCategory: "success",
|
|
1860
|
+
durationMs: Date.now() - started,
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
catch (error) {
|
|
1864
|
+
return {
|
|
1865
|
+
ok: false,
|
|
1866
|
+
stdout: "",
|
|
1867
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1868
|
+
failureCategory: "invalid-output",
|
|
1869
|
+
durationMs: Date.now() - started,
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
/** Path-escape / cross-case evidence errors must hard-fail finalize (same class as validate.hardFail). */
|
|
1874
|
+
function isFrontendTestEvidencePathHardError(error) {
|
|
1875
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1876
|
+
return /unsafe evidence|escapes (?:evidence root|its allowed root)|evidence realpath|cross-case/i.test(message);
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Lean finalize: evidence validate + result materialize in one shell.
|
|
1880
|
+
* - Case-level missing/malformed evidence is advisory (issues / advisoryFindings);
|
|
1881
|
+
* the node still succeeds so reports can render incomplete/failed outcomes.
|
|
1882
|
+
* - Path escape / unsafe evidence roots hard-fail the node.
|
|
1883
|
+
* - Controller/contract errors (missing sourceBinding, empty manifest) still fail closed.
|
|
1884
|
+
*/
|
|
1885
|
+
async function executeFrontendTestResultFinalize(input, meta) {
|
|
1886
|
+
const started = Date.now();
|
|
1887
|
+
let advisoryStdout = "";
|
|
1888
|
+
try {
|
|
1889
|
+
const evidence = await validateFrontendCaseEvidence({
|
|
1890
|
+
workspaceRoot: input.cwd,
|
|
1891
|
+
});
|
|
1892
|
+
advisoryStdout = `frontend case evidence validation cases=${evidence.cases} findings=${evidence.issues.length}${evidence.issues.length ? ` issues=${JSON.stringify(evidence.issues)}` : ""}`;
|
|
1893
|
+
// Advisory findings must not block materialize; only hardFail (path escape) does.
|
|
1894
|
+
if (evidence.hardFail) {
|
|
1895
|
+
return {
|
|
1896
|
+
ok: false,
|
|
1897
|
+
stdout: advisoryStdout,
|
|
1898
|
+
stderr: `frontend-test evidence hard-fail: ${JSON.stringify(evidence.issues)}`,
|
|
1899
|
+
failureCategory: "nonzero-exit",
|
|
1900
|
+
durationMs: Date.now() - started,
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
const artifact = await materializeFrontendTestResult({
|
|
1904
|
+
runDir: meta.runDir,
|
|
1905
|
+
workspaceRoot: input.cwd,
|
|
1906
|
+
artifactName: "frontend-test-result.json",
|
|
1907
|
+
outputDir: "contracts",
|
|
1908
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
1909
|
+
});
|
|
1910
|
+
return {
|
|
1911
|
+
ok: true,
|
|
1912
|
+
stdout: `${advisoryStdout}\nStructured artifact: ${artifact.path}\nSchema: ${artifact.schemaId}\nSHA-256: ${artifact.sha256}`,
|
|
1913
|
+
stderr: "",
|
|
1914
|
+
failureCategory: "success",
|
|
1915
|
+
durationMs: Date.now() - started,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
catch (error) {
|
|
1919
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1920
|
+
if (isFrontendTestEvidencePathHardError(error)) {
|
|
1921
|
+
return {
|
|
1922
|
+
ok: false,
|
|
1923
|
+
stdout: advisoryStdout,
|
|
1924
|
+
stderr: `frontend-test evidence hard-fail: ${detail}`,
|
|
1925
|
+
failureCategory: "nonzero-exit",
|
|
1926
|
+
durationMs: Date.now() - started,
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
return {
|
|
1930
|
+
ok: false,
|
|
1931
|
+
stdout: advisoryStdout,
|
|
1932
|
+
stderr: detail,
|
|
1933
|
+
failureCategory: "invalid-output",
|
|
1934
|
+
durationMs: Date.now() - started,
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
async function executeFrontendTestReports(input, meta) {
|
|
1939
|
+
const started = Date.now();
|
|
1940
|
+
try {
|
|
1941
|
+
const writeL5 = input.task.shell?.frontendTestReports?.l5 !== false;
|
|
1942
|
+
const lines = [];
|
|
1943
|
+
if (writeL5) {
|
|
1944
|
+
const l5 = await renderFrontendTestL5Report({
|
|
1945
|
+
workspaceRoot: input.cwd,
|
|
1946
|
+
runDir: meta.runDir,
|
|
1947
|
+
});
|
|
1948
|
+
lines.push(`Frontend L-5 report: ${l5.htmlPath}\nMarkdown: ${l5.markdownPath}\nStatus: ${l5.metrics.status}`);
|
|
1949
|
+
}
|
|
1950
|
+
const report = await renderFrontendTestHtmlReport({
|
|
1951
|
+
workspaceRoot: input.cwd,
|
|
1952
|
+
runDir: meta.runDir,
|
|
1953
|
+
});
|
|
1954
|
+
lines.push(`Frontend test report: ${report.htmlPath}\nMarkdown: ${report.markdownPath}\nOutcome: ${report.outcome}\nCases: ${report.caseCount}`);
|
|
1955
|
+
return {
|
|
1956
|
+
ok: true,
|
|
1957
|
+
stdout: lines.join("\n"),
|
|
1958
|
+
stderr: "",
|
|
1959
|
+
failureCategory: "success",
|
|
1960
|
+
durationMs: Date.now() - started,
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
catch (error) {
|
|
1964
|
+
return {
|
|
1965
|
+
ok: false,
|
|
1966
|
+
stdout: "",
|
|
1967
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1968
|
+
failureCategory: "invalid-output",
|
|
1969
|
+
durationMs: Date.now() - started,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1694
1973
|
async function executeFrontendLintBaseline(input, meta) {
|
|
1695
1974
|
const started = Date.now();
|
|
1696
1975
|
const shell = input.task.shell;
|
|
@@ -1927,15 +2206,24 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1927
2206
|
if (shell?.frontendTestCaseChecklist) {
|
|
1928
2207
|
return executeFrontendTestCaseChecklist(input, meta);
|
|
1929
2208
|
}
|
|
2209
|
+
if (shell?.frontendTestCaseManifest) {
|
|
2210
|
+
return executeFrontendTestCaseManifest(input, meta);
|
|
2211
|
+
}
|
|
1930
2212
|
if (shell?.frontendTestEvidenceValidation) {
|
|
1931
2213
|
return executeFrontendTestEvidenceValidation(input);
|
|
1932
2214
|
}
|
|
2215
|
+
if (shell?.frontendTestResultFinalize) {
|
|
2216
|
+
return executeFrontendTestResultFinalize(input, meta);
|
|
2217
|
+
}
|
|
1933
2218
|
if (shell?.frontendTestL5Report) {
|
|
1934
2219
|
return executeFrontendTestL5Report(input, meta);
|
|
1935
2220
|
}
|
|
1936
2221
|
if (shell?.frontendTestHtmlReport) {
|
|
1937
2222
|
return executeFrontendTestHtmlReport(input, meta);
|
|
1938
2223
|
}
|
|
2224
|
+
if (shell?.frontendTestReports) {
|
|
2225
|
+
return executeFrontendTestReports(input, meta);
|
|
2226
|
+
}
|
|
1939
2227
|
if (shell?.backendTestPipeline) {
|
|
1940
2228
|
return executeBackendTestPipelineWithWriteGuard(input, meta);
|
|
1941
2229
|
}
|
|
@@ -83,6 +83,27 @@ export const frontendTestConfigSchema = z.object({
|
|
|
83
83
|
* Default false: pipeline success is result materialize + retrospect report, not full green.
|
|
84
84
|
*/
|
|
85
85
|
strictOutcomeGate: z.boolean().optional(),
|
|
86
|
+
/**
|
|
87
|
+
* Maximum rerun attempts for blocked or missing-result frontend cases.
|
|
88
|
+
* Defaults to 2; the static graph always carries a single rerun select+map
|
|
89
|
+
* pair and bounds candidates within that pair via this value.
|
|
90
|
+
*/
|
|
91
|
+
maxRerunAttempts: z.number().int().min(0).max(4).optional(),
|
|
92
|
+
/**
|
|
93
|
+
* Report chain toggles for the lean frontend-test DAG.
|
|
94
|
+
* - retrospect: opt-in Pi retrospective node (default off). Pipeline success
|
|
95
|
+
* is frontend-test-result-v1 + main HTML/MD report, not the retrospect.
|
|
96
|
+
* - l5: write the deterministic L-5 dashboard from the merged reports node
|
|
97
|
+
* (default true; the merged reports node always renders the main
|
|
98
|
+
* HTML/MD report regardless of this flag).
|
|
99
|
+
*/
|
|
100
|
+
reports: z
|
|
101
|
+
.object({
|
|
102
|
+
retrospect: z.boolean().optional(),
|
|
103
|
+
l5: z.boolean().optional(),
|
|
104
|
+
})
|
|
105
|
+
.strict()
|
|
106
|
+
.optional(),
|
|
86
107
|
});
|
|
87
108
|
export const convergenceConfigSchema = z.object({
|
|
88
109
|
enabled: z.boolean().optional().default(false),
|