Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 3893783e4c 把数据库往返对照脚本挪出镜像会打包的目录
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 4m24s
脚本从 frontend/scripts 挪到 frontend/tests,不再从会被打包的目录引用测试夹具。业务代码没动,对照数字没重跑。tsc 通过,lint 0 个错误(125 条原有警告),镜像导入守卫 2 通过、0 失败。

Co-Authored-By: Grok (agent loop)
2026-10-14 07:41:19 +08:00
jesse-uxandGrok (agent loop) f197774812 合并最新 staging 到服务器内存这支
校正提速已经在测试线上。更新日志、缺陷记录和任务索引两边都留下。这单自己的修复还没开始。

Co-Authored-By: Grok (agent loop)
2026-10-14 07:37:49 +08:00
Jesse_ChenandClaude Opus 5.5 0a4ba23658 docs(tasks): home sidebar rerender fix brief (agent loop, issue #59, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 07:25:14 +08:00
Jesse_ChenandClaude Opus 5.5 85055cf9fa docs(tasks): node runtime resources fix brief (agent loop, issue #58, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 07:10:15 +08:00
jesse-uxandGrok (agent loop) 4c3485bc23 服务器长跑不再堆积取消监听,数据库少走几趟。
报告工人的等待到点就摘掉取消监听。本地数据库缓存表结构,并把设置身份合成一条语句,对照通过后默认打开,写成 off 仍走原来的语句。交付、重算和限流的内存表会过期。登录读缓存默认关闭,要开再设 IDENTITY_SESSION_COOKIE_CACHE=on。

自测:tsc 通过,lint 0 error,点名测试 134 通过,test:db 开关关和开都只有已知的 13 条失败,往返 49 降到 32。

Co-Authored-By: Grok (agent loop)
2026-10-14 03:32:39 +08:00
Claude ad8b24bf9b merge origin/staging into rect scoring perf (issue #57)
Independent Staging Quality Gate / validate (push) Successful in 15m24s
Independent Staging Quality Gate / publish (push) Successful in 3m39s
Docs-only conflicts (CHANGELOG, BUG_HISTORY 1318-1322 vs 1323-1325/1345)
resolved by keeping both; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 02:16:09 +08:00
Claude 66cd60081f merge origin/staging into upstream sync7 (issue #56)
Independent Staging Quality Gate / validate (push) Successful in 22m10s
Independent Staging Quality Gate / publish (push) Successful in 7m42s
Docs-only conflict in docs/tasks/README.md; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 01:18:13 +08:00
jesse-uxandGrok (agent loop) 6dd1ff7d0c 记下星历路径核查:本机会退回低精度,生产镜像未打开(BUG-1322)
T5 不改代码。进度、更新日志和任务索引一起补上。上游 e1909724 只作同类问题参照。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:50:09 +08:00
jesse-uxandGrok (agent loop) aae3fea011 接口大运年长改成 365.25 天,和推运脚本同一天(BUG-1321,上游 4d543209)
只改兜底时间线的结束日和进入本运年数这两处。同一大运起止相差 0 天。pytest tests/test_dasha_year_length_alignment.py 2 通过。增长合同仍过。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:49:21 +08:00
jesse-uxandGrok (agent loop) 4cdb997800 瑜伽角宫主和三方主按名字排序,不再随进程哈希变(BUG-1320,上游 0337b77f)
同一张盘在两个哈希种子下,宫主名单和瑜伽参与者顺序一致。pytest tests/test_yoga_lord_order_stable.py 1 通过。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:48:53 +08:00
jesse-uxandGrok (agent loop) 2748e43871 KP 改成宫头到宫头判宫,并写明口径(BUG-1319,上游 0337b77f)
本命 Bhava Chalit 表仍按中点,默认宫位不变。传了宫头却不声明就拒绝;没传宫头记整宫代理。报告多一行判宫口径。pytest tests/test_kp_cusp_house_assignment.py 3 通过,tests/test_bhava_chalit.py 仍通过。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:48:38 +08:00
jesse-uxandGrok (agent loop) d32ad7b7b1 婚恋评级改成证据档位,配偶星分数不再写成百分制(BUG-1318,上游 f891a2c1)
五档不再写稳定、离婚、再婚或婚前咨询,并写明这是结构指数。公开盘中英文各锁一条。配偶星证据行改成 0 到 1,不乘 100。pytest tests/test_d9_evidence_bands.py 3 通过。

Co-Authored-By: Grok (agent loop)
2026-10-14 00:48:25 +08:00
42 changed files with 2246 additions and 55 deletions
+11
View File
@@ -1,9 +1,20 @@
# 印度占星 Skill 更新日志
## 2026-10-14 — 服务器长期运行不再逐日涨内存;数据库访问少走几趟(待合入 staging)
- 服务器长期运行不再逐日涨内存;数据库访问少走几趟。Skill 版本不变(BUG-1326~1328、BUG-1346)。
## 2026-10-14 — 校正每轮重算更快(待合入 staging)
- 校正每轮重算更快(2 小时窗口约快一倍以上),结果不变。一次要算的分钟数超过 241 会直接拒绝,不再占着计算空位排盘。Skill 版本不 bump(BUG-1323~1325、BUG-1345)。
## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging)
- 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。
- KP 按宫头到下一宫头判宫,报告写明口径。本命宫位表的判宫不变。
- 接口里兜底大运的一年改成和推运脚本一样的 365.25 天。
- 星历路径只核查,没改。Skill 版本不变(BUG-1318~1321;BUG-1322 仍在核查)。
## 2026-10-13 — 明说要做生时校正时,第一步就能打开(待合入 staging)
- 新对话第一句就说「帮我做生时校正 / 生时纠正 / 核对出生时间」,会直接打开校正,只留一句过渡,不先写一段解读。
+8
View File
@@ -31,3 +31,11 @@ JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=<independent-openssl-rand-base64-32-output>
# these server-side values through env_file; they are not NEXT_PUBLIC values.
PERSONAL_REPORT_ENABLED=true
PERSONAL_REPORT_DAILY_LIMIT=5
# Local Postgres adapter. Unset or on enables both. Exact off restores the
# previous per-statement sequence. Measured default is on (2026-10-14).
JYOTISHA_PG_METADATA_CACHE=on
JYOTISHA_PG_MERGED_SESSION_SETUP=on
# Ordinary session reads stay uncached unless this is exactly on. Password
# change, account deletion, and admin routes bypass the cache. maxAge is 300s.
#IDENTITY_SESSION_COOKIE_CACHE=off
+2
View File
@@ -194,6 +194,8 @@ SITE_ADDRESS=https://staging.jyotisha.chat
Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production uses the same architecture only after the reviewed migration and cutover. See `docs/operations/self-hosted-identity.md` for identity validation.
The web service already loads its env file, so these server-only switches do not need a Compose change. `JYOTISHA_PG_METADATA_CACHE` and `JYOTISHA_PG_MERGED_SESSION_SETUP` default to on when unset; set either to `off` to restore the previous database roundtrip sequence. `IDENTITY_SESSION_COOKIE_CACHE` defaults to off; set it to `on` to cache ordinary user-session reads for 300 seconds. Names and defaults are also in `deploy/.env.staging.identity.example`. Do not put secrets in these three values.
After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful push gate for the exact SHA and must use the staging workflow ref.
### First-deploy sequence
+140 -5
View File
@@ -15304,17 +15304,17 @@
## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具
- 状态:resolved(修复已推 staging;以门禁 publish 通过与 `/api/health` 部署版本为最终证据)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 首次发现 / 最近更新:2026-10-01 / 2026-10-14
- 影响面:Gitea `backend-quality-gate` 的 publish 作业(`deploy/railway-web.Dockerfile` 构建阶段 `npm run build`)。validate 通过、publish 失败,未部署,staging 停在上一版。
- 用户现象:盘型口径(BUG-1115~1117、1131)推送后 staging 不更新。
- 触发条件:`frontend/scripts/rectification-segment-persisted-replay.ts`(盘型口径研究的 PG 持久回放 harness)`import … from "../tests/helpers/postgres-fixture.ts"`。web 镜像只复制 `frontend/src`、`frontend/scripts` 等,不复制 `frontend/tests`;`frontend/tsconfig.json` 的 include 为 `**/*.ts`,`next build` 类型检查覆盖 scripts,于是 `TS2307 Cannot find module '../tests/helpers/postgres-fixture.ts'`。
- 根因:本地与验收机都有 `frontend/tests`,`tsc --noEmit` 与 `next build` 均通过,门禁 validate 也在完整检出上跑,没有任何一步按镜像的复制清单构建。
- 修复:harness 移到 `frontend/tests/research/rectification-segment-persisted-replay.ts`(相对路径随之调整),Python driver `scripts/research/varga_resolution_persisted_replay.py` 指向新路径;新增 `frontend/tests/runtime-sources-no-test-imports.test.ts`:`src/` 与 `scripts/` 不得 import / `new URL` 引用 `tests/`。
- 验证:按 `railway-web.Dockerfile` 的 COPY 清单从提交 `git archive` 出构建目录,修复前 `npm run build` 报上述 TS2307、exit 1,修复后 exit 0、`/` Static;守卫测试 2/2,且对旧 harness 文本判定为违规;移动后 harness 1 例 ±10 冒烟 `runner_exit=0`。
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。
- 相关记录:BUG-1115~1117、BUG-1131。
- 复发自:无
- 修复版本:`codex/staging-publish-fix-20261001`。
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。2026-10-14 这道守卫在合入前拦住了同一形状:issue #58 的往返对照脚本曾放在 `frontend/scripts/` 并导入测试夹具。脚本改放到 `frontend/tests/bench-local-pg-roundtrips.ts`,文件名不是 `.test.ts`,不会被全量测试收进去。业务代码没有改。原任务书里的旧路径保持不动。
- 相关记录:BUG-1115~1117、BUG-1131、BUG-1327。
- 复发自:无。2026-10-14 是同一形状,被守卫拦住,未进测试环境。
- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。
## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究)
@@ -17720,6 +17720,81 @@
- 复发自:BUG-1306。
- 修复版本:分支 `codex/rectification-step0-entry-20261013`,尚未合入 staging。
## BUG-1318 | 婚恋评级仍写成稳定或婚前咨询,配偶星分数被写成百分制
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `f891a2c1`。任务书 `docs/tasks/TASK-upstream-sync7-20261014.md` T1。
- 现象:D9 综合评级仍写「婚姻稳定」「强烈建议婚前咨询」。这份文字进全读导出包,模型能读到。配偶星证据行把 0 到 1 的指数写成「婚姻质量 x/100」。
- 触发条件:算出婚恋几何指数,或关系时机证据带出配偶星。
- 根因:同步第六轮按标题筛提交时,漏了 10-02 到 10-03 的这条措辞修正。分数行把 0 到 1 当成了百分制。
- 修复:五档改成证据档位,并写明这是结构指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。中文报告用中文档位,英文报告用英文档位。证据行改成「婚姻质量指数 x(0到1)」,不乘 100。
- 验证:`tests/test_d9_evidence_bands.py` 3 通过。公开盘中英文各一条快照。配偶星行不再出现 `/100`。
- 防复发:五档文字不得含「稳定、离婚、再婚、婚前咨询、几段」;快照锁住公开盘的两句。
- 相关记录:BUG-1183。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1319 | KP 落宫沿用本命中点判宫,传了宫头也不声明口径
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `0337b77f`。任务书 T2。
- 现象:KP 用的行星落宫和本命 Bhava Chalit 表一样,按相邻宫头的中点判宫。宫头传进 KP 分析时,结果不写明判宫口径。
- 触发条件:打开 KP。本命 Bhava Chalit 表不走这条。
- 根因:同步第六轮漏了夹在上游隐私提交里的判宫修正。本命表和 KP 共用了中点函数。
- 修复:KP 改成宫头到下一宫头。本命表默认仍是中点,宫位数字不变。传了宫头却不声明口径就拒绝;没传宫头记整宫代理。KP 报告写出判宫口径。
- 验证:`tests/test_kp_cusp_house_assignment.py` 3 通过。同一颗星在两种口径下落入不同宫;默认中点与改前一致。
- 防复发:未知口径直接报错;报告正文必须出现 House occupancy basis。
- 相关记录:BUG-1318。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1320 | 瑜伽角宫主和三方主的顺序随进程哈希变化
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `0337b77f`。任务书 T3。
- 现象:同一张盘的角宫主、三方主列表用了集合去重。集合顺序会跟着进程哈希变,瑜伽参与者顺序不稳定。
- 触发条件:规则表达式读取这两份宫主列表。
- 根因:去重用了不保证顺序的集合。同步第六轮漏了上游把它们改成排序的那一处。
- 修复:两处都改成先去重再按名字排序。
- 验证:`tests/test_yoga_lord_order_stable.py` 1 通过。哈希种子 0 和 1 两个进程算出的参与者顺序相同。
- 防复发:两个子进程的名单必须逐项相同,并且等于排好序的名单。
- 相关记录:BUG-1319。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1321 | 接口里的大运年长和推运脚本差了一点,起止会对不齐
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `4d543209`。任务书 T4。
- 现象:接口的兜底大运和「进入本运几年」用 365.25636 天为一年。同一条时间线的推运脚本用 365.25 天。年限一长,日期会差一天。
- 触发条件:走兜底大运时间线,或计算进入当前大运的年数。
- 根因:两处常量没有跟着推运脚本改。同步第六轮漏了上游 10-03 的这一处。
- 修复:这两处改成 365.25,与推运脚本相同。其它大运系统的年长不动。
- 验证:`tests/test_dasha_year_length_alignment.py` 2 通过。同一大运起止相差 0 天。158 年的旧常量会落到下一天,新常量不会。
- 防复发:兜底时间线必须和推运脚本的起止是同一天;进入本运的年数按 365.25 除。
- 相关记录:BUG-1320。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1322 | 星历路径被设成空字符串后,计算退回低精度星历
- 状态:investigating
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `e1909724` 点出的同类问题。任务书 T5,本轮只核查、不改代码。
- 现象:排盘和过境入口都把星历路径设成空字符串。本机随后计算太阳位置时,返回标记含低精度星历位,不含星历文件位。
- 触发条件:调用排盘或过境计算。本轮没有改这条路径。
- 根因:尚未在生产镜像里核实。本机仓库里的星历文件只在参考资料目录,不在计算进程会去找的路径上。空路径是进程全局的,后一次调用会盖掉前面设过的路径。
- 修复:未改。建议另开一单:镜像里放一份星历文件,路径显式设上,并断言返回标记不再是低精度星历。
- 验证:本机一次探针。生产镜像未打开,不能当作已确认。
- 防复发:未做。等修复单再加断言。
- 相关记录:BUG-1321。
- 复发自:无。
- 修复版本:未修复。
## BUG-1323 | 校正引导窗口按候选两两比较,窗口一长就平方变慢
- 状态:fixed-pending-verify(尚未合入 staging)
@@ -17765,6 +17840,51 @@
- 复发自:无。
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
## BUG-1326 | 个人报告工人空转时,取消监听一直留在进程上
- 状态:resolved(代码+回归测试),生产内存曲线待部署后观察
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
- 来源:issue #58。任务书 T1。
- 现象:报告工人长时间没有活干时,进程内存逐日上涨。取消这次工人时,之前每一次等待都还挂着一个取消监听。
- 触发条件:工人循环用进程级取消信号,两次领任务之间按间隔等待。等待到点之后监听不摘。
- 根因:等待用了「只触发一次」的取消监听。这个选项只在真的取消时摘掉监听。计时器自己到点时,监听还留在信号上。信号活得和进程一样长,监听就一直积着。
- 修复:计时器到点时先摘掉监听再结束等待;真的取消时清掉计时器并结束等待。已经取消的信号仍立刻返回。计时器仍不挡住进程退出。引导打分里同一写法的等待也改成到点摘监听。
- 验证:`frontend/tests/personal-report-worker-sleep-listener.test.ts` 4 通过。空转和抛错各 200 次后,取消前监听数是 0,取消后循环马上结束。修之前的同形等待在第 200 次时留下 199 个监听(断言 `199 !== 0`)。
- 防复发:上述测试锁住「200 次之后监听数为 0」。
- 相关记录:BUG-1328。
- 复发自:无。
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
## BUG-1327 | 本地数据库每一问都重复查表结构和设置身份
- 状态:resolved(代码+回归测试)。2026-10-14 对照通过后,两个开关默认打开。
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
- 来源:issue #58。任务书 T2。
- 现象:同一次页面操作里,本地数据库要来回很多趟。表有哪些列、主键是什么、函数怎么调用,每次都现查。设置身份也要单独走一趟。
- 触发条件:网页通过本地 Postgres 适配层读写真业务表。
- 根因:元数据没有进程内缓存。设置角色和两条身份参数各是一条语句。事务的开始和提交仍各走一趟,这是故意留下的。
- 修复:`JYOTISHA_PG_METADATA_CACHE` 缓存列类型、主键和函数签名,300 秒过期;缓存键带连接串,列和函数再带角色。不缓存空结果,也不缓存「函数不存在」。业务语句报列、表、函数或类型不匹配时,清掉该连接串的全部元数据并照旧返回错误,不自动重试。关掉某个连接或全部连接时,对应元数据一起清掉。`JYOTISHA_PG_MERGED_SESSION_SETUP` 把设置角色和两条身份合成一条参数化语句。角色仍只允许既有的两个业务角色。开始和提交不并进这条语句。两个开关都写成 `off` 时,语句顺序与改前一致。未设置或 `on` 为打开。只读且没有身份时省掉事务的做法已否决:那会把角色留在连接上,还回池子后可能给下一个请求。
- 验证:不连数据库的往返单测锁住关闭时 6 条、打开且命中缓存时 4 条。真库测试确认事务内身份生效,连接归还后再借出时身份已空。对照脚本第二次(带三组原始数)整段往返 49 降到 32,耗时中位数 75.84 毫秒降到 36.48 毫秒,返回内容逐字节一致。第一次同样是 49 降到 32。`npm run test:db` 两轮都是 81 项、通过 68、失败 13、取消 0。失败名称与 Windows 已知清单逐条相同,没有多出来的。
- 防复发:往返单测锁住关闭时的语句顺序,以及角色、参数名、报错、过期和关闭连接都会让缓存失效。
- 相关记录:BUG-166、BUG-578、BUG-596、BUG-926。
- 复发自:无。
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
## BUG-1328 | 三张只进不出的内存表会随运行变大
- 状态:resolved
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
- 来源:issue #58。任务书 T3。
- 现象:校正交付去重、重算尝试和请求限流各有一张进程内的表。旧条目读的时候已经不算数,却不删除,进程活得越久表越大。
- 触发条件:长时间有人做校正或调用限流接口。
- 根因:写入只增加,没有过期清理,也没有条数上限。
- 修复:交付记录超过 60 秒就删,最多留 1 万条。重算记录改存时间,超过 6 小时就删,最多留 5 千条。限流按本进程见过的最大窗口定期删掉已经出窗的键;1 小时窗口的键不会在 61 秒时被删。限流的对外类型不变,调用方仍可传入自己的表。
- 验证:交付、重算、限流的新测试通过。窗口内的允许或拒绝与原来的断言一致,没有改旧断言。同一道校正题在 6 小时内仍不会重算第二次;超过 6 小时或被挤出后可能再算一次,这比「进程一重启就可能再算」更收敛。
- 防复发:硬上限和过期都有测试。1 小时窗口的键在短窗口清理后必须还在。
- 相关记录:BUG-1326。
- 复发自:无。
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
## BUG-1345 | 打分接口没有候选数上限,超宽窗口会占着重计算槽
- 状态:fixed-pending-verify(尚未合入 staging)
@@ -17780,3 +17900,18 @@
- 复发自:无。
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
## BUG-1346 | 每次带登录的请求都重新读一次会话
- 状态:resolved(代码+回归测试)。开关默认关闭:查库次数已下降,真实身份库的耗时对照还没做,未达到默认打开的门槛。
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #58。产品负责人 2026-10-14 决定可以开。任务书 T6。
- 现象:每个已登录请求都到身份库读一次会话。
- 触发条件:用户端 Better Auth 配置没有会话读缓存。后台没有另一份登录配置。
- 根因:用户端会话配置未启用 `cookieCache`。
- 修复:只有环境变量 `IDENTITY_SESSION_COOKIE_CACHE` 精确等于 `on` 时,用户端会话才缓存 300 秒。未设置或其它值与现在一样,每次读库。改密码、注销账户、以及全部后台入口每次都跳过缓存。改邮箱、两步验证和后台插件自带的敏感入口本来就会重新读库,这次没有改它们。咨询里的注销检查和后台的封禁检查仍每次查库。会话能活多久、改密码后是否作废、Cookie 的安全属性、表结构都没改。代价:缓存打开后,一台设备改了密码,其它设备上的旧登录最多还能再浏览 300 秒,这段时间做不了改密码和注销。
- 验证:`frontend/tests/identity-session-cookie-cache.test.ts` 4 通过。缓存打开时连续 3 次普通读取只查会话 1 次,敏感读取每次都查;缓存关闭时 3 次都查。会话被清掉后,敏感读取立即无效,普通读取在 300 秒内仍有效,超过后无效。
- 防复发:上述测试锁住开关关闭时不出现缓存配置,以及 300 秒后普通读取失效。
- 相关记录:BUG-1327。
- 复发自:无。
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
@@ -0,0 +1,73 @@
# 进度 · 服务器内存与数据库往返(2026-10-13)
issue #58。分支 `codex/node-runtime-resources-20261013`。
2026-10-14 按修复单把往返对照脚本从 `frontend/scripts/bench-local-pg-roundtrips.ts` 挪到 `frontend/tests/bench-local-pg-roundtrips.ts`。文件名不是 `.test.ts`,全量测试不会自动收进去。业务代码没有改,对照数字没有重跑。原任务书里的旧路径保持不动。
任务书基线写的是 `77057a819`,计划评论里的基线是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。本分支实际从当时的 `origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f` 拉出。没有因此停下来问产品,改动仍按任务书做。
## 做了什么
- 报告工人的等待:计时器到点就摘掉取消监听。引导打分里同一段等待也摘掉。空转不再往进程上堆监听。
- 本地数据库:列、主键、函数签名缓存 300 秒;设置角色和两条身份合成一条语句。开始和提交仍分开。两个开关默认打开,写成 `off` 时语句顺序与改前一致。
- 三张内存表:交付记录超过 60 秒删除,最多 1 万条;重算记录超过 6 小时删除,最多 5 千条;限流按见过的最大窗口清理已出窗的键。
- 登录读缓存:只有 `IDENTITY_SESSION_COOKIE_CACHE=on` 才缓存 300 秒。默认关闭。
## 不做的
只读、又没有身份时省掉事务:不做。那种做法只能用会话级角色,归还连接前再清。往返数不降,而且一旦没清掉,下一次请求会带着上一次的角色。违反「连接还回池子时不能带着角色或身份」。
`begin` 没有并进一条多语句查询。
## 语义变化
同一道校正题、同一份证据,6 小时后或因为超过 5 千条被挤出后,可能再重算一次。这比「进程重启后才会再算」更收敛。60 秒内的交付去重、窗口内的限流允许或拒绝,都没有改。
## 基线失败
修之前的等待写法,在第 200 次空转时取消信号上还有 199 个监听。同形脚本输出:`listenersAt200=199`,`assertion failed: 199 !== 0`。修之后的测试断言是 0,并且通过。
## 登录读取的调用点
敏感,每次跳过缓存:
- 改密码:`frontend/src/app/api/account/password/route.ts`
- 注销账户:`frontend/src/app/api/account/deletion/route.ts`
- 全部后台入口:`frontend/src/lib/admin/auth.ts`
其余业务接口走 `createServerSupabaseClient()`,不带跳过参数。开关打开后,这些普通读取可以走 300 秒缓存。
改邮箱、两步验证、后台插件自带的敏感入口本来就会重新读库,这次没改。咨询里检查「正在注销」的那次调用,以及后台查封禁的语句,都没有改成缓存。
## 对照结论
详见 `docs/testing/node-runtime-resources-ab-20261013.md`。两个数据库开关一起:往返 49 → 32(低于 70%),耗时中位数 75.84 → 36.48 毫秒(下降 51.9%),返回内容一致。`npm run test:db` 开关关、开关开各一轮,都是 81 项、通过 68、失败 13、取消 0。失败名称与 `tools/agent-loop/windows-known-failures.txt` 的 13 条相同,没有多出来的。运行器因此退出码是 1,这 13 条是 Windows 上原有的。两个数据库开关默认打开。
登录读缓存的查库次数在测试里从 3 降到 1,但没有对真实身份库做耗时对照,默认保持关闭。要打开,把 `IDENTITY_SESSION_COOKIE_CACHE` 设成 `on`。
网页容器已经用 env 文件读环境变量,所以这三个名字不必改 Compose。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning,未改 |
| 本单测试 | `tsx --test` 任务书点名的文件加登录缓存测试 | 134 通过,0 失败,0 取消 |
| 真库身份 | `database-local-postgres-session-setup.test.ts` | 1 通过。事务内角色和身份生效,连接归还后再借出时身份已空 |
| 数据库套件 | `npm run test:db`,两开关先关后开 | 各 81 / 通过 68 / 失败 13 / 取消 0,名称与已知清单一致 |
| 往返对照 | `tests/bench-local-pg-roundtrips.ts`(按修复单从 `scripts/` 挪来) | 见上,`payloadMismatch` false。数字未重跑 |
没有跑前端全量 `npm test`、Python 快速门、`next build`。生产内存曲线要等部署后看。真机两台设备改密码的步骤在 `docs/testing/node-runtime-resources-20261013.md`,登录缓存默认关着,那份清单的第 2 步要先把开关打开才看得到。
## 2026-10-14 修复单
按 `docs/tasks/TASK-node-runtime-resources-fix-20261014.md`:先合并最新 `origin/staging`,再把对照脚本挪进测试目录。业务代码没有改。对照数字没有重跑。
和上一版 `4c3485bc` 比,网页业务目录里多出来的只有合并进来的校正提速改动(`frontend/src/lib/rectification-agentic/v9/engine-client.ts`),不是这单写的。这单自己的提交不碰 `frontend/src`。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning,未改 |
| 镜像导入守卫 | `tsx --test tests/runtime-sources-no-test-imports.test.ts` | 2 通过,0 失败,0 取消 |
@@ -0,0 +1,41 @@
# PROGRESS · 上游同步第七轮(2026-10-14)
- 分支:`codex/upstream-sync7-20261014`
- 基线:任务书写的是 `77057a81`。开工时 `origin/staging` 已到 `99ef1bbd`,本分支从这里建。
- 结论:T1 到 T4 已做完并推这个分支。T5 只核查,代码没改。星历在这台电脑上会退回低精度,生产镜像没打开,记 BUG-1322,状态是核查中。
## 做了什么
| 项 | 结果 |
| --- | --- |
| T1 婚恋断语 | 五档改成证据档位,中文给中文报告,英文给英文报告。写明不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。配偶星那一行改成 0 到 1 的指数,不再写 `/100`。BUG-1318,上游 `f891a2c1`。 |
| T2 KP 判宫 | KP 按宫头到下一宫头。本命宫位表仍按中点,默认宫位不变。传了宫头却不说口径就拒绝;没传记整宫代理。报告多一行判宫口径。BUG-1319,上游 `0337b77f`。 |
| T3 瑜伽排序 | 角宫主、三方主先去重再按名字排序。BUG-1320,上游 `0337b77f`。 |
| T4 大运年长 | 接口里两处 365.25636 改成 365.25,和推运脚本同一天。别的大运系统没动。BUG-1321,上游 `4d543209`。 |
| T5 星历 | 见下。不改代码。BUG-1322,核查中。 |
## 检查
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 婚恋档位 | `pytest tests/test_d9_evidence_bands.py` | 3 通过 |
| KP 判宫 | `pytest tests/test_kp_cusp_house_assignment.py` | 3 通过 |
| 本命宫位表 | `pytest tests/test_bhava_chalit.py` | 通过(与上面一批共 20 通过、0 失败) |
| 几何指数旧测试 | `pytest tests/test_marriage_counting_geometry_only.py` | 通过(同上) |
| 瑜伽顺序 | `pytest tests/test_yoga_lord_order_stable.py` | 1 通过 |
| 大运年长 | `pytest tests/test_dasha_year_length_alignment.py` | 2 通过 |
| 接口增长合同 | `pytest tests/test_api_server_growth_contract.py` | 通过 |
| KP 报告标题回归 | `pytest tests/test_report_longform_parity.py` 里 KP 那一条 | 通过 |
| 快速门、前端全量 | 未跑 | 按执行分工,留给验收方 |
| 数据库 | 未跑 | 没动表 |
## T5 星历核查
本机把路径设成空字符串后,算太阳位置的返回标记带低精度星历,不带星历文件。仓库里的星历文件在参考资料目录,计算时用不到。排盘和过境两处都设成同一个空路径,所以这两处彼此一致,但都会退回低精度。路径是整个进程共用的,谁后设谁生效。
生产镜像这台电脑打不开,不能说线上也是这样。健康检查只看星历库能不能导入,看不出用的是哪一套。建议另开一单再改。
## 偏离
- 关系时机里还有一处用 65 和 45 去比这个 0 到 1 的指数。任务书只允许改配偶星那一行文字,所以没动。它现在加不上那一档分数。建议另开小单。
- 生时校正打分文件没有改。
+4 -2
View File
@@ -331,7 +331,7 @@
| `TASK-rectification-probe-supply-research-20260913.md` | — | 研究单:六题后引擎在剩余候选上再出带年月题的四种放宽规则,20 例公开 AA 数据离线量收益,有收益才立实现单 | 已合入(离线测量,不改线上) | `b063c668` |
| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` |
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
| `TASK-upstream-sync7-20261014.md` | — | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待执行 | — |
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
| `TASK-beta-main-path-20261014.md` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — |
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
@@ -468,8 +468,10 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-rect-scoring-perf-20261013.md` | `PROGRESS-rect-scoring-perf-20261013.md` | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待验收 | `codex/rect-scoring-perf-20261013` |
| `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` |
| `TASK-node-runtime-resources-20261013.md` | `PROGRESS-node-runtime-resources-20261013.md` | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待验收 | `codex/node-runtime-resources-20261013` |
| `TASK-node-runtime-resources-fix-20261014.md` | `PROGRESS-node-runtime-resources-20261013.md` | **#58 第 1 次打回**:`4c3485bc` 其余全过;压测脚本放在 `frontend/scripts/` 却从 `frontend/tests/` 导入,镜像构建会失败(BUG-1140 形状,任务书指定的路径有误)。挪到 `frontend/tests/` 下非 `.test` 文件名 | 待验收 | `codex/node-runtime-resources-20261013` |
| `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` |
| `TASK-home-sidebar-rerender-fix-20261014.md` | — | **#59 第 1 次打回**:`63509431` 其余全过;`chat-navigation-a11y-contract` 按源码文本断言 `onOpenBilling` 旧写法未跟改(§7.8) | 待领取 | `codex/home-sidebar-rerender-20261013` |
| `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` |
| `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
@@ -0,0 +1,36 @@
# TASK · 侧栏重渲染修复单(issue #59,第 1 次打回)
- 日期:2026-10-14
- 原任务书:`docs/tasks/TASK-home-sidebar-rerender-20261013.md`
- 被验 SHA:`63509431529d212c7847371e7717660d823b40e6`(`codex/home-sidebar-rerender-20261013`)
- 执行方:Grok,同一分支续做(落后 staging 时用 merge,不改写已推历史)
- BUG 编号:沿用 BUG-1329
## 1. 验收结论:一项未通过,其余全部通过
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 前端全量 `npm test`(与 staging 基线逐名比对) | **未通过:新增 1 条失败** | `frontend/tests/chat-navigation-a11y-contract.test.ts`「in-app destinations navigate client-side so the chat survives the round trip」:`The input did not match the regular expression /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/` |
| T1 稳定包装 + 比较后发布 | 通过 | 逐行看过:`latestRef` 在无依赖 effect 里写,发布 effect 在其后;未冻结首次闭包;`HomeProbe` 的故意不稳定输入未改 |
| T2 `visibleSessions` 记忆化 | 通过 | — |
| T3 `SidebarProvider` context 记忆化 | 通过 | — |
| 改既有断言 | 通过 | `session-list-lifecycle` 三栏齐全,且加强了(再改一版后仍调到最新) |
| tsc / lint | 通过 | 0 错 / 0 error |
**根因**:`use-home-shell-registration.ts` 把 `onOpenBilling` 从 `() => openAccountDialog("billing", { source: "account-menu" })` 改成了 `() => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); }`,而 `chat-navigation-a11y-contract.test.ts` 第 42 行按源码文本断言旧写法(AGENTS.md §7.8:改写语句前要 grep 按源码文本断言的合同测试,同一轮跟着改)。
## 2. 修法要求
1. 只改 `frontend/tests/chat-navigation-a11y-contract.test.ts` 第 42 行这条断言,按 §7.3 写「原值 / 新值 / 原因」三栏。新正则要**仍然锁住意图**:账户菜单里的「充值」打开 billing 且 `source: "account-menu"`。例如匹配 `onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}`;不得放宽成只查 `"billing"`。
2. 再 grep 一遍:`grep -rn "onOpenProfile\|onOpenGeneral\|onOpenLogout\|onNewChat\|onSelectSession\|onLoadMore" frontend/tests`,有按旧写法断言源码文本的同样处理(本次全量里只红了这一条,但要确认)。
3. 业务代码不改。先把最新 `origin/staging` 合并进分支(用 merge)。
4. PROGRESS 末尾记一行。
## 3. 验收标准
- 前端全量 `npm test` 失败清单与 staging 基线逐名相同;
- `git diff 63509431..<新 SHA> -- frontend/src` 为空。
## 4. 让步顺序
无。第 1 次打回;再打回会停下等产品负责人。
@@ -0,0 +1,39 @@
# TASK · 服务器内存与数据库往返修复单(issue #58,第 1 次打回)
- 日期:2026-10-14
- 原任务书:`docs/tasks/TASK-node-runtime-resources-20261013.md`
- 被验 SHA:`4c3485bc2352e8f9de097d81d9979092ee2bb280`(`codex/node-runtime-resources-20261013`)
- 执行方:Grok,同一分支续做(落后 staging 时用 merge,不改写已推历史)
- BUG 编号:沿用 BUG-1326~1328、1346
## 1. 验收结论:一项未通过,其余全部通过
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 前端全量 `npm test`(与 staging 基线逐名比对) | **未通过:新增 1 条真实失败** | `runtime-sources-no-test-imports.test.ts`「files copied into the web image never import from frontend/tests」:`actual: ['scripts/bench-local-pg-roundtrips.ts']` |
| 同上,另一条新增 | 环境缺口(不算未通过) | `database-local-postgres-session-setup.test.ts` 报 `spawnSync docker ENOENT`,本机无 Docker;以 Grok 的 test:db 回报为准 |
| T1 报告 worker 摘监听 / T1b | 通过 | 代码与新测试逐行看过 |
| T2 元数据缓存 + 合并身份设置 | 通过 | 角色仍只来自构造函数白名单;只缓存表结构;`off` 走原语句顺序;A/B 记录 49→32 往返、中位数降 51.9%、逐字节一致 |
| T3 三张内存表 | 通过 | — |
| T6 登录读缓存 | 通过(默认关,理由成立) | 改密码 / 注销 / 后台三处已强制绕过;默认关是因为没有身份库真实耗时对照,符合原任务书门槛 |
| tsc / lint / next build | 通过 | 0 错 / 0 error / `/` 仍 Static |
| 越界 / 隐私 | 通过 | — |
**根因**:`frontend/scripts/bench-local-pg-roundtrips.ts` 第 16 行 `import { startPostgresFixture } from "../tests/helpers/postgres-fixture.ts";`。web 镜像只复制 `frontend/src` 与 `frontend/scripts`,不复制 `frontend/tests`,而 `next build` 会类型检查 `frontend/` 下所有 `.ts`——推上 staging 后镜像构建会失败(BUG-1140,同一形状)。**这条路径是原任务书 T4 指定的,错在任务书,不是执行偏差。**
## 2. 修法要求
1. 把脚本挪到 `frontend/tests/bench-local-pg-roundtrips.ts`(文件名**不得**以 `.test.ts` 结尾,避免被 `npm test` 自动收进去),导入改成 `./helpers/postgres-fixture.ts`;脚本头注释里的运行命令改成 `npx tsx tests/bench-local-pg-roundtrips.ts`。
2. 同步改引用:`docs/tasks/PROGRESS-node-runtime-resources-20261013.md`、`docs/testing/node-runtime-resources-ab-20261013.md`。原任务书不改(历史记录),在 PROGRESS 里写一行「按修复单挪位置」。
3. 不改任何业务代码;A/B 数字不用重跑。
4. 先把最新 `origin/staging` 合并进分支(用 merge)。
## 3. 验收标准
- `frontend/tests/runtime-sources-no-test-imports.test.ts` 通过;
- 前端全量 `npm test` 的失败清单只剩 staging 基线那 24 条 + 本单新增的 Docker 测试(无 Docker 时);
- `git diff 4c3485bc2..<新 SHA> -- frontend/src` 为空(业务代码一字未动)。
## 4. 让步顺序
无。这是第 1 次打回;再打回会停下等产品负责人。
@@ -0,0 +1,13 @@
# 真机清单 · 登录缓存与长时间运行(2026-10-13)
对应 BUG-1326、BUG-1346。在 staging 上用两个浏览器(或一台电脑、一台手机)登录同一个测试账号。不要把账号、口令或页面原文记进仓库。
本轮代码里,登录读缓存默认关闭。要做下面第 2–4 步,服务器环境变量 `IDENTITY_SESSION_COOKIE_CACHE` 必须是 `on`。未设置或其它值时,改密码后另一台设备会马上要求重新登录,第 2 步不会出现「还能浏览大约 5 分钟」。
| 步 | 怎么做 | 通过标准 |
| --- | --- | --- |
| 1 | 两台设备都登录,各自打开首页并翻一两页 | 都能正常看到自己的会话,不要求反复登录 |
| 2 | 设备 A 修改密码。5 分钟内用设备 B 只浏览首页、会话列表和星盘,不要改密码、不要注销 | 设备 B 在这 5 分钟里仍能浏览 |
| 3 | 仍在这 5 分钟内,用设备 B 尝试修改密码或申请注销账户 | 被拒绝,不能拿旧登录做这两件事 |
| 4 | 等到改密码已超过 5 分钟,再用设备 B 刷新 | 被要求重新登录 |
| 5 | 用新密码在设备 B 重新登录,再浏览一次 | 能进入,会话列表还在 |
@@ -0,0 +1,21 @@
# 本地数据库往返对照(2026-10-14)
环境:Windows,Docker 29.8.0,Node v22.23.2。脚本 `frontend/tests/bench-local-pg-roundtrips.ts`(2026-10-14 按修复单从 `frontend/scripts/` 挪来,数字未重跑)。库是测试用的临时 Postgres,数据是虚构用户。每组 50 遍,重复 3 组,先各热身 1 遍。往返数是每组平均值的中位数。耗时中位数是三组中位数的中位数。p95 是全部 150 遍的单遍耗时。
判定用第二次运行(输出里带三组原始数)。第一次运行同样是 49 降到 32,中位数 89.51 毫秒降到 32.70 毫秒,内容一致,方向相同。
| 模式 | 开关 | 往返 | 中位数(毫秒) | p95(毫秒) | 三组(往返 / 中位数毫秒) |
| --- | --- | --- | --- | --- | --- |
| A | 两个都关 | 49 | 75.84 | 88.25 | 49 / 82.51;49 / 75.84;49 / 73.81 |
| B | 两个都开 | 32 | 36.48 | 39.47 | 32 / 36.66;32 / 36.48;32 / 35.71 |
| B1 | 只开元数据缓存 | 40 | 40.54 | 44.37 | 40 / 41.56;40 / 40.54;40 / 39.86 |
| B2 | 只开合并身份设置 | 41 | 67.83 | 72.75 | 41 / 68.46;41 / 66.47;41 / 67.83 |
- 往返:32 ≤ 49 的 70%(34.3)。通过。
- 耗时:下降 (75.84 − 36.48) / 75.84 = 51.9%,不低于 15%。通过。
- 四种模式的业务返回逐字节一致(`payloadMismatch` 为 false)。通过。
- 只开其中一个时,往返都还高于 70% 那一刀;只合并身份设置时,这一次耗时只降了约 10.6%。两个一起才过门槛,所以两个都默认打开。写成 `off` 仍走改前的语句顺序。
登录读缓存没有放进这次真库计时。内存里的登录测试看到普通读取的查库次数从 3 降到 1,但那不是身份库的耗时对照,所以 `IDENTITY_SESSION_COOKIE_CACHE` 保持默认关闭。
`npm run test:db` 两轮都是 81 项、通过 68、失败 13、取消 0。失败名称与 `tools/agent-loop/windows-known-failures.txt` 的 13 条逐条相同,没有多出来的。因此两个数据库开关默认打开。
@@ -21,7 +21,7 @@ export const dynamic = "force-dynamic";
*/
async function currentUserId(): Promise<string | null> {
const supabase = await createServerSupabaseClient();
const supabase = await createServerSupabaseClient({ disableCookieCache: true });
const { data: { user }, error } = await supabase.auth.getUser();
return error || !user ? null : user.id;
}
@@ -14,7 +14,10 @@ async function userSession(request: Request) {
return null;
}
const services = getIdentityAuthServices();
const session = await services.user.api.getSession({ headers: request.headers });
const session = await services.user.api.getSession({
headers: request.headers,
query: { disableCookieCache: true },
});
return session ? { services, session } : null;
}
+1
View File
@@ -60,6 +60,7 @@ export async function requirePermission(
requireIdentityServerSession(
getIdentityAuthServices().user.api,
adminHeaders,
{ disableCookieCache: true },
),
loadAdminSession,
});
@@ -34,10 +34,14 @@ export async function runBirthTimeScoringPoll(input: PollInput): Promise<PollRes
export function scoringPollDelay(attempt: number, signal: AbortSignal): Promise<void> {
const duration = Math.min(400 * (2 ** attempt), 3_200);
return new Promise((resolve) => {
const timer = window.setTimeout(resolve, duration);
signal.addEventListener("abort", () => {
const onAbort = () => {
window.clearTimeout(timer);
resolve();
}, { once: true });
};
const timer = window.setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, duration);
signal.addEventListener("abort", onAbort, { once: true });
});
}
+201 -11
View File
@@ -169,7 +169,18 @@ export function upsertConflictColumns(options?: { onConflict?: string }): string
.filter(Boolean);
}
async function primaryKeyColumns(client: PoolClient, table: string): Promise<string[]> {
async function primaryKeyColumns(
client: PoolClient,
table: string,
scope: MetadataScope,
): Promise<string[]> {
const cacheKey = metadataCacheEnabled()
? `${scope.connectionString}|${table}`
: null;
if (cacheKey) {
const cached = readTimed(metadataCache().primaryKeys, cacheKey);
if (cached) return [...cached];
}
identifier(table);
const result = await client.query<{ column_name: string }>(
`
@@ -182,7 +193,9 @@ async function primaryKeyColumns(client: PoolClient, table: string): Promise<str
`,
[table],
);
return result.rows.map((row) => row.column_name);
const columns = result.rows.map((row) => row.column_name);
if (cacheKey && columns.length > 0) writeTimed(metadataCache().primaryKeys, cacheKey, [...columns]);
return columns;
}
function queryError(error: unknown): QueryError {
@@ -220,7 +233,115 @@ function localDataPool(connectionString: string): Pool {
return pool;
}
const METADATA_TTL_MS = 300_000;
const METADATA_INVALIDATING_CODES = new Set(["42703", "42P01", "42883", "42804"]);
type MetadataScope = Readonly<{
connectionString: string;
role: LocalDatabaseRole | null;
}>;
type TimedCacheEntry<T> = Readonly<{ value: T; storedAt: number }>;
type LocalPostgresMetadataCache = {
columns: Map<string, TimedCacheEntry<Map<string, string>>>;
primaryKeys: Map<string, TimedCacheEntry<string[]>>;
functions: Map<string, TimedCacheEntry<FunctionMetadata>>;
};
const metadataGlobal = globalThis as typeof globalThis & {
jyotishaLocalPostgresMetadata?: LocalPostgresMetadataCache;
jyotishaLocalPostgresMetadataNow?: () => number;
};
function metadataCache(): LocalPostgresMetadataCache {
metadataGlobal.jyotishaLocalPostgresMetadata ??= {
columns: new Map(),
primaryKeys: new Map(),
functions: new Map(),
};
return metadataGlobal.jyotishaLocalPostgresMetadata;
}
function metadataNow(): number {
return metadataGlobal.jyotishaLocalPostgresMetadataNow?.() ?? Date.now();
}
function metadataCacheEnabled(): boolean {
// 2026-10-14 A/B passed. Unset means on. Exact "off" keeps the old query sequence.
return process.env.JYOTISHA_PG_METADATA_CACHE !== "off";
}
function mergedSessionSetupEnabled(): boolean {
return process.env.JYOTISHA_PG_MERGED_SESSION_SETUP !== "off";
}
function roleCacheKey(role: LocalDatabaseRole | null): string {
return role ?? "owner";
}
function readTimed<T>(
store: Map<string, TimedCacheEntry<T>>,
key: string,
): T | undefined {
const entry = store.get(key);
if (!entry) return undefined;
if (metadataNow() - entry.storedAt >= METADATA_TTL_MS) {
store.delete(key);
return undefined;
}
return entry.value;
}
function writeTimed<T>(
store: Map<string, TimedCacheEntry<T>>,
key: string,
value: T,
): void {
store.set(key, { value, storedAt: metadataNow() });
}
function dropMetadataForConnection(connectionString: string): void {
const cache = metadataGlobal.jyotishaLocalPostgresMetadata;
if (!cache) return;
const prefix = `${connectionString}|`;
for (const store of [cache.columns, cache.primaryKeys, cache.functions]) {
for (const key of store.keys()) {
if (key.startsWith(prefix)) store.delete(key);
}
}
}
function postgresErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object") return undefined;
const code = (error as { code?: unknown }).code;
return typeof code === "string" ? code : undefined;
}
export function clearLocalPostgresMetadataCacheForTests(): void {
metadataGlobal.jyotishaLocalPostgresMetadata = {
columns: new Map(),
primaryKeys: new Map(),
functions: new Map(),
};
}
export function setLocalPostgresMetadataClockForTests(now: (() => number) | null): void {
if (now) metadataGlobal.jyotishaLocalPostgresMetadataNow = now;
else delete metadataGlobal.jyotishaLocalPostgresMetadataNow;
}
export function installLocalPostgresPoolForTests(connectionString: string, pool: Pool): void {
poolGlobal.jyotishaLocalDataPools ??= new Map();
poolGlobal.jyotishaLocalDataPools.set(connectionString, pool);
}
export function localPostgresPoolForTests(connectionString: string): Pool {
return localDataPool(connectionString);
}
export async function closeLocalPostgresDataPool(connectionString: string): Promise<void> {
dropMetadataForConnection(connectionString);
const pools = poolGlobal.jyotishaLocalDataPools;
const pool = pools?.get(connectionString);
if (!pool) return;
@@ -229,6 +350,11 @@ export async function closeLocalPostgresDataPool(connectionString: string): Prom
}
export async function closeLocalPostgresDataPools(): Promise<void> {
metadataGlobal.jyotishaLocalPostgresMetadata = {
columns: new Map(),
primaryKeys: new Map(),
functions: new Map(),
};
const pools = poolGlobal.jyotishaLocalDataPools;
poolGlobal.jyotishaLocalDataPools = new Map();
if (!pools) return;
@@ -237,6 +363,7 @@ export async function closeLocalPostgresDataPools(): Promise<void> {
async function inBusinessTransaction<T>(
pool: Pool,
connectionString: string,
identity: LocalIdentity,
role: LocalDatabaseRole | null,
run: (client: PoolClient) => Promise<T>,
@@ -244,15 +371,32 @@ async function inBusinessTransaction<T>(
const client = await pool.connect();
try {
await client.query("begin");
if (role !== null) await client.query(`set local role ${role}`);
await client.query(
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
[identity?.id ?? "", identity?.email ?? ""],
);
if (mergedSessionSetupEnabled()) {
if (role !== null) {
await client.query(
"select set_config('role', $1, true), set_config('request.jwt.claim.sub', $2, true), set_config('request.jwt.claim.email', $3, true)",
[role, identity?.id ?? "", identity?.email ?? ""],
);
} else {
await client.query(
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
[identity?.id ?? "", identity?.email ?? ""],
);
}
} else {
if (role !== null) await client.query(`set local role ${role}`);
await client.query(
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
[identity?.id ?? "", identity?.email ?? ""],
);
}
const result = await run(client);
await client.query("commit");
return result;
} catch (error) {
if (METADATA_INVALIDATING_CODES.has(postgresErrorCode(error) ?? "")) {
dropMetadataForConnection(connectionString);
}
await client.query("rollback").catch(() => undefined);
throw error;
} finally {
@@ -263,7 +407,15 @@ async function inBusinessTransaction<T>(
async function columnTypes(
client: PoolClient,
table: string,
scope: MetadataScope,
): Promise<Map<string, string>> {
const cacheKey = metadataCacheEnabled()
? `${scope.connectionString}|${roleCacheKey(scope.role)}|${table}`
: null;
if (cacheKey) {
const cached = readTimed(metadataCache().columns, cacheKey);
if (cached) return new Map(cached);
}
const result = await client.query<{ column_name: string; udt_name: string }>(
`
select column_name, udt_name
@@ -272,7 +424,9 @@ async function columnTypes(
`,
[table],
);
return new Map(result.rows.map((row) => [row.column_name, row.udt_name]));
const types = new Map(result.rows.map((row) => [row.column_name, row.udt_name]));
if (cacheKey && types.size > 0) writeTimed(metadataCache().columns, cacheKey, new Map(types));
return types;
}
function databaseValue(type: string | undefined, value: unknown): unknown {
@@ -314,6 +468,7 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
constructor(
private readonly pool: Pool,
private readonly connectionString: string,
private readonly identity: LocalIdentity,
private readonly role: LocalDatabaseRole | null,
private readonly table: string,
@@ -516,10 +671,14 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
try {
return await inBusinessTransaction(
this.pool,
this.connectionString,
this.identity,
this.role,
async (client) => {
const types = await columnTypes(client, this.table);
const types = await columnTypes(client, this.table, {
connectionString: this.connectionString,
role: this.role,
});
const parameters: unknown[] = [];
let sql: string;
@@ -563,7 +722,10 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
if (this.mutation.kind === "upsert") {
let conflict = this.mutation.conflict;
if (conflict.length === 0) {
conflict = await primaryKeyColumns(client, this.table);
conflict = await primaryKeyColumns(client, this.table, {
connectionString: this.connectionString,
role: this.role,
});
}
if (conflict.length === 0) {
throw new Error("upsert requires a conflict target");
@@ -648,7 +810,21 @@ async function functionMetadata(
client: PoolClient,
functionName: string,
argumentNames: readonly string[],
scope: MetadataScope,
): Promise<FunctionMetadata> {
const cacheKey = metadataCacheEnabled()
? `${scope.connectionString}|${roleCacheKey(scope.role)}|${functionName}|${[...argumentNames].sort().join(",")}`
: null;
if (cacheKey) {
const cached = readTimed(metadataCache().functions, cacheKey);
if (cached) {
return {
...cached,
argument_names: cached.argument_names ? [...cached.argument_names] : null,
argument_types: [...cached.argument_types],
};
}
}
const result = await client.query<FunctionMetadata>(
`
select
@@ -671,6 +847,13 @@ async function functionMetadata(
);
const metadata = result.rows[0];
if (!metadata) throw new Error("database function not found");
if (cacheKey) {
writeTimed(metadataCache().functions, cacheKey, {
...metadata,
argument_names: metadata.argument_names ? [...metadata.argument_names] : null,
argument_types: [...metadata.argument_types],
});
}
return metadata;
}
@@ -691,12 +874,14 @@ export class LocalPostgresDataClient {
}>;
private readonly pool: Pool;
private readonly connectionString: string;
constructor(
connectionString: string,
private readonly identity: LocalIdentity,
private readonly role: LocalDatabaseRole | null,
) {
this.connectionString = connectionString;
if (role !== null && role !== "authenticated" && role !== "service_role") {
throw new Error("unsupported database role");
}
@@ -709,6 +894,7 @@ export class LocalPostgresDataClient {
from(table: string) {
return new LocalPostgresQueryBuilder(
this.pool,
this.connectionString,
this.identity,
this.role,
table,
@@ -723,12 +909,16 @@ export class LocalPostgresDataClient {
identifier(functionName);
return await inBusinessTransaction(
this.pool,
this.connectionString,
this.identity,
this.role,
async (client) => {
const names = Object.keys(args);
names.forEach(identifier);
const metadata = await functionMetadata(client, functionName, names);
const metadata = await functionMetadata(client, functionName, names, {
connectionString: this.connectionString,
role: this.role,
});
const typeByName = new Map(
(metadata.argument_names ?? []).map((name, index) => [
name,
@@ -173,13 +173,17 @@ function defaultRetryDelayMs(job: PersonalReportJobRecord): number {
async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return;
await new Promise<void>((resolve) => {
const timer = globalThis.setTimeout(resolve, milliseconds);
const candidate = timer as ReturnType<typeof globalThis.setTimeout> & { unref?: () => void };
candidate.unref?.();
signal?.addEventListener("abort", () => {
const onAbort = () => {
globalThis.clearTimeout(timer);
resolve();
}, { once: true });
};
const timer = globalThis.setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
const candidate = timer as ReturnType<typeof globalThis.setTimeout> & { unref?: () => void };
candidate.unref?.();
signal?.addEventListener("abort", onAbort, { once: true });
});
}
@@ -188,12 +188,47 @@ export async function rescoreMinuteAfterWindowChange(
export { persistBlockScanPayload } from "./score-persist.ts";
const rescoreAttempts = new Map<string, true>();
const RESCORE_ATTEMPT_TTL_MS = 6 * 60 * 60 * 1000;
const RESCORE_ATTEMPT_CAP = 5_000;
const rescoreAttempts = new Map<string, number>();
export function resetStaleMinuteRescoreAttemptsForTests(): void {
rescoreAttempts.clear();
}
function pruneRescoreAttempts(now: number): void {
for (const [key, at] of rescoreAttempts) {
if (now - at > RESCORE_ATTEMPT_TTL_MS) rescoreAttempts.delete(key);
}
while (rescoreAttempts.size > RESCORE_ATTEMPT_CAP) {
const oldest = rescoreAttempts.keys().next().value;
if (oldest === undefined) return;
rescoreAttempts.delete(oldest);
}
}
/** Returns true when this key should be scored now. A fresh key is remembered. */
function claimRescoreAttempt(key: string, now: number): boolean {
pruneRescoreAttempts(now);
const existing = rescoreAttempts.get(key);
if (existing !== undefined && now - existing <= RESCORE_ATTEMPT_TTL_MS) return false;
if (!rescoreAttempts.has(key) && rescoreAttempts.size >= RESCORE_ATTEMPT_CAP) {
const oldest = rescoreAttempts.keys().next().value;
if (oldest !== undefined) rescoreAttempts.delete(oldest);
}
rescoreAttempts.set(key, now);
return true;
}
export function noteRescoreAttemptForTests(key: string, at: number): void {
claimRescoreAttempt(key, at);
}
export function rescoreAttemptCountForTests(): number {
return rescoreAttempts.size;
}
function rescoreAttemptKey(caseId: string, fingerprint: string, askedTurnId?: string | null): string {
return `${caseId}:${askedTurnId ?? ""}:${fingerprint}`;
}
@@ -219,10 +254,9 @@ export async function rescoreStaleMinuteSnapshotIfNeeded(input: {
}
const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
const key = rescoreAttemptKey(input.caseId, fingerprint, input.askedTurnId);
if (rescoreAttempts.has(key)) {
if (!claimRescoreAttempt(key, Date.now())) {
return { dossier, snapshotCurrent: false, rescoreAttempted: false };
}
rescoreAttempts.set(key, true);
try {
await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId, {
keepAnswers: true,
@@ -6,11 +6,30 @@
*/
const WINDOW_MS = 60_000;
const DELIVERY_MARK_CAP = 10_000;
type DeliveryMark = Readonly<{ resultId: string; at: number; narration?: string }>;
const delivered = new Map<string, DeliveryMark>();
function pruneDelivered(now: number): void {
for (const [caseId, mark] of delivered) {
if (now - mark.at > WINDOW_MS) delivered.delete(caseId);
}
}
function makeRoomForDeliveryMark(): void {
while (delivered.size >= DELIVERY_MARK_CAP) {
const oldest = delivered.keys().next().value;
if (oldest === undefined) return;
delivered.delete(oldest);
}
}
export function deliveryTurnGuardSizeForTests(): number {
return delivered.size;
}
export type DeliveryTurnTrigger =
| "finalizeSuccessfulTurnExit"
| "persistExhaustionGateTurn"
@@ -39,8 +58,10 @@ export function markDeliveryTurn(input: {
}): void {
if (!input.caseId || !input.resultId) return;
const now = input.now ?? Date.now();
pruneDelivered(now);
const narration = input.narration?.replace(/\s+/g, " ").trim() || undefined;
const existing = delivered.get(input.caseId);
if (!existing) makeRoomForDeliveryMark();
if (existing && existing.resultId === input.resultId && now - existing.at <= WINDOW_MS) {
if (existing.narration) return;
delivered.set(input.caseId, {
+27
View File
@@ -32,9 +32,36 @@ function defaultStore(): RequestRateLimitStore {
return state.jyotishaRequestRateLimits;
}
let seenMaxWindowMs = 0;
let consumeCalls = 0;
export function resetRequestRateLimitMaintenanceForTests(): void {
seenMaxWindowMs = 0;
consumeCalls = 0;
}
function sweepExpiredRateLimitKeys(
store: RequestRateLimitStore,
now: number,
maxWindowMs: number,
): void {
for (const [key, stamps] of store) {
let newest = Number.NEGATIVE_INFINITY;
for (const stamp of stamps) {
if (stamp > newest) newest = stamp;
}
if (!Number.isFinite(newest) || newest <= now - maxWindowMs) store.delete(key);
}
}
export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision {
if (input.windowMs > seenMaxWindowMs) seenMaxWindowMs = input.windowMs;
consumeCalls += 1;
const now = input.now ?? Date.now();
const store = input.store ?? defaultStore();
if (consumeCalls % 1_000 === 0 || store.size > 5_000) {
sweepExpiredRateLimitKeys(store, now, seenMaxWindowMs);
}
const windowStart = now - input.windowMs;
const recent = (store.get(input.key) ?? []).filter((timestamp) => timestamp > windowStart);
if (recent.length >= input.limit) {
+4 -1
View File
@@ -9,12 +9,15 @@ import { getIdentityAuthServices } from "@/modules/identity/auth";
import { readIdentitySession } from "@/modules/identity/session";
import { getSupabasePublicConfig } from "./config";
export async function createServerSupabaseClient() {
export async function createServerSupabaseClient(options?: {
readonly disableCookieCache?: boolean;
}) {
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
const requestHeaders = new Headers(await headers());
const session = await readIdentitySession(
getIdentityAuthServices().user.api,
requestHeaders,
options?.disableCookieCache ? { disableCookieCache: true } : undefined,
);
return createLocalPostgresDataClient(
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
+19 -3
View File
@@ -1,5 +1,4 @@
import { createHmac } from "node:crypto";
import type { Pool } from "pg";
import type { BetterAuthOptions } from "better-auth";
import { admin, emailOTP, twoFactor, type EmailOTPOptions } from "better-auth/plugins";
@@ -11,10 +10,19 @@ export type AdminUserAuthorizer = (userId: string) => Promise<boolean>;
interface BuildAuthOptionsInput {
config: SelfHostedIdentityConfig;
database: Pool;
database: BetterAuthOptions["database"];
emailSender: EmailOtpSender;
}
export const IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS = 300;
/** Server-only. Unset or any value other than "on" keeps today's uncached session reads. */
export function identitySessionCookieCacheEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env.IDENTITY_SESSION_COOKIE_CACHE === "on";
}
function otpIdempotencyKey(
secret: string,
email: string,
@@ -86,7 +94,15 @@ export function buildAuthOptions({
trustedOrigins: [config.userOrigin, config.adminOrigin],
telemetry: { enabled: false },
user: identityModelMapping.user,
session: identityModelMapping.session,
session: identitySessionCookieCacheEnabled()
? {
...identityModelMapping.session,
cookieCache: {
enabled: true,
maxAge: IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
},
}
: identityModelMapping.session,
account: identityModelMapping.account,
verification: identityModelMapping.verification,
rateLimit: {
+17 -4
View File
@@ -18,8 +18,15 @@ export interface IdentityServerSession extends IdentitySession {
sessionToken: string;
}
export interface IdentitySessionQuery {
readonly disableCookieCache?: boolean;
}
export interface IdentitySessionReader {
getSession(input: { headers: Headers }): Promise<RawIdentitySession | null>;
getSession(input: {
headers: Headers;
query?: IdentitySessionQuery;
}): Promise<RawIdentitySession | null>;
}
export class IdentityAuthorizationError extends Error {
@@ -43,8 +50,12 @@ function parseRoles(role: string | null | undefined): string[] {
export async function readIdentityServerSession(
reader: IdentitySessionReader,
requestHeaders: Headers,
options?: IdentitySessionQuery,
): Promise<IdentityServerSession | null> {
const value = await reader.getSession({ headers: requestHeaders });
const value = await reader.getSession({
headers: requestHeaders,
...(options?.disableCookieCache ? { query: { disableCookieCache: true } } : {}),
});
if (!value) return null;
const expiresAt = new Date(value.session.expiresAt);
@@ -71,16 +82,18 @@ export async function readIdentityServerSession(
export async function readIdentitySession(
reader: IdentitySessionReader,
requestHeaders: Headers,
options?: IdentitySessionQuery,
): Promise<IdentitySession | null> {
const session = await readIdentityServerSession(reader, requestHeaders);
const session = await readIdentityServerSession(reader, requestHeaders, options);
return session ? { user: session.user, expiresAt: session.expiresAt } : null;
}
export async function requireIdentityServerSession(
reader: IdentitySessionReader,
requestHeaders: Headers,
options?: IdentitySessionQuery,
): Promise<IdentityServerSession> {
const session = await readIdentityServerSession(reader, requestHeaders);
const session = await readIdentityServerSession(reader, requestHeaders, options);
if (!session) throw new IdentityAuthorizationError("Authentication required", 401);
return session;
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Count local-Postgres adapter roundtrips for a fictional homepage plus consult write.
* Run from frontend/: npx tsx tests/bench-local-pg-roundtrips.ts
* Prints JSON. Does not print connection strings.
*/
import { spawnSync } from "node:child_process";
import type { Pool, PoolClient } from "pg";
import {
closeLocalPostgresDataPools,
clearLocalPostgresMetadataCacheForTests,
createLocalPostgresDataClient,
localPostgresPoolForTests,
} from "../src/lib/db/local-postgres-client-core.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const userId = "018f4e6d-7a11-7000-8000-0000000000bb";
const sessionId = "018f4e6d-7a11-7000-8000-0000000000cc";
const chartId = "018f4e6d-7a11-7000-8000-0000000000dd";
const schemaSql = `
create table if not exists public.profiles (
id uuid primary key,
display_name text
);
create table if not exists public.chart_profiles (
id uuid primary key,
user_id uuid not null,
label text,
updated_at timestamptz not null default now()
);
create table if not exists public.chat_sessions (
id uuid primary key,
user_id uuid not null,
title text,
updated_at timestamptz not null default now()
);
grant usage on schema public to authenticated, service_role;
grant select, insert, update, delete on public.profiles, public.chart_profiles, public.chat_sessions to authenticated, service_role;
insert into public.profiles (id, display_name) values ('${userId}', 'Fictional Person')
on conflict (id) do nothing;
insert into public.chart_profiles (id, user_id, label) values ('${chartId}', '${userId}', 'home')
on conflict (id) do nothing;
insert into public.chat_sessions (id, user_id, title) values ('${sessionId}', '${userId}', 'consult')
on conflict (id) do nothing;
create or replace function public.session_probe()
returns table(db_user text)
language sql stable
as $$ select current_user::text $$;
grant execute on function public.session_probe() to service_role, authenticated;
`;
type Mode = { cache: "on" | "off"; merged: "on" | "off" };
function percentile(values: number[], ratio: number): number {
const sorted = [...values].sort((left, right) => left - right);
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(ratio * sorted.length) - 1));
return sorted[index] ?? 0;
}
function median(values: number[]): number {
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.floor(sorted.length / 2)] ?? 0;
}
const benchCounter = { roundtrips: 0 };
const queryWrapped = new WeakSet<object>();
async function withCountingPools(
urls: readonly string[],
run: () => Promise<unknown>,
): Promise<{ roundtrips: number; elapsedMs: number; payload: string }> {
const restores: Array<() => void> = [];
benchCounter.roundtrips = 0;
for (const url of urls) {
const pool = localPostgresPoolForTests(url) as Pool & {
connect: () => Promise<PoolClient>;
};
const original = pool.connect.bind(pool);
pool.connect = async () => {
const client = await original();
if (!queryWrapped.has(client)) {
queryWrapped.add(client);
const query = client.query.bind(client) as (input: unknown, values?: unknown) => Promise<unknown>;
client.query = ((input: unknown, values?: unknown) => {
benchCounter.roundtrips += 1;
return values === undefined ? query(input) : query(input, values);
}) as PoolClient["query"];
}
return client;
};
restores.push(() => {
pool.connect = original;
});
}
const started = performance.now();
try {
const payload = await run();
return {
roundtrips: benchCounter.roundtrips,
elapsedMs: performance.now() - started,
payload: JSON.stringify(payload),
};
} finally {
for (const restore of restores) restore();
}
}
async function sequence(url: string, serviceUrl: string): Promise<unknown[]> {
const user = createLocalPostgresDataClient(url, { id: userId, email: "person@example.com" }, "authenticated");
const service = createLocalPostgresDataClient(serviceUrl, null, "service_role");
const profile = await user.from("profiles").select("id,display_name").eq("id", userId).maybeSingle();
const charts = await user.from("chart_profiles").select("id,label").eq("user_id", userId).order("updated_at", { ascending: false }).limit(40);
const sessions = await user.from("chat_sessions").select("id,title").eq("user_id", userId).order("updated_at", { ascending: false }).limit(40);
const updated = await user.from("chat_sessions").update({ title: "consult-bench" }).eq("id", sessionId);
const upsertConflict = await user.from("chat_sessions").upsert(
{ id: sessionId, user_id: userId, title: "consult-bench" },
{ onConflict: "id" },
);
const upsertKey = await user.from("chat_sessions").upsert({ id: sessionId, user_id: userId, title: "consult-bench" });
const rpc = await service.rpc("session_probe");
const serviceRead = await service.from("profiles").select("id").eq("id", userId).maybeSingle();
return [profile, charts, sessions, updated, upsertConflict, upsertKey, rpc, serviceRead].map((result) => ({
data: result.data ?? null,
error: result.error ?? null,
count: "count" in result ? result.count ?? null : null,
}));
}
async function measure(url: string, serviceUrl: string, mode: Mode): Promise<{
roundtrips: number;
medianMs: number;
p95Ms: number;
payload: string;
groups: Array<{ roundtrips: number; medianMs: number }>;
}> {
process.env.JYOTISHA_PG_METADATA_CACHE = mode.cache;
process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = mode.merged;
clearLocalPostgresMetadataCacheForTests();
await sequence(url, serviceUrl);
const groupMedians: number[] = [];
const groupRoundtrips: number[] = [];
const allTimes: number[] = [];
let payload = "";
for (let index = 0; index < 3; index += 1) {
const group: number[] = [];
let roundtrips = 0;
for (let repeat = 0; repeat < 50; repeat += 1) {
const sample = await withCountingPools([url, serviceUrl], () => sequence(url, serviceUrl));
group.push(sample.elapsedMs);
allTimes.push(sample.elapsedMs);
roundtrips += sample.roundtrips;
payload = sample.payload;
}
groupMedians.push(median(group));
groupRoundtrips.push(Math.round(roundtrips / 50));
}
return {
roundtrips: median(groupRoundtrips),
medianMs: median(groupMedians),
p95Ms: percentile(allTimes, 0.95),
payload,
groups: groupRoundtrips.map((roundtrips, index) => ({
roundtrips,
medianMs: Number((groupMedians[index] ?? 0).toFixed(2)),
})),
};
}
async function main(): Promise<void> {
const docker = spawnSync("docker", ["info"], { encoding: "utf8" });
if (docker.status !== 0) {
console.log(JSON.stringify({ ok: false, reason: "docker-unavailable" }));
process.exit(2);
}
const fixture = startPostgresFixture();
const appUrl = fixture.connectionUrl("app_runtime", "app-runtime-test-password");
const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password");
try {
fixture.psqlScriptAs("postgres", "postgres-test-password", schemaSql);
const modes: Record<string, Mode> = {
A: { cache: "off", merged: "off" },
B: { cache: "on", merged: "on" },
B1: { cache: "on", merged: "off" },
B2: { cache: "off", merged: "on" },
};
const results: Record<string, { roundtrips: number; medianMs: number; p95Ms: number; groups: Array<{ roundtrips: number; medianMs: number }> }> = {};
let payloadA = "";
let payloadMismatch = false;
for (const [name, mode] of Object.entries(modes)) {
const measured = await measure(appUrl, serviceUrl, mode);
results[name] = {
roundtrips: measured.roundtrips,
medianMs: Number(measured.medianMs.toFixed(2)),
p95Ms: Number(measured.p95Ms.toFixed(2)),
groups: measured.groups,
};
if (name === "A") payloadA = measured.payload;
else if (measured.payload !== payloadA) payloadMismatch = true;
clearLocalPostgresMetadataCacheForTests();
await closeLocalPostgresDataPools();
}
console.log(JSON.stringify({
ok: true,
repeats: 3,
repsPerGroup: 50,
payloadMismatch,
results,
node: process.version,
}));
} finally {
await closeLocalPostgresDataPools();
fixture.stop();
delete process.env.JYOTISHA_PG_METADATA_CACHE;
delete process.env.JYOTISHA_PG_MERGED_SESSION_SETUP;
}
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : "bench failed";
console.log(JSON.stringify({ ok: false, reason: message }));
process.exit(1);
});
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
closeLocalPostgresDataPools,
createLocalPostgresDataClient,
localPostgresPoolForTests,
} from "../src/lib/db/local-postgres-client-core.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const probeSql = `
create or replace function public.session_probe()
returns table(db_user text, claim_sub text)
language sql
stable
as $$
select current_user::text, current_setting('request.jwt.claim.sub', true);
$$;
grant usage on schema public to authenticated, service_role;
grant execute on function public.session_probe() to authenticated, service_role;
`;
test("merged session setup applies the role only inside the transaction", async () => {
const previous = {
cache: process.env.JYOTISHA_PG_METADATA_CACHE,
merged: process.env.JYOTISHA_PG_MERGED_SESSION_SETUP,
};
process.env.JYOTISHA_PG_METADATA_CACHE = "on";
process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = "on";
const fixture = startPostgresFixture();
const identity = {
id: "018f4e6d-7a11-7000-8000-0000000000aa",
email: "person@example.com",
};
try {
fixture.psqlScriptAs("postgres", "postgres-test-password", probeSql);
const url = fixture.connectionUrl("app_runtime", "app-runtime-test-password");
const client = createLocalPostgresDataClient(url, identity, "authenticated");
const probe = await client.rpc("session_probe");
assert.equal(probe.error, null, probe.error?.message);
const row = Array.isArray(probe.data) ? probe.data[0] as { db_user?: string; claim_sub?: string } : null;
assert.equal(row?.db_user, "authenticated");
assert.equal(row?.claim_sub, identity.id);
const pool = localPostgresPoolForTests(url);
const borrowed = await pool.connect();
try {
const after = await borrowed.query<{ db_user: string; claim_sub: string | null }>(
"select current_user as db_user, current_setting('request.jwt.claim.sub', true) as claim_sub",
);
assert.equal(after.rows[0]?.db_user, "app_runtime");
assert.equal(after.rows[0]?.claim_sub ?? "", "");
} finally {
borrowed.release();
}
} finally {
await closeLocalPostgresDataPools();
fixture.stop();
if (previous.cache === undefined) delete process.env.JYOTISHA_PG_METADATA_CACHE;
else process.env.JYOTISHA_PG_METADATA_CACHE = previous.cache;
if (previous.merged === undefined) delete process.env.JYOTISHA_PG_MERGED_SESSION_SETUP;
else process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = previous.merged;
}
});
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
alreadyDelivered,
deliveryCarrierNarration,
deliveryTurnGuardSizeForTests,
markDeliveryTurn,
resetDeliveryTurnGuardForTests,
} from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
test("a delivery mark older than 60 seconds is removed on the next write", () => {
resetDeliveryTurnGuardForTests();
markDeliveryTurn({
caseId: "case-a",
resultId: "result-a",
now: 0,
narration: "已经交付这段说明",
});
assert.equal(alreadyDelivered({
caseId: "case-a",
resultId: "result-a",
hasUserMessage: false,
now: 60_000,
}), true);
assert.equal(deliveryCarrierNarration({
caseId: "case-a",
resultId: "result-a",
now: 60_000,
}), "已经交付这段说明");
markDeliveryTurn({ caseId: "case-b", resultId: "result-b", now: 60_001 });
assert.equal(deliveryTurnGuardSizeForTests(), 1);
assert.equal(alreadyDelivered({
caseId: "case-a",
resultId: "result-a",
hasUserMessage: false,
now: 60_001,
}), false);
assert.equal(alreadyDelivered({
caseId: "case-b",
resultId: "result-b",
hasUserMessage: false,
now: 70_000,
}), true);
});
test("delivery marks stay within the hard cap", () => {
resetDeliveryTurnGuardForTests();
for (let index = 0; index < 10_001; index += 1) {
markDeliveryTurn({ caseId: `case-${index}`, resultId: "result", now: 1_000 });
}
assert.ok(deliveryTurnGuardSizeForTests() <= 10_000);
assert.equal(alreadyDelivered({
caseId: "case-10000",
resultId: "result",
hasUserMessage: false,
now: 1_000,
}), true);
resetDeliveryTurnGuardForTests();
});
@@ -0,0 +1,258 @@
import assert from "node:assert/strict";
import test from "node:test";
import { betterAuth } from "better-auth";
import { memoryAdapter, type MemoryDB } from "../node_modules/better-auth/node_modules/@better-auth/memory-adapter/dist/index.mjs";
import type { BetterAuthOptions } from "better-auth";
import {
buildAuthOptions,
IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
} from "../src/modules/identity/auth-factory.ts";
import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts";
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
import {
readIdentitySession,
requireIdentityServerSession,
type IdentitySessionReader,
} from "../src/modules/identity/session.ts";
const config: SelfHostedIdentityConfig = {
provider: "self-hosted",
databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha",
userOrigin: "https://staging.jyotisha.chat",
adminOrigin: "https://admin.staging.jyotisha.chat",
userSecret: "user-secret-that-is-at-least-32-bytes-long",
resendApiKey: "re_test",
resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
testOtp: null,
};
function withCacheFlag(value: string | undefined, run: () => void): void {
const previous = process.env.IDENTITY_SESSION_COOKIE_CACHE;
if (value === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
else process.env.IDENTITY_SESSION_COOKIE_CACHE = value;
try {
run();
} finally {
if (previous === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
else process.env.IDENTITY_SESSION_COOKIE_CACHE = previous;
}
}
test("user auth omits cookie cache unless the server switch is on", () => {
withCacheFlag(undefined, () => {
const options = buildAuthOptions({
config,
database: memoryAdapter({}),
emailSender: new FakeEmailOtpSender(),
});
assert.equal(options.advanced?.cookiePrefix, "jyotisha-user");
assert.equal(options.session && "cookieCache" in options.session, false);
});
withCacheFlag("on", () => {
const options = buildAuthOptions({
config,
database: memoryAdapter({}),
emailSender: new FakeEmailOtpSender(),
});
assert.deepEqual(options.session?.cookieCache, {
enabled: true,
maxAge: IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
});
});
});
test("sensitive session readers pass disableCookieCache and ordinary readers do not", async () => {
const queries: Array<{ disableCookieCache?: boolean }> = [];
const reader: IdentitySessionReader = {
async getSession(input) {
queries.push({ disableCookieCache: input.query?.disableCookieCache });
return {
session: {
id: "session-1",
token: "session-token",
expiresAt: new Date("2030-01-01T00:00:00.000Z"),
},
user: {
id: "018f4e6d-7a11-7000-8000-000000000001",
email: "person@example.com",
emailVerified: true,
name: "Person",
image: null,
role: "user",
},
};
},
};
await readIdentitySession(reader, new Headers());
await requireIdentityServerSession(reader, new Headers(), { disableCookieCache: true });
assert.deepEqual(queries, [
{ disableCookieCache: undefined },
{ disableCookieCache: true },
]);
});
type SetCookieHeaders = Headers & { getSetCookie?: () => string[] };
function cookieHeaderFrom(response: Response, previous = ""): string {
const headers = response.headers as SetCookieHeaders;
const setCookies = headers.getSetCookie?.()
?? (headers.get("set-cookie")?.split(/,(?=\s*[^;,=\s]+=[^;,]*)/g) ?? []);
const fresh = setCookies
.map((value) => value.split(";", 1)[0] ?? "")
.filter((value) => value.includes("=") && value.slice(value.indexOf("=") + 1).length > 0);
const jar = new Map<string, string>();
for (const part of previous.split("; ").filter(Boolean)) {
jar.set(part.slice(0, part.indexOf("=")), part);
}
for (const part of fresh) jar.set(part.slice(0, part.indexOf("=")), part);
return [...jar.values()].join("; ");
}
function createCacheAuth(enabled: boolean) {
const db: MemoryDB = {
users: [],
sessions: [],
accounts: [],
verifications: [],
two_factors: [],
otp_rate_limits: [],
};
const sessionReads: string[] = [];
const factory = memoryAdapter(db);
const database = ((options: BetterAuthOptions) => {
const adapter = factory(options);
return {
...adapter,
async findOne<T>(data: { model: string }) {
if (data.model === "session" || data.model === "sessions") sessionReads.push(data.model);
return adapter.findOne<T>(data as never);
},
};
}) as BetterAuthOptions["database"];
const previous = process.env.IDENTITY_SESSION_COOKIE_CACHE;
process.env.IDENTITY_SESSION_COOKIE_CACHE = enabled ? "on" : "off";
try {
const built = buildAuthOptions({
config,
database,
emailSender: new FakeEmailOtpSender(),
});
const auth = betterAuth({
...built,
emailAndPassword: {
...built.emailAndPassword,
enabled: true,
disableSignUp: false,
},
});
return { auth, db, sessionReads };
} finally {
if (previous === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
else process.env.IDENTITY_SESSION_COOKIE_CACHE = previous;
}
}
type CacheAuth = ReturnType<typeof createCacheAuth>["auth"];
async function signIn(auth: CacheAuth): Promise<string> {
const email = "person@example.com";
const password = "long-enough-password";
const origin = config.userOrigin;
const signedUp = await auth.api.signUpEmail({
body: { email, password, name: "Person" },
headers: new Headers({ origin }),
asResponse: true,
});
assert.equal(signedUp.status, 200, await signedUp.clone().text());
let cookie = cookieHeaderFrom(signedUp);
if (!cookie.includes("session_token")) {
const signedIn = await auth.api.signInEmail({
body: { email, password },
headers: new Headers({ origin }),
asResponse: true,
});
assert.equal(signedIn.status, 200, await signedIn.clone().text());
cookie = cookieHeaderFrom(signedIn, cookie);
}
return cookie;
}
async function readSession(
auth: CacheAuth,
cookie: string,
disableCookieCache = false,
): Promise<{ cookie: string; status: number; userId: string | null }> {
const response = await auth.api.getSession({
headers: new Headers({ cookie, origin: config.userOrigin }),
query: disableCookieCache ? { disableCookieCache: true } : undefined,
asResponse: true,
});
const body = await response.json() as { user?: { id?: string } } | null;
return {
cookie: cookieHeaderFrom(response, cookie),
status: response.status,
userId: body?.user?.id ?? null,
};
}
test("cookie cache serves three ordinary reads from one database read", async () => {
const { auth, db, sessionReads } = createCacheAuth(true);
const cookie = await signIn(auth);
sessionReads.length = 0;
let current = cookie
.split("; ")
.filter((part) => !part.slice(0, part.indexOf("=")).includes("session_data"))
.join("; ");
const reads: Array<string | null> = [];
for (let index = 0; index < 3; index += 1) {
const result = await readSession(auth, current);
current = result.cookie;
reads.push(result.userId);
}
assert.deepEqual(reads, [reads[0], reads[0], reads[0]]);
assert.ok(reads[0]);
assert.equal(sessionReads.length, 1);
const cachedCookie = current;
sessionReads.length = 0;
for (let index = 0; index < 3; index += 1) {
const result = await readSession(auth, cachedCookie, true);
assert.ok(result.userId);
}
assert.equal(sessionReads.length, 3);
const sessions = db.sessions ?? db.session;
assert.ok(Array.isArray(sessions));
sessions.length = 0;
const sensitive = await readSession(auth, cachedCookie, true);
assert.equal(sensitive.userId, null);
const ordinary = await readSession(auth, cachedCookie, false);
assert.equal(ordinary.userId, reads[0]);
const realNow = Date.now;
const started = realNow();
Date.now = () => started + (IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS + 1) * 1000;
try {
const expired = await readSession(auth, cachedCookie, false);
assert.equal(expired.userId, null);
} finally {
Date.now = realNow;
}
});
test("cookie cache off reads the database on every ordinary request", async () => {
const { auth, sessionReads } = createCacheAuth(false);
const cookie = await signIn(auth);
sessionReads.length = 0;
let current = cookie
.split("; ")
.filter((part) => !part.slice(0, part.indexOf("=")).includes("session_data"))
.join("; ");
for (let index = 0; index < 3; index += 1) {
const result = await readSession(auth, current);
assert.ok(result.userId);
current = result.cookie;
}
assert.equal(sessionReads.length, 3);
});
@@ -0,0 +1,286 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Pool } from "pg";
import {
clearLocalPostgresMetadataCacheForTests,
closeLocalPostgresDataPool,
createLocalPostgresDataClient,
installLocalPostgresPoolForTests,
setLocalPostgresMetadataClockForTests,
type LocalDatabaseRole,
} from "../src/lib/db/local-postgres-client-core.ts";
type QueryCall = { sql: string; params: readonly unknown[] };
function tag(sql: string): string {
const compact = sql.replace(/\s+/g, " ").trim().toLowerCase();
if (compact === "begin") return "begin";
if (compact === "commit") return "commit";
if (compact === "rollback") return "rollback";
if (compact.startsWith("set local role")) return "set local role";
if (compact.includes("set_config('role'")) return "set_config(role)";
if (compact.includes("set_config('request.jwt.claim.sub'")) return "set_config";
if (compact.includes("information_schema.columns")) return "information_schema";
if (compact.includes("from pg_index")) return "primary_key";
if (compact.includes("from pg_proc")) return "pg_proc";
return "business";
}
function installFakePool(
url: string,
options: {
columns?: ReadonlyArray<{ column_name: string; udt_name: string }> | null;
primaryKeys?: readonly string[] | null;
functions?: ReadonlyArray<Record<string, unknown>> | null;
failBusinessCode?: () => string | null;
} = {},
): QueryCall[] {
const calls: QueryCall[] = [];
const client = {
async query(sql: string, params: readonly unknown[] = []) {
calls.push({ sql, params });
const label = tag(sql);
if (label === "information_schema") {
const rows = options.columns === null
? []
: [...(options.columns ?? [{ column_name: "id", udt_name: "uuid" }])];
return { rows, rowCount: rows.length };
}
if (label === "primary_key") {
const names = options.primaryKeys === null ? [] : [...(options.primaryKeys ?? ["id"])];
const rows = names.map((column_name) => ({ column_name }));
return { rows, rowCount: rows.length };
}
if (label === "pg_proc") {
const rows = options.functions === null
? []
: [...(options.functions ?? [{
proretset: false,
return_type: "text",
argument_names: ["left_name", "right_name"],
argument_types: ["text", "text"],
}])];
return { rows, rowCount: rows.length };
}
if (label === "business") {
const code = options.failBusinessCode?.() ?? null;
if (code) {
const error = new Error("business sql failed") as Error & { code: string };
error.code = code;
throw error;
}
if (sql.toLowerCase().includes(" as value")) return { rows: [{ value: "ok" }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
},
release() {},
};
const pool = {
async connect() {
return client;
},
async end() {},
};
installLocalPostgresPoolForTests(url, pool as unknown as Pool);
return calls;
}
async function withSwitches(
values: Record<string, string | undefined>,
run: () => Promise<void>,
): Promise<void> {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
clearLocalPostgresMetadataCacheForTests();
setLocalPostgresMetadataClockForTests(null);
try {
await run();
} finally {
for (const [key, value] of previous) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
clearLocalPostgresMetadataCacheForTests();
setLocalPostgresMetadataClockForTests(null);
}
}
const identity = { id: "018f4e6d-7a11-7000-8000-0000000000aa", email: "person@example.com" };
function clientFor(url: string, role: LocalDatabaseRole | null = "authenticated") {
return createLocalPostgresDataClient(url, role === "service_role" ? null : identity, role);
}
test("metadata cache and merged session setup stay off the baseline query sequence", async () => {
await withSwitches({
JYOTISHA_PG_METADATA_CACHE: "off",
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
}, async () => {
const url = "postgresql://roundtrip-off@127.0.0.1:1/jyotisha";
const calls = installFakePool(url);
const client = clientFor(url);
const first = await client.from("profiles").select("id").eq("id", identity.id);
assert.equal(first.error, null);
assert.deepEqual(calls.map((call) => tag(call.sql)), [
"begin",
"set local role",
"set_config",
"information_schema",
"business",
"commit",
]);
await client.from("profiles").select("id").eq("id", identity.id);
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 2);
});
});
test("cached metadata and merged session setup drop a repeat select and rpc to four roundtrips", async () => {
await withSwitches({
JYOTISHA_PG_METADATA_CACHE: "on",
JYOTISHA_PG_MERGED_SESSION_SETUP: "on",
}, async () => {
const url = "postgresql://roundtrip-on@127.0.0.1:1/jyotisha";
const calls = installFakePool(url);
const client = clientFor(url);
await client.from("profiles").select("id").eq("id", identity.id);
calls.length = 0;
await client.from("profiles").select("id").eq("id", identity.id);
assert.deepEqual(calls.map((call) => tag(call.sql)), [
"begin",
"set_config(role)",
"business",
"commit",
]);
await client.rpc("sample_probe", { left_name: "a", right_name: "b" });
calls.length = 0;
const rpc = await client.rpc("sample_probe", { left_name: "a", right_name: "b" });
assert.equal(rpc.error, null);
assert.deepEqual(calls.map((call) => tag(call.sql)), [
"begin",
"set_config(role)",
"business",
"commit",
]);
await client.from("profiles").upsert({ id: identity.id });
calls.length = 0;
await client.from("profiles").upsert({ id: identity.id });
assert.deepEqual(calls.map((call) => tag(call.sql)), [
"begin",
"set_config(role)",
"business",
"commit",
]);
});
});
test("column cache is per role and function cache is per argument names", async () => {
await withSwitches({
JYOTISHA_PG_METADATA_CACHE: "on",
JYOTISHA_PG_MERGED_SESSION_SETUP: "on",
}, async () => {
const url = "postgresql://roundtrip-keys@127.0.0.1:1/jyotisha";
const calls = installFakePool(url);
const authenticated = clientFor(url, "authenticated");
const service = clientFor(url, "service_role");
await authenticated.from("profiles").select("id");
calls.length = 0;
await service.from("profiles").select("id");
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
await authenticated.rpc("sample_probe", { left_name: "a" });
calls.length = 0;
await authenticated.rpc("sample_probe", { right_name: "b" });
assert.equal(calls.filter((call) => tag(call.sql) === "pg_proc").length, 1);
});
});
test("undefined column, expiry, pool close, and empty metadata all miss the cache", async () => {
await withSwitches({
JYOTISHA_PG_METADATA_CACHE: "on",
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
}, async () => {
const url = "postgresql://roundtrip-invalidate@127.0.0.1:1/jyotisha";
let failuresLeft = 1;
const calls = installFakePool(url, {
failBusinessCode: () => {
if (failuresLeft <= 0) return null;
failuresLeft -= 1;
return "42703";
},
});
const client = clientFor(url);
const failed = await client.from("profiles").select("id");
assert.equal(failed.error?.code, "42703");
calls.length = 0;
const retried = await client.from("profiles").select("id");
assert.equal(retried.error, null);
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
});
});
test("metadata older than 300 seconds is queried again", async () => {
await withSwitches({
JYOTISHA_PG_METADATA_CACHE: "on",
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
}, async () => {
const url = "postgresql://roundtrip-ttl@127.0.0.1:1/jyotisha";
let now = 10_000;
setLocalPostgresMetadataClockForTests(() => now);
const calls = installFakePool(url);
const client = clientFor(url);
await client.from("profiles").select("id");
calls.length = 0;
await client.from("profiles").select("id");
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 0);
now += 300_000;
calls.length = 0;
await client.from("profiles").select("id");
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
});
});
test("an empty column list and a missing function are not cached", async () => {
await withSwitches({
JYOTISHA_PG_METADATA_CACHE: "on",
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
}, async () => {
const url = "postgresql://roundtrip-empty@127.0.0.1:1/jyotisha";
const calls = installFakePool(url, { columns: null, functions: null, primaryKeys: null });
const client = clientFor(url);
await client.from("profiles").select("id");
calls.length = 0;
await client.from("profiles").select("id");
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
const missing = await client.rpc("missing_probe", { left_name: "a" });
assert.match(missing.error?.message ?? "", /not found/);
calls.length = 0;
await client.rpc("missing_probe", { left_name: "a" });
assert.equal(calls.filter((call) => tag(call.sql) === "pg_proc").length, 1);
});
});
test("closing the pool drops that url from the metadata cache", async () => {
await withSwitches({
JYOTISHA_PG_METADATA_CACHE: "on",
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
}, async () => {
const url = "postgresql://roundtrip-close@127.0.0.1:1/jyotisha";
const calls = installFakePool(url);
const client = clientFor(url);
await client.from("profiles").select("id");
await closeLocalPostgresDataPool(url);
const reopenedCalls = installFakePool(url);
const reopened = clientFor(url);
await reopened.from("profiles").select("id");
assert.equal(reopenedCalls.filter((call) => tag(call.sql) === "information_schema").length, 1);
assert.ok(calls.length > 0);
});
});
@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import { getEventListeners } from "node:events";
import test from "node:test";
import { scoringPollDelay } from "../src/lib/birth-time-guided-polling.ts";
import { runPersonalReportWorkerLoop } from "../src/lib/personal-report-worker-core.ts";
const idle = {
recovery: { retryingCount: 0, failedCount: 0 },
outcome: "idle" as const,
jobId: null,
};
function keepAlive(): () => void {
const timer = setInterval(() => {}, 1_000);
return () => clearInterval(timer);
}
test("idle report-worker sleeps do not leave abort listeners", async () => {
const stop = keepAlive();
const controller = new AbortController();
let ticks = 0;
let listenersAt200 = -1;
let abortedAt = 0;
try {
await runPersonalReportWorkerLoop(
{
async tick() {
ticks += 1;
if (ticks === 200) {
listenersAt200 = getEventListeners(controller.signal, "abort").length;
abortedAt = Date.now();
controller.abort();
}
return idle;
},
},
{ pollIntervalMs: 1, signal: controller.signal },
);
assert.equal(listenersAt200, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
assert.ok(Date.now() - abortedAt < 250);
} finally {
stop();
}
});
test("thrown report-worker ticks do not leave abort listeners", async () => {
const stop = keepAlive();
const controller = new AbortController();
let ticks = 0;
let listenersAt200 = -1;
let abortedAt = 0;
try {
await runPersonalReportWorkerLoop(
{
async tick() {
ticks += 1;
if (ticks === 200) {
listenersAt200 = getEventListeners(controller.signal, "abort").length;
abortedAt = Date.now();
controller.abort();
}
throw new Error("tick failed");
},
},
{ pollIntervalMs: 1, signal: controller.signal },
);
assert.equal(listenersAt200, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
assert.ok(Date.now() - abortedAt < 250);
} finally {
stop();
}
});
test("scoring poll delay removes its abort listener when the timer fires", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
};
try {
const controller = new AbortController();
await scoringPollDelay(0, controller.signal);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
} finally {
(globalThis as { window?: unknown }).window = previousWindow;
}
});
test("scoring poll delay resolves as soon as the signal aborts", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
};
try {
const controller = new AbortController();
const started = Date.now();
const pending = scoringPollDelay(0, controller.signal);
controller.abort();
await pending;
assert.ok(Date.now() - started < 200);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
} finally {
(globalThis as { window?: unknown }).window = previousWindow;
}
});
+65 -1
View File
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { consumeRequestRateLimit } from "../src/lib/request-rate-limit.ts";
import {
consumeRequestRateLimit,
resetRequestRateLimitMaintenanceForTests,
} from "../src/lib/request-rate-limit.ts";
test("a user is allowed up to the window limit and then must wait", () => {
const store = new Map<string, number[]>();
@@ -46,3 +49,64 @@ test("a user is allowed up to the window limit and then must wait", () => {
assert.deepEqual(otherUser, { ok: true });
assert.deepEqual(afterWindow, { ok: true });
});
test("keys past the longest seen window are swept, and a one-hour key survives 61 seconds", () => {
resetRequestRateLimitMaintenanceForTests();
const shortWindow = new Map<string, number[]>();
consumeRequestRateLimit({
key: "old",
limit: 5,
windowMs: 60_000,
now: 0,
store: shortWindow,
});
for (let index = 0; index < 998; index += 1) {
consumeRequestRateLimit({
key: `pad:${index}`,
limit: 5,
windowMs: 60_000,
now: 0,
store: shortWindow,
});
}
consumeRequestRateLimit({
key: "trigger",
limit: 5,
windowMs: 60_000,
now: 61_000,
store: shortWindow,
});
assert.equal(shortWindow.has("old"), false);
assert.equal(shortWindow.get("old"), undefined);
assert.equal(shortWindow.has("trigger"), true);
assert.equal(shortWindow.size, 1);
resetRequestRateLimitMaintenanceForTests();
const mixed = new Map<string, number[]>();
consumeRequestRateLimit({
key: "synastry:person",
limit: 8,
windowMs: 60 * 60_000,
now: 0,
store: mixed,
});
consumeRequestRateLimit({
key: "ephemeris:person",
limit: 30,
windowMs: 60_000,
now: 0,
store: mixed,
});
for (let index = 0; index < 998; index += 1) {
consumeRequestRateLimit({
key: `later:${index}`,
limit: 5,
windowMs: 60_000,
now: 61_000,
store: mixed,
});
}
assert.equal(mixed.has("synastry:person"), true);
assert.equal(mixed.has("ephemeris:person"), true);
resetRequestRateLimitMaintenanceForTests();
});
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
noteRescoreAttemptForTests,
rescoreAttemptCountForTests,
resetStaleMinuteRescoreAttemptsForTests,
} from "../src/lib/rectification-agentic/v9/block-scan-answer.ts";
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
test("rescore attempts stay within 5000 entries", () => {
resetStaleMinuteRescoreAttemptsForTests();
for (let index = 0; index < 5_001; index += 1) {
noteRescoreAttemptForTests(`case-${index}:turn:fingerprint`, 1_000);
}
assert.ok(rescoreAttemptCountForTests() <= 5_000);
resetStaleMinuteRescoreAttemptsForTests();
});
test("a rescore attempt older than six hours is dropped on the next write", () => {
resetStaleMinuteRescoreAttemptsForTests();
noteRescoreAttemptForTests("case-old:turn:fingerprint", 0);
noteRescoreAttemptForTests("case-fresh:turn:fingerprint", SIX_HOURS_MS);
assert.equal(rescoreAttemptCountForTests(), 2);
noteRescoreAttemptForTests("case-new:turn:fingerprint", SIX_HOURS_MS + 1);
assert.equal(rescoreAttemptCountForTests(), 2);
noteRescoreAttemptForTests("case-fresh:turn:fingerprint", SIX_HOURS_MS + 1);
assert.equal(rescoreAttemptCountForTests(), 2);
resetStaleMinuteRescoreAttemptsForTests();
});
+23 -2
View File
@@ -272,6 +272,20 @@ class BhavaChalitCalculator:
# fallback: 最近的宫
return 1
def _planet_bhava_from_cusps(self, planet_lon: float, cusps: List[float]) -> int:
"""KP 宫头到宫头:宫 i(0 起)的范围是 cusp[i] → cusp[(i+1)%12]。
落在下一宫头上的点归下一宫。本命 Bhava Chalit 表不用这条。
"""
for i in range(12):
start = cusps[i]
end = cusps[(i + 1) % 12]
arc = _angular_dist(start, end)
pos = _angular_dist(start, planet_lon)
if arc > 0 and pos < arc:
return i + 1
return 1
def _planet_rashi_house(self, planet_lon: float, asc_lon: float) -> int:
"""整宫制宫位 (Rashi house)。"""
p_si = _sign_idx(planet_lon)
@@ -330,7 +344,8 @@ class BhavaChalitCalculator:
asc_lon: float, mc_lon: float,
house_system: str = 'sripati',
jd: float = None, lat: float = None,
lon: float = None) -> Dict:
lon: float = None,
house_assignment: str = 'sandhi') -> Dict:
"""根据 Bhava 边界重新分配行星宫位。
Parameters
@@ -346,13 +361,18 @@ class BhavaChalitCalculator:
dict with keys:
house_system, boundaries, planets
"""
if house_assignment not in ('sandhi', 'cusp_to_cusp'):
raise ValueError("house_assignment must be 'sandhi' or 'cusp_to_cusp'")
cusps = self.calculate_cusps(asc_lon, mc_lon, house_system,
jd, lat, lon)
sandhis = self.calculate_sandhis(cusps)
planets = {}
for pname, plon in planet_lons.items():
bhava = self._planet_bhava(plon, sandhis)
if house_assignment == 'cusp_to_cusp':
bhava = self._planet_bhava_from_cusps(plon, cusps)
else:
bhava = self._planet_bhava(plon, sandhis)
rashi = self._planet_rashi_house(plon, asc_lon)
si = _sign_idx(plon)
deg_in_sign = plon - si * 30
@@ -380,6 +400,7 @@ class BhavaChalitCalculator:
return {
'house_system': house_system,
'house_system_desc': self.HOUSE_SYSTEMS.get(house_system, ''),
'house_assignment': house_assignment,
'ascendant_lon': round(asc_lon, 4),
'mc_lon': round(mc_lon, 4),
'planets': planets,
+3 -3
View File
@@ -6378,7 +6378,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
periods = []
current = birth_dt
for lord in DASHA_ORDER:
end_date = current + timedelta(days=period_years * 365.25636)
end_date = current + timedelta(days=period_years * 365.25)
periods.append({
'lord': lord,
'years': period_years,
@@ -7398,7 +7398,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
current_idx, current_md = analyzer.find_current(timeline, today)
antardashas = analyzer.build_antardasha(current_md)
current_ad = analyzer.find_current_sub(antardashas, today)
years_into_md = max(0.0, (today - current_md['start']).days / 365.25636)
years_into_md = max(0.0, (today - current_md['start']).days / 365.25)
five_levels = enhanced.calculate_five_level_dasha(current_md['lord'], years_into_md)
formatted_timeline = [self._format_datetime_period(period) for period in timeline]
formatted_antardashas = [self._format_datetime_period(period) for period in antardashas]
@@ -7551,7 +7551,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
evidence.append({
'label': 'Darakaraka (DK)',
'value': f"{dk_planet} · H{darakaraka.get('dk_house', '-')}",
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量 {darakaraka.get('marriage_quality_score', '-')}/100",
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量指数 {darakaraka.get('marriage_quality_score', '-')}(0到1)",
})
except Exception as exc:
darakaraka = {'error': str(exc), 'dk_planet': dk_planet}
+7 -1
View File
@@ -13963,6 +13963,7 @@ def cmd_kp(args):
)
bhava_chart = kp_house_calculator.get_bhava_chalit_chart(
planet_lons, float(asc_lon), 0.0, 'placidus', jd, args.lat, args.lon,
house_assignment='cusp_to_cusp',
)
bhava_planets = bhava_chart.get('planets') if isinstance(bhava_chart.get('planets'), dict) else {}
@@ -13977,7 +13978,12 @@ def cmd_kp(args):
'house': bhava_row.get('bhava_house', pdata.get('house', 1)),
}
result = calc_kp_analysis(planet_positions, asc_sign, house_cusps=boundaries.get('cusps'))
result = calc_kp_analysis(
planet_positions,
asc_sign,
house_cusps=boundaries.get('cusps'),
house_occupancy_basis='cusp_to_cusp',
)
moon_lon = planets.get('Moon', {}).get('degree_raw', planets.get('Moon', {}).get('degree', 0.0)) if isinstance(planets.get('Moon'), dict) else 0.0
try:
birth_moment = datetime(int(args.year), int(args.month), int(args.day), int(args.hour), int(getattr(args, 'minute', 0) or 0))
+7
View File
@@ -371,6 +371,7 @@ def calc_kp_analysis(
planet_positions: Dict,
asc_sign: str = 'Aries',
house_cusps: Optional[List[float]] = None,
house_occupancy_basis: Optional[str] = None,
) -> Dict:
"""
完整KP分析(基于 diliprk/VedicAstro MIT 算法)。
@@ -379,6 +380,7 @@ def calc_kp_analysis(
planet_positions: 行星位置 {planet: {'sign': str, 'degree': float, 'house': int}}
asc_sign: 上升星座名称(无显式宫头时用于 whole-sign 代理)
house_cusps: 可选的 12 个实际宫头黄经。提供时优先用于 KP 宫头与显著星。
house_occupancy_basis: 传了宫头就必须声明判宫口径;没传宫头时记 whole_sign_proxy。
Returns:
完整KP分析结果
@@ -405,8 +407,11 @@ def calc_kp_analysis(
}
explicit_cusps = house_cusps is not None
if explicit_cusps and not house_occupancy_basis:
raise ValueError('house_occupancy_basis is required when house_cusps are provided')
if explicit_cusps and len(house_cusps) != 12:
raise ValueError('house_cusps must contain exactly 12 longitudes')
occupancy_basis = house_occupancy_basis if explicit_cusps else 'whole_sign_proxy'
# 2. 构建宫位信息(含 KP lords)。无实际宫头时保留历史 whole-sign 中点代理。
houses = []
@@ -438,6 +443,7 @@ def calc_kp_analysis(
'source': 'diliprk/VedicAstro MIT License',
'maturity_profile': kp_maturity_profile(),
'house_basis': 'explicit_cusps' if explicit_cusps else 'whole_sign_proxy',
'house_occupancy_basis': occupancy_basis,
'planets': {pname: {'kp_lords': data['kp_lords'], 'significators': planet_sig.get(pname, {})}
for pname, data in kp_planets.items()},
'houses': {h['house']: {'sign': h['sign'], 'cusp_longitude': h['cusp_longitude'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})}
@@ -494,6 +500,7 @@ def build_kp_report_pack(
f"- claim_status: `{maturity.get('claim_status')}`",
f"- truth_matrix_allowed: `{maturity.get('truth_matrix_allowed')}`",
f"- closed_oracle_parity_assets: `{', '.join(closed_parity_assets.keys())}`",
f"- House occupancy basis: `{kp_analysis.get('house_occupancy_basis') or 'whole_sign_proxy'}`",
f"- planet_count: `{len(planets)}`",
f"- house_count: `{len(houses)}`",
f"- dba_period_count: `{len(periods)}`",
+41 -7
View File
@@ -21,6 +21,23 @@ from typing import Dict, Optional, List
SIGN_CN = ['白羊座','金牛座','双子座','巨蟹座','狮子座','处女座',
'天秤座','天蝎座','射手座','摩羯座','水瓶座','双鱼座']
# D9 结构证据档位(BUG-1318)。中文给报告正文,英文键与英文短句给英文版。
# 不写婚姻稳定、离婚、再婚、段数或婚前咨询。
D9_CLAIM_BOUNDARY = (
'D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询'
)
D9_CLAIM_BOUNDARY_EN = (
'D9 structural evidence index. It does not predict marriage count, stability, '
'divorce, or partner quality, and it does not suggest premarital counseling.'
)
D9_EVIDENCE_BANDS = (
('supportive_evidence_stronger', '支持证据较强', 'Stronger supportive evidence'),
('supportive_evidence_mixed_positive', '支持证据偏正', 'Mixed supportive evidence'),
('mixed_evidence', '证据混杂', 'Mixed evidence'),
('pressure_evidence_mixed', '压力证据偏杂', 'Mixed pressure evidence'),
('pressure_evidence_stronger', '压力证据较强', 'Stronger pressure evidence'),
)
def sign_of(lon: float) -> int:
"""从黄道经度计算星座序号 (0-11)"""
@@ -348,25 +365,42 @@ def _assess_d9_marriage_quality(
quality_points -= 1
factors.append("D9 Ketu在7宫 → 关系中的分离倾向")
# 综合评级
# 证据档位,不是婚姻结局(BUG-1318,上游 f891a2c1)。
if quality_points >= 4:
rating = "高(关系质量好,伴侣支持强,婚姻稳定)"
band = D9_EVIDENCE_BANDS[0]
elif quality_points >= 1:
rating = "中上(关系质量尚可,需经营但基础好)"
band = D9_EVIDENCE_BANDS[1]
elif quality_points >= 0:
rating = "中(关系质量一般,需用心经营)"
band = D9_EVIDENCE_BANDS[2]
elif quality_points >= -2:
rating = "中下(关系有挑战,需提前沟通和调整期望)"
band = D9_EVIDENCE_BANDS[3]
else:
rating = "低(关系质量有重大挑战,强烈建议婚前咨询)"
band = D9_EVIDENCE_BANDS[4]
return {
'quality_rating': rating,
'quality_band': band[0],
'quality_rating': band[1],
'quality_rating_en': band[2],
'quality_points': quality_points,
'factors': factors,
'claim_boundary': D9_CLAIM_BOUNDARY,
'claim_boundary_en': D9_CLAIM_BOUNDARY_EN,
}
def render_d9_evidence_report(quality: Dict, language: str = 'zh') -> str:
"""中文报告用证据档位短句;英文版用英文短句。两边都带同一条边界。"""
if not isinstance(quality, dict):
quality = {}
if language == 'en':
band = quality.get('quality_rating_en') or quality.get('quality_band') or ''
boundary = quality.get('claim_boundary_en') or D9_CLAIM_BOUNDARY_EN
return f"D9 evidence band: {band}\n{boundary}"
band = quality.get('quality_rating') or ''
boundary = quality.get('claim_boundary') or D9_CLAIM_BOUNDARY
return f"D9 证据档位:{band}\n{boundary}"
def _marriage_recommendations(base: Dict, d9_quality: Dict) -> List[str]:
"""几何指数与伴侣数量、事件断语分开(BUG-1183);不随指数大小给不同结论。"""
return [
+1
View File
@@ -2140,6 +2140,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'### p123 KP Chart / Cusps',
'',
f"- House basis:{_cell(kp.get('house_basis'))}",
f"- House occupancy basis:{_cell(kp.get('house_occupancy_basis'))}",
'',
'| House | Sign | Cusp longitude | KP lord chain |',
'|-------|------|----------------|---------------|',
+4 -2
View File
@@ -1687,11 +1687,13 @@ class YogaEngine:
# 辅助:获取角宫主星列表
def _kendra_lords_list():
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
# set 顺序随进程哈希变化;排序后同一张盘的角宫主参与者顺序才稳定。
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
# 辅助:获取三方宫主星列表
def _trikona_lords_list():
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
# set 顺序随进程哈希变化;排序后同一张盘的三方主参与者顺序才稳定。
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
safe_globals = {
# 安全内置函数:只开放规则表达式需要的纯函数,避免 custom 规则因 all/any/len/set 缺失而静默失效。
+134
View File
@@ -0,0 +1,134 @@
"""BUG-1318: D9 marriage rating is an evidence band, not an outcome claim.
Upstream f891a2c1. The five bands must not say the relationship is stable,
ending, repeated, or in need of premarital counseling. The DK evidence line
keeps the 0-1 index and does not print a fake /100 score.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
ROOT = os.path.join(os.path.dirname(__file__), "..")
SCRIPTS = os.path.join(ROOT, "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from jyotish_engine import compute_chart_data # noqa: E402
def _api_handler():
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
spec = importlib.util.spec_from_file_location("_api_security_helpers", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module._handler()
from marriage_counting import ( # noqa: E402
D9_CLAIM_BOUNDARY,
D9_EVIDENCE_BANDS,
_assess_d9_marriage_quality,
marriage_counting_full_analysis,
render_d9_evidence_report,
)
from varga import calc_varga # noqa: E402
BANNED = re.compile(r"稳定|离婚|再婚|婚前咨询|几段")
def _quality(points_chart: dict) -> dict:
return _assess_d9_marriage_quality(points_chart, None)
def test_five_bands_have_no_outcome_claims_and_keep_the_boundary() -> None:
samples = {
"supportive_evidence_stronger": {"Venus": 331.0, "Mars": 271.0, "Jupiter": 91.0},
"supportive_evidence_mixed_positive": {"Venus": 31.0},
"mixed_evidence": {"Venus": 10.0},
"pressure_evidence_mixed": {"Venus": 151.0},
"pressure_evidence_stronger": {"Venus": 151.0, "Mars": 91.0, "Jupiter": 271.0},
}
seen = []
for key, chart in samples.items():
quality = _quality(chart)
assert quality["quality_band"] == key
assert quality["claim_boundary"] == D9_CLAIM_BOUNDARY
assert quality["claim_boundary_en"]
assert not BANNED.search(quality["quality_rating"])
assert not BANNED.search(quality["quality_rating_en"])
seen.append(quality["quality_band"])
assert seen == [band[0] for band in D9_EVIDENCE_BANDS]
def test_public_chart_report_snapshots_in_both_languages() -> None:
cases = json.loads(
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
)["cases"]
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
assert birth["source"]["rodden_rating"] == "AA"
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
chart, *_ = compute_chart_data(
year, month, day, hour, minute,
birth["latitude"], birth["longitude"], birth["timezone_offset"],
ayanamsa_name="raman",
)
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
d9 = {}
for name, lon in longitudes.items():
divisional = calc_varga(lon, 9)
d9[name] = divisional["sign_idx"] * 30 + divisional["degree_in_sign"]
result = marriage_counting_full_analysis(chart["houses"]["house_7"]["lord"], longitudes, d9)
quality = result["d9_marriage_quality"]
assert quality["quality_band"] == "mixed_evidence"
assert quality["quality_points"] == 0
chinese = render_d9_evidence_report(quality, "zh")
english = render_d9_evidence_report(quality, "en")
assert chinese == (
"D9 证据档位:证据混杂\n"
"D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询"
)
assert english == (
"D9 evidence band: Mixed evidence\n"
"D9 structural evidence index. It does not predict marriage count, stability, "
"divorce, or partner quality, and it does not suggest premarital counseling."
)
assert "/100" not in chinese
assert "/100" not in english
def test_dk_evidence_line_prints_the_zero_to_one_index() -> None:
cases = json.loads(
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
)["cases"]
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
chart, asc_idx, *_ = compute_chart_data(
year, month, day, hour, minute,
birth["latitude"], birth["longitude"], birth["timezone_offset"],
ayanamsa_name="raman",
)
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
handler = _api_handler()
evidence = handler._compute_relationship_timing_evidence(
{},
chart["planets"],
longitudes,
asc_idx,
chart["ascendant"]["sign"],
)
notes = [row["note"] for row in evidence["evidence"] if row.get("label") == "Darakaraka (DK)"]
assert len(notes) == 1
assert "/100" not in notes[0]
assert "婚姻质量指数" in notes[0]
assert "(0到1)" in notes[0]
score = evidence["darakaraka"]["marriage_quality_score"]
assert isinstance(score, float)
assert 0.0 <= score <= 1.0
assert str(score) in notes[0]
+62
View File
@@ -0,0 +1,62 @@
"""BUG-1321: the API fallback dasha year matches dasha_analyzer's 365.25 days.
Upstream 4d543209. The two 365.25636 constants in the API server made the
fallback timeline and the years-into-mahadasha figure drift from the analyzer.
"""
from __future__ import annotations
import importlib.util
import os
import sys
from datetime import datetime, timedelta
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra # noqa: E402
def _api_handler():
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
spec = importlib.util.spec_from_file_location("_api_security_helpers_dasha", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module._handler()
def test_fallback_timeline_matches_analyzer_to_the_day() -> None:
birth = datetime(2000, 1, 1)
nak_info, progress, _pada = lon_to_nakshatra(0.0)
timeline, _elapsed, _remaining, _lord = build_dasha_timeline("2000-01-01", nak_info, progress)
handler = _api_handler()
years = timeline[0]["years"]
periods = handler._fallback_dasha_periods(birth, "generic", {"years": years * 9})
assert periods[0]["lord"] == timeline[0]["lord"]
assert periods[0]["start"] == timeline[0]["start"].strftime("%Y-%m-%d")
assert periods[0]["end"] == timeline[0]["end"].strftime("%Y-%m-%d")
fallback_days = (
datetime.strptime(periods[0]["end"], "%Y-%m-%d")
- datetime.strptime(periods[0]["start"], "%Y-%m-%d")
).days
analyzer_days = (timeline[0]["end"].date() - timeline[0]["start"].date()).days
assert fallback_days - analyzer_days == 0
long_periods = handler._fallback_dasha_periods(birth, "generic", {"years": 1422})
expected = (birth + timedelta(days=158 * 365.25)).strftime("%Y-%m-%d")
stale = (birth + timedelta(days=158 * 365.25636)).strftime("%Y-%m-%d")
assert expected != stale
assert long_periods[0]["end"] == expected
def test_years_into_mahadasha_uses_the_analyzer_year() -> None:
birth = datetime(2000, 1, 1)
today = datetime(2003, 1, 1)
handler = _api_handler()
layer = handler._compute_vimshottari_analysis_layer(birth, 0.0, current_date="2003-01-01")
days = (today - birth).days
years_into = layer["five_levels"]["mahadasha"]["years_into_period"]
assert years_into == days / 365.25
assert years_into != days / 365.25636
+58
View File
@@ -0,0 +1,58 @@
"""BUG-1319: KP house occupancy is cusp-to-cusp and must be declared.
Upstream 0337b77f. Natal Bhava Chalit stays on the sandhi midpoint rule.
"""
from __future__ import annotations
import os
import sys
import pytest
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from bhava_chalit import BhavaChalitCalculator # noqa: E402
from kp_system import build_kp_report_pack, calc_kp_analysis # noqa: E402
def test_cusp_to_cusp_differs_from_sandhi_and_default_houses_stay() -> None:
calc = BhavaChalitCalculator()
planets = {"Sun": 40.0}
sandhi = calc.get_bhava_chalit_chart(planets, 15.0, 270.0, "equal")
explicit = calc.get_bhava_chalit_chart(
planets, 15.0, 270.0, "equal", house_assignment="sandhi",
)
cusp = calc.get_bhava_chalit_chart(
planets, 15.0, 270.0, "equal", house_assignment="cusp_to_cusp",
)
assert sandhi["house_assignment"] == "sandhi"
assert explicit["planets"]["Sun"]["bhava_house"] == sandhi["planets"]["Sun"]["bhava_house"] == 2
assert cusp["house_assignment"] == "cusp_to_cusp"
assert cusp["planets"]["Sun"]["bhava_house"] == 1
assert cusp["planets"]["Sun"]["bhava_house"] != sandhi["planets"]["Sun"]["bhava_house"]
def test_unknown_house_assignment_is_rejected() -> None:
calc = BhavaChalitCalculator()
with pytest.raises(ValueError, match="house_assignment"):
calc.get_bhava_chalit_chart({"Sun": 10.0}, 15.0, 270.0, "equal", house_assignment="whole")
def test_kp_cusps_require_an_occupancy_basis_and_the_report_names_it() -> None:
planets = {"Sun": {"sign": "Taurus", "degree": 10.0, "house": 2}}
cusps = [15.0 + 30.0 * index for index in range(12)]
with pytest.raises(ValueError, match="house_occupancy_basis"):
calc_kp_analysis(planets, "Aries", house_cusps=cusps)
with pytest.raises(ValueError, match="house_occupancy_basis"):
calc_kp_analysis(planets, "Aries", house_cusps=cusps, house_occupancy_basis="")
declared = calc_kp_analysis(
planets, "Aries", house_cusps=cusps, house_occupancy_basis="cusp_to_cusp",
)
assert declared["house_occupancy_basis"] == "cusp_to_cusp"
proxy = calc_kp_analysis(planets, "Aries")
assert proxy["house_occupancy_basis"] == "whole_sign_proxy"
markdown = build_kp_report_pack(declared)["exports"]["markdown"]
assert "House occupancy basis: `cusp_to_cusp`" in markdown
+72
View File
@@ -0,0 +1,72 @@
"""BUG-1320: kendra and trikona lord lists do not follow PYTHONHASHSEED.
Upstream 0337b77f. Two processes with different hash seeds must list the
same yoga participants in the same order.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_PROBE = r"""
import json
import sys
sys.path.insert(0, "scripts")
from yoga_engine import YogaContext, YogaEngine, detect_yogas
planets = {
"Sun": {"sign": "Leo", "house": 5, "degree": 10},
"Moon": {"sign": "Cancer", "house": 4, "degree": 5},
"Mars": {"sign": "Aries", "house": 1, "degree": 8},
"Mercury": {"sign": "Virgo", "house": 6, "degree": 12},
"Jupiter": {"sign": "Sagittarius", "house": 9, "degree": 15},
"Venus": {"sign": "Libra", "house": 7, "degree": 3},
"Saturn": {"sign": "Capricorn", "house": 10, "degree": 20},
"Rahu": {"sign": "Taurus", "house": 2, "degree": 9},
"Ketu": {"sign": "Scorpio", "house": 8, "degree": 9},
}
rules = "references/yoga_rules.json"
engine = YogaEngine(rules)
ctx = YogaContext(planets, "Aries")
namespace = engine._build_custom_exec_globals(ctx, {}, {})
rows = detect_yogas(planets, "Aries", rules)
print(json.dumps({
"kendra": namespace["kendra_lords_list"](),
"trikona": namespace["trikona_lords_list"](),
"participants": [
[row.get("rule_id"), str(row.get("combination"))]
for row in rows
],
}, ensure_ascii=False))
"""
def _run(seed: str) -> dict:
env = os.environ.copy()
env["PYTHONHASHSEED"] = seed
env["PYTHONIOENCODING"] = "utf-8"
completed = subprocess.run(
[sys.executable, "-c", _PROBE],
cwd=ROOT,
env=env,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return json.loads(completed.stdout)
def test_yoga_participants_keep_the_same_order_across_hash_seeds() -> None:
first = _run("0")
second = _run("1")
assert first == second
assert first["kendra"] == sorted(first["kendra"])
assert first["trikona"] == sorted(first["trikona"])
assert first["kendra"] == ["Mars", "Moon", "Saturn", "Venus"]
assert first["trikona"] == ["Jupiter", "Mars", "Sun"]