@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,439 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
|
|
4
|
+
import { appendJsonlLineAtomic, writeJsonlAtomic, } from "../../infrastructure/harness/atomic-write.js";
|
|
5
|
+
/**
|
|
6
|
+
* M1 typed event store (AC-001).
|
|
7
|
+
*
|
|
8
|
+
* A pure, side-effect-free event store for the frontend DAG refactor. The store
|
|
9
|
+
* distinguishes three phases — `staged`, `quarantined`, `committed` — and
|
|
10
|
+
* partitions records by `attemptId` so that sibling attempts cannot observe
|
|
11
|
+
* each other's facts. `committed` is the single terminal phase.
|
|
12
|
+
*
|
|
13
|
+
* Phase transition rules (enforced by the helpers below, not by loose field
|
|
14
|
+
* assignment):
|
|
15
|
+
* staged --adopt success--> committed (terminal)
|
|
16
|
+
* staged --validation fail--> quarantined
|
|
17
|
+
* quarantined --(no transition)--> (never visible to committed readers)
|
|
18
|
+
*
|
|
19
|
+
* This module only defines data + transitions. Persistence is delegated to the
|
|
20
|
+
* existing atomic-write boundary (`appendJsonlLineAtomic` / `writeJsonlAtomic`),
|
|
21
|
+
* and hashing reuses `sha256OfCanonicalJson` from the task-contract boundary so
|
|
22
|
+
* the fact identity is deterministic and reusable.
|
|
23
|
+
*/
|
|
24
|
+
export const TYPED_EVENT_SCHEMA_VERSION = 1;
|
|
25
|
+
export const TYPED_EVENT_FACT_KINDS = [
|
|
26
|
+
"terminal-commit",
|
|
27
|
+
"disconnect",
|
|
28
|
+
"duplicate-tool-call",
|
|
29
|
+
"length-before-terminal",
|
|
30
|
+
"reasoning-timeout",
|
|
31
|
+
"provider-ended-without-terminal",
|
|
32
|
+
"read-only-fact-channel",
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* M2 shadow-only fact catalog (AC-001 shadow dual write). These facts are
|
|
36
|
+
* audit-only observations derived from the authoritative text/JSON outputs of
|
|
37
|
+
* the frontend-contract-pi / frontend-scout-pi / frontend-plan-pi nodes. They
|
|
38
|
+
* are never branch-control signals and never participate in writer admission.
|
|
39
|
+
*
|
|
40
|
+
* Kept deliberately separate from `TYPED_EVENT_FACT_KINDS`: widening the shadow
|
|
41
|
+
* catalog must never widen the provider capability matrix (which stays frozen
|
|
42
|
+
* at its seven kinds). `target-surface` is shared by the scout and plan
|
|
43
|
+
* origins, so the union of the two catalogs is 12 unique kinds.
|
|
44
|
+
*/
|
|
45
|
+
export const SHADOW_FACT_KINDS = [
|
|
46
|
+
"requirement",
|
|
47
|
+
"constraint",
|
|
48
|
+
"open-question",
|
|
49
|
+
"split-proposal",
|
|
50
|
+
"target-surface",
|
|
51
|
+
"design-evidence",
|
|
52
|
+
"component-choice",
|
|
53
|
+
"state-flow",
|
|
54
|
+
"data-flow",
|
|
55
|
+
"mock-api",
|
|
56
|
+
"design-deviation",
|
|
57
|
+
"dependency",
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* M5 review terminal fact catalog (AC-001). These two committed typed tools are
|
|
61
|
+
* the authoritative terminal verdict of `frontend-review-pi`. They are kept in
|
|
62
|
+
* a dedicated catalog so widening the review terminal surface never widens the
|
|
63
|
+
* frozen 7/7/12 provider/shadow/event catalogs.
|
|
64
|
+
*/
|
|
65
|
+
export const REVIEW_TERMINAL_FACT_KINDS = [
|
|
66
|
+
"approve_review",
|
|
67
|
+
"request_review_changes",
|
|
68
|
+
];
|
|
69
|
+
/**
|
|
70
|
+
* M8 design terminal fact catalog. `frontend-design-review-pi` emits its
|
|
71
|
+
* authoritative terminal verdict through the committed typed tools
|
|
72
|
+
* `approve_design` / `request_design_changes` instead of the legacy first-line
|
|
73
|
+
* `VERDICT: pass|request-revision` text protocol. Kept in a dedicated catalog
|
|
74
|
+
* so widening the design terminal surface never widens the frozen provider /
|
|
75
|
+
* shadow / event / review catalogs.
|
|
76
|
+
*/
|
|
77
|
+
export const DESIGN_TERMINAL_FACT_KINDS = [
|
|
78
|
+
"approve_design",
|
|
79
|
+
"request_design_changes",
|
|
80
|
+
];
|
|
81
|
+
/**
|
|
82
|
+
* A+B: frontend-contract-pi incremental typed fact catalog. The contract node
|
|
83
|
+
* submits requirement/constraint/evidence-expectation/handoff-intent/
|
|
84
|
+
* open-question/split-proposal facts plus the `contract-finalized` terminal
|
|
85
|
+
* fact carrying the terminal disposition (`ready | ready-with-assumptions |
|
|
86
|
+
* blocked` + optional `blockingOwner`). Kept in a dedicated catalog so
|
|
87
|
+
* widening the contract surface never widens the frozen provider / shadow /
|
|
88
|
+
* event / review / design catalogs.
|
|
89
|
+
*/
|
|
90
|
+
export const CONTRACT_FACT_KINDS = [
|
|
91
|
+
"requirement",
|
|
92
|
+
"constraint",
|
|
93
|
+
"evidence-expectation",
|
|
94
|
+
"handoff-intent",
|
|
95
|
+
"open-question",
|
|
96
|
+
"split-proposal",
|
|
97
|
+
"openspec-selection",
|
|
98
|
+
"contract-finalized",
|
|
99
|
+
];
|
|
100
|
+
export const CONTRACT_TERMINAL_FACT_KINDS = ["contract-finalized"];
|
|
101
|
+
export const contractDispositionSchema = z.enum([
|
|
102
|
+
"ready",
|
|
103
|
+
"ready-with-assumptions",
|
|
104
|
+
"blocked",
|
|
105
|
+
]);
|
|
106
|
+
export const contractBlockingOwnerSchema = z.enum([
|
|
107
|
+
"blocked-human",
|
|
108
|
+
"blocked-external",
|
|
109
|
+
]);
|
|
110
|
+
/**
|
|
111
|
+
* A+B: `frontend-plan-pi` terminal fact. The plan node now records its decision
|
|
112
|
+
* ledger through seven incremental `record_*` tools and closes with exactly one
|
|
113
|
+
* `finalize_plan` terminal fact.
|
|
114
|
+
*/
|
|
115
|
+
export const PLAN_TERMINAL_FACT_KINDS = ["finalize_plan"];
|
|
116
|
+
/**
|
|
117
|
+
* A+B (AC-009): typed issue category shared by review and design change
|
|
118
|
+
* requests. The five-value enum replaces free-form issueCategory strings.
|
|
119
|
+
*/
|
|
120
|
+
export const FRONTEND_ISSUE_CATEGORY_VALUES = [
|
|
121
|
+
"implementation-mismatch",
|
|
122
|
+
"approved-design-defect",
|
|
123
|
+
"target-surface-defect",
|
|
124
|
+
"contract-requirement-gap",
|
|
125
|
+
"unknown",
|
|
126
|
+
];
|
|
127
|
+
export const frontendIssueCategorySchema = z.enum(FRONTEND_ISSUE_CATEGORY_VALUES);
|
|
128
|
+
/** Runtime control facts committed at frontend shape boundaries. Kept outside
|
|
129
|
+
* the frozen provider/shadow catalogs so adding control authority cannot widen
|
|
130
|
+
* either legacy capability surface. */
|
|
131
|
+
export const FRONTEND_SHAPE_FACT_KINDS = ["frontend-shape"];
|
|
132
|
+
export const typedEventPhaseSchema = z.enum([
|
|
133
|
+
"staged",
|
|
134
|
+
"quarantined",
|
|
135
|
+
"committed",
|
|
136
|
+
]);
|
|
137
|
+
const ALL_TYPED_EVENT_FACT_KIND_VALUES = [
|
|
138
|
+
...new Set([
|
|
139
|
+
...TYPED_EVENT_FACT_KINDS,
|
|
140
|
+
...SHADOW_FACT_KINDS,
|
|
141
|
+
...REVIEW_TERMINAL_FACT_KINDS,
|
|
142
|
+
...DESIGN_TERMINAL_FACT_KINDS,
|
|
143
|
+
...FRONTEND_SHAPE_FACT_KINDS,
|
|
144
|
+
...CONTRACT_FACT_KINDS,
|
|
145
|
+
...PLAN_TERMINAL_FACT_KINDS,
|
|
146
|
+
]),
|
|
147
|
+
];
|
|
148
|
+
export const typedEventFactKindSchema = z.enum(ALL_TYPED_EVENT_FACT_KIND_VALUES);
|
|
149
|
+
/** A typed event fact is a `kind`-discriminated record. The schema stays
|
|
150
|
+
* permissive beyond `kind` (arbitrary JSON-safe payload) so provider/model
|
|
151
|
+
* shadow facts can carry their own fields, but `kind` is always validated. */
|
|
152
|
+
export const typedEventFactSchema = z
|
|
153
|
+
.record(z.unknown())
|
|
154
|
+
.superRefine((value, ctx) => {
|
|
155
|
+
if (!typedEventFactKindSchema.safeParse(value.kind).success) {
|
|
156
|
+
ctx.addIssue({
|
|
157
|
+
code: z.ZodIssueCode.custom,
|
|
158
|
+
message: "fact.kind must be a valid typed event fact kind",
|
|
159
|
+
path: ["kind"],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
/** A single review finding shared by both typed terminal verdicts. Mirrors the
|
|
164
|
+
* legacy JSON review finding shape so typed↔JSON equivalence stays total. */
|
|
165
|
+
export const reviewFindingSchema = z
|
|
166
|
+
.object({
|
|
167
|
+
severity: z.enum(["Critical", "Important", "Minor", "Info"]),
|
|
168
|
+
file: z.string().min(1).optional(),
|
|
169
|
+
line: z.number().int().positive().optional(),
|
|
170
|
+
issue: z.string().min(1),
|
|
171
|
+
requiredChange: z.string().min(1).optional(),
|
|
172
|
+
})
|
|
173
|
+
.strict();
|
|
174
|
+
/** `approve_review` terminal fact payload. Approval may carry informational
|
|
175
|
+
* findings (Minor/Info) but never Critical/Important — the same rule the legacy
|
|
176
|
+
* JSON verdict applies to `verdict: "pass"`. */
|
|
177
|
+
export const approveReviewFactSchema = z
|
|
178
|
+
.object({
|
|
179
|
+
kind: z.literal("approve_review"),
|
|
180
|
+
verdict: z.literal("approve_review"),
|
|
181
|
+
findings: z.array(reviewFindingSchema),
|
|
182
|
+
})
|
|
183
|
+
.strict()
|
|
184
|
+
.superRefine((value, ctx) => {
|
|
185
|
+
if (value.findings.some((finding) => finding.severity === "Critical" ||
|
|
186
|
+
finding.severity === "Important")) {
|
|
187
|
+
ctx.addIssue({
|
|
188
|
+
code: z.ZodIssueCode.custom,
|
|
189
|
+
message: "approve_review cannot carry Critical or Important findings",
|
|
190
|
+
path: ["findings"],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
/** `request_review_changes` terminal fact payload. Requires a typed issue
|
|
195
|
+
* category, at least one evidence ref, and at least one finding. */
|
|
196
|
+
export const requestReviewChangesFactSchema = z
|
|
197
|
+
.object({
|
|
198
|
+
kind: z.literal("request_review_changes"),
|
|
199
|
+
verdict: z.literal("request_review_changes"),
|
|
200
|
+
issueCategory: frontendIssueCategorySchema,
|
|
201
|
+
evidenceRefs: z.array(z.string().min(1)).min(1),
|
|
202
|
+
findings: z.array(reviewFindingSchema).min(1),
|
|
203
|
+
})
|
|
204
|
+
.strict();
|
|
205
|
+
/** `approve_design` terminal fact payload. Approval may carry informational
|
|
206
|
+
* findings (Minor/Info) but never Critical/Important — the same rule the legacy
|
|
207
|
+
* text protocol applies to `VERDICT: pass`. */
|
|
208
|
+
export const approveDesignFactSchema = z
|
|
209
|
+
.object({
|
|
210
|
+
kind: z.literal("approve_design"),
|
|
211
|
+
verdict: z.literal("approve_design"),
|
|
212
|
+
findings: z.array(reviewFindingSchema),
|
|
213
|
+
})
|
|
214
|
+
.strict()
|
|
215
|
+
.superRefine((value, ctx) => {
|
|
216
|
+
if (value.findings.some((finding) => finding.severity === "Critical" ||
|
|
217
|
+
finding.severity === "Important")) {
|
|
218
|
+
ctx.addIssue({
|
|
219
|
+
code: z.ZodIssueCode.custom,
|
|
220
|
+
message: "approve_design cannot carry Critical or Important findings",
|
|
221
|
+
path: ["findings"],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
/** `request_design_changes` terminal fact payload. Requires a typed issue
|
|
226
|
+
* category, at least one evidence ref, and at least one finding. */
|
|
227
|
+
export const requestDesignChangesFactSchema = z
|
|
228
|
+
.object({
|
|
229
|
+
kind: z.literal("request_design_changes"),
|
|
230
|
+
verdict: z.literal("request_design_changes"),
|
|
231
|
+
issueCategory: frontendIssueCategorySchema,
|
|
232
|
+
evidenceRefs: z.array(z.string().min(1)).min(1),
|
|
233
|
+
findings: z.array(reviewFindingSchema).min(1),
|
|
234
|
+
})
|
|
235
|
+
.strict();
|
|
236
|
+
export const typedEventRecordSchema = z
|
|
237
|
+
.object({
|
|
238
|
+
schemaVersion: z.literal(TYPED_EVENT_SCHEMA_VERSION),
|
|
239
|
+
eventId: z.string().min(1),
|
|
240
|
+
requestId: z.string().min(1),
|
|
241
|
+
attemptId: z.string().min(1),
|
|
242
|
+
phase: typedEventPhaseSchema,
|
|
243
|
+
payloadSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
244
|
+
fact: typedEventFactSchema,
|
|
245
|
+
revision: z.number().int().nonnegative(),
|
|
246
|
+
recordedAt: z.string().min(1),
|
|
247
|
+
quarantineReason: z.string().min(1).optional(),
|
|
248
|
+
})
|
|
249
|
+
.strict()
|
|
250
|
+
.superRefine((value, ctx) => {
|
|
251
|
+
if (value.phase === "quarantined" && !value.quarantineReason) {
|
|
252
|
+
ctx.addIssue({
|
|
253
|
+
code: z.ZodIssueCode.custom,
|
|
254
|
+
message: "quarantined event requires a quarantineReason",
|
|
255
|
+
path: ["quarantineReason"],
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
/** A fact kind that closes the attempt. Only one such fact may commit per
|
|
260
|
+
* attempt; the transaction layer's terminal conflict gate depends on this. */
|
|
261
|
+
export function isTerminalFactKind(kind) {
|
|
262
|
+
return (kind === "terminal-commit" ||
|
|
263
|
+
kind === "provider-ended-without-terminal" ||
|
|
264
|
+
REVIEW_TERMINAL_FACT_KINDS.includes(kind) ||
|
|
265
|
+
DESIGN_TERMINAL_FACT_KINDS.includes(kind) ||
|
|
266
|
+
PLAN_TERMINAL_FACT_KINDS.includes(kind) ||
|
|
267
|
+
CONTRACT_TERMINAL_FACT_KINDS.includes(kind));
|
|
268
|
+
}
|
|
269
|
+
/** Deterministic fact identity shared by staging and adoption. */
|
|
270
|
+
export function typedEventPayloadSha256(fact) {
|
|
271
|
+
return sha256OfCanonicalJson(fact);
|
|
272
|
+
}
|
|
273
|
+
export class TypedEventStoreError extends Error {
|
|
274
|
+
code;
|
|
275
|
+
constructor(code, message) {
|
|
276
|
+
super(message);
|
|
277
|
+
this.name = "TypedEventStoreError";
|
|
278
|
+
this.code = code;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export function createTypedEventStore(initial) {
|
|
282
|
+
return {
|
|
283
|
+
revision: initial?.revision ?? 0,
|
|
284
|
+
records: [...(initial?.records ?? [])],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function replaceRecord(store, eventId, next) {
|
|
288
|
+
const index = store.records.findIndex((record) => record.eventId === eventId);
|
|
289
|
+
if (index < 0) {
|
|
290
|
+
throw new TypedEventStoreError("EVENT_NOT_FOUND", `no typed event with eventId ${eventId}`);
|
|
291
|
+
}
|
|
292
|
+
store.records[index] = next;
|
|
293
|
+
}
|
|
294
|
+
/** Stage a fact as `staged` without any commit decision. */
|
|
295
|
+
export function stageTypedEventRecord(store, input) {
|
|
296
|
+
const existing = store.records.find((record) => record.eventId === input.eventId);
|
|
297
|
+
if (existing) {
|
|
298
|
+
throw new TypedEventStoreError("EVENT_ID_CONFLICT", `eventId ${input.eventId} already exists with phase ${existing.phase}`);
|
|
299
|
+
}
|
|
300
|
+
const record = typedEventRecordSchema.parse({
|
|
301
|
+
schemaVersion: TYPED_EVENT_SCHEMA_VERSION,
|
|
302
|
+
eventId: input.eventId,
|
|
303
|
+
requestId: input.requestId,
|
|
304
|
+
attemptId: input.attemptId,
|
|
305
|
+
phase: "staged",
|
|
306
|
+
payloadSha256: typedEventPayloadSha256(input.fact),
|
|
307
|
+
fact: input.fact,
|
|
308
|
+
revision: 0,
|
|
309
|
+
recordedAt: input.recordedAt ?? new Date().toISOString(),
|
|
310
|
+
});
|
|
311
|
+
store.records.push(record);
|
|
312
|
+
return record;
|
|
313
|
+
}
|
|
314
|
+
/** Quarantine a staged event after validation failure. */
|
|
315
|
+
export function quarantineTypedEventRecord(store, eventId, reason) {
|
|
316
|
+
const record = store.records.find((candidate) => candidate.eventId === eventId);
|
|
317
|
+
if (!record) {
|
|
318
|
+
throw new TypedEventStoreError("EVENT_NOT_FOUND", `no typed event with eventId ${eventId}`);
|
|
319
|
+
}
|
|
320
|
+
if (record.phase !== "staged") {
|
|
321
|
+
throw new TypedEventStoreError("INVALID_PHASE_TRANSITION", `event ${eventId} is ${record.phase}, not staged`);
|
|
322
|
+
}
|
|
323
|
+
const next = typedEventRecordSchema.parse({
|
|
324
|
+
...record,
|
|
325
|
+
phase: "quarantined",
|
|
326
|
+
quarantineReason: reason,
|
|
327
|
+
});
|
|
328
|
+
replaceRecord(store, eventId, next);
|
|
329
|
+
return next;
|
|
330
|
+
}
|
|
331
|
+
/** Commit a staged event at `revision`. Committed is terminal. */
|
|
332
|
+
export function commitTypedEventRecord(store, eventId, revision) {
|
|
333
|
+
const record = store.records.find((candidate) => candidate.eventId === eventId);
|
|
334
|
+
if (!record) {
|
|
335
|
+
throw new TypedEventStoreError("EVENT_NOT_FOUND", `no typed event with eventId ${eventId}`);
|
|
336
|
+
}
|
|
337
|
+
if (record.phase !== "staged") {
|
|
338
|
+
throw new TypedEventStoreError("INVALID_PHASE_TRANSITION", `event ${eventId} is ${record.phase}, not staged`);
|
|
339
|
+
}
|
|
340
|
+
const { quarantineReason: _reason, ...rest } = record;
|
|
341
|
+
void _reason;
|
|
342
|
+
const next = typedEventRecordSchema.parse({
|
|
343
|
+
...rest,
|
|
344
|
+
phase: "committed",
|
|
345
|
+
revision,
|
|
346
|
+
});
|
|
347
|
+
replaceRecord(store, eventId, next);
|
|
348
|
+
return next;
|
|
349
|
+
}
|
|
350
|
+
/** Explicitly adopt a quarantined event as committed at `revision`. This is the
|
|
351
|
+
* only quarantined → committed transition and is reserved for the explicit
|
|
352
|
+
* `adopt_staged_fact` path (a later attempt adopts a fact a prior attempt
|
|
353
|
+
* quarantined). Committed is terminal; the quarantine reason is dropped. */
|
|
354
|
+
export function commitQuarantinedTypedEventRecord(store, eventId, revision) {
|
|
355
|
+
const record = store.records.find((candidate) => candidate.eventId === eventId);
|
|
356
|
+
if (!record) {
|
|
357
|
+
throw new TypedEventStoreError("EVENT_NOT_FOUND", `no typed event with eventId ${eventId}`);
|
|
358
|
+
}
|
|
359
|
+
if (record.phase !== "quarantined") {
|
|
360
|
+
throw new TypedEventStoreError("INVALID_PHASE_TRANSITION", `event ${eventId} is ${record.phase}, not quarantined`);
|
|
361
|
+
}
|
|
362
|
+
const { quarantineReason: _reason, ...rest } = record;
|
|
363
|
+
void _reason;
|
|
364
|
+
const next = typedEventRecordSchema.parse({
|
|
365
|
+
...rest,
|
|
366
|
+
phase: "committed",
|
|
367
|
+
revision,
|
|
368
|
+
});
|
|
369
|
+
replaceRecord(store, eventId, next);
|
|
370
|
+
return next;
|
|
371
|
+
}
|
|
372
|
+
/** Read committed events only. Staged/quarantined events are never returned
|
|
373
|
+
* here (AC-001: quarantine facts must not leak downstream). */
|
|
374
|
+
export function readCommittedEvents(store, attemptId) {
|
|
375
|
+
return store.records.filter((record) => record.phase === "committed" &&
|
|
376
|
+
(attemptId === undefined || record.attemptId === attemptId));
|
|
377
|
+
}
|
|
378
|
+
/** Read events for a single attempt, optionally filtered by phase. */
|
|
379
|
+
export function readEventsByAttempt(store, attemptId, options) {
|
|
380
|
+
const phases = options?.phases;
|
|
381
|
+
return store.records.filter((record) => record.attemptId === attemptId &&
|
|
382
|
+
(!phases || phases.includes(record.phase)));
|
|
383
|
+
}
|
|
384
|
+
/** Find a record by requestId, optionally scoped to phase/attempt. */
|
|
385
|
+
export function findEventByRequestId(store, requestId, options) {
|
|
386
|
+
return store.records.find((record) => record.requestId === requestId &&
|
|
387
|
+
(options?.phase === undefined || record.phase === options.phase) &&
|
|
388
|
+
(options?.attemptId === undefined ||
|
|
389
|
+
record.attemptId === options.attemptId));
|
|
390
|
+
}
|
|
391
|
+
export function findCommittedEventByRequestId(store, requestId) {
|
|
392
|
+
return findEventByRequestId(store, requestId, { phase: "committed" });
|
|
393
|
+
}
|
|
394
|
+
/** Whether the attempt already has a committed terminal fact. */
|
|
395
|
+
export function hasCommittedTerminalEvent(store, attemptId) {
|
|
396
|
+
return store.records.some((record) => record.attemptId === attemptId &&
|
|
397
|
+
record.phase === "committed" &&
|
|
398
|
+
isTerminalFactKind(record.fact.kind));
|
|
399
|
+
}
|
|
400
|
+
/* ------------------------------------------------------------------ *
|
|
401
|
+
* Persistence (JSONL). Reuses the atomic-write boundary so every write *
|
|
402
|
+
* is either fully present or absent. *
|
|
403
|
+
* ------------------------------------------------------------------ */
|
|
404
|
+
function parseTypedEventRecordLine(line) {
|
|
405
|
+
return typedEventRecordSchema.parse(JSON.parse(line));
|
|
406
|
+
}
|
|
407
|
+
export async function readTypedEventStoreFromJsonl(filePath) {
|
|
408
|
+
let raw;
|
|
409
|
+
try {
|
|
410
|
+
raw = await readFile(filePath, "utf8");
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
if (error &&
|
|
414
|
+
typeof error === "object" &&
|
|
415
|
+
"code" in error &&
|
|
416
|
+
error.code === "ENOENT") {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
return raw
|
|
422
|
+
.split("\n")
|
|
423
|
+
.map((line) => line.trim())
|
|
424
|
+
.filter(Boolean)
|
|
425
|
+
.map((line) => parseTypedEventRecordLine(line));
|
|
426
|
+
}
|
|
427
|
+
/** Load a store from disk, deriving `revision` from the highest committed
|
|
428
|
+
* record revision. */
|
|
429
|
+
export async function loadTypedEventStore(filePath) {
|
|
430
|
+
const records = await readTypedEventStoreFromJsonl(filePath);
|
|
431
|
+
const revision = records.reduce((max, record) => Math.max(max, record.revision), 0);
|
|
432
|
+
return { revision, records };
|
|
433
|
+
}
|
|
434
|
+
export async function appendTypedEventRecordJsonl(filePath, record) {
|
|
435
|
+
await appendJsonlLineAtomic(filePath, record);
|
|
436
|
+
}
|
|
437
|
+
export async function writeTypedEventStoreJsonl(filePath, records) {
|
|
438
|
+
await writeJsonlAtomic(filePath, records);
|
|
439
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { commitQuarantinedTypedEventRecord, commitTypedEventRecord, findCommittedEventByRequestId, hasCommittedTerminalEvent, stageTypedEventRecord, typedEventPayloadSha256, } from "./frontend-typed-event-store.js";
|
|
3
|
+
export class TypedEventTransactionError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
constructor(code, message, details) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "TypedEventTransactionError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Stage a fact without committing. Returns the eventId for later adoption. */
|
|
14
|
+
export function stageTypedEventFact(options) {
|
|
15
|
+
const eventId = options.eventId ?? randomUUID();
|
|
16
|
+
const record = stageTypedEventRecord(options.store, {
|
|
17
|
+
eventId,
|
|
18
|
+
requestId: options.requestId,
|
|
19
|
+
attemptId: options.attemptId,
|
|
20
|
+
fact: options.fact,
|
|
21
|
+
recordedAt: (options.now ?? new Date()).toISOString(),
|
|
22
|
+
});
|
|
23
|
+
return { eventId, record };
|
|
24
|
+
}
|
|
25
|
+
function resolveReplayOrConflict(options, existing) {
|
|
26
|
+
const expectedPayload = typedEventPayloadSha256(options.fact);
|
|
27
|
+
if (existing.payloadSha256 !== expectedPayload) {
|
|
28
|
+
throw new TypedEventTransactionError("REQUEST_ID_REUSE_CONFLICT", `requestId ${options.requestId} was used with a different payload hash`, { existingPayloadSha256: existing.payloadSha256, expectedPayload });
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
eventId: existing.eventId,
|
|
33
|
+
record: existing,
|
|
34
|
+
idempotentReplay: true,
|
|
35
|
+
revision: existing.revision,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Explicitly adopt a staged fact as committed.
|
|
40
|
+
*
|
|
41
|
+
* Order of checks matters and is deterministic:
|
|
42
|
+
* 1. requestId idempotency (pre-check + re-check inside the critical section);
|
|
43
|
+
* 2. terminal conflict gate;
|
|
44
|
+
* 3. revision drift;
|
|
45
|
+
* 4. staged-record integrity;
|
|
46
|
+
* 5. commit (single logical commit point) + optional post-commit hook.
|
|
47
|
+
*/
|
|
48
|
+
export async function adoptTypedEventFact(options) {
|
|
49
|
+
const defaultRunExclusive = async (fn) => {
|
|
50
|
+
return await fn();
|
|
51
|
+
};
|
|
52
|
+
const runExclusive = options.runExclusive ?? defaultRunExclusive;
|
|
53
|
+
const preExisting = findCommittedEventByRequestId(options.store, options.requestId);
|
|
54
|
+
if (preExisting)
|
|
55
|
+
return resolveReplayOrConflict(options, preExisting);
|
|
56
|
+
return runExclusive(async () => {
|
|
57
|
+
const underLock = findCommittedEventByRequestId(options.store, options.requestId);
|
|
58
|
+
if (underLock)
|
|
59
|
+
return resolveReplayOrConflict(options, underLock);
|
|
60
|
+
if (hasCommittedTerminalEvent(options.store, options.attemptId)) {
|
|
61
|
+
throw new TypedEventTransactionError("TERMINAL_CONFLICT", `attempt ${options.attemptId} already has a committed terminal fact; refusing late commit of requestId ${options.requestId}`);
|
|
62
|
+
}
|
|
63
|
+
if (options.store.revision !== options.expectedRevision) {
|
|
64
|
+
throw new TypedEventTransactionError("REVISION_CONFLICT", `expectedRevision ${options.expectedRevision} does not match store revision ${options.store.revision}`, {
|
|
65
|
+
expectedRevision: options.expectedRevision,
|
|
66
|
+
currentRevision: options.store.revision,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const staged = options.store.records.find((record) => record.eventId === options.eventId);
|
|
70
|
+
if (!staged) {
|
|
71
|
+
throw new TypedEventTransactionError("EVENT_NOT_FOUND", `no typed event with eventId ${options.eventId}`);
|
|
72
|
+
}
|
|
73
|
+
if (staged.phase !== "staged") {
|
|
74
|
+
throw new TypedEventTransactionError("EVENT_NOT_STAGED", `event ${options.eventId} is ${staged.phase}, not staged`);
|
|
75
|
+
}
|
|
76
|
+
if (staged.requestId !== options.requestId ||
|
|
77
|
+
staged.attemptId !== options.attemptId) {
|
|
78
|
+
throw new TypedEventTransactionError("EVENT_MISMATCH", `staged event ${options.eventId} does not match requestId/attemptId`);
|
|
79
|
+
}
|
|
80
|
+
if (staged.payloadSha256 !== typedEventPayloadSha256(options.fact)) {
|
|
81
|
+
throw new TypedEventTransactionError("EVENT_PAYLOAD_MISMATCH", `staged event ${options.eventId} payload hash does not match the adoption fact`);
|
|
82
|
+
}
|
|
83
|
+
const nextRevision = options.expectedRevision + 1;
|
|
84
|
+
const record = commitTypedEventRecord(options.store, options.eventId, nextRevision);
|
|
85
|
+
options.store.revision = nextRevision;
|
|
86
|
+
if (options.afterCommit) {
|
|
87
|
+
try {
|
|
88
|
+
await options.afterCommit({ record, revision: nextRevision });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
throw new TypedEventTransactionError("POST_COMMIT_ERROR", `post-commit step failed after requestId ${options.requestId} was committed: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
eventId: options.eventId,
|
|
97
|
+
record,
|
|
98
|
+
idempotentReplay: false,
|
|
99
|
+
revision: nextRevision,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A+B (AC-005): explicitly adopt a quarantined (or still-staged) fact from a
|
|
105
|
+
* previous attempt into the committed ledger. This is the `adopt_staged_fact`
|
|
106
|
+
* semantics: a fact that failed validation and was quarantined becomes visible
|
|
107
|
+
* to committed readers only through an explicit adopt by a later attempt.
|
|
108
|
+
*
|
|
109
|
+
* Reuses the existing adoption double-check (requestId idempotency, terminal
|
|
110
|
+
* conflict gate, revision CAS) without re-staging: the fact payload is the
|
|
111
|
+
* already-recorded staged/quarantined payload, so adoption cannot mutate it.
|
|
112
|
+
*/
|
|
113
|
+
export async function adoptStagedFact(options) {
|
|
114
|
+
const defaultRunExclusive = async (fn) => {
|
|
115
|
+
return await fn();
|
|
116
|
+
};
|
|
117
|
+
const runExclusive = options.runExclusive ?? defaultRunExclusive;
|
|
118
|
+
const preExisting = findCommittedEventByRequestId(options.store, options.requestId);
|
|
119
|
+
if (preExisting) {
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
eventId: preExisting.eventId,
|
|
123
|
+
record: preExisting,
|
|
124
|
+
idempotentReplay: true,
|
|
125
|
+
revision: preExisting.revision,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return runExclusive(async () => {
|
|
129
|
+
const underLock = findCommittedEventByRequestId(options.store, options.requestId);
|
|
130
|
+
if (underLock) {
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
eventId: underLock.eventId,
|
|
134
|
+
record: underLock,
|
|
135
|
+
idempotentReplay: true,
|
|
136
|
+
revision: underLock.revision,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (hasCommittedTerminalEvent(options.store, options.attemptId)) {
|
|
140
|
+
throw new TypedEventTransactionError("TERMINAL_CONFLICT", `attempt ${options.attemptId} already has a committed terminal fact; refusing late adoption of requestId ${options.requestId}`);
|
|
141
|
+
}
|
|
142
|
+
if (options.store.revision !== options.expectedRevision) {
|
|
143
|
+
throw new TypedEventTransactionError("REVISION_CONFLICT", `expectedRevision ${options.expectedRevision} does not match store revision ${options.store.revision}`, {
|
|
144
|
+
expectedRevision: options.expectedRevision,
|
|
145
|
+
currentRevision: options.store.revision,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const existing = options.store.records.find((record) => record.eventId === options.eventId);
|
|
149
|
+
if (!existing) {
|
|
150
|
+
throw new TypedEventTransactionError("EVENT_NOT_FOUND", `no typed event with eventId ${options.eventId}`);
|
|
151
|
+
}
|
|
152
|
+
if (existing.phase !== "staged" && existing.phase !== "quarantined") {
|
|
153
|
+
throw new TypedEventTransactionError("EVENT_NOT_STAGED", `event ${options.eventId} is ${existing.phase}, not staged/quarantined`);
|
|
154
|
+
}
|
|
155
|
+
if (existing.requestId !== options.requestId ||
|
|
156
|
+
existing.attemptId !== options.attemptId) {
|
|
157
|
+
throw new TypedEventTransactionError("EVENT_MISMATCH", `staged event ${options.eventId} does not match requestId/attemptId`);
|
|
158
|
+
}
|
|
159
|
+
const nextRevision = options.expectedRevision + 1;
|
|
160
|
+
const record = existing.phase === "quarantined"
|
|
161
|
+
? commitQuarantinedTypedEventRecord(options.store, options.eventId, nextRevision)
|
|
162
|
+
: commitTypedEventRecord(options.store, options.eventId, nextRevision);
|
|
163
|
+
options.store.revision = nextRevision;
|
|
164
|
+
if (options.afterCommit) {
|
|
165
|
+
try {
|
|
166
|
+
await options.afterCommit({ record, revision: nextRevision });
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
throw new TypedEventTransactionError("POST_COMMIT_ERROR", `post-commit step failed after requestId ${options.requestId} was committed: ${error instanceof Error ? error.message : String(error)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
eventId: options.eventId,
|
|
175
|
+
record,
|
|
176
|
+
idempotentReplay: false,
|
|
177
|
+
revision: nextRevision,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}
|