@tea-agent/loop-agent 0.35.2 → 0.35.3
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 +2 -0
- package/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/bin/loop-agent.js +37 -1
- package/dist/build-stamp.json +6 -0
- package/dist/cli/program.js +2 -2
- package/dist/executors/dag-pi-executor.js +44 -0
- package/dist/shared/package-metadata.js +42 -0
- package/dist/worker/console/chat/assistant-content.js +11 -0
- package/dist/worker/console/chat/pi-runtime.js +6 -2
- package/dist/worker/console/chat/turn-process.js +17 -9
- package/dist/worker/console/chat/workspace-landing.js +1 -1
- package/dist/worker/console/static/assets/index-DuVLjCIT.js +57 -0
- package/dist/worker/console/static/index.html +1 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +15 -3
- package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +1 -0
- package/dist/worker/loop-agent/loop-agent-client.js +17 -3
- package/dist/worker/observability/read-model.js +20 -0
- package/dist/worker/observe/spec-evidence.js +3 -8
- package/dist/worker/observe/static/views/dag-inspector.js +6 -71
- package/dist/worker/preflight.js +2 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +33 -23
- package/dist/workflows/dag/contract-output-registry.js +14 -0
- package/dist/workflows/dag/contract-validator-registrations.js +8 -0
- package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
- package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
- package/dist/workflows/dag/frontend-recovery-plan.js +73 -0
- package/dist/workflows/dag/frontend-recovery-root-manifest.js +123 -0
- package/dist/workflows/dag/frontend-recovery-run.js +539 -0
- package/dist/workflows/dag/frontend-repair.js +219 -18
- package/dist/workflows/dag/frontend-verification-trace.js +47 -32
- package/dist/workflows/dag/frontend-writer-recovery.js +106 -0
- package/dist/workflows/dag/frontend-writer-rollback.js +821 -0
- package/dist/workflows/dag/init-hybrid.js +49 -24
- package/dist/workflows/dag/node-execution.js +89 -0
- package/dist/workflows/dag/recovery-recommendation.js +58 -0
- package/dist/workflows/dag/runner.js +245 -11
- package/dist/workflows/dag/scheduler.js +257 -3
- package/dist/workflows/dag/types.js +130 -2
- package/package.json +4 -3
- package/dist/worker/console/static/assets/index-gVHrlqI9.js +0 -56
|
@@ -4,10 +4,69 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import {
|
|
7
|
+
import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
|
|
8
8
|
import { findPackageRoot } from "../../shared/package-metadata.js";
|
|
9
9
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
10
10
|
import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
|
|
11
|
+
export const frontendNormalizationActionSchema = z.enum([
|
|
12
|
+
"remove-trailing-commas",
|
|
13
|
+
"strip-comments",
|
|
14
|
+
"canonicalize-requirement-ids",
|
|
15
|
+
"canonicalize-verification-alias",
|
|
16
|
+
"inject-source-binding",
|
|
17
|
+
]);
|
|
18
|
+
export function sha256Hex(input) {
|
|
19
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function sortKeysDeep(value) {
|
|
22
|
+
if (Array.isArray(value))
|
|
23
|
+
return value.map(sortKeysDeep);
|
|
24
|
+
if (value && typeof value === "object") {
|
|
25
|
+
const record = value;
|
|
26
|
+
const sorted = {};
|
|
27
|
+
for (const key of Object.keys(record).sort()) {
|
|
28
|
+
sorted[key] = sortKeysDeep(record[key]);
|
|
29
|
+
}
|
|
30
|
+
return sorted;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
/** Deterministic JSON serialization: fixed key order, 2-space indent, no
|
|
35
|
+
* trailing newline. Shared by candidate/canonical/digest hashes so the same
|
|
36
|
+
* input always reproduces the same digest. */
|
|
37
|
+
export function serializeDeterministicJson(value) {
|
|
38
|
+
return JSON.stringify(sortKeysDeep(value), null, 2);
|
|
39
|
+
}
|
|
40
|
+
export function deterministicSha256(value) {
|
|
41
|
+
return sha256Hex(serializeDeterministicJson(value));
|
|
42
|
+
}
|
|
43
|
+
/** A contract materialization failure carries its classification plus the
|
|
44
|
+
* audit context collected up to the failure point, so the prewrite gate can
|
|
45
|
+
* persist a complete frontend-prewrite-result-v1 even when the canonical
|
|
46
|
+
* contract is never materialized. */
|
|
47
|
+
export class FrontendContractFailure extends Error {
|
|
48
|
+
kind;
|
|
49
|
+
candidateRawSha256;
|
|
50
|
+
candidateJsonSha256;
|
|
51
|
+
normalizationActions;
|
|
52
|
+
constructor(options) {
|
|
53
|
+
super(options.message);
|
|
54
|
+
this.name = "FrontendContractFailure";
|
|
55
|
+
this.kind = options.kind;
|
|
56
|
+
this.candidateRawSha256 = options.candidateRawSha256;
|
|
57
|
+
this.candidateJsonSha256 = options.candidateJsonSha256 ?? null;
|
|
58
|
+
this.normalizationActions = options.normalizationActions ?? [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Write a JSON artifact with deterministic key order and return its sha256
|
|
62
|
+
* over the exact file bytes, so the canonical contract identity matches what
|
|
63
|
+
* downstream verify/repair/review re-read from disk. */
|
|
64
|
+
export async function writeDeterministicJsonArtifact(runDir, relativePath, value) {
|
|
65
|
+
const json = `${serializeDeterministicJson(value)}\n`;
|
|
66
|
+
const targetPath = path.join(runDir, relativePath);
|
|
67
|
+
await writeTextArtifactFile(targetPath, json);
|
|
68
|
+
return { path: targetPath, sha256: sha256Hex(json) };
|
|
69
|
+
}
|
|
11
70
|
/**
|
|
12
71
|
* Extract frozen command labels from the DAG run spec (run.json).
|
|
13
72
|
* The run spec is written before any node executes, so it is always available
|
|
@@ -132,7 +191,32 @@ const gap = z
|
|
|
132
191
|
blocking: z.boolean(),
|
|
133
192
|
})
|
|
134
193
|
.strict();
|
|
194
|
+
/**
|
|
195
|
+
* Normalize unstable model emissions before the strict schema sees them:
|
|
196
|
+
* recursively drop null values (object entries and array elements). A null for
|
|
197
|
+
* an optional field becomes "absent" (accepted); a null for a required field
|
|
198
|
+
* still fails, but with a clean Required issue instead of a confusing type
|
|
199
|
+
* mismatch. Unknown keys and misspellings remain strict rejections.
|
|
200
|
+
*/
|
|
201
|
+
function stripNullValuesDeep(value) {
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
return value
|
|
204
|
+
.filter((item) => item !== null)
|
|
205
|
+
.map((item) => stripNullValuesDeep(item));
|
|
206
|
+
}
|
|
207
|
+
if (value && typeof value === "object") {
|
|
208
|
+
const record = value;
|
|
209
|
+
const stripped = {};
|
|
210
|
+
for (const [key, item] of Object.entries(record)) {
|
|
211
|
+
if (item !== null)
|
|
212
|
+
stripped[key] = stripNullValuesDeep(item);
|
|
213
|
+
}
|
|
214
|
+
return stripped;
|
|
215
|
+
}
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
135
218
|
export const frontendImplementationContractSchema = z
|
|
219
|
+
.preprocess(stripNullValuesDeep, z
|
|
136
220
|
.object({
|
|
137
221
|
schemaVersion: z.literal(1),
|
|
138
222
|
sourceBinding: z
|
|
@@ -189,7 +273,9 @@ export const frontendImplementationContractSchema = z
|
|
|
189
273
|
"not-needed",
|
|
190
274
|
]),
|
|
191
275
|
productionDefaultOff: z.literal(true),
|
|
192
|
-
activation: z.preprocess((value) =>
|
|
276
|
+
activation: z.preprocess((value) => value === "" || value === null || value === undefined
|
|
277
|
+
? "explicit activation boundary"
|
|
278
|
+
: value, z.string().min(1)),
|
|
193
279
|
endpoints: z.array(z
|
|
194
280
|
.object({
|
|
195
281
|
method: z.enum([
|
|
@@ -339,7 +425,7 @@ export const frontendImplementationContractSchema = z
|
|
|
339
425
|
}
|
|
340
426
|
});
|
|
341
427
|
}
|
|
342
|
-
});
|
|
428
|
+
}));
|
|
343
429
|
export async function assertFrontendSourceBindingFresh(input) {
|
|
344
430
|
for (const source of input.binding.sources) {
|
|
345
431
|
const absolute = resolveDagTaskSourcePath({
|
|
@@ -378,8 +464,13 @@ function secretIssues(value, at = "$", issues = []) {
|
|
|
378
464
|
}
|
|
379
465
|
return issues;
|
|
380
466
|
}
|
|
381
|
-
|
|
467
|
+
function extractFrontendImplementationJsonWithAudit(text) {
|
|
382
468
|
const trimmed = text.trim();
|
|
469
|
+
const actions = [];
|
|
470
|
+
const recordAction = (action) => {
|
|
471
|
+
if (!actions.includes(action))
|
|
472
|
+
actions.push(action);
|
|
473
|
+
};
|
|
383
474
|
const parse = (source) => {
|
|
384
475
|
try {
|
|
385
476
|
return JSON.parse(source);
|
|
@@ -392,6 +483,10 @@ export function extractFrontendImplementationJson(text) {
|
|
|
392
483
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
393
484
|
.replace(/^\s*\/\/.*$/gm, "");
|
|
394
485
|
const withoutTrailingCommas = withoutComments.replace(/,\s*([}\]])/g, "$1");
|
|
486
|
+
if (withoutComments !== source)
|
|
487
|
+
recordAction("strip-comments");
|
|
488
|
+
if (withoutTrailingCommas !== withoutComments)
|
|
489
|
+
recordAction("remove-trailing-commas");
|
|
395
490
|
try {
|
|
396
491
|
return JSON.parse(withoutTrailingCommas);
|
|
397
492
|
}
|
|
@@ -450,7 +545,7 @@ export function extractFrontendImplementationJson(text) {
|
|
|
450
545
|
Array.isArray(record.verificationTargets));
|
|
451
546
|
};
|
|
452
547
|
if (trimmed.startsWith("{") && trimmed.endsWith("}"))
|
|
453
|
-
return parse(trimmed);
|
|
548
|
+
return { value: parse(trimmed), actions };
|
|
454
549
|
const balancedObjects = [];
|
|
455
550
|
for (let start = 0; start < trimmed.length; start += 1) {
|
|
456
551
|
if (trimmed[start] !== "{")
|
|
@@ -488,9 +583,9 @@ export function extractFrontendImplementationJson(text) {
|
|
|
488
583
|
}
|
|
489
584
|
const balancedContracts = balancedObjects.filter(isContract);
|
|
490
585
|
if (balancedContracts.length === 1)
|
|
491
|
-
return balancedContracts[0];
|
|
586
|
+
return { value: balancedContracts[0], actions };
|
|
492
587
|
if (balancedObjects.length === 1)
|
|
493
|
-
return balancedObjects[0];
|
|
588
|
+
return { value: balancedObjects[0], actions };
|
|
494
589
|
const blocks = [...trimmed.matchAll(/```json\s*\n([\s\S]*?)\n```/gi)];
|
|
495
590
|
if (blocks.length === 0)
|
|
496
591
|
throw new Error("output must contain exactly one fenced json object");
|
|
@@ -508,13 +603,16 @@ export function extractFrontendImplementationJson(text) {
|
|
|
508
603
|
}
|
|
509
604
|
const contractCandidates = candidates.filter(isContract);
|
|
510
605
|
if (contractCandidates.length === 1)
|
|
511
|
-
return contractCandidates[0];
|
|
606
|
+
return { value: contractCandidates[0], actions };
|
|
512
607
|
if (candidates.length === 1)
|
|
513
|
-
return candidates[0];
|
|
608
|
+
return { value: candidates[0], actions };
|
|
514
609
|
if (candidates.length === 0)
|
|
515
610
|
throw new Error("output must contain exactly one valid fenced json object (found 0)");
|
|
516
611
|
throw new Error(`output must contain exactly one valid frontend contract json object (found ${contractCandidates.length || candidates.length})`);
|
|
517
612
|
}
|
|
613
|
+
export function extractFrontendImplementationJson(text) {
|
|
614
|
+
return extractFrontendImplementationJsonWithAudit(text).value;
|
|
615
|
+
}
|
|
518
616
|
/**
|
|
519
617
|
* Build the authoritative frontend-implementation-contract sourceBinding from
|
|
520
618
|
* the DAG-owned binding. Model JSON must not invent taskId/path/sha256; the
|
|
@@ -752,7 +850,14 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
|
|
|
752
850
|
const requirementId = canonicalizeRequirementId(asString(gap.requirementId));
|
|
753
851
|
// Model gaps are advisory. Blocking status is reconstructed below
|
|
754
852
|
// from the source binding and executable verification targets.
|
|
755
|
-
|
|
853
|
+
// Drop a null/empty optional requirementId so the strict schema
|
|
854
|
+
// accepts model emissions (null is not a valid string).
|
|
855
|
+
const { requirementId: _rawRequirementId, ...rest } = gap;
|
|
856
|
+
return {
|
|
857
|
+
...rest,
|
|
858
|
+
...(requirementId ? { requirementId } : {}),
|
|
859
|
+
blocking: false,
|
|
860
|
+
};
|
|
756
861
|
})
|
|
757
862
|
: [];
|
|
758
863
|
const derivedBlockingGaps = canonicalBinding.requirementIds
|
|
@@ -1228,40 +1333,87 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
1228
1333
|
evidenceGaps,
|
|
1229
1334
|
};
|
|
1230
1335
|
}
|
|
1231
|
-
|
|
1336
|
+
/**
|
|
1337
|
+
* Deterministic contract validation shared by the prewrite gate and the plan
|
|
1338
|
+
* node output self-check. Extracts, security-checks, coerces, and schema-parses
|
|
1339
|
+
* a model-emitted frontend contract, so schema/typo/null violations surface as
|
|
1340
|
+
* invalid-output at the producing node (where they can retry) instead of only
|
|
1341
|
+
* failing later at the prewrite gate.
|
|
1342
|
+
*/
|
|
1343
|
+
export async function analyzeFrontendImplementationContract(input) {
|
|
1232
1344
|
if (!input.sourceBinding)
|
|
1233
1345
|
throw new Error("frontend implementation contract gate requires DAG sourceBinding");
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1346
|
+
let rawOutput;
|
|
1347
|
+
if (input.rawContractText !== undefined) {
|
|
1348
|
+
rawOutput = input.rawContractText;
|
|
1349
|
+
}
|
|
1350
|
+
else if (input.fromNodeId !== undefined) {
|
|
1351
|
+
const record = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
|
|
1352
|
+
rawOutput = record.assistantText ?? record.stdout ?? "";
|
|
1353
|
+
}
|
|
1354
|
+
else {
|
|
1355
|
+
throw new Error("analyzeFrontendImplementationContract requires fromNodeId or rawContractText");
|
|
1356
|
+
}
|
|
1357
|
+
const rawContractText = rawOutput.trim();
|
|
1358
|
+
const candidateRawSha256 = sha256Hex(rawOutput);
|
|
1359
|
+
const normalizationActions = [];
|
|
1360
|
+
const pushAction = (action) => {
|
|
1361
|
+
if (!normalizationActions.includes(action))
|
|
1362
|
+
normalizationActions.push(action);
|
|
1363
|
+
};
|
|
1364
|
+
function fail(kind, message, candidateJsonSha256) {
|
|
1365
|
+
throw new FrontendContractFailure({
|
|
1366
|
+
kind,
|
|
1367
|
+
message,
|
|
1368
|
+
candidateRawSha256,
|
|
1369
|
+
candidateJsonSha256,
|
|
1370
|
+
normalizationActions,
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1237
1373
|
if (rawContractText.includes('"files":["/'))
|
|
1238
|
-
|
|
1374
|
+
fail("blocked", "invalid-output: absolute frontend contract path is forbidden", null);
|
|
1239
1375
|
if (/(?:"(?:files|file|implementationTargets|fixture|consumer)"\s*:\s*(?:\[\s*)?)"\//.test(rawContractText))
|
|
1240
|
-
|
|
1376
|
+
fail("blocked", "invalid-output: absolute frontend contract path is forbidden", null);
|
|
1241
1377
|
if (/(?:"strategy"\s*:\s*")(?!native\b|browser-intercept\b|request-adapter\b|not-needed\b)[^"]+"/.test(rawContractText))
|
|
1242
|
-
|
|
1378
|
+
fail("blocked", "invalid-output: unsupported mock strategy", null);
|
|
1379
|
+
let parsed;
|
|
1243
1380
|
try {
|
|
1244
|
-
|
|
1381
|
+
const extracted = extractFrontendImplementationJsonWithAudit(rawContractText);
|
|
1382
|
+
parsed = extracted.value;
|
|
1383
|
+
for (const action of extracted.actions)
|
|
1384
|
+
pushAction(action);
|
|
1245
1385
|
}
|
|
1246
1386
|
catch (error) {
|
|
1247
|
-
|
|
1387
|
+
fail("retryable-invalid", `invalid-output: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
1248
1388
|
}
|
|
1389
|
+
const candidateJsonSha256 = deterministicSha256(parsed);
|
|
1249
1390
|
const secrets = secretIssues(parsed);
|
|
1250
1391
|
if (secrets.length)
|
|
1251
|
-
|
|
1392
|
+
fail("blocked", `invalid-output: ${secrets.join("; ")}`, candidateJsonSha256);
|
|
1252
1393
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1253
|
-
|
|
1394
|
+
fail("retryable-invalid", "invalid-output: frontend contract must be a JSON object", candidateJsonSha256);
|
|
1254
1395
|
const parsedTargetObject = parsed.targets;
|
|
1255
1396
|
const parsedFiles = parsedTargetObject && typeof parsedTargetObject === "object"
|
|
1256
1397
|
? parsedTargetObject.files
|
|
1257
1398
|
: undefined;
|
|
1258
1399
|
if (Array.isArray(parsedFiles) && parsedFiles.some((file) => typeof file === "string" && file.startsWith("/")))
|
|
1259
|
-
|
|
1400
|
+
fail("blocked", "invalid-output: absolute frontend contract path is forbidden", candidateJsonSha256);
|
|
1260
1401
|
// Normalize model-emitted compact requirement IDs before deriving the
|
|
1261
1402
|
// canonical binding or invoking the strict zod schema.
|
|
1403
|
+
const beforeIds = JSON.stringify(parsed);
|
|
1262
1404
|
parsed = canonicalizeRequirementIdsInPayload(parsed);
|
|
1405
|
+
if (JSON.stringify(parsed) !== beforeIds)
|
|
1406
|
+
pushAction("canonicalize-requirement-ids");
|
|
1407
|
+
const beforeAliases = JSON.stringify(parsed);
|
|
1263
1408
|
parsed = canonicalizeVerificationTargetAliases(parsed);
|
|
1264
|
-
|
|
1409
|
+
if (JSON.stringify(parsed) !== beforeAliases)
|
|
1410
|
+
pushAction("canonicalize-verification-alias");
|
|
1411
|
+
try {
|
|
1412
|
+
assertFrontendContractPathsSafe(parsed);
|
|
1413
|
+
}
|
|
1414
|
+
catch (error) {
|
|
1415
|
+
fail("blocked", error instanceof Error ? error.message : String(error), candidateJsonSha256);
|
|
1416
|
+
}
|
|
1265
1417
|
// Validate verificationTarget commandLabels against frozen command set.
|
|
1266
1418
|
// The frozen set is derived from DAG verification shell task verifyEvidence.
|
|
1267
1419
|
const frozenLabels = await deriveFrozenCommandLabelsFromRun(input.runDir);
|
|
@@ -1272,7 +1424,7 @@ export async function materializeFrontendImplementationContract(input) {
|
|
|
1272
1424
|
for (const vt of parsedVt) {
|
|
1273
1425
|
const label = asString(asRecord(vt)?.commandLabel);
|
|
1274
1426
|
if (label && !frozen.has(label)) {
|
|
1275
|
-
|
|
1427
|
+
fail("blocked", `invalid-output: verificationTarget commandLabel "${label}" is not in the frozen command set [${[...frozen].join(", ")}]`, candidateJsonSha256);
|
|
1276
1428
|
}
|
|
1277
1429
|
}
|
|
1278
1430
|
}
|
|
@@ -1280,52 +1432,94 @@ export async function materializeFrontendImplementationContract(input) {
|
|
|
1280
1432
|
const parsedTargets = asRecord(parsed)?.targets;
|
|
1281
1433
|
const parsedTargetFiles = asStringArray(asRecord(parsedTargets)?.files);
|
|
1282
1434
|
if (parsedTargetFiles.some((file) => file.startsWith("/") || file.includes("\\")))
|
|
1283
|
-
|
|
1435
|
+
fail("blocked", "invalid-output: frontend contract target paths must be relative POSIX paths", candidateJsonSha256);
|
|
1284
1436
|
const parsedStates = asRecord(parsed)?.uiStates;
|
|
1285
1437
|
if (Array.isArray(parsedStates) && parsedStates.some((item) => {
|
|
1286
1438
|
const state = asRecord(item);
|
|
1287
1439
|
return state?.applicable === true &&
|
|
1288
1440
|
(!asString(state.expectedBehavior) || asStringArray(state.implementationTargets).length === 0 || asStringArray(state.verificationTargetIds).length === 0);
|
|
1289
1441
|
}))
|
|
1290
|
-
|
|
1442
|
+
fail("retryable-invalid", "invalid-output: applicable UI state requires behavior, implementation, and verification", candidateJsonSha256);
|
|
1291
1443
|
const parsedMockApi = asRecord(parsed)?.mockApi;
|
|
1292
1444
|
if (asRecord(parsedMockApi) &&
|
|
1293
1445
|
typeof asRecord(parsedMockApi)?.strategy === "string" &&
|
|
1294
1446
|
!["native", "browser-intercept", "request-adapter", "not-needed"].includes(String(asRecord(parsedMockApi)?.strategy)))
|
|
1295
|
-
|
|
1447
|
+
fail("blocked", `invalid-output: unsupported mock strategy ${String(asRecord(parsedMockApi)?.strategy)}`, candidateJsonSha256);
|
|
1296
1448
|
const baseCanonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
|
|
1297
1449
|
const canonicalBinding = withDerivedRequirementIdsWhenUnscoped(baseCanonicalBinding, parsed);
|
|
1298
1450
|
// Always inject DAG-owned identity. Model-provided sourceBinding is advisory
|
|
1299
|
-
// only and must not fail a otherwise-valid contract
|
|
1300
|
-
//
|
|
1301
|
-
const
|
|
1451
|
+
// only and must not fail a otherwise-valid contract. Record the action only
|
|
1452
|
+
// when the model's emitted binding actually differs from canonical identity.
|
|
1453
|
+
const modelBinding = asRecord(asRecord(parsed)?.sourceBinding);
|
|
1454
|
+
if (serializeDeterministicJson(modelBinding ?? null) !== serializeDeterministicJson(canonicalBinding))
|
|
1455
|
+
pushAction("inject-source-binding");
|
|
1302
1456
|
// There is exactly one post-security candidate. A fallback candidate would
|
|
1303
1457
|
// allow malformed raw fields to bypass the boundary checks above.
|
|
1458
|
+
const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
|
|
1304
1459
|
const candidate = deriveFrontendVerificationCoverage({
|
|
1305
1460
|
...(asRecord(normalizedContract) ?? parsed),
|
|
1306
1461
|
sourceBinding: canonicalBinding,
|
|
1307
1462
|
}, canonicalBinding);
|
|
1308
1463
|
const result = frontendImplementationContractSchema.safeParse(candidate);
|
|
1309
1464
|
if (!result.success)
|
|
1310
|
-
|
|
1465
|
+
fail("retryable-invalid", `invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, candidateJsonSha256);
|
|
1311
1466
|
const blockingGaps = [
|
|
1312
1467
|
...result.data.evidenceGaps,
|
|
1313
1468
|
...result.data.requirements.flatMap((item) => item.evidenceGap ? [item.evidenceGap] : []),
|
|
1314
1469
|
].filter((item) => item.blocking);
|
|
1315
1470
|
if (blockingGaps.length > 0)
|
|
1316
|
-
|
|
1471
|
+
fail("blocked", `frontend contract has blocking evidence gap: ${blockingGaps
|
|
1317
1472
|
.map((item) => item.requirementId ?? item.description)
|
|
1318
|
-
.join(", ")}
|
|
1473
|
+
.join(", ")}`, candidateJsonSha256);
|
|
1319
1474
|
for (const requirementId of canonicalBinding.requirementIds)
|
|
1320
1475
|
if (!result.data.requirements.some((item) => item.id === requirementId) &&
|
|
1321
1476
|
!result.data.evidenceGaps.some((item) => item.requirementId === requirementId))
|
|
1322
|
-
|
|
1323
|
-
|
|
1477
|
+
fail("blocked", `frontend contract does not cover ${requirementId}`, candidateJsonSha256);
|
|
1478
|
+
return {
|
|
1479
|
+
canonical: result.data,
|
|
1480
|
+
candidateRawSha256,
|
|
1481
|
+
candidateJsonSha256,
|
|
1482
|
+
normalizationActions,
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
export async function writeFrontendImplementationContractArtifact(input) {
|
|
1486
|
+
const written = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(input.outputDir, input.artifactName), input.canonical);
|
|
1324
1487
|
return {
|
|
1325
|
-
path:
|
|
1326
|
-
sha256:
|
|
1327
|
-
.update(`${JSON.stringify(result.data, null, 2)}\n`)
|
|
1328
|
-
.digest("hex"),
|
|
1488
|
+
path: written.path,
|
|
1489
|
+
sha256: written.sha256,
|
|
1329
1490
|
schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
|
|
1330
1491
|
};
|
|
1331
1492
|
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Node-output self-check for plan nodes that produce the implementation
|
|
1495
|
+
* contract. Mirrors the prewrite gate validation so schema/typo/null violations
|
|
1496
|
+
* are caught at the producing node and retried instead of failing the run later.
|
|
1497
|
+
* Skipped when no sourceBinding is available (the gate remains the authority).
|
|
1498
|
+
*/
|
|
1499
|
+
export async function validateFrontendContractNodeOutput(input) {
|
|
1500
|
+
if (!input.sourceBinding)
|
|
1501
|
+
return { ok: true };
|
|
1502
|
+
try {
|
|
1503
|
+
const analysis = await analyzeFrontendImplementationContract({
|
|
1504
|
+
runDir: input.runDir,
|
|
1505
|
+
rawContractText: input.text,
|
|
1506
|
+
sourceBinding: input.sourceBinding,
|
|
1507
|
+
});
|
|
1508
|
+
return { ok: true, contract: analysis.canonical };
|
|
1509
|
+
}
|
|
1510
|
+
catch (error) {
|
|
1511
|
+
return {
|
|
1512
|
+
ok: false,
|
|
1513
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
export async function materializeFrontendImplementationContract(input) {
|
|
1518
|
+
const analysis = await analyzeFrontendImplementationContract(input);
|
|
1519
|
+
return writeFrontendImplementationContractArtifact({
|
|
1520
|
+
runDir: input.runDir,
|
|
1521
|
+
outputDir: input.outputDir,
|
|
1522
|
+
artifactName: input.artifactName,
|
|
1523
|
+
canonical: analysis.canonical,
|
|
1524
|
+
});
|
|
1525
|
+
}
|