@tea-agent/loop-agent 0.39.0-beta.2 → 0.39.0-beta.4
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 +4 -1
- package/CHANGELOG.md +187 -92
- package/README.md +9 -5
- package/bin/loop-agent.js +7 -3
- package/dist/application/task-lifecycle/recommendations.js +13 -2
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +16 -5
- package/dist/cli/program.js +16 -2
- package/dist/cli/update/notifier.js +2 -2
- package/dist/cli.js +2 -1
- package/dist/commands/dag-rerun.js +55 -1
- package/dist/commands/init-model-catalog.js +464 -0
- package/dist/commands/init-upgrade.js +265 -97
- package/dist/commands/init.js +429 -28
- package/dist/commands/inspect-next.js +8 -0
- package/dist/executors/dag-pi-executor.js +1691 -29
- package/dist/executors/pi-extension-resolver.js +233 -0
- package/dist/executors/pi-playwright-cli-tool.js +51 -12
- package/dist/executors/pi-sdk-executor.js +180 -33
- package/dist/executors/shell-executor.js +1152 -171
- package/dist/executors/shell-write-guard.js +14 -0
- package/dist/shared/dag-prompt-override.js +28 -0
- package/dist/shared/openspec-spec.js +70 -4
- package/dist/shared/operator/capabilities.js +32 -0
- package/dist/shared/playwright-cli-command-policy.js +15 -0
- package/dist/shared/update/console-notifier.js +100 -0
- package/dist/{cli → shared}/update/npm-client.js +41 -8
- package/dist/{cli → shared}/update/state.js +41 -13
- package/dist/task/config-types.js +19 -5
- package/dist/task/contract/project.js +3 -0
- package/dist/task/contract/schema.js +2 -1
- package/dist/task/frontend-project-capability.js +60 -17
- package/dist/task/source-prepare/parse-intent.js +24 -4
- package/dist/task/source-prepare/prepare.js +36 -0
- package/dist/worker/cli.js +31 -1
- package/dist/worker/console/chat/pi-console-config.js +81 -10
- package/dist/worker/console/chat/routes.js +1 -0
- package/dist/worker/console/dag-execution-receipt.js +33 -0
- package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
- package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
- package/dist/worker/console/index.js +3 -0
- package/dist/worker/console/operation-store.js +226 -6
- package/dist/worker/console/operator-actions.js +263 -0
- package/dist/worker/console/operator-user-error.js +2 -2
- package/dist/worker/console/routes.js +43 -2
- package/dist/worker/console/security.js +63 -9
- package/dist/worker/console/server.js +151 -6
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-zsNyvGaH.js} +1 -1
- package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-BDjZ5kE1.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-BkgaujnN.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-CqxOJi0j.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-C2JHLdRi.js} +1 -1
- package/dist/worker/console/static/assets/channel-BuqaGKAT.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-DF_YSvOS.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-D3Ort5Vo.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-XfAfZeUC.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-SLBibhf5.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-Dz29Ye_f.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-DB2G4H8V.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-CIoGkxdu.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-CG78FGkn.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-ZDHAvXPd.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-BKkkC4VA.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DNdSHjH9.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DNdSHjH9.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-SposUZsO.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-BSCtQpD7.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-DgFHrw9l.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-BVBasYbj.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-6r_4rN_c.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-JmuaB41P.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-xXvAY3sk.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-D2B3lY2o.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-tK54tMEX.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-CNtELzV-.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-C1xKMKcW.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-CZ7dlPMz.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-BqGb42Xe.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-D_BlXEtt.js} +1 -1
- package/dist/worker/console/static/assets/{index-DTZOKgAn.js → index-D5zGX6fG.js} +68 -68
- package/dist/worker/console/static/assets/index-DBbhESQ_.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-Vce2vmIe.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-DDwVBAvI.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-y_Ew-wv4.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-CjXJFp1K.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-1itMst1Q.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-C-D3ZRUa.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-BiYywmZc.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-l2fydNWA.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-Dz0hNbYW.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-BujI_mLZ.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-pWe84beb.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-ByYu2frg.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-CoPnilFP.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-1Md2LnS0.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-DqqqAoSH.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-C4sGkf--.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8rmop_9.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-ByxJrUzW.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B0ky-kBM.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-D8MLgM0O.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-DnBis-wZ.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-uuyYT7UO.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-BjgabhwK.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/active-run-badge.js +17 -0
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
- package/dist/worker/loop-agent/loop-agent-client.js +7 -3
- package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
- package/dist/worker/observe/node-input.js +74 -3
- package/dist/worker/observe/node-process.js +377 -0
- package/dist/worker/observe/routes.js +36 -3
- package/dist/worker/observe/shell-handler-keys.js +34 -0
- package/dist/worker/observe/static/api.js +66 -0
- package/dist/worker/observe/static/constants.js +8 -1
- package/dist/worker/observe/static/dag-history-labels.js +4 -0
- package/dist/worker/observe/static/format.js +23 -0
- package/dist/worker/observe/static/markdown-render.js +20 -1
- package/dist/worker/observe/static/operator-chrome.js +4 -0
- package/dist/worker/observe/static/state.js +13 -2
- package/dist/worker/observe/static/styles.css +73 -0
- package/dist/worker/observe/static/views/dag-inspector.js +612 -8
- package/dist/worker/observe/static/views/dag.js +8 -1
- package/dist/worker/observe/static/views/failures.js +5 -2
- package/dist/worker/observe/static/views/session-timeline.js +78 -9
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +20 -2
- package/dist/workflows/dag/contract-output-registry.js +15 -0
- package/dist/workflows/dag/contract-validator-registrations.js +1 -2
- package/dist/workflows/dag/failure-category.js +12 -0
- package/dist/workflows/dag/frontend-closeout.js +221 -0
- package/dist/workflows/dag/frontend-design-policy.js +366 -0
- package/dist/workflows/dag/frontend-human-decision.js +182 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +378 -160
- package/dist/workflows/dag/frontend-prewrite-gate.js +343 -391
- package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
- package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
- package/dist/workflows/dag/frontend-recovery-controller.js +201 -0
- package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
- package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
- package/dist/workflows/dag/frontend-recovery-run.js +33 -20
- package/dist/workflows/dag/frontend-repair.js +7 -424
- package/dist/workflows/dag/frontend-review-context.js +195 -8
- package/dist/workflows/dag/frontend-risk.js +15 -2
- package/dist/workflows/dag/frontend-shadow-dual-write.js +665 -0
- package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
- package/dist/workflows/dag/frontend-shape-facts.js +409 -0
- package/dist/workflows/dag/frontend-shape.js +425 -0
- package/dist/workflows/dag/frontend-split-application-service.js +203 -0
- package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
- package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
- package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
- package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
- package/dist/workflows/dag/frontend-test-markdown.js +61 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +160 -28
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
- package/dist/workflows/dag/frontend-typed-event-store.js +439 -0
- package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
- package/dist/workflows/dag/frontend-verification-trace.js +52 -17
- package/dist/workflows/dag/frontend-worktree-diff.js +97 -18
- package/dist/workflows/dag/frontend-writer-admission.js +260 -0
- package/dist/workflows/dag/frontend-writer-status.js +244 -0
- package/dist/workflows/dag/init-hybrid.js +572 -500
- package/dist/workflows/dag/node-execution.js +307 -70
- package/dist/workflows/dag/prompt.js +10 -18
- package/dist/workflows/dag/rerun-plan.js +144 -5
- package/dist/workflows/dag/rerun-run.js +58 -6
- package/dist/workflows/dag/retry-policy.js +228 -4
- package/dist/workflows/dag/runner.js +314 -119
- package/dist/workflows/dag/scheduler.js +114 -16
- package/dist/workflows/dag/structured-output-repair.js +712 -0
- package/dist/workflows/dag/types.js +235 -23
- package/dist/workflows/dag/upstream-artifacts.js +0 -4
- package/dist/workflows/dag/validate.js +60 -10
- package/docs/init-surface.manifest.json +5 -0
- package/docs/operations/README.md +1 -1
- package/docs/templates/agent-dag.schema.json +44 -0
- package/docs/templates/frontend-test-dag.json +8 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/docs/templates/init-managed-agents.md +13 -3
- package/harness.json +3 -3
- package/package.json +5 -1
- package/scripts/next-info.mjs +356 -0
- package/scripts/next-publish-gate.mjs +238 -0
- package/scripts/release-source-binding.mjs +251 -0
- package/skills/codebase-scout/SKILL.md +1 -1
- package/skills/fe-test-ui-scout/SKILL.md +65 -0
- package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
- package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
- package/skills/frontend-implementation/SKILL.md +4 -4
- package/skills/frontend-implementation/references/design-spec.md +11 -7
- package/skills/frontend-implementation/references/node-contracts.md +20 -11
- package/skills/loop-agent/references/command-reference.md +11 -4
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/playwright-cli/SKILL.md +22 -0
- package/skills/playwright-cli-case-generator/SKILL.md +20 -1
- package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
- package/dist/worker/console/static/assets/index-Ya5FE7cD.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
|
|
6
|
+
import { CANONICAL_TASK_ID_PATTERN } from "../../task/runtime.js";
|
|
7
|
+
import { parseFrontendShapeTransitionCapsule, } from "./frontend-shape.js";
|
|
8
|
+
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
9
|
+
const RUN_ID_PATTERN = /^\d{8}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
|
+
const NON_CANONICAL_SHAPE_TERMINAL_STATUSES = new Set(["finished", "partial_failed", "superseded", "abandoned"]);
|
|
11
|
+
const recordEnvelopeSchema = z.object({
|
|
12
|
+
schemaVersion: z.literal(1),
|
|
13
|
+
taskId: z.string().min(1),
|
|
14
|
+
parentRunId: z.string().min(1),
|
|
15
|
+
terminalOutcome: z.enum(["reshape-required", "split-required"]),
|
|
16
|
+
capsuleDigest: z.string().regex(DIGEST_PATTERN),
|
|
17
|
+
committedAt: z.string().datetime(),
|
|
18
|
+
capsule: z.unknown(),
|
|
19
|
+
}).strict();
|
|
20
|
+
function assertTaskId(taskId) {
|
|
21
|
+
if (!CANONICAL_TASK_ID_PATTERN.test(taskId)) {
|
|
22
|
+
throw new Error(`invalid frontend shape capsule taskId: ${taskId}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function assertRunId(runId) {
|
|
26
|
+
if (!RUN_ID_PATTERN.test(runId)) {
|
|
27
|
+
throw new Error(`invalid frontend shape capsule parentRunId: ${runId}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function assertDigest(digest) {
|
|
31
|
+
if (!DIGEST_PATTERN.test(digest)) {
|
|
32
|
+
throw new Error(`invalid frontend shape capsule digest: ${digest}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function canonicalEqual(left, right) {
|
|
36
|
+
return sha256OfCanonicalJson(left) === sha256OfCanonicalJson(right);
|
|
37
|
+
}
|
|
38
|
+
function parseRecord(value) {
|
|
39
|
+
const envelope = recordEnvelopeSchema.parse(value);
|
|
40
|
+
assertTaskId(envelope.taskId);
|
|
41
|
+
assertRunId(envelope.parentRunId);
|
|
42
|
+
const capsule = parseFrontendShapeTransitionCapsule(envelope.capsule);
|
|
43
|
+
if (envelope.capsuleDigest !== capsule.capsuleDigest) {
|
|
44
|
+
throw new Error("frontend shape capsule record digest drifted");
|
|
45
|
+
}
|
|
46
|
+
if (envelope.taskId !== capsule.taskId || envelope.parentRunId !== capsule.parentRunId) {
|
|
47
|
+
throw new Error("frontend shape capsule record task/run binding drifted");
|
|
48
|
+
}
|
|
49
|
+
if (envelope.committedAt !== capsule.committedAt) {
|
|
50
|
+
throw new Error("frontend shape capsule record committedAt drifted");
|
|
51
|
+
}
|
|
52
|
+
const expectedOutcome = capsule.kind === "split" ? "split-required" : "reshape-required";
|
|
53
|
+
if (envelope.terminalOutcome !== expectedOutcome) {
|
|
54
|
+
throw new Error("frontend shape capsule record terminal outcome drifted");
|
|
55
|
+
}
|
|
56
|
+
return { ...envelope, capsule };
|
|
57
|
+
}
|
|
58
|
+
export function frontendShapeCapsuleRecordPath(repoRoot, taskId, capsuleDigest) {
|
|
59
|
+
assertTaskId(taskId);
|
|
60
|
+
assertDigest(capsuleDigest);
|
|
61
|
+
return path.join(path.resolve(repoRoot), ".harness", "tasks", taskId, "artifacts", "frontend-shape-transitions", `${capsuleDigest}.json`);
|
|
62
|
+
}
|
|
63
|
+
export async function ensureFrontendShapeCapsuleRecord(input) {
|
|
64
|
+
assertTaskId(input.taskId);
|
|
65
|
+
assertRunId(input.parentRunId);
|
|
66
|
+
const capsule = parseFrontendShapeTransitionCapsule(input.capsule);
|
|
67
|
+
const record = parseRecord({
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
taskId: input.taskId,
|
|
70
|
+
parentRunId: input.parentRunId,
|
|
71
|
+
terminalOutcome: input.terminalOutcome,
|
|
72
|
+
capsuleDigest: capsule.capsuleDigest,
|
|
73
|
+
committedAt: capsule.committedAt,
|
|
74
|
+
capsule,
|
|
75
|
+
});
|
|
76
|
+
const targetPath = frontendShapeCapsuleRecordPath(input.repoRoot, input.taskId, capsule.capsuleDigest);
|
|
77
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
78
|
+
const tempPath = path.join(path.dirname(targetPath), `.${capsule.capsuleDigest}.${process.pid}.${randomUUID()}.tmp`);
|
|
79
|
+
await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx" });
|
|
80
|
+
try {
|
|
81
|
+
await link(tempPath, targetPath);
|
|
82
|
+
return { path: targetPath, created: true, record };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const code = error.code;
|
|
86
|
+
if (code !== "EEXIST")
|
|
87
|
+
throw error;
|
|
88
|
+
let existing;
|
|
89
|
+
try {
|
|
90
|
+
existing = parseRecord(JSON.parse(await readFile(targetPath, "utf8")));
|
|
91
|
+
}
|
|
92
|
+
catch (parseError) {
|
|
93
|
+
throw new Error(`frontend shape capsule immutable record collision: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
94
|
+
}
|
|
95
|
+
if (!canonicalEqual(existing, record)) {
|
|
96
|
+
throw new Error("frontend shape capsule immutable record collision");
|
|
97
|
+
}
|
|
98
|
+
return { path: targetPath, created: false, record: existing };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await unlink(tempPath).catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function readCompletedState(repoRoot, parentRunId) {
|
|
105
|
+
const statePath = path.join(path.resolve(repoRoot), ".harness", "dag-runs", "completed", parentRunId, "state.json");
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(await readFile(statePath, "utf8"));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error.code === "ENOENT")
|
|
111
|
+
return undefined;
|
|
112
|
+
throw new Error(`invalid completed frontend shape parent state ${parentRunId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function assertCompletedBinding(record, state) {
|
|
116
|
+
if (typeof state !== "object" || state === null || Array.isArray(state)) {
|
|
117
|
+
throw new Error("completed frontend shape parent state must be an object");
|
|
118
|
+
}
|
|
119
|
+
const value = state;
|
|
120
|
+
if (value.runId !== record.parentRunId)
|
|
121
|
+
throw new Error("completed frontend shape parent runId drifted");
|
|
122
|
+
const shapeState = value.frontendShapeState;
|
|
123
|
+
if (value.status !== "failed") {
|
|
124
|
+
if (NON_CANONICAL_SHAPE_TERMINAL_STATUSES.has(String(value.status)) &&
|
|
125
|
+
typeof shapeState === "object" &&
|
|
126
|
+
shapeState !== null &&
|
|
127
|
+
!Array.isArray(shapeState) &&
|
|
128
|
+
["terminalOutcome", "transitionCommittedAt", "transitionCapsule"].some((key) => key in shapeState)) {
|
|
129
|
+
throw new Error(`completed frontend shape parent status ${String(value.status)} is not canonical`);
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if (typeof shapeState !== "object" || shapeState === null || Array.isArray(shapeState))
|
|
134
|
+
return false;
|
|
135
|
+
const binding = shapeState;
|
|
136
|
+
if (binding.terminalOutcome !== record.terminalOutcome)
|
|
137
|
+
throw new Error("completed frontend shape terminal outcome drifted");
|
|
138
|
+
if (binding.transitionCommittedAt !== record.committedAt) {
|
|
139
|
+
throw new Error("completed frontend shape committedAt drifted");
|
|
140
|
+
}
|
|
141
|
+
let stateCapsule;
|
|
142
|
+
try {
|
|
143
|
+
stateCapsule = parseFrontendShapeTransitionCapsule(binding.transitionCapsule);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
throw new Error(`completed frontend shape capsule invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
147
|
+
}
|
|
148
|
+
if (!canonicalEqual(stateCapsule, record.capsule))
|
|
149
|
+
throw new Error("completed frontend shape capsule binding drifted");
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
export async function discoverLatestCommittedFrontendShapeCapsule(input) {
|
|
153
|
+
assertTaskId(input.taskId);
|
|
154
|
+
assertDigest(input.sourceDigest);
|
|
155
|
+
const directory = path.dirname(frontendShapeCapsuleRecordPath(input.repoRoot, input.taskId, "0".repeat(64)));
|
|
156
|
+
let entries;
|
|
157
|
+
try {
|
|
158
|
+
entries = await readdir(directory);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code === "ENOENT")
|
|
162
|
+
return undefined;
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
const candidates = [];
|
|
166
|
+
for (const entry of entries.filter((name) => name.endsWith(".json")).sort()) {
|
|
167
|
+
const digest = entry.slice(0, -5);
|
|
168
|
+
assertDigest(digest);
|
|
169
|
+
let record;
|
|
170
|
+
try {
|
|
171
|
+
record = parseRecord(JSON.parse(await readFile(path.join(directory, entry), "utf8")));
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw new Error(`invalid frontend shape capsule record ${entry}: ${error instanceof Error ? error.message : String(error)}`);
|
|
175
|
+
}
|
|
176
|
+
if (record.taskId !== input.taskId || record.capsuleDigest !== digest) {
|
|
177
|
+
throw new Error(`frontend shape capsule record path binding drifted: ${entry}`);
|
|
178
|
+
}
|
|
179
|
+
const state = await readCompletedState(input.repoRoot, record.parentRunId);
|
|
180
|
+
if (state === undefined || !assertCompletedBinding(record, state))
|
|
181
|
+
continue;
|
|
182
|
+
if (record.capsule.sourceDigest !== input.sourceDigest)
|
|
183
|
+
continue;
|
|
184
|
+
candidates.push(record);
|
|
185
|
+
}
|
|
186
|
+
candidates.sort((left, right) => {
|
|
187
|
+
const byTime = right.committedAt.localeCompare(left.committedAt);
|
|
188
|
+
return byTime !== 0 ? byTime : right.capsuleDigest.localeCompare(left.capsuleDigest);
|
|
189
|
+
});
|
|
190
|
+
return candidates[0]?.capsule;
|
|
191
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
|
|
2
|
+
import { buildFrontendShapeTransitionCapsule, deriveFrontendRuntimeShapeFacts, evaluateFrontendShapeEscalation, parseFrontendShapeTransitionCapsule, } from "./frontend-shape.js";
|
|
3
|
+
import { createTypedEventStore, readCommittedEvents, } from "./frontend-typed-event-store.js";
|
|
4
|
+
import { adoptTypedEventFact, stageTypedEventFact, } from "./frontend-typed-event-transaction.js";
|
|
5
|
+
export const FRONTEND_SHAPE_BOUNDARIES = [
|
|
6
|
+
{ boundary: "post-Contract", sourceNodeId: "frontend-contract-pi", targetNodeIds: ["frontend-scout-pi"] },
|
|
7
|
+
{ boundary: "post-Scout", sourceNodeId: "frontend-scout-pi", targetNodeIds: ["frontend-plan-pi"] },
|
|
8
|
+
{ boundary: "post-Plan", sourceNodeId: "frontend-plan-pi", targetNodeIds: ["frontend-design-policy-shell", "frontend-design-review-pi"] },
|
|
9
|
+
{ boundary: "post-Policy", sourceNodeId: "frontend-design-policy-shell", targetNodeIds: ["frontend-writer-admission-shell"] },
|
|
10
|
+
];
|
|
11
|
+
function canonicalFactFields(fact) {
|
|
12
|
+
return {
|
|
13
|
+
...fact,
|
|
14
|
+
evidenceRefs: [...new Set(fact.evidenceRefs)].sort(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function computeFrontendShapeNodeDigest(node) {
|
|
18
|
+
return sha256OfCanonicalJson({
|
|
19
|
+
status: node.status,
|
|
20
|
+
stdout: node.stdout ?? "",
|
|
21
|
+
stderr: node.stderr ?? "",
|
|
22
|
+
assistantText: node.assistantText ?? "",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function buildCommittedFrontendShapeFact(input) {
|
|
26
|
+
if (!input.factId || !input.sourceNodeId || !input.taskId) {
|
|
27
|
+
throw new Error("committed frontend shape fact requires factId, sourceNodeId, and taskId");
|
|
28
|
+
}
|
|
29
|
+
if (!/^[a-f0-9]{64}$/.test(input.sourceNodeDigest)) {
|
|
30
|
+
throw new Error("committed frontend shape fact requires a canonical sourceNodeDigest");
|
|
31
|
+
}
|
|
32
|
+
if (input.evidenceRefs.length === 0 || input.evidenceRefs.some((ref) => !ref.trim())) {
|
|
33
|
+
throw new Error("committed frontend shape fact requires non-empty evidenceRefs");
|
|
34
|
+
}
|
|
35
|
+
const runtimeFacts = deriveFrontendRuntimeShapeFacts({
|
|
36
|
+
requirementMarkdown: input.shapeInput.requirementMarkdown,
|
|
37
|
+
constraintMarkdown: input.shapeInput.constraintMarkdown,
|
|
38
|
+
allowedPaths: input.shapeInput.allowedPaths,
|
|
39
|
+
splitRequired: input.shapeInput.splitSignal.splitRequired,
|
|
40
|
+
});
|
|
41
|
+
if (input.runtimeFacts && sha256OfCanonicalJson(input.runtimeFacts) !== sha256OfCanonicalJson(runtimeFacts)) {
|
|
42
|
+
throw new Error("committed frontend runtime facts do not match runtime recomputation");
|
|
43
|
+
}
|
|
44
|
+
const withoutDigest = canonicalFactFields({
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
factId: input.factId,
|
|
47
|
+
phase: "committed",
|
|
48
|
+
sourceNodeId: input.sourceNodeId,
|
|
49
|
+
sourceNodeDigest: input.sourceNodeDigest,
|
|
50
|
+
taskId: input.taskId,
|
|
51
|
+
shapeInput: input.shapeInput,
|
|
52
|
+
runtimeFacts,
|
|
53
|
+
evidenceRefs: input.evidenceRefs,
|
|
54
|
+
committedAt: input.committedAt,
|
|
55
|
+
...(input.typedEventBinding ? { typedEventBinding: input.typedEventBinding } : {}),
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
...withoutDigest,
|
|
59
|
+
factDigest: sha256OfCanonicalJson(withoutDigest),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const runtimeTopologyBindings = new WeakMap();
|
|
63
|
+
export function bindFrontendShapeFactToRunTopology(fact, frozenTopology) {
|
|
64
|
+
runtimeTopologyBindings.set(fact, frozenTopology);
|
|
65
|
+
}
|
|
66
|
+
export function bindFrontendShapeStateToRunTopology(state) {
|
|
67
|
+
const shapeState = state.frontendShapeState;
|
|
68
|
+
if (!shapeState?.frozenTopology)
|
|
69
|
+
return;
|
|
70
|
+
for (const fact of Object.values(shapeState.committedFacts)) {
|
|
71
|
+
if (fact)
|
|
72
|
+
bindFrontendShapeFactToRunTopology(fact, shapeState.frozenTopology);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function parseCommittedFrontendShapeFact(value) {
|
|
76
|
+
if (typeof value !== "object" || value === null)
|
|
77
|
+
throw new Error("frontend shape fact must be an object");
|
|
78
|
+
const fact = value;
|
|
79
|
+
if (fact.schemaVersion !== 1 || fact.phase !== "committed")
|
|
80
|
+
throw new Error("frontend shape fact is not committed");
|
|
81
|
+
if (!fact.typedEventBinding ||
|
|
82
|
+
!fact.typedEventBinding.eventId ||
|
|
83
|
+
!fact.typedEventBinding.requestId ||
|
|
84
|
+
!fact.typedEventBinding.attemptId ||
|
|
85
|
+
!Number.isInteger(fact.typedEventBinding.revision) ||
|
|
86
|
+
fact.typedEventBinding.revision <= 0 ||
|
|
87
|
+
!/^[a-f0-9]{64}$/.test(fact.typedEventBinding.payloadSha256)) {
|
|
88
|
+
throw new Error("frontend shape fact lacks a valid committed typed-event binding");
|
|
89
|
+
}
|
|
90
|
+
const rebuilt = buildCommittedFrontendShapeFact(fact);
|
|
91
|
+
if (fact.factDigest !== rebuilt.factDigest)
|
|
92
|
+
throw new Error("frontend shape fact digest drifted");
|
|
93
|
+
return rebuilt;
|
|
94
|
+
}
|
|
95
|
+
export function boundaryForTargetNode(nodeId) {
|
|
96
|
+
return FRONTEND_SHAPE_BOUNDARIES.find((entry) => entry.targetNodeIds.includes(nodeId));
|
|
97
|
+
}
|
|
98
|
+
function canonicalStrings(values) {
|
|
99
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
|
100
|
+
}
|
|
101
|
+
function parseFrontendShapeEvidence(evidence) {
|
|
102
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
103
|
+
throw new Error("frontend shape evidence payload is missing");
|
|
104
|
+
}
|
|
105
|
+
const value = evidence;
|
|
106
|
+
const stringFields = ["sourceNodeId", "sourceNodeDigest", "taskId", "requirementMarkdown", "constraintMarkdown"];
|
|
107
|
+
for (const field of stringFields) {
|
|
108
|
+
if (typeof value[field] !== "string" || (field !== "constraintMarkdown" && !value[field].trim())) {
|
|
109
|
+
throw new Error(`frontend shape evidence requires ${field}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!/^[a-f0-9]{64}$/.test(value.sourceNodeDigest))
|
|
113
|
+
throw new Error("frontend shape evidence source digest is invalid");
|
|
114
|
+
if (!Array.isArray(value.targetPaths) || value.targetPaths.some((item) => typeof item !== "string"))
|
|
115
|
+
throw new Error("frontend shape evidence targetPaths are invalid");
|
|
116
|
+
if (!Array.isArray(value.routes) || value.routes.some((item) => typeof item !== "string"))
|
|
117
|
+
throw new Error("frontend shape evidence routes are invalid");
|
|
118
|
+
if (!Array.isArray(value.evidenceRefs) || value.evidenceRefs.length === 0 || value.evidenceRefs.some((item) => typeof item !== "string" || !item.trim()))
|
|
119
|
+
throw new Error("frontend shape evidence requires evidenceRefs");
|
|
120
|
+
if (typeof value.splitRequired !== "boolean")
|
|
121
|
+
throw new Error("frontend shape evidence requires splitRequired");
|
|
122
|
+
return {
|
|
123
|
+
sourceNodeId: value.sourceNodeId,
|
|
124
|
+
sourceNodeDigest: value.sourceNodeDigest,
|
|
125
|
+
taskId: value.taskId,
|
|
126
|
+
requirementMarkdown: value.requirementMarkdown,
|
|
127
|
+
constraintMarkdown: value.constraintMarkdown,
|
|
128
|
+
targetPaths: canonicalStrings(value.targetPaths),
|
|
129
|
+
routes: canonicalStrings(value.routes),
|
|
130
|
+
splitRequired: value.splitRequired,
|
|
131
|
+
evidenceRefs: canonicalStrings(value.evidenceRefs),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function parseShapeEvidenceRecord(record) {
|
|
135
|
+
if (record.phase !== "committed" || record.fact.kind !== "frontend-shape") {
|
|
136
|
+
throw new Error("frontend shape evidence must be a committed frontend-shape event");
|
|
137
|
+
}
|
|
138
|
+
return parseFrontendShapeEvidence(record.fact.evidence);
|
|
139
|
+
}
|
|
140
|
+
export function isCanonicalFrontendShapeDag(tasksById) {
|
|
141
|
+
return FRONTEND_SHAPE_BOUNDARIES.some((entry) => tasksById.has(entry.sourceNodeId) && entry.targetNodeIds.some((id) => tasksById.has(id)));
|
|
142
|
+
}
|
|
143
|
+
export async function commitFrontendShapeEvidenceTransaction(input) {
|
|
144
|
+
const validatedEvidence = parseFrontendShapeEvidence(input.evidence);
|
|
145
|
+
const mapping = FRONTEND_SHAPE_BOUNDARIES.find((entry) => entry.sourceNodeId === validatedEvidence.sourceNodeId);
|
|
146
|
+
if (!mapping || !input.state.frontendShapeState)
|
|
147
|
+
throw new Error("frontend shape evidence source is not a canonical enabled boundary");
|
|
148
|
+
const frozenTopology = input.state.frontendShapeState.frozenTopology;
|
|
149
|
+
const node = input.state.nodes[validatedEvidence.sourceNodeId];
|
|
150
|
+
if (!node || node.status !== "FINISHED")
|
|
151
|
+
throw new Error("frontend shape evidence source is not finished");
|
|
152
|
+
if (computeFrontendShapeNodeDigest(node) !== validatedEvidence.sourceNodeDigest)
|
|
153
|
+
throw new Error("frontend shape evidence source digest drifted before commit");
|
|
154
|
+
const staged = stageTypedEventFact({
|
|
155
|
+
store: input.store,
|
|
156
|
+
requestId: input.requestId,
|
|
157
|
+
attemptId: input.attemptId,
|
|
158
|
+
eventId: input.eventId,
|
|
159
|
+
fact: { kind: "frontend-shape", evidence: input.evidence },
|
|
160
|
+
});
|
|
161
|
+
const adopted = await adoptTypedEventFact({
|
|
162
|
+
store: input.store,
|
|
163
|
+
requestId: input.requestId,
|
|
164
|
+
attemptId: input.attemptId,
|
|
165
|
+
eventId: staged.eventId,
|
|
166
|
+
fact: { kind: "frontend-shape", evidence: input.evidence },
|
|
167
|
+
expectedRevision: input.expectedStoreRevision,
|
|
168
|
+
});
|
|
169
|
+
const [record] = readCommittedEvents(input.store, input.attemptId).filter((candidate) => candidate.eventId === adopted.eventId);
|
|
170
|
+
if (!record)
|
|
171
|
+
throw new Error("committed frontend shape evidence is not readable after adoption");
|
|
172
|
+
const evidence = parseShapeEvidenceRecord(record);
|
|
173
|
+
const shapeInput = {
|
|
174
|
+
allowedPaths: evidence.targetPaths,
|
|
175
|
+
requirementMarkdown: [evidence.requirementMarkdown, ...evidence.routes.map((route) => `runtime-route:${route}`)].join("\n"),
|
|
176
|
+
constraintMarkdown: evidence.constraintMarkdown,
|
|
177
|
+
splitSignal: { splitRequired: evidence.splitRequired, runtimeSupportsSplit: true },
|
|
178
|
+
};
|
|
179
|
+
const nextRevision = input.state.frontendShapeState.revision + 1;
|
|
180
|
+
const fact = buildCommittedFrontendShapeFact({
|
|
181
|
+
factId: `${input.state.runId}:${mapping.boundary}:${nextRevision}`,
|
|
182
|
+
sourceNodeId: evidence.sourceNodeId,
|
|
183
|
+
sourceNodeDigest: evidence.sourceNodeDigest,
|
|
184
|
+
taskId: evidence.taskId,
|
|
185
|
+
shapeInput,
|
|
186
|
+
evidenceRefs: evidence.evidenceRefs,
|
|
187
|
+
committedAt: record.recordedAt,
|
|
188
|
+
typedEventBinding: {
|
|
189
|
+
eventId: record.eventId,
|
|
190
|
+
requestId: record.requestId,
|
|
191
|
+
attemptId: record.attemptId,
|
|
192
|
+
payloadSha256: record.payloadSha256,
|
|
193
|
+
revision: record.revision,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
if (frozenTopology)
|
|
197
|
+
bindFrontendShapeFactToRunTopology(fact, frozenTopology);
|
|
198
|
+
const outcome = evaluateFrontendShapeBoundary({
|
|
199
|
+
boundary: mapping.boundary,
|
|
200
|
+
sourceNode: { status: node.status, digest: evidence.sourceNodeDigest },
|
|
201
|
+
fact,
|
|
202
|
+
frozenTopology,
|
|
203
|
+
});
|
|
204
|
+
input.state.frontendShapeState.committedFacts[mapping.boundary] = fact;
|
|
205
|
+
input.state.frontendShapeState.revision = nextRevision;
|
|
206
|
+
return { fact, outcome };
|
|
207
|
+
}
|
|
208
|
+
function extractRuntimeShapeEvidence(input) {
|
|
209
|
+
const node = input.state.nodes[input.sourceNodeId];
|
|
210
|
+
const text = `${node.stdout ?? ""}\n${node.assistantText ?? ""}`;
|
|
211
|
+
const targetPaths = canonicalStrings([
|
|
212
|
+
...(text.match(/(?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+|\*\*)/g) ?? []),
|
|
213
|
+
]);
|
|
214
|
+
const routes = canonicalStrings(text.match(/\/(?:[A-Za-z0-9_:[\]-]+\/?)+/g) ?? []);
|
|
215
|
+
const splitRequired = /["']splitRequired["']\s*:\s*true|\bsplit-required\s+shape\b/i.test(text);
|
|
216
|
+
return {
|
|
217
|
+
sourceNodeId: input.sourceNodeId,
|
|
218
|
+
sourceNodeDigest: computeFrontendShapeNodeDigest(node),
|
|
219
|
+
taskId: input.taskId,
|
|
220
|
+
requirementMarkdown: [input.requirementMarkdown, text].filter(Boolean).join("\n"),
|
|
221
|
+
constraintMarkdown: input.constraintMarkdown ?? "",
|
|
222
|
+
targetPaths,
|
|
223
|
+
routes,
|
|
224
|
+
splitRequired,
|
|
225
|
+
evidenceRefs: [`node:${input.sourceNodeId}:stdout`],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Commit a typed, structured runtime fact immediately after a canonical source
|
|
229
|
+
* finishes. The scheduler later invokes the same evaluator and re-hashes the
|
|
230
|
+
* source record, so adoption is not a freshness shortcut. */
|
|
231
|
+
export async function commitRuntimeFrontendShapeFact(input) {
|
|
232
|
+
const mapping = FRONTEND_SHAPE_BOUNDARIES.find((entry) => entry.sourceNodeId === input.sourceNodeId);
|
|
233
|
+
if (!mapping || !input.state.frontendShapeState)
|
|
234
|
+
return undefined;
|
|
235
|
+
const node = input.state.nodes[input.sourceNodeId];
|
|
236
|
+
if (!node || node.status !== "FINISHED")
|
|
237
|
+
return undefined;
|
|
238
|
+
const store = createTypedEventStore();
|
|
239
|
+
const committed = await commitFrontendShapeEvidenceTransaction({
|
|
240
|
+
state: input.state,
|
|
241
|
+
tasksById: input.tasksById,
|
|
242
|
+
store,
|
|
243
|
+
requestId: `${input.state.runId}:${mapping.boundary}`,
|
|
244
|
+
attemptId: `${input.state.runId}:${input.sourceNodeId}`,
|
|
245
|
+
eventId: `${input.state.runId}:${mapping.boundary}:shape-evidence`,
|
|
246
|
+
expectedStoreRevision: 0,
|
|
247
|
+
evidence: extractRuntimeShapeEvidence(input),
|
|
248
|
+
});
|
|
249
|
+
return committed.fact;
|
|
250
|
+
}
|
|
251
|
+
export function evaluateFrontendShapeBoundary(input) {
|
|
252
|
+
const mapping = FRONTEND_SHAPE_BOUNDARIES.find((entry) => entry.boundary === input.boundary);
|
|
253
|
+
if (!input.sourceNode || input.sourceNode.status !== "FINISHED") {
|
|
254
|
+
return { kind: "blocked", reason: `frontend-shape-source-not-finished:${mapping.sourceNodeId}` };
|
|
255
|
+
}
|
|
256
|
+
let fact;
|
|
257
|
+
try {
|
|
258
|
+
fact = parseCommittedFrontendShapeFact(input.fact);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
return { kind: "blocked", reason: `frontend-shape-fact-invalid:${error instanceof Error ? error.message : String(error)}` };
|
|
262
|
+
}
|
|
263
|
+
if (fact.sourceNodeId !== mapping.sourceNodeId) {
|
|
264
|
+
return { kind: "blocked", reason: "frontend-shape-source-binding-mismatch" };
|
|
265
|
+
}
|
|
266
|
+
if (fact.sourceNodeDigest !== input.sourceNode.digest) {
|
|
267
|
+
return { kind: "blocked", reason: "frontend-shape-source-digest-drift" };
|
|
268
|
+
}
|
|
269
|
+
const frozenTopology = input.frozenTopology ?? (typeof input.fact === "object" && input.fact !== null
|
|
270
|
+
? runtimeTopologyBindings.get(input.fact)
|
|
271
|
+
: undefined);
|
|
272
|
+
if (!frozenTopology) {
|
|
273
|
+
return { kind: "blocked", reason: "frontend-shape-current-topology-missing" };
|
|
274
|
+
}
|
|
275
|
+
const escalation = evaluateFrontendShapeEscalation({
|
|
276
|
+
...fact.runtimeFacts,
|
|
277
|
+
currentTopologyCapability: frozenTopology.capability,
|
|
278
|
+
});
|
|
279
|
+
return { kind: escalation.outcome, reason: escalation.reason };
|
|
280
|
+
}
|
|
281
|
+
function downstreamPendingIds(tasksById, sourceNodeId, state) {
|
|
282
|
+
const downstream = new Set();
|
|
283
|
+
let changed = true;
|
|
284
|
+
while (changed) {
|
|
285
|
+
changed = false;
|
|
286
|
+
for (const task of tasksById.values()) {
|
|
287
|
+
if (downstream.has(task.id))
|
|
288
|
+
continue;
|
|
289
|
+
if (task.depends_on.includes(sourceNodeId) || task.depends_on.some((id) => downstream.has(id))) {
|
|
290
|
+
downstream.add(task.id);
|
|
291
|
+
changed = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return [...downstream].filter((id) => state.nodes[id]?.status === "PENDING");
|
|
296
|
+
}
|
|
297
|
+
function buildTransitionCapsule(state, request, taskSourceDigest) {
|
|
298
|
+
const frozenTopology = state.frontendShapeState?.frozenTopology;
|
|
299
|
+
if (!frozenTopology)
|
|
300
|
+
throw new Error("frontend shape transition requires current frozen topology");
|
|
301
|
+
const common = {
|
|
302
|
+
schemaVersion: 1,
|
|
303
|
+
taskId: request.fact.taskId,
|
|
304
|
+
parentRunId: state.runId,
|
|
305
|
+
sourceDigest: taskSourceDigest,
|
|
306
|
+
fromShapeDigest: frozenTopology.canonicalDigest,
|
|
307
|
+
shapeFactsDigest: request.fact.factDigest,
|
|
308
|
+
committedFactIds: [request.fact.factId],
|
|
309
|
+
evidenceRefs: request.fact.evidenceRefs,
|
|
310
|
+
committedAt: request.fact.committedAt,
|
|
311
|
+
};
|
|
312
|
+
const capsule = request.outcome === "split-required"
|
|
313
|
+
? buildFrontendShapeTransitionCapsule({
|
|
314
|
+
...common,
|
|
315
|
+
kind: "split",
|
|
316
|
+
splitProposalRef: request.fact.evidenceRefs[0],
|
|
317
|
+
splitProposalDigest: sha256OfCanonicalJson(request.fact.evidenceRefs),
|
|
318
|
+
requirementCoverageDigest: sha256OfCanonicalJson(request.fact.shapeInput.requirementMarkdown ?? ""),
|
|
319
|
+
})
|
|
320
|
+
: buildFrontendShapeTransitionCapsule({
|
|
321
|
+
...common,
|
|
322
|
+
kind: "reshape",
|
|
323
|
+
requestedMinimumSize: "standard",
|
|
324
|
+
requestedMinimumRisk: "high",
|
|
325
|
+
escalationSignals: request.fact.runtimeFacts.escalationSignals,
|
|
326
|
+
});
|
|
327
|
+
return parseFrontendShapeTransitionCapsule(capsule);
|
|
328
|
+
}
|
|
329
|
+
export async function commitFrontendShapeTransitionCas(input) {
|
|
330
|
+
const current = input.state.frontendShapeState;
|
|
331
|
+
if (current?.transitionCapsule) {
|
|
332
|
+
let capsule;
|
|
333
|
+
let expected;
|
|
334
|
+
try {
|
|
335
|
+
capsule = parseFrontendShapeTransitionCapsule(current.transitionCapsule);
|
|
336
|
+
expected = buildTransitionCapsule(input.state, input.request, input.taskSourceDigest);
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
return { ok: false, reason: `frontend-shape-capsule-invalid:${error instanceof Error ? error.message : String(error)}` };
|
|
340
|
+
}
|
|
341
|
+
if (input.state.status !== "failed" ||
|
|
342
|
+
current.terminalOutcome !== input.request.outcome ||
|
|
343
|
+
current.lastBoundary !== input.request.boundary ||
|
|
344
|
+
current.transitionCommittedAt !== capsule.committedAt ||
|
|
345
|
+
capsule.taskId !== input.request.fact.taskId ||
|
|
346
|
+
capsule.parentRunId !== input.state.runId ||
|
|
347
|
+
capsule.sourceDigest !== input.taskSourceDigest ||
|
|
348
|
+
capsule.shapeFactsDigest !== input.request.fact.factDigest ||
|
|
349
|
+
capsule.capsuleDigest !== expected.capsuleDigest) {
|
|
350
|
+
return { ok: false, reason: "frontend-shape-terminal-transition-conflict" };
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
await input.ensureRecord(capsule, input.request.outcome);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
return { ok: false, reason: `frontend-shape-record-write-failed:${error instanceof Error ? error.message : String(error)}` };
|
|
357
|
+
}
|
|
358
|
+
return { ok: true, idempotent: true, capsule };
|
|
359
|
+
}
|
|
360
|
+
if (!current || current.revision !== input.request.expectedRevision) {
|
|
361
|
+
return { ok: false, reason: "frontend-shape-revision-conflict" };
|
|
362
|
+
}
|
|
363
|
+
if (["finished", "partial_failed", "failed", "superseded", "abandoned"].includes(input.state.status)) {
|
|
364
|
+
return { ok: false, reason: "frontend-shape-run-already-terminal" };
|
|
365
|
+
}
|
|
366
|
+
let capsule;
|
|
367
|
+
try {
|
|
368
|
+
capsule = buildTransitionCapsule(input.state, input.request, input.taskSourceDigest);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
return { ok: false, reason: `frontend-shape-capsule-invalid:${error instanceof Error ? error.message : String(error)}` };
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await input.ensureRecord(capsule, input.request.outcome);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
return { ok: false, reason: `frontend-shape-record-write-failed:${error instanceof Error ? error.message : String(error)}` };
|
|
378
|
+
}
|
|
379
|
+
const next = structuredClone(input.state);
|
|
380
|
+
const committedAt = capsule.committedAt;
|
|
381
|
+
next.status = "failed";
|
|
382
|
+
next.finishedAt = committedAt;
|
|
383
|
+
next.terminalReason = `frontend shape ${input.request.outcome} at ${input.request.boundary}: ${input.request.reason}`;
|
|
384
|
+
next.frontendShapeState = {
|
|
385
|
+
...next.frontendShapeState,
|
|
386
|
+
revision: input.request.expectedRevision + 1,
|
|
387
|
+
lastBoundary: input.request.boundary,
|
|
388
|
+
lastOutcome: input.request.outcome,
|
|
389
|
+
terminalOutcome: input.request.outcome,
|
|
390
|
+
transitionCapsule: capsule,
|
|
391
|
+
transitionCommittedAt: committedAt,
|
|
392
|
+
};
|
|
393
|
+
for (const nodeId of downstreamPendingIds(input.tasksById, input.request.fact.sourceNodeId, next)) {
|
|
394
|
+
const node = next.nodes[nodeId];
|
|
395
|
+
node.status = "SKIPPED";
|
|
396
|
+
node.skippedReason = `frontend-shape-${input.request.outcome}`;
|
|
397
|
+
node.finishedAt = committedAt;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await input.persist(next);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
return { ok: false, reason: `frontend-shape-state-write-failed:${error instanceof Error ? error.message : String(error)}` };
|
|
404
|
+
}
|
|
405
|
+
for (const key of Object.keys(input.state))
|
|
406
|
+
delete input.state[key];
|
|
407
|
+
Object.assign(input.state, next);
|
|
408
|
+
return { ok: true, idempotent: false, capsule };
|
|
409
|
+
}
|