@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24
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/README.md +102 -194
- package/README.zh-CN.md +115 -210
- package/bin/gotry-backend.js +53 -0
- package/bin/gotry-bootstrap.js +466 -35
- package/bin/gotry-inner.js +255 -36
- package/bin/gotry-process-liveness.d.ts +27 -0
- package/bin/gotry-process-liveness.js +97 -0
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +2 -2
- package/client/client.js +174 -0
- package/cordis.gotry-patch.yml +70 -93
- package/dist/capabilities/agent-reach-deep.js +45 -122
- package/dist/capabilities/agent-reach.js +63 -148
- package/dist/capabilities/anything.js +89 -75
- package/dist/capabilities/artifacts.js +123 -104
- package/dist/capabilities/channel-health.js +65 -45
- package/dist/capabilities/channel-registry.js +108 -87
- package/dist/capabilities/doctor.js +357 -235
- package/dist/capabilities/effect.js +223 -309
- package/dist/capabilities/fact-log.js +28 -14
- package/dist/capabilities/flyai.js +245 -156
- package/dist/capabilities/ground-transfer.js +509 -0
- package/dist/capabilities/hbcli.js +144 -148
- package/dist/capabilities/hotelbyte-transaction.js +194 -0
- package/dist/capabilities/incident-log.js +131 -61
- package/dist/capabilities/model-override.js +30 -10
- package/dist/capabilities/opensky.js +54 -43
- package/dist/capabilities/resilience.js +50 -32
- package/dist/capabilities/session/action-cache.js +50 -44
- package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
- package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
- package/dist/capabilities/session/adapters/dida-portal.js +206 -0
- package/dist/capabilities/session/adapters/meituan-local.js +35 -54
- package/dist/capabilities/session/adapters/rail-12306.js +169 -112
- package/dist/capabilities/session/benchmark.js +85 -149
- package/dist/capabilities/session/extension-bridge.js +320 -259
- package/dist/capabilities/session/extension-channel.js +57 -76
- package/dist/capabilities/session/extension-distribution.js +115 -119
- package/dist/capabilities/session/extract.js +31 -21
- package/dist/capabilities/session/golden-score.js +55 -33
- package/dist/capabilities/session/health-watch.js +146 -154
- package/dist/capabilities/session/read-guard.js +55 -47
- package/dist/capabilities/session/static-flight-golden.js +49 -36
- package/dist/capabilities/session/transport.js +54 -61
- package/dist/capabilities/session/wizard.js +63 -93
- package/dist/capabilities/session-consent.js +143 -47
- package/dist/capabilities/session-login.js +95 -126
- package/dist/capabilities/session-search.js +391 -253
- package/dist/capabilities/sponsor-plugin.js +341 -0
- package/dist/capabilities/visa-policy.js +87 -73
- package/dist/capabilities/weather.js +163 -235
- package/dist/scripts/action-cache-tests.js +24 -59
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
- package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
- package/dist/scripts/agent-reach-deep-tests.js +30 -40
- package/dist/scripts/agent-reach-tests.js +26 -18
- package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
- package/dist/scripts/anything-tests.js +58 -62
- package/dist/scripts/artifact-client-contract-tests.js +69 -0
- package/dist/scripts/artifacts-capability-tests.js +235 -0
- package/dist/scripts/async-collect.js +26 -15
- package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
- package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
- package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
- package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
- package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
- package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
- package/dist/scripts/booking-executor-tests.js +80 -0
- package/dist/scripts/booking-recovery-chain-tests.js +182 -0
- package/dist/scripts/booking-saga-tests.js +61 -123
- package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
- package/dist/scripts/bootstrap-tests.js +1383 -128
- package/dist/scripts/build-changelog.js +89 -130
- package/dist/scripts/build-metrics-report.js +135 -143
- package/dist/scripts/changelog-tests.js +35 -82
- package/dist/scripts/channel-probe-tests.js +44 -116
- package/dist/scripts/channel-probe.js +84 -167
- package/dist/scripts/channel-registry-tests.js +97 -185
- package/dist/scripts/companion-tests.js +25 -70
- package/dist/scripts/dida-cookie-probe.js +87 -0
- package/dist/scripts/dida-sw-probe.js +114 -0
- package/dist/scripts/diff-test.js +17 -12
- package/dist/scripts/doctor-tests.js +428 -120
- package/dist/scripts/dsh-artifact-e2e.js +150 -0
- package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
- package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
- package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
- package/dist/scripts/dsh-runtime-closure.js +75 -63
- package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
- package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
- package/dist/scripts/dsh-target-closure-proof.js +178 -0
- package/dist/scripts/effect-tests.js +165 -552
- package/dist/scripts/engine-run.js +1 -3
- package/dist/scripts/engine-tests.js +16 -10
- package/dist/scripts/evaluation-cadence-tests.js +69 -242
- package/dist/scripts/evaluation-contract-tests.js +193 -499
- package/dist/scripts/extension-distribution-cli.js +19 -15
- package/dist/scripts/extension-distribution-tests.js +98 -211
- package/dist/scripts/extension-tests.js +366 -389
- package/dist/scripts/fact-gate-tests.js +725 -263
- package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
- package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
- package/dist/scripts/flight-malformed-tests.js +159 -0
- package/dist/scripts/flyai-tests.js +325 -68
- package/dist/scripts/g5-guard-tests.js +131 -0
- package/dist/scripts/g5-guard.js +189 -0
- package/dist/scripts/gen-lunar-anchors.js +106 -0
- package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
- package/dist/scripts/ground-transfer-tests.js +956 -0
- package/dist/scripts/hbcli-e2e-tests.js +71 -90
- package/dist/scripts/hbcli-live-optin-tests.js +96 -0
- package/dist/scripts/hbcli-release-contract-tests.js +122 -0
- package/dist/scripts/hbcli-tests.js +41 -115
- package/dist/scripts/health-watch-cli.js +42 -28
- package/dist/scripts/hotel-date-gate-tests.js +496 -0
- package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
- package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
- package/dist/scripts/i18n-tests.js +16 -9
- package/dist/scripts/incident-tests.js +281 -87
- package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
- package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
- package/dist/scripts/issue-271-liveness-tests.js +547 -0
- package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
- package/dist/scripts/issue-289-model-retry-tests.js +441 -0
- package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
- package/dist/scripts/issue-338-home-city-e2e.js +577 -0
- package/dist/scripts/issue343-real-entry-e2e.js +160 -0
- package/dist/scripts/journey-tests.js +22 -27
- package/dist/scripts/kernel-manifest-evidence.js +109 -0
- package/dist/scripts/kernel-manifest-gate.js +126 -0
- package/dist/scripts/kernel-manifest-tests.js +212 -0
- package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
- package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
- package/dist/scripts/kernel-manifest.js +369 -0
- package/dist/scripts/ledger-repair-apply-tests.js +316 -0
- package/dist/scripts/ledger-repair-plan-tests.js +375 -0
- package/dist/scripts/ledger-tests.js +325 -340
- package/dist/scripts/ledger-workflow-crash.js +10 -20
- package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
- package/dist/scripts/memory-capture-tests.js +242 -61
- package/dist/scripts/memory-decay-tests.js +18 -52
- package/dist/scripts/memory-lifecycle-tests.js +657 -0
- package/dist/scripts/memory-lifecycle.js +196 -0
- package/dist/scripts/memory-metrics.js +24 -12
- package/dist/scripts/memory-value-report.js +474 -222
- package/dist/scripts/memory-value-tests.js +328 -0
- package/dist/scripts/metrics-report-tests.js +62 -199
- package/dist/scripts/model-override-e2e.js +66 -105
- package/dist/scripts/nightly-evidence-tests.js +64 -89
- package/dist/scripts/nightly-evidence.js +108 -111
- package/dist/scripts/nudge-digest.js +39 -28
- package/dist/scripts/onboarding-tests.js +63 -88
- package/dist/scripts/opensky-check.js +18 -13
- package/dist/scripts/opensky-tests.js +28 -28
- package/dist/scripts/persona-surface-guard-tests.js +37 -12
- package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
- package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
- package/dist/scripts/price-drift-tests.js +126 -227
- package/dist/scripts/price-drift-watch.js +209 -253
- package/dist/scripts/probe-poi-tests.js +52 -45
- package/dist/scripts/product-metrics.js +187 -273
- package/dist/scripts/publish-preverify.js +47 -41
- package/dist/scripts/realtime-pricing-tests.js +37 -76
- package/dist/scripts/replay-async.js +20 -18
- package/dist/scripts/replay-real.js +16 -18
- package/dist/scripts/replay.js +52 -82
- package/dist/scripts/session-attach-diagnose.js +16 -12
- package/dist/scripts/session-attach-poc.js +34 -37
- package/dist/scripts/session-benchmark.js +66 -147
- package/dist/scripts/session-dida-live-e2e.js +151 -0
- package/dist/scripts/session-extract-tests.js +14 -35
- package/dist/scripts/session-login.js +14 -13
- package/dist/scripts/session-tests.js +545 -583
- package/dist/scripts/sf-live-benchmark.js +92 -110
- package/dist/scripts/sf-live-cli-tests.js +2 -9
- package/dist/scripts/sf-soft-score-tests.js +19 -60
- package/dist/scripts/sf-summary-tests.js +149 -0
- package/dist/scripts/sf-summary.js +466 -84
- package/dist/scripts/skeleton-check.js +16 -14
- package/dist/scripts/skeleton-integration-test.js +11 -10
- package/dist/scripts/skills-contract-tests.js +51 -46
- package/dist/scripts/smoke-session-gate-tests.js +12 -20
- package/dist/scripts/smoke.js +541 -422
- package/dist/scripts/sponsor-reuse-tests.js +262 -0
- package/dist/scripts/state-cli-tests.js +226 -101
- package/dist/scripts/state-cli.js +549 -212
- package/dist/scripts/static-golden-tests.js +46 -180
- package/dist/scripts/time-eval-tests.js +210 -192
- package/dist/scripts/travel-timeline-tests.js +29 -93
- package/dist/scripts/turn-handoff-collect-tests.js +46 -93
- package/dist/scripts/turn-handoff-collect.js +71 -99
- package/dist/scripts/turn-policy-tests.js +28 -29
- package/dist/scripts/typed-contract-canary.js +77 -162
- package/dist/scripts/tz-resolver-sanity.js +50 -0
- package/dist/scripts/tz-resolver-tests.js +584 -0
- package/dist/scripts/unified-tests.js +23 -19
- package/dist/scripts/visa-policy-tests.js +31 -44
- package/dist/scripts/weather-tests.js +121 -543
- package/dist/scripts/wish-channel-gate-tests.js +29 -86
- package/dist/scripts/write-gate-tests.js +522 -0
- package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
- package/dist/scripts/z3-lifecycle-tests.js +170 -0
- package/dist/scripts/z3-race-repeat-tests.js +39 -0
- package/dist/scripts/z3-race-tests.js +18 -12
- package/dist/src/artifact-gate.js +652 -221
- package/dist/src/backend/kernel.js +91 -0
- package/dist/src/backend/modules/booking-copilot.js +55 -0
- package/dist/src/backend/modules/booking-executor.js +124 -0
- package/dist/src/backend/modules/session-search.js +194 -0
- package/dist/src/benchmark-agent-conformance.js +296 -262
- package/dist/src/benchmark-environment-bridge.js +298 -413
- package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
- package/dist/src/benchmark-tool-isolation.js +94 -67
- package/dist/src/bookable-facts.js +299 -174
- package/dist/src/booking-saga.js +64 -120
- package/dist/src/booking-surface/availability-policy.js +443 -576
- package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
- package/dist/src/booking-surface/contracts.js +24 -80
- package/dist/src/booking-surface/dsh-planner.js +510 -281
- package/dist/src/booking-surface/dsh-plugin.js +17 -1
- package/dist/src/booking-surface/error-codes.js +82 -10
- package/dist/src/booking-surface/index.js +0 -3
- package/dist/src/booking-surface/profile.js +10 -54
- package/dist/src/booking-surface/recovery-chain.js +367 -0
- package/dist/src/booking-surface/runtime.js +765 -891
- package/dist/src/booking-surface/server.js +200 -251
- package/dist/src/booking-surface/startup.js +58 -54
- package/dist/src/booking-surface/validation.js +150 -218
- package/dist/src/bridge.js +20 -13
- package/dist/src/companions.js +48 -74
- package/dist/src/contracts.js +21 -43
- package/dist/src/dsh-llm.js +82 -120
- package/dist/src/engine.js +253 -239
- package/dist/src/evaluation-cadence.js +72 -209
- package/dist/src/evaluation-contracts.js +220 -843
- package/dist/src/flight-pack-adapter.js +54 -0
- package/dist/src/flight-pack-contract.js +2 -0
- package/dist/src/gotry-backend.js +70 -0
- package/dist/src/hotel-date-gate.js +118 -0
- package/dist/src/i18n.js +18 -7
- package/dist/src/index.js +1425 -1629
- package/dist/src/journey.js +132 -81
- package/dist/src/ledger-repair-apply.js +538 -0
- package/dist/src/ledger-repair-plan.js +463 -0
- package/dist/src/loop.js +257 -166
- package/dist/src/memory-capture.js +180 -32
- package/dist/src/memory-decay.js +30 -14
- package/dist/src/memory-lifecycle.js +1310 -0
- package/dist/src/memory-utility.js +27 -40
- package/dist/src/mock-llm.js +36 -61
- package/dist/src/model.js +81 -36
- package/dist/src/realtime-pricing.js +63 -72
- package/dist/src/slot-spec.js +84 -106
- package/dist/src/state-ledger.js +310 -471
- package/dist/src/subagent-job-id-guard.js +58 -0
- package/dist/src/time-anchor.js +231 -33
- package/dist/src/tool-packet.js +20 -8
- package/dist/src/travel-slots.js +58 -35
- package/dist/src/travel-timeline.js +44 -63
- package/dist/src/turn-deadline.js +121 -127
- package/dist/src/turn-policy.js +61 -89
- package/dist/src/tz-resolver.js +143 -0
- package/dist/src/unified.js +504 -381
- package/dist/src/wish-pool.js +39 -14
- package/dist/src/write-gate.js +495 -0
- package/dist/src/z3-shared.js +273 -7
- package/extension/README.md +4 -3
- package/extension/background.js +125 -14
- package/extension/content-bridge.js +23 -1
- package/extension/content-main.js +15 -4
- package/extension/manifest.json +16 -8
- package/package.json +251 -220
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/anything.ts +1 -1
- package/ts/capabilities/artifacts.ts +46 -9
- package/ts/capabilities/flyai.ts +156 -37
- package/ts/capabilities/hbcli.ts +1 -1
- package/ts/capabilities/incident-log.ts +88 -59
- package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
- package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
- package/ts/capabilities/session/extension-bridge.ts +226 -101
- package/ts/capabilities/session/extension-channel.ts +22 -9
- package/ts/capabilities/session/wizard.ts +2 -2
- package/ts/capabilities/session-consent.ts +98 -14
- package/ts/capabilities/session-login.ts +1 -0
- package/ts/capabilities/session-search.ts +315 -31
- package/ts/dsh-runtime/vendor/README.md +8 -4
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
- package/ts/package.json +236 -16
- package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
- package/ts/scripts/state-cli.ts +418 -51
- package/ts/src/artifact-gate.ts +561 -15
- package/ts/src/benchmark-environment-bridge.ts +37 -20
- package/ts/src/bookable-facts.ts +183 -7
- package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
- package/ts/src/booking-surface/dsh-planner.ts +240 -32
- package/ts/src/booking-surface/dsh-plugin.js +17 -1
- package/ts/src/booking-surface/error-codes.ts +75 -0
- package/ts/src/booking-surface/recovery-chain.ts +409 -0
- package/ts/src/booking-surface/server.ts +84 -51
- package/ts/src/booking-surface/validation.ts +2 -0
- package/ts/src/contracts.ts +12 -2
- package/ts/src/dsh-llm.ts +4 -8
- package/ts/src/index.ts +609 -90
- package/ts/src/loop.ts +116 -7
- package/ts/src/memory-capture.ts +227 -3
- package/ts/src/mock-llm.ts +4 -8
- package/ts/src/model.ts +88 -2
- package/ts/src/state-ledger.ts +47 -29
- package/ts/src/subagent-job-id-guard.ts +72 -0
- package/ts/src/time-anchor.ts +217 -13
- package/ts/src/unified.ts +316 -54
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WriteGate 机制层回归(write_gate.v1,issue #231,run-all §60):
|
|
3
|
+
* 「持久化可信审批 + 原子 outbox」的离线否证套件。零真实供应商/交易调用——
|
|
4
|
+
* 本模块 API 止于 outbox 入队/领取/派发前复验与本地 rejected/unknown 记账;
|
|
5
|
+
* 产品运行时在 M5 Entry(#136)前不实例化 WriteGate(非运行时激活,默认路径零变化)。
|
|
6
|
+
*
|
|
7
|
+
* 反例覆盖(#231 验收 3 + #306 D-22 锚点 + write-gate-production-design §12 派发否证):
|
|
8
|
+
* 1 契约封闭性:指纹 canonical 稳定(键序无关/任一授权字段漂移即变)、seam 封闭集
|
|
9
|
+
* 2 主路径:L2 登记 → 呈现前备妥 → 可信宿主确认发行 → 原子消费+outbox 单事务
|
|
10
|
+
* (恰一次 claim 消费 / pending→confirmed 携 receipt / 恰一行 queued intent / saga 审计链合法)
|
|
11
|
+
* 3 授权源:模型冒充真人确认(合法 actor+合法快照,仅摘要/伪造 nonce)→ 拒绝且零 outbox;
|
|
12
|
+
* 伪造 receipt_id 拒绝;重新呈现后旧挑战/旧 nonce 确认拒绝
|
|
13
|
+
* 4 一次性消费与并发:同 receipt 重放 approval-claimed;双连接竞争;双进程领取竞争恰一赢家
|
|
14
|
+
* 5 绑定/过期:金额漂移/traveler-guests 摘要漂移 → fingerprint-mismatch;receipt/挑战过期;
|
|
15
|
+
* 跨 intent 重放/跨租户 receipt 拒绝
|
|
16
|
+
* 6 存储物理红线(裸 SQL 直接否证,应用层 if 不可替代):空 receipt CHECK、跨租户复合外键、
|
|
17
|
+
* dispatching 必携领取字段 CHECK、queued 必无领取字段 CHECK、attempt_id 不可变触发器、
|
|
18
|
+
* dispatch_status 只进不退触发器、nonce 唯一约束
|
|
19
|
+
* 7 崩溃注入:事务1 三崩溃点(claim 消费/pending 转移/outbox 写入)注入 RAISE(ABORT)
|
|
20
|
+
* → 全有或全无;恢复后重放恰一次;崩溃→重启(重开 DB)后 durable 领取恰一次
|
|
21
|
+
* 8 派发前复验 + L4 撤回:撤回在发行/消费/派发三点阻断未来副作用;复验失败 → rejected
|
|
22
|
+
* 零写且永不回 queued;unknown 口径(dispatching 不回退,next_action=query,禁止盲目重试)
|
|
23
|
+
* 运行(在 ts/ 下):npx tsx scripts/write-gate-tests.ts
|
|
24
|
+
* 纪律:所有 stateRoot 走 mkdtemp(/tmp)隔离,不触 ts/gotry-state 与 dsh-runtime 共享状态。
|
|
25
|
+
*/
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { dirname, join } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import Database from 'better-sqlite3';
|
|
33
|
+
import { StateLedger, ensureLedger } from '../src/state-ledger.js';
|
|
34
|
+
import { WRITE_GATE_SCHEMA, WRITE_GATE_SEAMS, WRITE_REQUEST_FINGERPRINT_SCHEMA, WriteGate, canonicalJson, computeRequestFingerprint, } from '../src/write-gate.js';
|
|
35
|
+
let pass = 0;
|
|
36
|
+
let fail = 0;
|
|
37
|
+
function assert(cond, msg) {
|
|
38
|
+
if (cond) {
|
|
39
|
+
pass++;
|
|
40
|
+
console.log(` ok - ${msg}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
fail++;
|
|
44
|
+
console.error(` FAIL - ${msg}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function sqliteErrorCode(fn) {
|
|
48
|
+
try {
|
|
49
|
+
fn();
|
|
50
|
+
return 'NO-ERROR';
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return e.code ?? 'UNKNOWN';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const TS_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
57
|
+
const sha = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
|
|
58
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
59
|
+
function baseFields(over = {}) {
|
|
60
|
+
return {
|
|
61
|
+
tenantId: 'local',
|
|
62
|
+
actorRef: 'actor:founder',
|
|
63
|
+
principalRef: 'principal:traveler-1',
|
|
64
|
+
seam: 'hotelbyte-hotel-book-confirm',
|
|
65
|
+
supplier: 'hotelbyte:staicli@0.0.3:buyer-digest-abc',
|
|
66
|
+
productRef: 'sessionId-x/ratePkgId-y',
|
|
67
|
+
travelTerms: '2026-10-01..2026-10-02|1room|2guests|free-cancel|tax-incl',
|
|
68
|
+
amountTotal: 128800,
|
|
69
|
+
currency: 'CNY',
|
|
70
|
+
commissionDisclosure: 'commission:5%',
|
|
71
|
+
validUntil: new Date(Date.now() + 3600_000).toISOString(),
|
|
72
|
+
presentationKey: sha('card-v1'),
|
|
73
|
+
...over,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** 一条走通到 outbox 入队的主路径(返回句柄供各反例在此基础上漂移) */
|
|
77
|
+
function fullFlow(wg, ledger, idemKey, over = {}) {
|
|
78
|
+
const fields = baseFields(over);
|
|
79
|
+
ledger.requestPendingWrite({ idemKey, seam: fields.seam, payload: { note: 'fixture' } });
|
|
80
|
+
const prep = wg.preparePresentation({ idemKey, fields, expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
81
|
+
if (!prep.ok)
|
|
82
|
+
throw new Error(`prepare failed: ${prep.reason}`);
|
|
83
|
+
const conf = wg.trustedHostConfirm({ challengeId: prep.card.challenge_id, deliveryNonce: prep.trustedDelivery.deliveryNonce, actorRef: fields.actorRef, seam: fields.seam });
|
|
84
|
+
if (!conf.ok)
|
|
85
|
+
throw new Error(`confirm failed: ${conf.reason}`);
|
|
86
|
+
const consumed = wg.consumeApprovalAndEnqueue({ receiptId: conf.receiptId, idemKey, fields, effectName: 'hotel-book' });
|
|
87
|
+
if (!consumed.ok)
|
|
88
|
+
throw new Error(`consume failed: ${consumed.reason}`);
|
|
89
|
+
return { receiptId: conf.receiptId, fields };
|
|
90
|
+
}
|
|
91
|
+
/** 发行到「已确认未消费」(供消费点反例在 receipt 在手时注入撤回/漂移) */
|
|
92
|
+
function flowToReceipt(wg, ledger, idemKey, over = {}) {
|
|
93
|
+
const fields = baseFields(over);
|
|
94
|
+
ledger.requestPendingWrite({ idemKey, seam: fields.seam, payload: { note: 'fixture' } });
|
|
95
|
+
const prep = wg.preparePresentation({ idemKey, fields, expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
96
|
+
if (!prep.ok)
|
|
97
|
+
throw new Error(`prepare failed: ${prep.reason}`);
|
|
98
|
+
const conf = wg.trustedHostConfirm({ challengeId: prep.card.challenge_id, deliveryNonce: prep.trustedDelivery.deliveryNonce, actorRef: fields.actorRef, seam: fields.seam });
|
|
99
|
+
if (!conf.ok)
|
|
100
|
+
throw new Error(`confirm failed: ${conf.reason}`);
|
|
101
|
+
return { receiptId: conf.receiptId, fields };
|
|
102
|
+
}
|
|
103
|
+
// ---- 1:契约封闭性 ------------------------------------------------------------------
|
|
104
|
+
{
|
|
105
|
+
console.log(' -- 1 契约封闭性(指纹 canonical / seam 封闭集)');
|
|
106
|
+
const f1 = baseFields();
|
|
107
|
+
const digestA = computeRequestFingerprint(f1, sha('nonce-1'));
|
|
108
|
+
const digestB = computeRequestFingerprint({ ...f1, sponsorRef: undefined }, sha('nonce-1'));
|
|
109
|
+
assert(WRITE_GATE_SCHEMA === 'write_gate.v1' && WRITE_REQUEST_FINGERPRINT_SCHEMA === 'gotry_write_request_fingerprint.v1', 'schema 词与设计 §3/§4 逐字一致');
|
|
110
|
+
assert(digestA === digestB && digestA.length === 64, '指纹对等输入稳定(64 hex);canonical 序列化键序无关');
|
|
111
|
+
const drift = [
|
|
112
|
+
['金额+1', { ...f1, amountTotal: f1.amountTotal + 1 }, sha('nonce-1')],
|
|
113
|
+
['币种漂移', { ...f1, currency: 'USD' }, sha('nonce-1')],
|
|
114
|
+
['条款漂移(holder/guests 摘要)', { ...f1, travelTerms: f1.travelTerms.replace('2guests', '3guests') }, sha('nonce-1')],
|
|
115
|
+
['披露漂移', { ...f1, commissionDisclosure: 'none' }, sha('nonce-1')],
|
|
116
|
+
['卡片版本漂移', { ...f1, presentationKey: sha('card-v2') }, sha('nonce-1')],
|
|
117
|
+
['nonce 漂移', f1, sha('nonce-2')],
|
|
118
|
+
['traveler 漂移', { ...f1, principalRef: 'principal:traveler-2' }, sha('nonce-1')],
|
|
119
|
+
['actor 漂移', { ...f1, actorRef: 'actor:other' }, sha('nonce-1')],
|
|
120
|
+
['Buyer/路由漂移', { ...f1, supplier: f1.supplier.replace('buyer-digest-abc', 'buyer-digest-zzz') }, sha('nonce-1')],
|
|
121
|
+
];
|
|
122
|
+
assert(drift.every(([, f, n]) => computeRequestFingerprint(f, n) !== digestA), '任一授权字段漂移 → 指纹字节不等(呈现/确认后不可变性的判定基准)');
|
|
123
|
+
assert(WRITE_GATE_SEAMS.join(',') === 'hotelbyte-hotel-book-confirm', '具名 seam 词汇封闭集(冻结属 M5 Entry 拍板,当前仅设计文档字面量)');
|
|
124
|
+
assert(canonicalJson({ b: 1, a: [2, { c: 3 }] }) === canonicalJson({ a: [2, { c: 3 }], b: 1 }), 'canonicalJson 递归键排序');
|
|
125
|
+
}
|
|
126
|
+
// ---- 2/3/4/5:主路径与全部 API 级反例(隔离账本 #1) ----------------------------------
|
|
127
|
+
const root1 = mkdtempSync(join(tmpdir(), 'gotry-write-gate-'));
|
|
128
|
+
try {
|
|
129
|
+
const ledger = ensureLedger(root1);
|
|
130
|
+
const wg = new WriteGate(ledger);
|
|
131
|
+
const sagaEventsOf = (key) => ledger.db.prepare(`SELECT seq, kind, payload FROM events WHERE tenant_id = ? AND subject_id = ? AND kind IN ('write.pending','write.confirmed','write.compensated') ORDER BY seq`).all(ledger.tenant, key);
|
|
132
|
+
{
|
|
133
|
+
console.log(' -- 2 主路径:L2 → 呈现前备妥 → 可信确认 → 原子消费+outbox(单事务)');
|
|
134
|
+
const fields = baseFields();
|
|
135
|
+
ledger.requestPendingWrite({ idemKey: 'wg:main', seam: fields.seam, payload: { note: 'main' } });
|
|
136
|
+
const prep = wg.preparePresentation({ idemKey: 'wg:main', fields, expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
137
|
+
assert(prep.ok, '呈现前备妥成功(L2 已登记 + seam 在封闭集)');
|
|
138
|
+
if (prep.ok) {
|
|
139
|
+
const card = prep.card;
|
|
140
|
+
assert(card.request_fingerprint_sha256 === computeRequestFingerprint(fields, card.delivery_nonce_digest), '卡片指纹 = 字段+nonce 摘要的服务端冻结值(呈现前已含 nonce)');
|
|
141
|
+
assert(!('deliveryNonce' in card) && card.delivery_nonce_digest === sha(prep.trustedDelivery.deliveryNonce), '卡片只含 nonce 摘要;原始 nonce 只在可信宿主交付面');
|
|
142
|
+
const rawNonceRow = ledger.db.prepare('SELECT COUNT(*) AS n FROM prepared_challenges WHERE delivery_nonce_digest = ?').get(prep.trustedDelivery.deliveryNonce);
|
|
143
|
+
assert(rawNonceRow.n === 0, '原始 nonce 不落库(只存摘要)');
|
|
144
|
+
const conf = wg.trustedHostConfirm({ challengeId: card.challenge_id, deliveryNonce: prep.trustedDelivery.deliveryNonce, actorRef: fields.actorRef, seam: fields.seam });
|
|
145
|
+
assert(conf.ok, '可信宿主真人确认回调 → 发行 receipt(issuance 记录,consumed_at 仍空)');
|
|
146
|
+
if (conf.ok) {
|
|
147
|
+
const claim = wg.getClaim(conf.receiptId);
|
|
148
|
+
assert(claim !== undefined && claim['consumed_at'] === null, '发行记录在案且未消费');
|
|
149
|
+
const ch = wg.getChallenge(card.challenge_id);
|
|
150
|
+
assert(ch !== undefined && ch['status'] === 'confirmed', '挑战 prepared→confirmed(prepared 不构成授权,确认后才可消费)');
|
|
151
|
+
const consumed = wg.consumeApprovalAndEnqueue({ receiptId: conf.receiptId, idemKey: 'wg:main', fields, effectName: 'hotel-book' });
|
|
152
|
+
assert(consumed.ok, '原子消费 + outbox 入队成功');
|
|
153
|
+
const intent = wg.getIntent('wg:main');
|
|
154
|
+
assert(intent !== undefined && intent['dispatch_status'] === 'queued' && intent['receipt_id'] === conf.receiptId, 'durable outbox 恰一行 queued,绑定 receipt');
|
|
155
|
+
assert(intent !== undefined && intent['attempt_id'] === null && intent['fencing_token'] === null, 'queued 行无领取字段(领取前不存在 attempt)');
|
|
156
|
+
const pendingRow = ledger.listPendingWrites().find(w => w.idem_key === 'wg:main');
|
|
157
|
+
assert(pendingRow?.status === 'confirmed' && pendingRow?.receipt === conf.receiptId, 'pending_writes pending→confirmed 且携 receipt(saga 物理面对账)');
|
|
158
|
+
const claimAfter = wg.getClaim(conf.receiptId);
|
|
159
|
+
assert(typeof claimAfter['consumed_at'] === 'string', 'approval_claims.consumed_at 恰被置一次');
|
|
160
|
+
const chAfter = wg.getChallenge(card.challenge_id);
|
|
161
|
+
assert(chAfter['status'] === 'consumed', '挑战 confirmed→consumed(字母表闭环)');
|
|
162
|
+
const kinds = sagaEventsOf('wg:main').map(e => e.kind).join(',');
|
|
163
|
+
assert(kinds === 'write.pending,write.confirmed', 'saga 审计链 = write.pending→write.confirmed(被拒调用零事件)');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
{
|
|
168
|
+
console.log(' -- 3 授权源:模型冒充/伪造凭据/重新呈现后旧挑战');
|
|
169
|
+
const fields = baseFields();
|
|
170
|
+
ledger.requestPendingWrite({ idemKey: 'wg:model', seam: fields.seam, payload: {} });
|
|
171
|
+
const prep = wg.preparePresentation({ idemKey: 'wg:model', fields, expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
172
|
+
if (!prep.ok)
|
|
173
|
+
throw new Error('unreachable');
|
|
174
|
+
// 模型可见面只有卡片(摘要);即使携带合法 actor + 合法快照,凭摘要/伪造 nonce 的确认一律拒绝
|
|
175
|
+
const m1 = wg.trustedHostConfirm({ challengeId: prep.card.challenge_id, deliveryNonce: prep.card.delivery_nonce_digest, actorRef: fields.actorRef, seam: fields.seam });
|
|
176
|
+
const m2 = wg.trustedHostConfirm({ challengeId: prep.card.challenge_id, deliveryNonce: sha('forged'), actorRef: fields.actorRef, seam: fields.seam });
|
|
177
|
+
const m3 = wg.trustedHostConfirm({ challengeId: prep.card.challenge_id, deliveryNonce: prep.trustedDelivery.deliveryNonce, actorRef: 'actor:attacker', seam: fields.seam });
|
|
178
|
+
assert(!m1.ok && m1.reason === 'nonce-mismatch' && !m2.ok && m2.reason === 'nonce-mismatch', '摘要/伪造 nonce 冒充真人确认 → nonce-mismatch(字段匹配 ≠ 用户许可)');
|
|
179
|
+
assert(!m3.ok && m3.reason === 'binding-mismatch', 'actor 绑定不一致 → binding-mismatch');
|
|
180
|
+
assert(wg.getClaim('rc-forged') === undefined, '被拒确认零发行记录');
|
|
181
|
+
const claimRows = ledger.db.prepare("SELECT COUNT(*) AS n FROM approval_claims WHERE idem_key = 'wg:model'").get().n;
|
|
182
|
+
const intentRows = ledger.db.prepare("SELECT COUNT(*) AS n FROM write_effect_intents WHERE idem_key = 'wg:model'").get().n;
|
|
183
|
+
assert(claimRows === 0 && intentRows === 0, '模型冒充路径零 claim、零 outbox 行(无 outbox 即无派发权)');
|
|
184
|
+
const fake = wg.consumeApprovalAndEnqueue({ receiptId: 'rc-not-issued', idemKey: 'wg:model', fields, effectName: 'hotel-book' });
|
|
185
|
+
assert(!fake.ok && fake.reason === 'no-issuance-record', '客户端自supply receipt_id → no-issuance-record(只有账本发行记录有效)');
|
|
186
|
+
// 重新呈现:同 idem 二次备妥 → 旧挑战(携旧 nonce)立即失效,旧 nonce 不得再确认
|
|
187
|
+
const oldNonce = prep.trustedDelivery.deliveryNonce;
|
|
188
|
+
const prep2 = wg.preparePresentation({ idemKey: 'wg:model', fields, expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
189
|
+
if (!prep2.ok)
|
|
190
|
+
throw new Error('unreachable: ' + prep2.reason);
|
|
191
|
+
assert(prep2.card.challenge_id !== prep.card.challenge_id, '重新呈现生成新挑战');
|
|
192
|
+
const stale = wg.trustedHostConfirm({ challengeId: prep.card.challenge_id, deliveryNonce: oldNonce, actorRef: fields.actorRef, seam: fields.seam });
|
|
193
|
+
assert(!stale.ok && stale.reason === 'challenge-not-prepared', '重新呈现后旧挑战/旧 nonce 确认 → 拒绝(stale 失败关闭)');
|
|
194
|
+
const fresh = wg.trustedHostConfirm({ challengeId: prep2.card.challenge_id, deliveryNonce: prep2.trustedDelivery.deliveryNonce, actorRef: fields.actorRef, seam: fields.seam });
|
|
195
|
+
assert(fresh.ok, '只有最新挑战可被可信宿主确认');
|
|
196
|
+
}
|
|
197
|
+
{
|
|
198
|
+
console.log(' -- 4 一次性消费:同 receipt 重放 / 双连接竞争 / 双进程领取竞争恰一赢家');
|
|
199
|
+
const { receiptId, fields } = fullFlow(wg, ledger, 'wg:replay');
|
|
200
|
+
const replay = wg.consumeApprovalAndEnqueue({ receiptId, idemKey: 'wg:replay', fields, effectName: 'hotel-book' });
|
|
201
|
+
assert(!replay.ok && replay.reason === 'approval-claimed', '同 receipt 重放 → approval-claimed');
|
|
202
|
+
const intents = wg.listIntents().filter(i => i['idem_key'] === 'wg:replay');
|
|
203
|
+
assert(intents.length === 1, '重放后 outbox 仍恰一行(不重复入队)');
|
|
204
|
+
const confirmedEvents = ledger.readEvents('write.confirmed', 50).filter(e => e.subject_id === 'wg:replay');
|
|
205
|
+
assert(confirmedEvents.length === 1, 'write.confirmed 恰一次(单次确认)');
|
|
206
|
+
// 双连接(独立句柄)重放同一 receipt:输家 approval-claimed,零新行
|
|
207
|
+
const db2 = new Database(ledger.dbPath);
|
|
208
|
+
db2.pragma('busy_timeout = 5000');
|
|
209
|
+
const ledger2 = new StateLedger(db2, root1, 'local');
|
|
210
|
+
const wg2 = new WriteGate(ledger2);
|
|
211
|
+
const { receiptId: rid2, fields: f2 } = fullFlow(wg, ledger, 'wg:replay2');
|
|
212
|
+
const loser = wg2.consumeApprovalAndEnqueue({ receiptId: rid2, idemKey: 'wg:replay2', fields: f2, effectName: 'hotel-book' });
|
|
213
|
+
assert(!loser.ok && loser.reason === 'approval-claimed', '第二连接重放同一 receipt → approval-claimed(条件 UPDATE 恰 1 行语义)');
|
|
214
|
+
db2.close();
|
|
215
|
+
// 双进程领取竞争:两个真实子进程就绪后同时 claim 同一 queued intent,恰一赢家
|
|
216
|
+
const fullFlow3 = fullFlow(wg, ledger, 'wg:race');
|
|
217
|
+
void fullFlow3;
|
|
218
|
+
const goPath = join(root1, 'go');
|
|
219
|
+
const readyA = join(root1, 'ready-a');
|
|
220
|
+
const readyB = join(root1, 'ready-b');
|
|
221
|
+
const childSrc = `
|
|
222
|
+
import { createRequire } from 'node:module'
|
|
223
|
+
import { existsSync, writeFileSync } from 'node:fs'
|
|
224
|
+
const req = createRequire(process.env.TS_PKG)
|
|
225
|
+
const Database = req('better-sqlite3')
|
|
226
|
+
const { StateLedger } = await import(process.env.STATE_LEDGER_TS)
|
|
227
|
+
const { WriteGate } = await import(process.env.WRITE_GATE_TS)
|
|
228
|
+
const db = new Database(process.env.DB_PATH)
|
|
229
|
+
db.pragma('busy_timeout = 10000')
|
|
230
|
+
const ledger = new StateLedger(db, process.env.STATE_ROOT, 'local')
|
|
231
|
+
const wg = new WriteGate(ledger)
|
|
232
|
+
writeFileSync(process.env.CHILD_READY, 'ready')
|
|
233
|
+
while (!existsSync(process.env.GO_PATH)) { await new Promise(r => setTimeout(r, 5)) }
|
|
234
|
+
const v = wg.claimForDispatch({ idemKey: 'wg:race', claimedBy: 'proc-' + process.pid, leaseUntil: new Date(Date.now() + 60000).toISOString() })
|
|
235
|
+
console.log(JSON.stringify(v))
|
|
236
|
+
`;
|
|
237
|
+
const childPath = join(root1, 'claim-child.mjs');
|
|
238
|
+
writeFileSync(childPath, childSrc);
|
|
239
|
+
const childEnv = {
|
|
240
|
+
...process.env,
|
|
241
|
+
TS_PKG: join(TS_DIR, 'package.json'),
|
|
242
|
+
STATE_LEDGER_TS: 'file://' + join(TS_DIR, 'src', 'state-ledger.ts'),
|
|
243
|
+
WRITE_GATE_TS: 'file://' + join(TS_DIR, 'src', 'write-gate.ts'),
|
|
244
|
+
DB_PATH: ledger.dbPath,
|
|
245
|
+
STATE_ROOT: root1,
|
|
246
|
+
GO_PATH: goPath,
|
|
247
|
+
};
|
|
248
|
+
const tsxCli = join(TS_DIR, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
249
|
+
const startChild = (readyFlag) => spawn(process.execPath, [tsxCli, childPath], { cwd: TS_DIR, env: { ...childEnv, CHILD_READY: readyFlag } });
|
|
250
|
+
const waitExit = (p) => new Promise((resolve, reject) => {
|
|
251
|
+
let out = '';
|
|
252
|
+
let err = '';
|
|
253
|
+
p.stdout?.on('data', d => { out += String(d); });
|
|
254
|
+
p.stderr?.on('data', d => { err += String(d); });
|
|
255
|
+
p.on('error', reject);
|
|
256
|
+
p.on('exit', () => resolve({ stdout: out, stderr: err }));
|
|
257
|
+
});
|
|
258
|
+
const c1 = startChild(readyA);
|
|
259
|
+
const c2 = startChild(readyB);
|
|
260
|
+
const deadline = Date.now() + 120_000;
|
|
261
|
+
while ((!existsSync(readyA) || !existsSync(readyB)) && Date.now() < deadline)
|
|
262
|
+
await sleep(50);
|
|
263
|
+
assert(existsSync(readyA) && existsSync(readyB), '两个竞争子进程均已就绪(真双进程)');
|
|
264
|
+
writeFileSync(goPath, 'go');
|
|
265
|
+
const [r1, r2] = await Promise.all([waitExit(c1), waitExit(c2)]);
|
|
266
|
+
const out1 = r1.stdout.trim();
|
|
267
|
+
const out2 = r2.stdout.trim();
|
|
268
|
+
const wins = [out1, out2].filter(o => o.includes('"ok":true')).length;
|
|
269
|
+
assert(wins === 1, `双进程领取竞争恰一赢家(out1=${out1 || 'ERR:' + r1.stderr.slice(-160)} out2=${out2 || 'ERR:' + r2.stderr.slice(-160)})`);
|
|
270
|
+
const raceIntent = wg.getIntent('wg:race');
|
|
271
|
+
assert(raceIntent['dispatch_status'] === 'dispatching' && typeof raceIntent['attempt_id'] === 'string' && raceIntent['fencing_token'] === 1, '赢家居于 dispatching 且携唯一 attempt/fencing(先于任何外部调用落库)');
|
|
272
|
+
const winnerRows = ledger.db.prepare("SELECT COUNT(*) AS n FROM write_effect_intents WHERE attempt_id IS NOT NULL AND idem_key = 'wg:race'").get();
|
|
273
|
+
assert(winnerRows.n === 1, '同 intent 恰一个持久化 attempt(输家零变更)');
|
|
274
|
+
const claimAgain = wg.claimForDispatch({ idemKey: 'wg:race', claimedBy: 'main', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
275
|
+
assert(!claimAgain.ok && claimAgain.reason === 'not-claimable', '派发后重试领取 → not-claimable(不可重新派发)');
|
|
276
|
+
}
|
|
277
|
+
{
|
|
278
|
+
console.log(' -- 5 绑定/过期:字段漂移 / 过期 / 跨 intent / 跨租户');
|
|
279
|
+
const bindKeyOf = (over) => 'wg:bind-' + sha(JSON.stringify(over)).slice(0, 8);
|
|
280
|
+
const mk = (over) => {
|
|
281
|
+
const key = bindKeyOf(over);
|
|
282
|
+
const fields = baseFields(over);
|
|
283
|
+
ledger.requestPendingWrite({ idemKey: key, seam: fields.seam, payload: {} });
|
|
284
|
+
const prep = wg.preparePresentation({ idemKey: key, fields, expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
285
|
+
if (!prep.ok)
|
|
286
|
+
throw new Error('prepare: ' + prep.reason);
|
|
287
|
+
const conf = wg.trustedHostConfirm({ challengeId: prep.card.challenge_id, deliveryNonce: prep.trustedDelivery.deliveryNonce, actorRef: fields.actorRef, seam: fields.seam });
|
|
288
|
+
if (!conf.ok)
|
|
289
|
+
throw new Error('confirm: ' + conf.reason);
|
|
290
|
+
return { receiptId: conf.receiptId, fields };
|
|
291
|
+
};
|
|
292
|
+
// 金额漂移(绑定一致、指纹不等)
|
|
293
|
+
const amount = mk({});
|
|
294
|
+
const amountKey = bindKeyOf({});
|
|
295
|
+
const amountDrift = wg.consumeApprovalAndEnqueue({ receiptId: amount.receiptId, idemKey: amountKey, fields: { ...amount.fields, amountTotal: amount.fields.amountTotal + 1 }, effectName: 'hotel-book' });
|
|
296
|
+
assert(!amountDrift.ok && amountDrift.reason === 'fingerprint-mismatch', '呈现/确认后金额+1 → fingerprint-mismatch');
|
|
297
|
+
assert((wg.getIntent(amountKey) ?? null) === null, '金额漂移消费零 outbox 行');
|
|
298
|
+
// guests 摘要漂移(绑定一致、指纹不等)——确认后改履约字段零写
|
|
299
|
+
const terms = mk({ principalRef: 'principal:traveler-9' });
|
|
300
|
+
const termsKey = bindKeyOf({ principalRef: 'principal:traveler-9' });
|
|
301
|
+
const swapped = wg.consumeApprovalAndEnqueue({ receiptId: terms.receiptId, idemKey: termsKey, fields: { ...terms.fields, travelTerms: terms.fields.travelTerms.replace('2guests', '9guests') }, effectName: 'hotel-book' });
|
|
302
|
+
assert(!swapped.ok && swapped.reason === 'fingerprint-mismatch', '确认后改 holder/guests 摘要 → fingerprint-mismatch(换旅客/人数零写)');
|
|
303
|
+
// 跨 intent 重放
|
|
304
|
+
const crossIntent = wg.consumeApprovalAndEnqueue({ receiptId: amount.receiptId, idemKey: 'wg:other-intent', fields: amount.fields, effectName: 'hotel-book' });
|
|
305
|
+
assert(!crossIntent.ok && crossIntent.reason === 'cross-intent-replay', '同 receipt 跨 intent 重放 → cross-intent-replay');
|
|
306
|
+
assert((wg.getIntent('wg:other-intent') ?? null) === null, '跨 intent 重放零 outbox 行');
|
|
307
|
+
// receipt 过期(报价/授权有效期在消费点已过)
|
|
308
|
+
const pastValid = new Date(Date.now() - 1000).toISOString();
|
|
309
|
+
const exp = mk({ validUntil: pastValid });
|
|
310
|
+
const expired = wg.consumeApprovalAndEnqueue({ receiptId: exp.receiptId, idemKey: bindKeyOf({ validUntil: pastValid }), fields: exp.fields, effectName: 'hotel-book' });
|
|
311
|
+
assert(!expired.ok && expired.reason === 'receipt-expired', '消费点凭据过期 → receipt-expired(零供应商写)');
|
|
312
|
+
assert((wg.getIntent(bindKeyOf({ validUntil: pastValid })) ?? null) === null, '过期消费零 outbox 行');
|
|
313
|
+
// 挑战过期:prepared 且 expires_at 已过 → 不得确认;无确认即无发行无派发
|
|
314
|
+
ledger.requestPendingWrite({ idemKey: 'wg:chexp', seam: baseFields().seam, payload: {} });
|
|
315
|
+
const chexp = wg.preparePresentation({ idemKey: 'wg:chexp', fields: baseFields(), expiresAt: new Date(Date.now() - 1000).toISOString() });
|
|
316
|
+
if (!chexp.ok)
|
|
317
|
+
throw new Error('unreachable: ' + chexp.reason);
|
|
318
|
+
const lateConfirm = wg.trustedHostConfirm({ challengeId: chexp.card.challenge_id, deliveryNonce: chexp.trustedDelivery.deliveryNonce, actorRef: baseFields().actorRef, seam: baseFields().seam });
|
|
319
|
+
assert(!lateConfirm.ok && lateConfirm.reason === 'challenge-expired', '过期挑战确认 → challenge-expired');
|
|
320
|
+
assert(wg.sweepExpiredChallenges() >= 1, '过期清理 sweep 生效(prepared → expired)');
|
|
321
|
+
// 跨租户:tenant-b 用 A 的 receipt 消费/领取 → 租户作用域查无;A/B 同 idem_key 意图隔离
|
|
322
|
+
const dbB = new Database(ledger.dbPath);
|
|
323
|
+
dbB.pragma('busy_timeout = 5000');
|
|
324
|
+
const ledgerB = new StateLedger(dbB, root1, 'tenant-b');
|
|
325
|
+
const wgB = new WriteGate(ledgerB);
|
|
326
|
+
const tenantA = fullFlow(wg, ledger, 'wg:tenant-a');
|
|
327
|
+
const crossTenant = wgB.consumeApprovalAndEnqueue({ receiptId: tenantA.receiptId, idemKey: 'wg:tenant-a', fields: tenantA.fields, effectName: 'hotel-book' });
|
|
328
|
+
assert(!crossTenant.ok && crossTenant.reason === 'no-issuance-record', '跨租户消费他租户 receipt → 查无发行记录(tenant 一等字段)');
|
|
329
|
+
const crossClaim = wgB.claimForDispatch({ idemKey: 'wg:tenant-a', claimedBy: 'tenant-b-worker', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
330
|
+
assert(!crossClaim.ok && crossClaim.reason === 'missing-intent', 'tenant-b 按 idem_key 领取 A 的意图 → missing-intent(cross-tenant affected rows=0)');
|
|
331
|
+
ledgerB.requestPendingWrite({ idemKey: 'wg:tenant-a', seam: baseFields().seam, payload: {} });
|
|
332
|
+
const prepB = wgB.preparePresentation({ idemKey: 'wg:tenant-a', fields: baseFields(), expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
333
|
+
assert(prepB.ok, '同 idem_key 在 tenant-b 可独立备妥(互不可见)');
|
|
334
|
+
dbB.close();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
rmSync(root1, { recursive: true, force: true });
|
|
339
|
+
}
|
|
340
|
+
// ---- 6/6b/8:存储物理红线 + 撤回/复验(隔离账本 #2,不关闭连接) ------------------------
|
|
341
|
+
const root2 = mkdtempSync(join(tmpdir(), 'gotry-write-gate-phys-'));
|
|
342
|
+
try {
|
|
343
|
+
const ledger = ensureLedger(root2);
|
|
344
|
+
const wg = new WriteGate(ledger);
|
|
345
|
+
{
|
|
346
|
+
console.log(' -- 6 存储物理红线(裸 SQL 直接否证;应用层 if 不可替代)');
|
|
347
|
+
const { receiptId } = fullFlow(wg, ledger, 'wg:phys');
|
|
348
|
+
// 空 receipt → CHECK;outbox 零行
|
|
349
|
+
const emptyReceipt = sqliteErrorCode(() => ledger.db.prepare(`INSERT INTO write_effect_intents (tenant_id, idem_key, effect_name, receipt_id, supplier_attempt_key, request_fingerprint_sha256, nonce_digest, actor_ref, principal_ref, seam, attempt_budget, next_action, dispatch_status, created, updated)
|
|
350
|
+
VALUES ('local','wg:phys-empty','x','', 'sa-empty', ?, ?, 'a', 'p', 'hotelbyte-hotel-book-confirm', 1, 'dispatch', 'queued', '2026-01-01', '2026-01-01')`).run(sha('f'), sha('n')));
|
|
351
|
+
assert(emptyReceipt === 'SQLITE_CONSTRAINT_CHECK', `空 receipt 直插 outbox → 物理 CHECK 拒绝(${emptyReceipt})`);
|
|
352
|
+
assert((wg.getIntent('wg:phys-empty') ?? null) === null, '空 receipt 的 outbox 行不存在(零行)');
|
|
353
|
+
// 跨租户 receipt → 复合外键;outbox 零行
|
|
354
|
+
const crossTenant = sqliteErrorCode(() => ledger.db.prepare(`INSERT INTO write_effect_intents (tenant_id, idem_key, effect_name, receipt_id, supplier_attempt_key, request_fingerprint_sha256, nonce_digest, actor_ref, principal_ref, seam, attempt_budget, next_action, dispatch_status, created, updated)
|
|
355
|
+
VALUES ('tenant-z','wg:phys-cross','x', ?, 'sa-cross', ?, ?, 'a', 'p', 'hotelbyte-hotel-book-confirm', 1, 'dispatch', 'queued', '2026-01-01', '2026-01-01')`).run(receiptId, sha('f2'), sha('n2')));
|
|
356
|
+
assert(crossTenant === 'SQLITE_CONSTRAINT_FOREIGNKEY', `跨租户 receipt 直插 → 复合外键物理拒绝(${crossTenant})`);
|
|
357
|
+
assert((wg.getIntent('wg:phys-cross') ?? null) === null, '跨租户 receipt 的 outbox 行不存在(tenant-z 作用域零行)');
|
|
358
|
+
// nonce 唯一约束(物理)
|
|
359
|
+
const dupNonce = sqliteErrorCode(() => ledger.db.prepare(`INSERT INTO prepared_challenges (tenant_id, challenge_id, idem_key, request_fingerprint_sha256, presentation_key, delivery_nonce_digest, actor_ref, principal_ref, seam, amount_total, currency, valid_until, prepared_at, expires_at, status)
|
|
360
|
+
VALUES ('local','ch-dup','wg:phys', ?, 'pk', (SELECT delivery_nonce_digest FROM prepared_challenges WHERE tenant_id='local' LIMIT 1), 'a', 'p', 'hotelbyte-hotel-book-confirm', 1, 'CNY', '2027-01-01', '2026-01-01', '2027-01-01', 'prepared')`).run(sha('f3')));
|
|
361
|
+
assert(dupNonce === 'SQLITE_CONSTRAINT_UNIQUE', `同 nonce 摘要重复呈现面 → UNIQUE 物理拒绝(${dupNonce})`);
|
|
362
|
+
// dispatching 直插但缺领取字段 → CHECK(「调用先于领取落库」在存储层不可能成立)
|
|
363
|
+
const missingAttempt = sqliteErrorCode(() => ledger.db.prepare(`INSERT INTO write_effect_intents (tenant_id, idem_key, effect_name, receipt_id, supplier_attempt_key, request_fingerprint_sha256, nonce_digest, actor_ref, principal_ref, seam, attempt_budget, next_action, dispatch_status, created, updated)
|
|
364
|
+
VALUES ('local','wg:phys-noattempt','x', ?, 'sa-noattempt', ?, ?, 'a', 'p', 'hotelbyte-hotel-book-confirm', 1, 'dispatch', 'dispatching', '2026-01-01', '2026-01-01')`).run(receiptId, sha('f4'), sha('n4')));
|
|
365
|
+
assert(missingAttempt === 'SQLITE_CONSTRAINT_CHECK', `dispatching 直插缺 attempt/领取字段 → 物理 CHECK 拒绝(${missingAttempt})`);
|
|
366
|
+
// queued 直插携领取字段 → CHECK
|
|
367
|
+
const queuedWithAttempt = sqliteErrorCode(() => ledger.db.prepare(`INSERT INTO write_effect_intents (tenant_id, idem_key, effect_name, receipt_id, supplier_attempt_key, request_fingerprint_sha256, nonce_digest, actor_ref, principal_ref, seam, attempt_budget, next_action, dispatch_status, attempt_id, fencing_token, claimed_by, lease_until, created, updated)
|
|
368
|
+
VALUES ('local','wg:phys-qwa','x', ?, 'sa-qwa', ?, ?, 'a', 'p', 'hotelbyte-hotel-book-confirm', 1, 'dispatch', 'queued', 'at-x', 1, 'w', '2027-01-01', '2026-01-01', '2026-01-01')`).run(receiptId, sha('f5'), sha('n5')));
|
|
369
|
+
assert(queuedWithAttempt === 'SQLITE_CONSTRAINT_CHECK', `queued 携领取字段直插 → 物理 CHECK 拒绝(${queuedWithAttempt})`);
|
|
370
|
+
}
|
|
371
|
+
{
|
|
372
|
+
console.log(' -- 6b attempt_id 不可变 / dispatch_status 只进不退(触发器物理否证)');
|
|
373
|
+
fullFlow(wg, ledger, 'wg:fence');
|
|
374
|
+
const got = wg.claimForDispatch({ idemKey: 'wg:fence', claimedBy: 'w1', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
375
|
+
assert(got.ok, '领取成功(dispatching + 不可变 attempt 落库)');
|
|
376
|
+
if (got.ok) {
|
|
377
|
+
const swapAttempt = sqliteErrorCode(() => ledger.db.prepare(`UPDATE write_effect_intents SET attempt_id = 'at-forged' WHERE tenant_id = 'local' AND idem_key = 'wg:fence'`).run());
|
|
378
|
+
assert(swapAttempt === 'SQLITE_CONSTRAINT_TRIGGER', `attempt_id 落库后改写 → 触发器物理 ABORT(${swapAttempt})`);
|
|
379
|
+
const backToQueued = sqliteErrorCode(() => ledger.db.prepare(`UPDATE write_effect_intents SET dispatch_status = 'queued' WHERE tenant_id = 'local' AND idem_key = 'wg:fence'`).run());
|
|
380
|
+
assert(backToQueued === 'SQLITE_CONSTRAINT_TRIGGER', `dispatching → queued(崩溃/租约过期重置)→ 触发器物理 ABORT(${backToQueued})`);
|
|
381
|
+
const intent = wg.getIntent('wg:fence');
|
|
382
|
+
assert(intent['attempt_id'] === got.attempt_id && intent['dispatch_status'] === 'dispatching', '两次物理拒绝后行状态不变(attempt/dispatching 保持)');
|
|
383
|
+
}
|
|
384
|
+
fullFlow(wg, ledger, 'wg:fence-d');
|
|
385
|
+
wg.claimForDispatch({ idemKey: 'wg:fence-d', claimedBy: 'w1', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
386
|
+
wg.markIntentDispatched({ idemKey: 'wg:fence-d' });
|
|
387
|
+
const dispatchedToQueued = sqliteErrorCode(() => ledger.db.prepare(`UPDATE write_effect_intents SET dispatch_status = 'queued' WHERE tenant_id = 'local' AND idem_key = 'wg:fence-d'`).run());
|
|
388
|
+
assert(dispatchedToQueued === 'SQLITE_CONSTRAINT_TRIGGER', 'dispatched → queued 物理拒绝');
|
|
389
|
+
fullFlow(wg, ledger, 'wg:fence-r');
|
|
390
|
+
wg.claimForDispatch({ idemKey: 'wg:fence-r', claimedBy: 'w1', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
391
|
+
wg.rejectIntentBeforeDispatch({ idemKey: 'wg:fence-r', reason: 'quote-expired' });
|
|
392
|
+
const rejectedToQueued = sqliteErrorCode(() => ledger.db.prepare(`UPDATE write_effect_intents SET dispatch_status = 'queued' WHERE tenant_id = 'local' AND idem_key = 'wg:fence-r'`).run());
|
|
393
|
+
assert(rejectedToQueued === 'SQLITE_CONSTRAINT_TRIGGER', 'rejected → queued 物理拒绝(拒绝即终态,永不回队)');
|
|
394
|
+
}
|
|
395
|
+
{
|
|
396
|
+
console.log(' -- 8 派发前复验 + L4 撤回(发行/消费/派发三点阻断未来副作用)');
|
|
397
|
+
// (a) 撤回在派发前(principal 级):领取仍成功,复验失败 → rejected 终态零写,永不回 queued
|
|
398
|
+
const c = fullFlow(wg, ledger, 'wg:rev-dispatch', { principalRef: 'principal:revoke-me' });
|
|
399
|
+
const claimC = wg.claimForDispatch({ idemKey: 'wg:rev-dispatch', claimedBy: 'w1', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
400
|
+
assert(claimC.ok, '领取成功(领取≠派发;复验在任何外部调用前拦截)');
|
|
401
|
+
wg.revokeAuthority({ principalRef: 'principal:revoke-me', reason: 'traveler revoked consent' });
|
|
402
|
+
const rev = wg.preDispatchRevalidate({ idemKey: 'wg:rev-dispatch', fields: c.fields });
|
|
403
|
+
assert(!rev.ok && rev.reason === 'authority-revoked', '撤回后派发前复验 → authority-revoked(L4 撤回阻断未来副作用)');
|
|
404
|
+
const rejected = wg.rejectIntentBeforeDispatch({ idemKey: 'wg:rev-dispatch', reason: 'authority-revoked' });
|
|
405
|
+
assert(rejected.ok, '复验失败 → outbox 层置 rejected(零供应商写)');
|
|
406
|
+
const rj = wg.getIntent('wg:rev-dispatch');
|
|
407
|
+
assert(rj['dispatch_status'] === 'rejected' && rj['reject_reason'] === 'authority-revoked', 'rejected 终态携原因(reject_reason)');
|
|
408
|
+
const back = sqliteErrorCode(() => ledger.db.prepare(`UPDATE write_effect_intents SET dispatch_status = 'queued' WHERE tenant_id = 'local' AND idem_key = 'wg:rev-dispatch'`).run());
|
|
409
|
+
assert(back === 'SQLITE_CONSTRAINT_TRIGGER', 'rejected 不得回 queued(物理)');
|
|
410
|
+
// (b) 撤回不能抹已执行效果:dispatched 行不受影响(已执行效果只能走补偿,#233 面)
|
|
411
|
+
const disp = wg.getIntent('wg:fence-d');
|
|
412
|
+
assert(disp['dispatch_status'] === 'dispatched', '撤回不影响已派发行(已执行效果只能走补偿,不由撤回消除)');
|
|
413
|
+
// (c) 派发点凭据过期(队列中过期):now 注入跨过 valid_until → receipt-expired → rejected
|
|
414
|
+
const t0 = new Date().toISOString();
|
|
415
|
+
const tValid = new Date(Date.parse(t0) + 60_000).toISOString();
|
|
416
|
+
const expKey = 'wg:rev-expiry';
|
|
417
|
+
ledger.requestPendingWrite({ idemKey: expKey, seam: baseFields().seam, payload: {} });
|
|
418
|
+
const expPrep = wg.preparePresentation({ idemKey: expKey, fields: baseFields({ validUntil: tValid }), expiresAt: new Date(Date.parse(t0) + 3600_000).toISOString(), now: t0 });
|
|
419
|
+
if (!expPrep.ok)
|
|
420
|
+
throw new Error('unreachable');
|
|
421
|
+
const expConf = wg.trustedHostConfirm({ challengeId: expPrep.card.challenge_id, deliveryNonce: expPrep.trustedDelivery.deliveryNonce, actorRef: baseFields().actorRef, seam: baseFields().seam, now: t0 });
|
|
422
|
+
if (!expConf.ok)
|
|
423
|
+
throw new Error('unreachable');
|
|
424
|
+
const expConsume = wg.consumeApprovalAndEnqueue({ receiptId: expConf.receiptId, idemKey: expKey, fields: baseFields({ validUntil: tValid }), effectName: 'hotel-book', now: t0 });
|
|
425
|
+
assert(expConsume.ok, '有效期窗口内消费入队成功');
|
|
426
|
+
const tLate = new Date(Date.parse(t0) + 120_000).toISOString();
|
|
427
|
+
wg.claimForDispatch({ idemKey: expKey, claimedBy: 'w2', leaseUntil: new Date(Date.parse(tLate) + 60000).toISOString(), now: tLate });
|
|
428
|
+
const revE = wg.preDispatchRevalidate({ idemKey: expKey, fields: baseFields({ validUntil: tValid }), now: tLate });
|
|
429
|
+
assert(!revE.ok && revE.reason === 'receipt-expired', '排队期间凭据过期,派发点复验 → receipt-expired(零供应商写,重新报价+确认)');
|
|
430
|
+
wg.rejectIntentBeforeDispatch({ idemKey: expKey, reason: 'receipt-expired' });
|
|
431
|
+
assert(wg.getIntent(expKey)['dispatch_status'] === 'rejected', '过期效果置 rejected 终态(永不回 queued)');
|
|
432
|
+
// (d) 复验健康路径 + unknown 口径
|
|
433
|
+
const h = fullFlow(wg, ledger, 'wg:reval-ok');
|
|
434
|
+
wg.claimForDispatch({ idemKey: 'wg:reval-ok', claimedBy: 'w2', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
435
|
+
const revH = wg.preDispatchRevalidate({ idemKey: 'wg:reval-ok', fields: h.fields });
|
|
436
|
+
assert(revH.ok, '授权/有效期/不可变指纹/无撤回 → 复验通过');
|
|
437
|
+
const unk = wg.markIntentUnknown({ idemKey: 'wg:reval-ok', reason: 'supplier-timeout' });
|
|
438
|
+
assert(unk.ok, '崩溃/超时 → unknown 记账');
|
|
439
|
+
const ui = wg.getIntent('wg:reval-ok');
|
|
440
|
+
assert(ui['dispatch_status'] === 'dispatching' && ui['next_action'] === 'query', 'unknown:dispatching 保持(不回 queued),next_action=query(只许查询/人工对账)');
|
|
441
|
+
const blindRetry = wg.claimForDispatch({ idemKey: 'wg:reval-ok', claimedBy: 'w3', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
442
|
+
assert(!blindRetry.ok, 'unknown 期间盲目重试领取 → 拒绝(禁止盲目重试)');
|
|
443
|
+
// (e) 撤回在消费前(actor 级):已发行 receipt 的消费被拒,零 outbox
|
|
444
|
+
const b = flowToReceipt(wg, ledger, 'wg:rev-consume', { actorRef: 'actor:revoke-me' });
|
|
445
|
+
wg.revokeAuthority({ actorRef: 'actor:revoke-me', reason: 'actor revoked' });
|
|
446
|
+
const consumeRevoked = wg.consumeApprovalAndEnqueue({ receiptId: b.receiptId, idemKey: 'wg:rev-consume', fields: b.fields, effectName: 'hotel-book' });
|
|
447
|
+
assert(!consumeRevoked.ok && consumeRevoked.reason === 'authority-revoked', '撤回在消费前 → authority-revoked(零 outbox)');
|
|
448
|
+
assert((wg.getIntent('wg:rev-consume') ?? null) === null, '撤回消费路径零 outbox 行');
|
|
449
|
+
const claimRow = wg.getClaim(b.receiptId);
|
|
450
|
+
assert(claimRow['consumed_at'] === null, '被撤回的发行记录保持未消费');
|
|
451
|
+
// (f) 撤回在发行前(seam 级,封闭集内唯一名;放最后——撤回后该 seam 不可再发行)
|
|
452
|
+
wg.revokeAuthority({ seam: 'hotelbyte-hotel-book-confirm', reason: 'L4 policy revoked' });
|
|
453
|
+
ledger.requestPendingWrite({ idemKey: 'wg:rev-issue', seam: baseFields().seam, payload: {} });
|
|
454
|
+
const issuePrep = wg.preparePresentation({ idemKey: 'wg:rev-issue', fields: baseFields(), expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
455
|
+
assert(issuePrep.ok, '撤回不拦呈现前备妥(备妥 ≠ 授权)');
|
|
456
|
+
if (issuePrep.ok) {
|
|
457
|
+
const issueConf = wg.trustedHostConfirm({ challengeId: issuePrep.card.challenge_id, deliveryNonce: issuePrep.trustedDelivery.deliveryNonce, actorRef: baseFields().actorRef, seam: baseFields().seam });
|
|
458
|
+
assert(!issueConf.ok && issueConf.reason === 'authority-revoked', '撤回后可信确认 → authority-revoked(不发行 receipt)');
|
|
459
|
+
const issuedRows = ledger.db.prepare("SELECT COUNT(*) AS n FROM approval_claims WHERE idem_key = 'wg:rev-issue'").get().n;
|
|
460
|
+
assert(issuedRows === 0, '撤回后零发行记录');
|
|
461
|
+
assert((wg.getIntent('wg:rev-issue') ?? null) === null, '撤回后零 outbox 行');
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
rmSync(root2, { recursive: true, force: true });
|
|
467
|
+
}
|
|
468
|
+
// ---- 7:崩溃注入 + 崩溃后重启恢复(隔离账本 #3;本块会关闭连接) ------------------------
|
|
469
|
+
const root3 = mkdtempSync(join(tmpdir(), 'gotry-write-gate-crash-'));
|
|
470
|
+
try {
|
|
471
|
+
const ledger = ensureLedger(root3);
|
|
472
|
+
const wg = new WriteGate(ledger);
|
|
473
|
+
console.log(' -- 7 崩溃注入:事务1 三崩溃点全有或全无;恢复后重放恰一次;重启后 durable 领取恰一次');
|
|
474
|
+
const crashCase = (key, label, triggerBody) => {
|
|
475
|
+
const f = baseFields();
|
|
476
|
+
ledger.requestPendingWrite({ idemKey: key, seam: f.seam, payload: {} });
|
|
477
|
+
const prep = wg.preparePresentation({ idemKey: key, fields: f, expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
|
478
|
+
if (!prep.ok)
|
|
479
|
+
throw new Error('prepare ' + prep.reason);
|
|
480
|
+
const conf = wg.trustedHostConfirm({ challengeId: prep.card.challenge_id, deliveryNonce: prep.trustedDelivery.deliveryNonce, actorRef: f.actorRef, seam: f.seam });
|
|
481
|
+
if (!conf.ok)
|
|
482
|
+
throw new Error('confirm ' + conf.reason);
|
|
483
|
+
ledger.db.exec(`DROP TRIGGER IF EXISTS crash_inject`);
|
|
484
|
+
ledger.db.exec(`CREATE TRIGGER crash_inject ${triggerBody}`);
|
|
485
|
+
let threw = false;
|
|
486
|
+
try {
|
|
487
|
+
wg.consumeApprovalAndEnqueue({ receiptId: conf.receiptId, idemKey: key, fields: f, effectName: 'hotel-book' });
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
threw = true;
|
|
491
|
+
}
|
|
492
|
+
ledger.db.exec(`DROP TRIGGER IF EXISTS crash_inject`);
|
|
493
|
+
assert(threw, `[${label}] 消费事务被注入 ABORT 中断`);
|
|
494
|
+
const claim = ledger.db.prepare(`SELECT consumed_at FROM approval_claims WHERE tenant_id = 'local' AND receipt_id = ?`).get(conf.receiptId);
|
|
495
|
+
const pending = ledger.listPendingWrites().find(w => w.idem_key === key);
|
|
496
|
+
const intent = wg.getIntent(key);
|
|
497
|
+
const confirmedEv = ledger.readEvents('write.confirmed', 100).filter(e => e.subject_id === key);
|
|
498
|
+
assert(claim.consumed_at === null && pending?.status === 'pending' && intent === undefined && confirmedEv.length === 0, `[${label}] 崩溃后零残留:claim 未消费 / pending 未转移 / outbox 零行 / 零 write.confirmed(全有或全无)`);
|
|
499
|
+
const retry = wg.consumeApprovalAndEnqueue({ receiptId: conf.receiptId, idemKey: key, fields: f, effectName: 'hotel-book' });
|
|
500
|
+
assert(retry.ok, `[${label}] 恢复后重放恰一次成功`);
|
|
501
|
+
const after = wg.getIntent(key);
|
|
502
|
+
assert(after !== undefined && after['dispatch_status'] === 'queued', `[${label}] 重放后 outbox 恰一行 queued(不重复入队)`);
|
|
503
|
+
};
|
|
504
|
+
crashCase('wg:crash-a', 'claim 消费点', `BEFORE UPDATE OF consumed_at ON approval_claims WHEN NEW.consumed_at IS NOT NULL AND NEW.tenant_id = 'local' BEGIN SELECT RAISE(ABORT, 'injected crash at claim consumption'); END`);
|
|
505
|
+
crashCase('wg:crash-b', 'pending 转移点', `BEFORE UPDATE OF status ON pending_writes WHEN NEW.status = 'confirmed' AND NEW.tenant_id = 'local' BEGIN SELECT RAISE(ABORT, 'injected crash at pending transition'); END`);
|
|
506
|
+
crashCase('wg:crash-c', 'outbox 写入点', `BEFORE INSERT ON write_effect_intents WHEN NEW.tenant_id = 'local' BEGIN SELECT RAISE(ABORT, 'injected crash at outbox append'); END`);
|
|
507
|
+
// 崩溃后重启恢复:全部连接关闭重开(durable 语义)——intent 仍在,领取恰一次
|
|
508
|
+
ledger.close();
|
|
509
|
+
const ledgerRe = ensureLedger(root3);
|
|
510
|
+
const wgRe = new WriteGate(ledgerRe);
|
|
511
|
+
const dur = wgRe.claimForDispatch({ idemKey: 'wg:crash-c', claimedBy: 'recovered-worker', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
512
|
+
assert(dur.ok, '崩溃→重启(重开 DB)后 queued intent 可恢复领取(durable outbox)');
|
|
513
|
+
const dur2 = wgRe.claimForDispatch({ idemKey: 'wg:crash-c', claimedBy: 'second-worker', leaseUntil: new Date(Date.now() + 60000).toISOString() });
|
|
514
|
+
assert(!dur2.ok && dur2.reason === 'not-claimable', '恢复后第二 worker 领取 → not-claimable(不可重复派发)');
|
|
515
|
+
ledgerRe.close();
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
rmSync(root3, { recursive: true, force: true });
|
|
519
|
+
}
|
|
520
|
+
console.log(`\nWRITE GATE TESTS: ${pass} ok, ${fail} fail`);
|
|
521
|
+
if (fail > 0)
|
|
522
|
+
process.exit(1);
|