merge origin/staging into the chart subprocess gate branch

Keep the pushed fix commit and bring in the bounce brief plus the data-layer fix.

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-15 10:11:38 +08:00
co-authored by Grok (agent loop)
23 changed files with 554 additions and 48 deletions
+4
View File
@@ -4,6 +4,10 @@
- 合盘排两张盘时关掉用不上的外部概览,两张盘一起排。个人报告在没装参照引擎时不再为注定失败的回放反复起进程。轻重任务要不要分开限流,这一单只给了模拟表,还没改规则。
## 2026-10-15 — 旧版校正评分轮询不再中途报错(待合入 staging)
- 旧版生时校正在查询评分进度时,会因为多读了一步数据库结果而直接失败。现在按数据库实际返回的那一行读取。其他查询行为不变。Skill 版本不变(BUG-1351)。
## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging)
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。
+17
View File
@@ -36191,3 +36191,20 @@
- 相关记录:BUG-203(锁文件,不是这一条)、BUG-1063(人物表列级读取权)、BUG-1348。
- 复发自:无。
- 修复版本:分支 `codex/release-path-production-20261014`。编号接在 BUG-1349 之后。
## BUG-1351 | 自托管下旧版校正评分轮询 rpc 结果多读一步,必抛 TypeError
- 状态:resolved
- 首次发现:2026-10-13
- 最近更新:2026-10-15
- 影响面:旧版生时校正(legacy-guided-v1)的评分轮询,`POST /api/birth-time-journey` 且类型为 `poll_scoring`
- 用户现象:这类存量校正在查看评分进度时直接失败,页面拿不到「已领取 / 处理中 / 已完成」。
- 触发条件:自托管数据库(当前 staging 与生产都是这条路径)上,对 `claim_birth_time_scoring_job` 的返回值再调用 `.single()`。该函数返回的是表格里的一行,本地适配层把整表收成数组,没有 `.single`。
- 根因:数据访问层被标成完整的 Supabase 客户端,编译器看不出适配层没有 `.single`。原有测试用内存仓库,没有走过真实适配层。
- 修复:去掉 `.single()`。返回值如果是数组就取第一行,如果已是单行对象就直接用,再做原来的字段校验。Supabase 回滚分支不带 `.single()` 时同样返回数组,行为一致。
- 验证:修复前两条单元测试都抛出 `supabase.rpc(...).single is not a function`。修复后这两条通过。另有一条真实迁移测试:函数返回值是数组,认领结果为已领取,且不抛错。`tsc --noEmit` 0 错。
- 防复发:适配层 `implements` 自己的 `DataClient`。新增调用如果用了适配层没有的方法,编译直接失败。评分认领有数据库级用例。
- 相关记录:BUG-124、BUG-130、BUG-576、BUG-926
- 复发自:无
- 修复版本:分支 `codex/data-layer-types-20261013`。任务书预留 BUG-1343;开工时本文件最大号已是 BUG-1350,1343 没有条目。按「从当前最大号往后」编为 BUG-1351。
@@ -0,0 +1,92 @@
# PROGRESS · 数据访问层 DataClient 类型(第一阶段)
- 分支:`codex/data-layer-types-20261013`
- 基线:`origin/staging` `9274491681f6fcacbc87e3797441d2c5221de874`
- 前置:`origin/codex/node-runtime-resources-20261013` 已是 `origin/staging` 的祖先(`merge-base --is-ancestor` 退出码 0)。`local-postgres-client-core.ts` 只加了 `import type` 和两处 `implements`。
- `frontend/src/lib/rectification-agentic/session.ts` 已不在,本单没有改它。
- 编号:任务书预留 BUG-1343。开工时 `docs/BUG_HISTORY.md` 最大号是 BUG-1350,1343 没有条目。按台账规则从最大号往后,记为 **BUG-1351**。
## T1 · 编译器暴露的 2 条
`DataClient` 换上之后、缺陷尚未修时,`tsc --noEmit` 退出码 2,恰好 2 条:
```
src/app/api/chart-profiles/route.ts(59,40): error TS7006: Parameter 'row' implicitly has an 'any' type.
src/lib/birth-time-scoring-job-store.ts(103,10): error TS2339: Property 'single' does not exist on type 'Promise<Readonly<{ data: any; error: Readonly<{ message: string; code?: string | undefined; }> | null; count?: number | null | undefined; }>>'.
```
`grep`:`as unknown as SupabaseClient` 在 `frontend/src` 无结果。`@supabase/supabase-js` 只剩 `admin-client-core.ts` 的 `createClient` 回滚分支。
## T2 · 缺陷修复红转绿
修复前,`claimScoringJob` 对返回数组的假 `rpc` 调用 `.single()`:
- `claimScoringJob reads the first row when rpc returns an array`:失败,`TypeError: supabase.rpc(...).single is not a function`
- `claimScoringJob also accepts the single-object rpc shape`:同样失败
去掉 `.single()` 并兼容数组与单对象之后,这两条通过。真实迁移用例 `claim_birth_time_scoring_job returns an array and claimScoringJob accepts it` 通过:`data` 是数组,第一行 `claim_state` 为 `claimed`,store 返回 `{ kind: "claimed", algorithmVersion }`。第一次启动测试库时迁移进程只留下笼统的 `database migration failed`(脚本会把非预期异常收成这一句),容器未到 Healthy。同一条用例在容器 Healthy 后重跑通过。
`chart-profiles/route.ts` 的 `row` 标为 `unknown`。
修完后 `tsc --noEmit` 退出码 0。`npm run lint` 退出码 0,0 error,123 warning(与改动前的存量警告数量一致,本单没有新增 warning)。
## T3 · 转译差分
类型层改动用 esbuild 0.28.1、`--format=esm`、`--tsconfig=frontend/tsconfig.json`,旧文件与新文件都按这份 tsconfig 转译。除 T2 的两个文件和新建的 `data-client.ts` 外,全部 `SAME`:
- `src/lib/birth-time-journey-case-loader.ts`
- `src/lib/birth-time-journey-store.ts`
- `src/lib/birth-time-journey-turn-persistence.ts`
- `src/lib/db/local-postgres-client-core.ts`
- `src/lib/rectification-agentic/v9/case-service.ts`
- `src/lib/rectification-agentic/v9/product-domain.ts`
- `src/lib/rectification-agentic/v9/tool-service.ts`(只改了一句注释;转译后注释被去掉,产物相同)
- `src/lib/rectification-in-chat-server.ts`
- `src/lib/supabase/admin-client-core.ts`
- `src/lib/supabase/server.ts`
- `src/mastra/rectification-v9-tools.ts`
任务书把旧文件写到系统临时目录再转译。esbuild 0.28.1 会沿文件所在目录寻找 `tsconfig.json`。临时目录找不到本仓库的 `tsconfig.json`(`target` 为 ES2017),类字段的转译结果会和仓库内文件不一致,即使源码只有类型或注释差别也会报 DIFF。两边都带上同一份 `--tsconfig` 后为 SAME。这不是运行时变化。
## T4 · 把 data 收成 unknown 的错误清单
临时把 `UntypedData` 改成 `unknown` 后 `tsc --noEmit` 退出码 2,共 93 条、12 个文件。随后已改回 `any`。
| 条数 | 文件 |
| ---: | --- |
| 24 | `src/app/api/account/route.ts` |
| 17 | `src/app/api/consult/route.ts`(其中 1 条是 `DataClient` 不能赋给 `GuardedTitleClient`,`consult/route.ts` 约 1055 行) |
| 16 | `src/app/api/consult/status/route.ts` |
| 12 | `src/app/api/payment/epay/status/route.ts` |
| 7 | `src/lib/epay/availability.ts` |
| 5 | `src/lib/rectification-agentic/v9/product-domain.ts` |
| 4 | `src/app/api/sessions/route.ts` |
| 3 | `src/lib/rectification-agentic/v9/agent-route-request.ts` |
| 2 | `src/app/api/chart-profiles/[id]/route.ts` |
| 1 | `src/app/api/account/avatar/route.ts` |
| 1 | `src/app/api/chart-profiles/route.ts` |
| 1 | `src/app/api/sessions/[id]/route.ts` |
和任务书写作时的 122 条相比,`consult/route.ts` 少了 10 条,`chart-profiles/route.ts` 少了 1 条(本单已把 `row` 标成 `unknown`)。没有单独再出现一条 `session-title-guard.ts` 的错误,`GuardedTitleClient` 那条记在 `consult/route.ts` 里。这些留给第二阶段,本单没有改那些业务文件。
## test:db
`npm run test:db`(`tsx --test --test-concurrency=1 tests/database-*.test.ts`)约 19 分钟,退出码 1。汇总:85 条,通过 72,失败 13,取消 0,跳过 0。
失败的 13 条与 `tools/agent-loop/windows-known-failures.txt` 逐条一致,没有多出来的失败:
- staging backups are encrypted, atomic, private, and retain the newest three
- rejects destructive backup directory aliases and symlink components before mutation
- rejects unsafe writable backup parents before creating the target
- rejects a direct canonical sticky shared backup directory before chmod
- creates every absent backup path component privately despite a permissive caller umask
- same-second backups publish once without overwriting the completed archive
- find enumeration failures preserve existing backups and do not report completion
- refuses full disks and removes a failed-pipeline partial file
- database env validator accepts punctuated literal required secrets
- database env validator enforces an explicit staging owner uid without printing values
- database env validator accepts a private valid file without printing values
- personal report job migration is atomic, lease-bound, recoverable, and owner read-only
- personal report sections enforce owner-read RLS and service-owned durable transitions
本单新增的 3 条(两条假客户端、一条真实迁移认领)都在通过的 72 条里。
+5 -2
View File
@@ -213,6 +213,7 @@
| --- | --- | --- | --- | --- |
| `TASK-rectification-step0-entry-20261013.md` | — | **明说要校正时第一步就能打开(agent 循环 issue #54,BUG-1306 复发)**:第 0 步只放排盘工具、规则正向句埋在禁止句里、「纠正」撞词,模型没带 explicitRequest 调工具(BUG-1317)。产品选 B:第 0 步放出校正工具 + 规则重排;推翻 BUG-923 第 0 步限制,保险用现有 runtime-contract-retry。执行方 Grok | 待领取 | `codex/rectification-step0-entry-20261013` |
| `TASK-report-focus-card-20261013.md` | — | **报告开篇「重点」做成摘要卡(agent 循环 issue #55)**:10-08 加的开篇重点从未设计——三重「重点」、标题无样式、MD/AD/PD 行话、数据句堆砌(BUG-1316)。产品选 A 摘要卡 + 名字与日期 + 保留提醒句;引擎出 `jyotish-focus` 围栏,前端一个组件渲染。执行方 Grok | 待领取 | `codex/report-focus-card-20261013` |
| `TASK-chart-ascendant-row-20261015.md` | — | **星盘页上升行无星宿 / 表头错位 / 大运卡左边线(issue #53 第二轮)**:`/api/chart` 重建上升时丢了星宿三字段,BUG-1201 从未打通(golden 取自引擎内部);状态改「不适用」(推翻 DESIGN 原口径);居中列表头没居中;当前大运 inset 左线去掉。执行方 Grok | 待领取 | `codex/chart-ascendant-row-20261015` |
| `TASK-report-translation-gaps-20261013.md` | — | **个人报告中英混杂、正文图盘空白、上升行、同位列(agent 循环 issue #53)**:中文完整数据版靠短词替换英文,正文残留 218 处英文词与 14 处中英粘连,卫生检查只有黑名单(BUG-1312);完整数据版图盘节只有空标题、无围栏,BUG-1203 的回归(BUG-1313);报告本命表无上升行(BUG-1314);D9 同位列「—」读如没数据,改「是/否」(BUG-1315)。分盘核对段随 T1 自动消失。执行方 Grok | 待领取 | `codex/report-translation-gaps-20261013` |
| `TASK-report-translation-gaps-fix-20261013.md` | — | **#53 第 1 次打回**:`a295afa4` 实现四项都验过,但分支未基于最新 staging,且全量 pytest 新增 1 条失败(报告 Jaimini 星位名改成「灵魂星(AK)」后 `test_chara_karaka_8_bphs_order` 未跟改)。要求 rebase + 按三栏改测试,不改 `KARAKA_CN` | 待领取 | `codex/report-translation-gaps-20261013` |
| `TASK-report-full-data-edition-20261007.md` | `PROGRESS-report-full-data-edition-20261007.md` | **报告改为完整数据版、删除阅读版**:网站阅读版中文 7.6 万字符 vs 上游 `pl9_ai_density` 97 万;移植原始数据附录 / 清理器 / 时间补充,中英文;我方报告口径(BUG-1199~1229)全部保持;旧报告仍可打开;排在 sync6 之后 | 已验收(Claude 补修 BUG-1282 后合入 staging) | `codex/report-full-data-edition-20261007`(未推 staging) |
@@ -482,12 +483,14 @@
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | `PROGRESS-api-chart-subprocess-gate-20261013.md` | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待验收 | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | `PROGRESS-api-chart-subprocess-gate-20261013.md` | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 第 1 次打回,见修复单 | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-api-chart-subprocess-gate-fix-20261015.md` | — | **#64 第 1 次打回**:`336286e4` 内容通过(合盘 1.19→0.015 s、正文逐字节一致);Python 合同按源码检查合盘路由旧写法未跟改;A/B 脚本默认覆盖入库记录 | 待领取 | `codex/api-chart-subprocess-gate-20261013` |
| `TASK-answer-effort-ab-20261013.md` | — | 答题步推理强度开关 + A/B(需模型 key) | 待领取(排队:consult-prep-parallel) | `codex/answer-effort-ab-20261013` |
| `TASK-answer-effort-ab-fix-20261015.md` | — | **#65 第 1 次打回**:`ab7f66be` 开关逻辑通过(默认请求体不变);研究脚本从 `frontend/tests` 读文件(BUG-1140) | 待领取 | `codex/answer-effort-ab-20261013` |
| `TASK-consult-route-split-20261013.md` | — | consult POST 拆分第一阶段:开流前加载 + 计费结算 | 待领取(排队:consult-prep-parallel、answer-effort-ab) | `codex/consult-route-split-20261013` |
| `TASK-pl9-render-split-20261013.md` | — | render_pl9_markdown 第一阶段:搬出无依赖内部函数 | 待领取(排队:api-chart-subprocess-gate) | `codex/pl9-render-split-20261013` |
| `TASK-python-hygiene-20261013.md` | — | 免费层排队开关报反、静默吞异常、功能吉凶兜底合一、测试专用脚本归位(BUG-1340) | 待领取(排队:pl9-render-split) | `codex/python-hygiene-20261013` |
| `TASK-dasha-unify-20261013.md` | — | /api/dasha 改走引擎同一套实现、删占位注册表、Ashtottari 出生余额(BUG-1341、1344) | 待领取(排队:python-hygiene) | `codex/dasha-unify-20261013` |
| `TASK-api-server-service-extract-20261013.md` | — | API 单体第一阶段:拆出不带 HTTP 的计算类,生产伪造 handler 归零 | 待领取(排队:dasha-unify) | `codex/api-server-service-extract-20261013` |
| `TASK-data-layer-types-20261013.md` | — | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1343) | 待领取(排队:node-runtime-resources) | `codex/data-layer-types-20261013` |
| `TASK-data-layer-types-20261013.md` | `PROGRESS-data-layer-types-20261013.md` | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1351;任务书预留的 1343 已被更大编号越过) | 待验收 | `codex/data-layer-types-20261013` |
| `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` |
@@ -0,0 +1,36 @@
# TASK · 答题推理强度开关修复单(issue #65,第 1 次打回)
- 日期:2026-10-15
- 原任务书:`docs/tasks/TASK-answer-effort-ab-20261013.md`
- 被验 SHA:`ab7f66be14d93a5ccbc3433f3c44e134a65b1e21`(`codex/answer-effort-ab-20261013`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
## 1. 验收结论:开关逻辑通过,前端全量多出 1 条失败
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 开关默认关时请求体不变 | 通过 | `CONSULT_ANSWER_REASONING_EFFORT` 未设或为空时 `answerEffortOptions` 返回 `{}`;非法值只警告一次按未设处理 |
| 只在答题步加强度 | 通过 | 主循环第 0 步 `answerReady=false`;公开日历从第一步即答题 |
| 改既有断言 | 通过 | `consult-answer-clock-20261001.test.ts` 三栏齐全 |
| tsc / lint / Python 全量 | 通过 | Python 与 staging 逐条一致(一条 prashna 族测试并发下抖动,单跑通过) |
| **前端全量 `npm test`** | **未通过:新增 1 条** | `frontend/tests/runtime-sources-no-test-imports.test.ts`「files copied into the web image never import from frontend/tests」 |
**根因**:`frontend/scripts/research/answer-effort-probe.mts` 约 186 行 `new URL("../../tests/fixtures/answer-effort-switch-20261013.json", import.meta.url)`。web 镜像只复制 `frontend/src` 与 `frontend/scripts`,`next build` 会类型检查它们——推上 staging 后镜像构建会失败(BUG-1140;#58、#62 已因同一形状各打回一次)。
## 2. 修法要求
1. 把 `answer-effort-probe.mts` 挪到 `frontend/tests/answer-effort-probe.mts`(文件名不得以 `.test.ts` / `.test.tsx` 结尾),路径改相对;同步改 PROGRESS 与 `docs/testing/answer-effort-ab-20261013.md` 里的命令。
2. **交回前必须本机跑一次** `npx tsx --test tests/runtime-sources-no-test-imports.test.ts`,fail 与 cancelled 为 0,贴进回报。
3. 业务代码不改;开关默认值不改。
## 3. 非阻断提醒(不要求本轮改,写进 PROGRESS 即可)
开关打开时,`prepareStep` 返回的 `providerOptions` 来自 `agentGenerationSettings(model, { thinking: "enabled", ... })`。确认它不会把基础流选项里原有的 `providerOptions`(思考预算、缓存等)整体覆盖掉;若会覆盖,在 BLOCKED.md 记下,等有模型 key 的 A/B 一并核对。
## 4. 验收标准
前端全量 `npm test` 失败清单与 staging 基线逐名相同;`git diff ab7f66be..<新 SHA> -- frontend/src` 为空。
## 5. 让步顺序
无。第 1 次打回。
@@ -0,0 +1,37 @@
# TASK · 合盘与报告子进程修复单(issue #64,第 1 次打回)
- 日期:2026-10-15
- 原任务书:`docs/tasks/TASK-api-chart-subprocess-gate-20261013.md`
- 被验 SHA:`336286e428c7cb43cac71eae5286e112c0b500b7`(`codex/api-chart-subprocess-gate-20261013`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- BUG 编号:沿用本单编号
## 1. 验收结论:内容通过,Python 全量多出 1 条失败
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 合盘带 skip、两两并发 | 通过 | Claude 在 Linux 复跑 `scripts/research/api_chart_subprocess_ab.py`:串行 1.19 s / 4 个子进程 → 并发带 skip 0.015 s / 0 个,摘要逐字节一致 |
| 报告正文 | 通过 | 同脚本 `report_bytes_match: true`(本机装了 PyJHora,短路不触发,子进程数不变;生产不装,以 Grok 回报为准) |
| 进程内缓存 | 通过 | 只缓存「PyJHora 是否可导入」「外部解释器路径」「静态 oracle 文件校验(键含路径 + mtime + 大小)」,不含出生资料(BUG-721) |
| 主文件 | 通过 | 类方法数不变,行数减少 |
| 前端全量 / tsc / lint | 通过 | 失败清单与 staging 基线相同 |
| **Python 全量(与 staging 逐条比对)** | **未通过:新增 1 条** | `tests/test_supabase_user_data_contract.py::test_synastry_route_orchestrates_python_chart_and_ashtakoot`:`assert 'postPython("/api/chart", selfPayload)' in route` |
**根因**:这条 Python 合同按源码文本检查 `frontend/src/app/api/synastry/route.ts` 里的 `postPython("/api/chart", selfPayload)`。本单把它改成了 `Promise.all` 里的 `postPython("/api/chart", { ...selfPayload, skip_vedastro_main_entry_overview: true })`,合同没跟着改(AGENTS.md §7.8;Python 测试也会读前端源码,所以前端改动要 grep `tests/` 和 `frontend/tests/` 两处)。
另:本机复跑时 `api_chart_subprocess_ab.py` 会**覆盖仓库里的** `docs/testing/api-chart-subprocess-gate-ab-20261013.json`。研究脚本默认不应写已入库的记录文件:改为默认写到临时目录或 `artifacts/`,只有显式 `--out <路径>` 时才写指定文件。
## 2. 修法要求
1. 改 `tests/test_supabase_user_data_contract.py` 那条断言,按 §7.3 写「原值 / 新值 / 原因」三栏,**意图不放宽**:仍要锁住「合盘路由向 Python `/api/chart` 请求本人和对方两张盘」,新断言要同时锁住带 `skip_vedastro_main_entry_overview: true`;该测试里其它 token(如 ashtakoot 调用)不动。
2. `grep -rn "synastry/route\|postPython(\"/api/chart\"\|postPython(\"/api/varga_full\"" tests frontend/tests`,其它按源码文本断言的同样处理。
3. `api_chart_subprocess_ab.py` 默认不覆盖入库文件(见上)。
4. 业务代码不改。PROGRESS 末尾记一行。
## 3. 验收标准
Claude 全量 pytest 与 staging 基线逐条比对无新增;前端全量无新增;`git diff 336286e4..<新 SHA> -- frontend/src scripts/*.py` 只有第 3 条的脚本改动。
## 4. 让步顺序
无。第 1 次打回。
@@ -0,0 +1,102 @@
# TASK · 星盘页上升行无星宿、表头错位、大运卡左边线(issue #53 第二轮)
- 日期:2026-10-15
- 来源:issue #53,产品负责人 10-15 在 staging 星盘页反馈:D1 本命与月亮盘的上升行「星宿 / 宿主 / pada / 状态」全是「—」;分盘表「庙旺」表头与内容不对齐;大运卡左侧那条线要去掉
- 执行方:**Grok(Windows + Docker)**,分支 `codex/chart-ascendant-row-20261015`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:开工时核对 `docs/BUG_HISTORY.md` 最大号(写作时为 BUG-1350);**BUG-1351 已被 issue #65 的分支占用,本单从 BUG-1352 起**
## 1. 基线
`origin/staging` `10d0c4bba`。
## 2. 事故实证与根因
### A · 上升行星宿 / 宿主 / pada 为空(D1 与月亮盘同因)——BUG-1201 修复从未打通
- 引擎正确:`scripts/jyotish_engine.py::compute_chart_data`(约 12378 行)给上升加了 `**_nakshatra_fields(asc_deg)`;`scripts/domain_calculation_service.py::compute_chart`(约 242 行)原样传出。
- **`/api/chart` 把它们丢了**:`scripts/jyotish_api_server.py::_compute_chart_sync` 约 6660–6666 行重建 `result['ascendant']`,只留 `sign / sign_idx / degree / degree_in_sign / lon`;`result['modules']['chart']['ascendant']` 用的是同一个字典。该段来自 06-24 的 `494e3870`,BUG-1201 的 `ae18952e` 没碰它。虚构盘(1990-06-15 12:00 北京)实测 `_compute_chart_sync` 返回的上升没有任何星宿键。
- 前端映射没问题:`frontend/src/lib/chart-view-mapper.ts::ascendantRow`(约 390–409 行)读 `nakshatra / nakshatra_pada / nakshatra_lord`,缺失时按设计回落「—」。月亮盘复用同一个 `view.vedic.ascendant`(`chart-vedic-tab.tsx` 约 351–355 行)。
- **不是缓存问题**:服务器缓存 900 s、前端 5 分钟,`cache_schema_version` 已是 6;BUG-1201 那次把 4→5 也没用,因为接口输出根本没变。
- **为什么没拦住**:`tests/test_report_chart_blank_columns.py` 只调 `compute_chart_data`;前端 golden `frontend/tests/fixtures/chart-view-golden.json` 取自 `domain_calculation_service.compute_chart` 而不是接口真实响应,又手工补了 `_nakshatra_fields`——违反 AGENTS.md §7.4「合同 fixture 必须来自真实引擎响应」,所以丢字段的那一层从没被测到。
### B · 上升行「状态」写「—」
`chart-vedic-tab.tsx::AscendantRow` 约 99 行写死 `<td>—</td>`,注释与 `frontend/DESIGN.md` 约 1181 行写「上升没有尊贵,状态格写「—」」——这是当时的有意设计,不是回归。产品负责人这次把它算作「没数据」。
### C · 表头与内容不对齐
`frontend/src/app/globals.css` 约 5619–5623 行把 `.chart-page-planet-table` 的所有 `th / td` 设为左对齐;约 5658 行只把 `td.chart-page-planet-mid` 居中;对应的 `<th scope="col">` 没有这个类,于是表头靠左、内容居中。所有 `.chart-page-planet-table` 都有:D1 / 月亮盘的「速度、宿主、pada」,分盘的「庙旺、同位」,行运的「速度」,Bhava 的「换宫」。
### D · 大运卡左边线
`globals.css` 约 5774–5778 行 `.chart-page-dasha-period.is-current { …; box-shadow: inset 2px 0 0 var(--color-focus); }`(`--color-focus` 是陶土色),挂在当前大运上(默认展开的那一张,`chart-dasha-tab.tsx` 约 115 行)。来自 `1e3570b4`;`frontend/DESIGN.md`「大运 Tab」一节没写,也没有测试引用。
## 3. 决策记录
- 产品负责人在 issue #53 授权修复上面四项。
- **B(按推荐先定,产品不同意回一句即改)**:上升行「状态」改写「不适用」,与分盘表上升「庙旺」格(BUG-1099)、报告上升行(BUG-1314)同一口径。**这推翻 `frontend/DESIGN.md` 约 1181 行「状态格写「—」」**,DESIGN.md 同步改。
- **D(按推荐先定)**:只去掉左边那条线,当前大运的浅底与细边框保留,用来标「现在走的是这一段」。
- 不推翻 BUG-1201 的设计(上升行要有星宿 / 宿主 / pada),这次是把它真正打通;记为新 BUG「复发自 BUG-1201(修复未打通接口层)」,并在 BUG-1201 记录里补一行更正其验证结论。
## 4. 硬红线
1. `scripts/jyotish_api_server.py`:类方法数与 `JyotishAPIHandler.__new__` 伪造点不得增长(`tests/test_api_server_growth_contract.py`;注释里写这个词也算);只在 `_compute_chart_sync` 里补字段。
2. 不改引擎数值;不改 `_nakshatra_fields`、`compute_chart_data`。
3. 合同 fixture 必须来自**接口真实响应**(§7.4);不得手工补字段。
4. 改既有断言写「原值 / 新值 / 原因」三栏;开工先 grep(§7.8,Python 测试也会读前端源码):
`grep -rn "chart-page-planet-mid\|AscendantRow\|上升没有尊贵\|is-current\|chart-view-golden\|ascendant\b" frontend/tests tests | grep -v node_modules`
已知:`frontend/tests/chart-varga-table.test.tsx` 约 101–103 行断言上升行末尾 `<td>—</td>`;`frontend/tests/fixtures/chart-d1-table-baseline.html`;`chart-page-view.test.tsx`、`chart-page-hook.test.ts`。
5. `frontend/scripts/**`、`frontend/src/**` 不得从 `frontend/tests/**` 导入或读文件(BUG-1140,近期已 5 次打回)。交回前必须本机跑 `npx tsx --test tests/runtime-sources-no-test-imports.test.ts`。
6. 不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/**`;不动数据库;不升级依赖。
## 5. 任务分解
### T1 · 接口带出上升的星宿字段(新 BUG,复发自 BUG-1201)
- `_compute_chart_sync` 重建 `result['ascendant']` 时,从 `ascendant_data` 带上 `nakshatra`、`nakshatra_pada`、`nakshatra_lord`(引擎已有就原样带;没有就调 `_nakshatra_fields(lon)`);`cache_schema_version` 6 → 7。
- 验收:
- 新回归测试**经过 `/api/chart` 处理函数**(用现有测试里已有的调用方式,不得新增 `JyotishAPIHandler.__new__`):虚构盘的响应 `ascendant` 有三个字段,且等于 `_nakshatra_fields(lon)`;`modules.chart.ascendant` 同样有。
- 用这次的**接口真实响应**重新生成 `chart-view-golden.json` 的上升部分,`fixtureProvenance` 写明命令;删掉手工补字段的那一步。
- 前端:D1 与月亮盘上升行渲染出星宿、宿主、pada(测试断言具体值,不是只断言「不是 —」)。
### T2 · 上升行状态「不适用」
- `AscendantRow` 状态格改「不适用」;DESIGN.md 约 1181 行同步改;`chart-varga-table.test.tsx` 与 baseline html 按三栏改。
### T3 · 表头对齐
- 给居中列对应的 `<th scope="col">` 加 `chart-page-planet-mid`(或用列选择器),让表头与内容同为居中;涉及 D1 / 月亮盘、分盘、行运、Bhava 四类表。8 列表的 markup 若被 DESIGN.md 约 1195 行写成「冻结」,改用 CSS 选择器、不改 markup,并在 DESIGN.md 记一句。
- 验收:渲染测试断言每张表里居中列的 `th` 与 `td` 同为居中(类名或计算后的选择器);DESIGN.md 更新。
### T4 · 去掉大运卡左边线
- 删 `.chart-page-dasha-period.is-current` 的 `box-shadow` 那一行,浅底与边框保留;DESIGN.md「大运 Tab」一节加一句「当前大运用浅底和细边框标出,不画左侧强调线」。
- 验收:一条 CSS 合同测试断言 `.chart-page-dasha-period.is-current` 不含 `box-shadow`。
### T5 · 记录
- `docs/BUG_HISTORY.md`:新 BUG(上升星宿字段被 `/api/chart` 丢弃,复发自 BUG-1201,写清为何 golden 没拦住);BUG-1201 记录补一行更正;表头错位、左边线各一条或合为一条 UI 记录。
- `docs/tasks/PROGRESS-chart-ascendant-row-20261015.md`、`CHANGELOG.md`、`frontend/DESIGN.md`。
- 真机清单 `docs/testing/chart-ascendant-row-20261015.md`。
## 6. 让步顺序
T1 → T3 → T2 → T4。T1 不可让。
## 7. 验收命令分工
| 谁 | 命令 |
| --- | --- |
| Grok 自跑 | `python -m pytest tests/test_report_chart_blank_columns.py tests/test_api_server_growth_contract.py <新测试> -q`;前端 `tsc --noEmit`、`npm run lint`、改到 / 新增的前端测试单文件,**加** `tests/runtime-sources-no-test-imports.test.ts`(fail 与 cancelled 为 0)。前端全量与 Python 快速门不跑 |
| Claude 验收 | 上述 + Python 全量与基线逐条比对 + 快速门 + 前端全量 `npm test` + `next build`(`/` 仍 Static,首屏 gzip ±2%) |
| 不需要 | `test:db` |
## 8. 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/chart-ascendant-row-20261015 .worktrees/chart-ascendant-row-20261015 origin/staging
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # BUG-1351 已被 #65 占用
grep -rn "chart-page-planet-mid\|AscendantRow\|上升没有尊贵\|is-current\|chart-view-golden" frontend/tests tests
```
@@ -0,0 +1,9 @@
# 真机清单 · 星盘页上升行、表头、大运卡(issue #53 第二轮)
部署到 staging 后,打开本人星盘页(手机和电脑各一次):
1. D1 本命表第一行「上升」:星宿、宿主、pada 都有值;状态写「不适用」。
2. 切到月亮盘:上升那一行同样有星宿、宿主、pada,状态「不适用」。
3. 切到任一分盘(如 D9):「庙旺」「同位」表头和下面的内容都居中对齐。
4. D1 表的「速度、宿主、pada」,行运表的「速度」,Bhava 表的「换宫」:表头和内容对齐。
5. 大运 Tab:当前那段大运没有左侧那条彩色竖线,仍有浅色底能看出是「现在这一段」。
+1 -1
View File
@@ -56,7 +56,7 @@ export async function GET() {
.order("updated_at", { ascending: false });
if (error) throw error;
const profiles = (data ?? []).map((row) => toClientProfile(row as Record<string, unknown>));
const profiles = (data ?? []).map((row: unknown) => toClientProfile(row as Record<string, unknown>));
return NextResponse.json({
profiles,
limit: MAX_SAVED_SUBJECTS,
@@ -1,4 +1,4 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import { z } from "zod";
import {
parseRectificationQuestionnaire,
@@ -34,7 +34,7 @@ export type JourneyLoadClient = {
from(table: string): { select(columns: string): JourneyLoadQuery };
};
export function createJourneyLoadClient(supabase: SupabaseClient): JourneyLoadClient {
export function createJourneyLoadClient(supabase: DataClient): JourneyLoadClient {
return {
from(table) {
return {
+2 -2
View File
@@ -1,6 +1,6 @@
import "server-only";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import type {
BirthTimeJourneyStore,
} from "./birth-time-journey-service.ts";
@@ -24,7 +24,7 @@ import { createDynamicScoringJobStore } from "./birth-time-dynamic-scoring-job-s
export { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts";
export function createSupabaseBirthTimeJourneyStore(
supabase: SupabaseClient,
supabase: DataClient,
now: () => Date = () => new Date(),
): BirthTimeJourneyStore {
const loadClient = createJourneyLoadClient(supabase);
@@ -1,4 +1,4 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import { z } from "zod";
import type { JourneySnapshot } from "./birth-time-journey.ts";
import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts";
@@ -56,7 +56,7 @@ function canonicalAskedDomains(input: readonly EvidenceDomain[]): readonly Evide
}
export function createJourneyTurnPersistence(
supabase: SupabaseClient,
supabase: DataClient,
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
) {
return {
@@ -1,4 +1,4 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import { z } from "zod";
import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts";
import type {
@@ -58,7 +58,7 @@ async function requireReloaded(
}
export function createSupabaseScoringJobStore(
supabase: SupabaseClient,
supabase: DataClient,
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
): ScoringStoreMethods {
return {
@@ -91,18 +91,17 @@ export function createSupabaseScoringJobStore(
},
async claimScoringJob(identity) {
const { data, error } = await supabase
.rpc("claim_birth_time_scoring_job", {
p_user_id: identity.userId,
p_case_id: identity.caseId,
p_job_id: identity.jobId,
p_evidence_fingerprint: identity.evidenceFingerprint,
p_algorithm_version: identity.algorithmVersion,
p_now: identity.now,
})
.single();
const { data, error } = await supabase.rpc("claim_birth_time_scoring_job", {
p_user_id: identity.userId,
p_case_id: identity.caseId,
p_job_id: identity.jobId,
p_evidence_fingerprint: identity.evidenceFingerprint,
p_algorithm_version: identity.algorithmVersion,
p_now: identity.now,
});
if (error) throw mapScoringRpcError(error.message, error.code);
const parsed = claimSchema.safeParse(data);
const row = Array.isArray(data) ? data[0] ?? null : data;
const parsed = claimSchema.safeParse(row);
if (!parsed.success) throw new BirthTimeJourneyStoreError("load_case");
return {
kind: parsed.data.claim_state,
+46
View File
@@ -0,0 +1,46 @@
export type DataQueryError = Readonly<{ message: string; code?: string }>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- phase 1: equals SupabaseClient<any>; phase 2 narrows per table
type UntypedData = any;
export type DataQueryResult = Readonly<{
data: UntypedData;
error: DataQueryError | null;
count?: number | null;
}>;
export interface DataQueryBuilder extends PromiseLike<DataQueryResult> {
select(columns?: string): DataQueryBuilder;
insert(value: Record<string, unknown> | readonly Record<string, unknown>[]): DataQueryBuilder;
upsert(
value: Record<string, unknown> | readonly Record<string, unknown>[],
options?: { onConflict?: string },
): DataQueryBuilder;
update(values: Record<string, unknown>): DataQueryBuilder;
delete(options?: { count?: string }): DataQueryBuilder;
eq(column: string, value: unknown): DataQueryBuilder;
neq(column: string, value: unknown): DataQueryBuilder;
gt(column: string, value: unknown): DataQueryBuilder;
lte(column: string, value: unknown): DataQueryBuilder;
like(column: string, value: unknown): DataQueryBuilder;
in(column: string, value: readonly unknown[]): DataQueryBuilder;
is(column: string, value: unknown): DataQueryBuilder;
not(column: string, operator: string, value: unknown): DataQueryBuilder;
or(expression: string): DataQueryBuilder;
order(column: string, options?: { ascending?: boolean }): DataQueryBuilder;
limit(value: number): DataQueryBuilder;
abortSignal(signal: AbortSignal): DataQueryBuilder;
single(): Promise<DataQueryResult>;
maybeSingle(): Promise<DataQueryResult>;
}
export interface DataClient {
readonly auth: Readonly<{
getUser: () => Promise<{
data: { user: { id: string; email?: string | null } | null };
error: unknown;
}>;
}>;
from(table: string): DataQueryBuilder;
rpc(fn: string, args?: Readonly<Record<string, unknown>>): Promise<DataQueryResult>;
}
@@ -1,4 +1,5 @@
import { Pool, type PoolClient } from "pg";
import type { DataClient, DataQueryBuilder } from "./data-client";
type LocalIdentity = Readonly<{ id: string; email: string | null }> | null;
export type LocalDatabaseRole = "authenticated" | "service_role";
@@ -457,7 +458,7 @@ export function queryValue(type: string | undefined, value: unknown): unknown {
return value;
}
class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
class LocalPostgresQueryBuilder implements DataQueryBuilder {
private selectedColumns: string[] | null = null;
private mutation: Mutation | null = null;
private readonly filters: Filter[] = [];
@@ -863,7 +864,7 @@ function castType(value: string): string {
return value;
}
export class LocalPostgresDataClient {
export class LocalPostgresDataClient implements DataClient {
readonly auth: Readonly<{
getUser: () => Promise<
Readonly<{
@@ -8,7 +8,7 @@
*/
import { createHash } from "node:crypto";
import { requestCandidateIntervals, type DatedCandidateRange } from "../core/candidate-window.ts";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts";
import { declaredClockRange } from "../../declared-birth-window.ts";
import {
@@ -43,7 +43,7 @@ import {
type RectificationEntrySummary,
} from "./open-request.ts";
type AccountingClient = SupabaseClient;
type AccountingClient = DataClient;
export type V9BaselineSnapshot = Readonly<{
birth_date: string;
@@ -1,4 +1,4 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import { RectificationCaseServiceError } from "./case-service.ts";
import type { OpenRectificationCaseRequest } from "./open-request.ts";
@@ -17,7 +17,7 @@ const domainsBySessionTheme: Readonly<Record<string, string>> = {
/** Only an owned persisted consultation is a product-domain source. */
export async function resolveRectificationProductDomain(
client: SupabaseClient,
client: DataClient,
userId: string,
request: OpenRectificationCaseRequest,
): Promise<string> {
@@ -44,7 +44,7 @@ import { decisionStateFingerprint } from "../core/decision-fingerprint";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Minimal structural RPC client. SupabaseClient satisfies this; tests can
* Minimal structural RPC client. DataClient satisfies this; tests can
* pass a fake with a single rpc() method.
*/
export type RectificationRpcClient = {
@@ -1,6 +1,6 @@
import "server-only";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import { resolveFeaturePricing } from "@/lib/feature-pricing";
import { isProductEnabled } from "@/lib/product-access";
import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
@@ -27,7 +27,7 @@ import {
/** C3: whether the offer card may be shown, decided by the server and never by the model. */
export async function decideRectificationOffer(input: {
accounting: SupabaseClient;
accounting: DataClient;
userId: string;
subjectRole: "self" | "other";
modelId: string;
@@ -42,7 +42,7 @@ export async function decideRectificationOffer(input: {
type SessionRow = { id: string; messages: unknown; session_type?: string | null };
async function readConsultationSession(client: SupabaseClient, userId: string, sessionId: string): Promise<SessionRow | null> {
async function readConsultationSession(client: DataClient, userId: string, sessionId: string): Promise<SessionRow | null> {
const { data, error } = await client
.from("chat_sessions")
.select("id,messages,session_type")
@@ -54,7 +54,7 @@ async function readConsultationSession(client: SupabaseClient, userId: string, s
return (row.session_type ?? "consultation") === "consultation" ? row : null;
}
async function writeMessages(client: SupabaseClient, userId: string, sessionId: string, messages: unknown[]): Promise<boolean> {
async function writeMessages(client: DataClient, userId: string, sessionId: string, messages: unknown[]): Promise<boolean> {
const { error } = await client
.from("chat_sessions")
.update({ messages })
@@ -71,7 +71,7 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId:
* Does not touch consultation_requests.
*/
export async function persistOpenedDirectOffer(input: {
client: SupabaseClient;
client: DataClient;
userId: string;
sessionId: string;
sentence: string;
@@ -96,7 +96,7 @@ export async function persistOpenedDirectOffer(input: {
/** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */
export async function bindRectificationSource(input: {
client: SupabaseClient;
client: DataClient;
userId: string;
sourceSessionId: string;
caseId: string;
@@ -118,7 +118,7 @@ const SOURCE_SEARCH_LIMIT = 20;
* from history has none.
*/
export async function findRectificationSource(
client: SupabaseClient,
client: DataClient,
userId: string,
caseId: string,
): Promise<RectificationSource | null> {
@@ -139,8 +139,8 @@ export async function findRectificationSource(
* never from the browser.
*/
export async function returnToRectificationSource(input: {
client: SupabaseClient;
accounting: SupabaseClient;
client: DataClient;
accounting: DataClient;
userId: string;
caseId: string;
now?: () => Date;
@@ -1,11 +1,12 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { createClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client-core";
import {
getSupabaseUrl,
SupabaseConfigurationError,
} from "./config";
export function createAdminSupabaseClient() {
export function createAdminSupabaseClient(): DataClient {
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
const connectionString = process.env.SERVICE_DATABASE_URL?.trim();
if (!connectionString) {
@@ -18,7 +19,7 @@ export function createAdminSupabaseClient() {
connectionString,
null,
"service_role",
) as unknown as SupabaseClient;
) as DataClient;
}
const url = getSupabaseUrl();
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
@@ -26,9 +27,10 @@ export function createAdminSupabaseClient() {
throw new SupabaseConfigurationError(["SUPABASE_SERVICE_ROLE_KEY"]);
}
// 回滚分支,运行时形状是超集
return createClient(url, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
}) as unknown as DataClient;
}
export function isAdminEmail(email: string | null | undefined) {
+5 -4
View File
@@ -1,8 +1,8 @@
import "server-only";
import { createServerClient } from "@supabase/ssr";
import type { SupabaseClient } from "@supabase/supabase-js";
import { cookies, headers } from "next/headers";
import type { DataClient } from "@/lib/db/data-client";
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
import { readDatabaseUrl } from "@/lib/db/config";
import { getIdentityAuthServices } from "@/modules/identity/auth";
@@ -11,7 +11,7 @@ import { getSupabasePublicConfig } from "./config";
export async function createServerSupabaseClient(options?: {
readonly disableCookieCache?: boolean;
}) {
}): Promise<DataClient> {
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
const requestHeaders = new Headers(await headers());
const session = await readIdentitySession(
@@ -22,11 +22,12 @@ export async function createServerSupabaseClient(options?: {
return createLocalPostgresDataClient(
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
session ? { id: session.user.id, email: session.user.email } : null,
) as unknown as SupabaseClient;
) as DataClient;
}
const { url, anonKey } = getSupabasePublicConfig();
const cookieStore = await cookies();
// 回滚分支,运行时形状是超集
return createServerClient(url, anonKey, {
cookies: {
getAll: () => cookieStore.getAll(),
@@ -40,5 +41,5 @@ export async function createServerSupabaseClient(options?: {
}
},
},
});
}) as unknown as DataClient;
}
@@ -12,7 +12,7 @@
import { windowIntervalsWidth } from "../lib/rectification-agentic/core/candidate-window.ts";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DataClient } from "@/lib/db/data-client";
import type { PublicRectificationMethod } from "@/lib/rectification-agentic/v9/public-receipt";
import {
canonicalToolInputFingerprint,
@@ -196,7 +196,7 @@ export type RectificationV9Context = Readonly<{
turnId: string;
attemptId?: string;
userMessage?: string | null;
accounting: SupabaseClient;
accounting: DataClient;
engineBase?: string;
decision?: RectificationDecision;
productDelivery?: import("../lib/rectification-agentic/v9/product-delivery.ts").ProductDelivery | null;
@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
closeLocalPostgresDataPools,
createLocalPostgresDataClient,
} from "../src/lib/db/local-postgres-client-core.ts";
import type { DataClient } from "../src/lib/db/data-client.ts";
import { birthTimeScoringAlgorithmVersion } from "../src/lib/birth-time-scoring-job.ts";
import { createSupabaseScoringJobStore } from "../src/lib/birth-time-scoring-job-store.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
function dockerAvailable(): boolean {
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
encoding: "utf8",
stdio: "ignore",
}).status === 0;
}
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
const ids = {
user: "e1111111-1111-4111-8111-111111111111",
caseId: "e2222222-2222-4222-8222-222222222222",
job: "e3333333-3333-4333-8333-333333333333",
shapeJob: "e4444444-4444-4444-8444-444444444444",
};
const fingerprint = "claim-shape-fingerprint";
function sqlLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
const identity = {
userId: ids.user,
caseId: ids.caseId,
jobId: ids.job,
evidenceFingerprint: fingerprint,
algorithmVersion: birthTimeScoringAlgorithmVersion,
now: "2026-10-15T00:00:00.000Z",
};
test("claimScoringJob reads the first row when rpc returns an array", async () => {
const rpc = async () => ({
data: [{ claim_state: "claimed", algorithm_version: birthTimeScoringAlgorithmVersion }],
error: null,
});
const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null);
const claim = await store.claimScoringJob(identity);
assert.deepEqual(claim, {
kind: "claimed",
algorithmVersion: birthTimeScoringAlgorithmVersion,
});
});
test("claimScoringJob also accepts the single-object rpc shape", async () => {
const rpc = async () => ({
data: { claim_state: "processing", algorithm_version: birthTimeScoringAlgorithmVersion },
error: null,
});
const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null);
const claim = await store.claimScoringJob(identity);
assert.deepEqual(claim, {
kind: "processing",
algorithmVersion: birthTimeScoringAlgorithmVersion,
});
});
test("claim_birth_time_scoring_job returns an array and claimScoringJob accepts it", { skip: skipWithoutDocker }, async () => {
const fixture = startPostgresFixture();
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
});
assert.equal(
migration.status,
0,
JSON.stringify({
status: migration.status,
stderr: migration.stderr,
stdoutTail: String(migration.stdout).slice(-1500),
}),
);
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
`insert into identity.users (id, name, email, email_verified, email_verified_at)
values (${sqlLiteral(ids.user)}, 'Claim Shape', 'claim-shape@example.com', true, now());`,
);
fixture.psql(`
insert into public.birth_time_rectification_cases (
id, user_id, status, reported_date, source
) values (
${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, 'rectifying', '1990-01-01', 'family_exact'
);
insert into public.birth_time_rectification_scoring_jobs (
id, case_id, user_id, evidence_fingerprint, algorithm_version, status, expires_at
) values (
${sqlLiteral(ids.job)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)},
${sqlLiteral(fingerprint)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)},
'pending', '2026-10-15T01:00:00Z'
), (
${sqlLiteral(ids.shapeJob)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)},
${sqlLiteral(`${fingerprint}-shape`)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)},
'pending', '2026-10-15T01:00:00Z'
);
update public.birth_time_rectification_cases
set journey_protocol = 'legacy-guided-v1',
turn_state = ${sqlLiteral(JSON.stringify({
nextAction: { kind: "score_pending", jobId: ids.shapeJob },
}))}::jsonb
where id = ${sqlLiteral(ids.caseId)};
`);
const client = createLocalPostgresDataClient(
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
null,
"service_role",
);
const direct = await client.rpc("claim_birth_time_scoring_job", {
p_user_id: ids.user,
p_case_id: ids.caseId,
p_job_id: ids.shapeJob,
p_evidence_fingerprint: `${fingerprint}-shape`,
p_algorithm_version: birthTimeScoringAlgorithmVersion,
p_now: identity.now,
});
assert.equal(direct.error, null, direct.error?.message);
assert.equal(Array.isArray(direct.data), true);
assert.equal(direct.data[0]?.claim_state, "claimed");
fixture.psql(`
update public.birth_time_rectification_cases
set turn_state = ${sqlLiteral(JSON.stringify({
nextAction: { kind: "score_pending", jobId: ids.job },
}))}::jsonb
where id = ${sqlLiteral(ids.caseId)};
`);
const store = createSupabaseScoringJobStore(client, async () => null);
const claim = await store.claimScoringJob(identity);
assert.deepEqual(claim, {
kind: "claimed",
algorithmVersion: birthTimeScoringAlgorithmVersion,
});
} finally {
await closeLocalPostgresDataPools();
fixture.stop();
}
});