@tea-agent/loop-agent 0.24.5 → 0.24.7
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 +32 -0
- package/dist/executors/shell-executor.js +46 -3
- package/dist/worker/delivery/verification-bundle.js +26 -5
- package/dist/worker/observe/dag-run-artifacts.js +90 -0
- package/dist/worker/observe/node-input.js +444 -0
- package/dist/worker/observe/routes.js +17 -0
- package/dist/worker/observe/static/api.js +9 -0
- package/dist/worker/observe/static/constants.js +9 -0
- package/dist/worker/observe/static/state.js +14 -0
- package/dist/worker/observe/static/styles.css +74 -0
- package/dist/worker/observe/static/views/dag-inspector.js +371 -15
- package/dist/worker/outcomes/projector.js +5 -1
- package/dist/workflows/dag/backend-test-result-contract.js +103 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +106 -41
- package/dist/workflows/dag/init-hybrid.js +4 -6
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/types.js +2 -0
- package/dist/workflows/dag/validate.js +3 -2
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
@@ -68,6 +68,111 @@ export const frontendTestResultContractSchema = z.object({
|
|
|
68
68
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["integrationMode"], message: "real integration requires passed outcome" });
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
|
+
async function isNonEmptyFile(filePath) {
|
|
72
|
+
try {
|
|
73
|
+
const info = await stat(filePath);
|
|
74
|
+
return info.isFile() && info.size > 0;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function isSafeEvidenceShellPath(value) {
|
|
81
|
+
return (typeof value === "string" &&
|
|
82
|
+
value.length > 0 &&
|
|
83
|
+
!path.isAbsolute(value) &&
|
|
84
|
+
!path.win32.isAbsolute(value) &&
|
|
85
|
+
!value.includes(".."));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Validate frontend browser evidence without going through a shell command.
|
|
89
|
+
* Keeping this in the Node executor avoids CMD/Git Bash/PowerShell quoting and
|
|
90
|
+
* backslash interpretation for the former long `node -e` command.
|
|
91
|
+
*/
|
|
92
|
+
export async function validateFrontendCaseEvidence(input) {
|
|
93
|
+
const manifestPath = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.json");
|
|
94
|
+
try {
|
|
95
|
+
await stat(manifestPath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error("missing testcase/frontend/cases/manifest.json");
|
|
99
|
+
}
|
|
100
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
101
|
+
if (!Array.isArray(manifest.cases))
|
|
102
|
+
throw new Error("invalid frontend case manifest");
|
|
103
|
+
const statuses = new Set(["passed", "failed", "blocked"]);
|
|
104
|
+
const issues = [];
|
|
105
|
+
let hardFail = false;
|
|
106
|
+
for (const rawCase of manifest.cases) {
|
|
107
|
+
const item = rawCase;
|
|
108
|
+
const id = typeof item?.caseId === "string" ? item.caseId : "?";
|
|
109
|
+
const dir = item?.evidenceDir;
|
|
110
|
+
const prefix = `testcase/frontend/evidence/${id}`;
|
|
111
|
+
if (!item ||
|
|
112
|
+
typeof item.caseId !== "string" ||
|
|
113
|
+
typeof dir !== "string" ||
|
|
114
|
+
!isSafeEvidenceShellPath(dir) ||
|
|
115
|
+
!(dir === prefix || dir.startsWith(`${prefix}/`))) {
|
|
116
|
+
hardFail = true;
|
|
117
|
+
issues.push({ ruleId: "unsafe-evidence-dir", caseId: id, detail: String(dir) });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const execution = path.join(input.workspaceRoot, dir, "execution.md");
|
|
121
|
+
const resultPath = path.join(input.workspaceRoot, dir, "case-result.json");
|
|
122
|
+
if (!(await isNonEmptyFile(execution))) {
|
|
123
|
+
issues.push({ ruleId: "missing-execution", caseId: id, detail: "execution.md is missing or empty" });
|
|
124
|
+
}
|
|
125
|
+
let result = null;
|
|
126
|
+
if (!(await isNonEmptyFile(resultPath))) {
|
|
127
|
+
issues.push({ ruleId: "missing-case-result", caseId: id, detail: "case-result.json is missing" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
result = JSON.parse(await readFile(resultPath, "utf8"));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
issues.push({ ruleId: "invalid-case-result", caseId: id, detail: "case-result.json is malformed" });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!result || result.caseId !== id) {
|
|
138
|
+
issues.push({ ruleId: "case-result-identity", caseId: id, detail: "case-result caseId does not match manifest" });
|
|
139
|
+
}
|
|
140
|
+
if (!statuses.has(String(result?.status))) {
|
|
141
|
+
issues.push({ ruleId: "invalid-case-status", caseId: id, detail: "status must be passed, failed, or blocked" });
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(result?.evidencePaths)) {
|
|
144
|
+
issues.push({ ruleId: "invalid-evidence-paths", caseId: id, detail: "evidencePaths must be an array" });
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
for (const evidencePath of result.evidencePaths) {
|
|
148
|
+
if (!isSafeEvidenceShellPath(evidencePath)) {
|
|
149
|
+
hardFail = true;
|
|
150
|
+
issues.push({ ruleId: "unsafe-evidence-path", caseId: id, detail: String(evidencePath) });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const currentPrefix = `${prefix}/`;
|
|
154
|
+
if (evidencePath.startsWith("testcase/frontend/evidence/") && !evidencePath.startsWith(currentPrefix)) {
|
|
155
|
+
hardFail = true;
|
|
156
|
+
issues.push({ ruleId: "cross-case-evidence-path", caseId: id, detail: evidencePath });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const target = evidencePath.startsWith("testcase/")
|
|
160
|
+
? path.join(input.workspaceRoot, evidencePath)
|
|
161
|
+
: path.join(input.workspaceRoot, dir, evidencePath);
|
|
162
|
+
if (!(await isNonEmptyFile(target))) {
|
|
163
|
+
issues.push({ ruleId: "missing-evidence", caseId: id, detail: `${evidencePath} is missing or empty` });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (result?.status === "passed" && (!Array.isArray(result.evidencePaths) || !result.evidencePaths.some((entry) => /\.(png|jpg|jpeg|webp|zip|har|webm|mp4)$/i.test(String(entry))))) {
|
|
168
|
+
issues.push({ ruleId: "passed-without-browser-evidence", caseId: id, detail: "passed case has no screenshot, HAR, video, or equivalent browser artifact" });
|
|
169
|
+
}
|
|
170
|
+
if (result?.status === "blocked" && (typeof result.blockedReason !== "string" || !result.blockedReason.trim())) {
|
|
171
|
+
issues.push({ ruleId: "blocked-reason", caseId: id, detail: "blocked result has no usable reason" });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { cases: manifest.cases.length, issues, hardFail };
|
|
175
|
+
}
|
|
71
176
|
function sha256(content) {
|
|
72
177
|
return createHash("sha256").update(content).digest("hex");
|
|
73
178
|
}
|
|
@@ -214,43 +319,3 @@ export function buildFrontendTestOutcomeGateShellSnippet(options) {
|
|
|
214
319
|
`node -e 'const fs=require("fs");const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const ok=r.outcome==="passed"&&r.integrationMode==="real"&&Number(r.totals?.failed||0)===0&&Number(r.totals?.blocked||0)===0&&Array.isArray(r.acceptanceCoverage?.missing)&&r.acceptanceCoverage.missing.length===0;console.log("frontend-test outcome="+r.outcome+" integrationMode="+r.integrationMode);if(!ok)process.exit(1);' "\${RESULT}"`,
|
|
215
320
|
].join("; ");
|
|
216
321
|
}
|
|
217
|
-
/**
|
|
218
|
-
* Shared frontend-test evidence advisory check for map children + node 7.
|
|
219
|
-
* Hard-fails only for unsafe evidence directories or evidence paths. Missing,
|
|
220
|
-
* malformed, or empty evidence is reported without mutating case outputs.
|
|
221
|
-
*/
|
|
222
|
-
export function buildFrontendCaseEvidenceValidateShellSnippet() {
|
|
223
|
-
const body = [
|
|
224
|
-
"const fs=require('fs'),path=require('path');",
|
|
225
|
-
"const manifestPath='testcase/frontend/cases/manifest.json';",
|
|
226
|
-
"if(!fs.existsSync(manifestPath))throw new Error('missing '+manifestPath);",
|
|
227
|
-
"const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
|
|
228
|
-
"if(!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');",
|
|
229
|
-
"const statuses=new Set(['passed','failed','blocked']);",
|
|
230
|
-
"const issues=[];",
|
|
231
|
-
"let hardFail=false;",
|
|
232
|
-
"function isSafeRel(p){return typeof p==='string'&&p.length>0&&!path.isAbsolute(p)&&!path.win32.isAbsolute(p)&&!p.includes('..');}",
|
|
233
|
-
"for(const c of manifest.cases){",
|
|
234
|
-
" const id=c&&typeof c.caseId==='string'?c.caseId:'?';",
|
|
235
|
-
" const dir=c&&c.evidenceDir;",
|
|
236
|
-
" const prefix='testcase/frontend/evidence/'+id;",
|
|
237
|
-
" if(!c||typeof c.caseId!=='string'||typeof dir!=='string'||!isSafeRel(dir)||!(dir===prefix||dir.startsWith(prefix+'/'))){",
|
|
238
|
-
" hardFail=true; issues.push({ruleId:'unsafe-evidence-dir',caseId:id,detail:String(dir)}); continue;",
|
|
239
|
-
" }",
|
|
240
|
-
" const execution=path.join(dir,'execution.md');",
|
|
241
|
-
" const resultPath=path.join(dir,'case-result.json');",
|
|
242
|
-
" if(!fs.existsSync(execution)||!fs.statSync(execution).isFile()||fs.statSync(execution).size===0)issues.push({ruleId:'missing-execution',caseId:id,detail:'execution.md is missing or empty'});",
|
|
243
|
-
" let result=null;",
|
|
244
|
-
" if(!fs.existsSync(resultPath)){issues.push({ruleId:'missing-case-result',caseId:id,detail:'case-result.json is missing'});continue;}",
|
|
245
|
-
" try{result=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch(e){issues.push({ruleId:'invalid-case-result',caseId:id,detail:'case-result.json is malformed'});continue;}",
|
|
246
|
-
" if(!result||result.caseId!==id)issues.push({ruleId:'case-result-identity',caseId:id,detail:'case-result caseId does not match manifest'});",
|
|
247
|
-
" if(!statuses.has(result&&result.status))issues.push({ruleId:'invalid-case-status',caseId:id,detail:'status must be passed, failed, or blocked'});",
|
|
248
|
-
" if(!Array.isArray(result&&result.evidencePaths)){issues.push({ruleId:'invalid-evidence-paths',caseId:id,detail:'evidencePaths must be an array'});}else{for(const p of result.evidencePaths){if(!isSafeRel(p)){hardFail=true;issues.push({ruleId:'unsafe-evidence-path',caseId:id,detail:String(p)});continue;}const currentPrefix=prefix+'/';if(p.startsWith('testcase/frontend/evidence/')&&!p.startsWith(currentPrefix)){hardFail=true;issues.push({ruleId:'cross-case-evidence-path',caseId:id,detail:p});continue;}const target=p.startsWith('testcase/')?p:path.join(dir,p);if(!fs.existsSync(target)||!fs.statSync(target).isFile()||fs.statSync(target).size===0)issues.push({ruleId:'missing-evidence',caseId:id,detail:p+' is missing or empty'});}}",
|
|
249
|
-
" if(result&&result.status==='passed'&&(!Array.isArray(result.evidencePaths)||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4)$/i.test(p))))issues.push({ruleId:'passed-without-browser-evidence',caseId:id,detail:'passed case has no screenshot, HAR, video, or equivalent browser artifact'});",
|
|
250
|
-
" if(result&&result.status==='blocked'&&(typeof result.blockedReason!=='string'||!result.blockedReason.trim()))issues.push({ruleId:'blocked-reason',caseId:id,detail:'blocked result has no usable reason'});",
|
|
251
|
-
"}",
|
|
252
|
-
"if(hardFail){console.error('frontend-test evidence hard-fail: '+JSON.stringify(issues)); process.exit(1);}",
|
|
253
|
-
"console.log('frontend case evidence advisory validation ok cases='+manifest.cases.length+' findings='+issues.length+(issues.length?(' issues='+JSON.stringify(issues)):''));",
|
|
254
|
-
].join("");
|
|
255
|
-
return ["node -e", JSON.stringify(body)].join(" ");
|
|
256
|
-
}
|
|
@@ -25,7 +25,7 @@ import { normalizeTaskRequirementText, resolveTaskDagTemplateSelection, } from "
|
|
|
25
25
|
import { BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT, buildBackendTestExecutionPreflightShellSnippet, } from "./backend-test-execution-contract.js";
|
|
26
26
|
import { buildBackendTestOutcomeGateShellSnippet } from "./backend-test-result-contract.js";
|
|
27
27
|
import { buildBackendTestIntakeContext } from "./backend-test-intake-context.js";
|
|
28
|
-
import {
|
|
28
|
+
import { buildFrontendTestOutcomeGateShellSnippet } from "./frontend-test-result-contract.js";
|
|
29
29
|
import { classifyFrontendRisk, } from "./frontend-risk.js";
|
|
30
30
|
import { discoverFrontendProjectCapability, } from "./frontend-project-capability.js";
|
|
31
31
|
import { FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, loadFrontendImplementationContractJsonSchema, } from "./frontend-implementation-contract.js";
|
|
@@ -3504,8 +3504,6 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
3504
3504
|
"process.stdout.write(JSON.stringify({cases:manifest.cases}));",
|
|
3505
3505
|
].join("")),
|
|
3506
3506
|
].join(" ");
|
|
3507
|
-
// Heal malformed/missing case evidence to blocked; only unsafe evidenceDir hard-fails.
|
|
3508
|
-
const evidenceValidation = buildFrontendCaseEvidenceValidateShellSnippet();
|
|
3509
3507
|
const frontendTestOutcomeGate = buildFrontendTestOutcomeGateShellSnippet();
|
|
3510
3508
|
const frontendCaseQualityAdvisory = [
|
|
3511
3509
|
"node -e",
|
|
@@ -3753,9 +3751,9 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
3753
3751
|
writeSet: [`${evidenceRoot}/**`],
|
|
3754
3752
|
allowedPaths: ["testcase/frontend/cases/**", `${evidenceRoot}/**`],
|
|
3755
3753
|
forbiddenPaths: forbidden,
|
|
3756
|
-
outputContract: "Deterministic evidence gate:
|
|
3757
|
-
subtask_prompt: "Validate frontend case evidence before result materialization.
|
|
3758
|
-
shell: { commands: [
|
|
3754
|
+
outputContract: "Deterministic evidence gate: missing/malformed evidence is advisory; only unsafe evidenceDir or evidence paths hard-fail. Does not block retrospect.",
|
|
3755
|
+
subtask_prompt: "Validate frontend case evidence before result materialization. Keep missing or malformed evidence as advisory findings; only path-escape failures abort the node.",
|
|
3756
|
+
shell: { commands: [], frontendTestEvidenceValidation: {}, cwd: ".", timeoutMs: 120000 },
|
|
3759
3757
|
}, {
|
|
3760
3758
|
id: "materialize-frontend-test-result-shell",
|
|
3761
3759
|
depends_on: ["validate-frontend-case-evidence-shell"],
|
|
@@ -593,8 +593,9 @@ export async function executeDagNode(input) {
|
|
|
593
593
|
node.structuredArtifactSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
594
594
|
node.structuredArtifactSchemaId = task.shell.jsonArtifactGate.schemaId;
|
|
595
595
|
}
|
|
596
|
-
else if (task.shell?.backendTestPipeline === "classification-result-context"
|
|
597
|
-
|
|
596
|
+
else if (task.shell?.backendTestPipeline === "classification-result-context" ||
|
|
597
|
+
task.shell?.backendTestPipeline === "markdown-execute-html") {
|
|
598
|
+
// Legacy 15-node and Markdown-first 8-node pipelines materialize
|
|
598
599
|
// contracts/backend-test-result.json without jsonArtifactGate. Bind it
|
|
599
600
|
// so Outcome adapters project kind=backend-test-result for Ready Planner.
|
|
600
601
|
const artifactPath = path.join(runDir, "contracts", "backend-test-result.json");
|
|
@@ -280,6 +280,7 @@ export const dagBackendTestPipelineSchema = z.enum([
|
|
|
280
280
|
"markdown-traceability",
|
|
281
281
|
"markdown-execute-html",
|
|
282
282
|
]);
|
|
283
|
+
export const dagFrontendTestEvidenceValidationSchema = z.object({}).strict();
|
|
283
284
|
export const dagShellConfigSchema = z.object({
|
|
284
285
|
commands: z.array(z.string()).default([]),
|
|
285
286
|
preset: dagShellPresetSchema.optional(),
|
|
@@ -293,6 +294,7 @@ export const dagShellConfigSchema = z.object({
|
|
|
293
294
|
frontendLintBaseline: dagFrontendLintBaselineSchema.optional(),
|
|
294
295
|
frontendVerificationBundle: dagFrontendVerificationBundleSchema.optional(),
|
|
295
296
|
frontendReviewContext: dagFrontendReviewContextSchema.optional(),
|
|
297
|
+
frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
|
|
296
298
|
backendTestPipeline: dagBackendTestPipelineSchema.optional(),
|
|
297
299
|
verifyEvidence: dagShellVerifyEvidenceSchema.optional(),
|
|
298
300
|
repairArtifactGate: dagRepairArtifactGateSchema.optional(),
|
|
@@ -461,10 +461,11 @@ function validateShellTaskConfig(task, spec, issues) {
|
|
|
461
461
|
!shell.backendTestPipeline &&
|
|
462
462
|
!shell.frontendPrewriteGate &&
|
|
463
463
|
!shell.frontendVerificationBundle &&
|
|
464
|
-
!shell.frontendReviewContext
|
|
464
|
+
!shell.frontendReviewContext &&
|
|
465
|
+
!shell.frontendTestEvidenceValidation) {
|
|
465
466
|
issues.push({
|
|
466
467
|
type: "missing-shell-commands",
|
|
467
|
-
message: `shell task ${task.id} requires
|
|
468
|
+
message: `shell task ${task.id} requires a supported shell operation or non-empty shell.commands`,
|
|
468
469
|
});
|
|
469
470
|
}
|
|
470
471
|
if (commands.some((command) => command.trim().length === 0)) {
|