docs: close the TTL gap, re-anchor drifted code refs, add anchor tooling

opengaps.md: the TTL item is now closed on the code side. The 21-value
table is keyed by ttl constant rather than by line number, so it stops
drifting. The honest caveat is called out in the open instead of being
implied away: the two real-Redis assertions are wired but unrunnable with
the credentials on hand, so 'assertion ready' is not the same as 'verified
on real hardware'. The residual-risk note that was still sitting under the
closed item has been removed along with it.

charter.md: test baseline 88 -> 89 passed.

deployment.md, design.md, retired.md: 41 stale src/ line anchors. The
drift was real and the audit missed it every time, because
audit_anchors.py only asserts that a referenced line exists and is
non-blank. Three are off-by-one; the rest are wrong code --
state.rs:379 pointed at XREADGROUP XACK while the claim was about
read_batch, retired.md:99 pointed into Intent::Search::parse, the two
route-table anchors landed on state: Arc::new(state),, and one
register_push anchor sat 31 lines above the fn.

scripts/docs_check/reanchor.py: maps HEAD -> working-tree line numbers
straight off git diff -U0 hunks, applies the fix or reports only with
--check. scripts/docs_check/show_anchors.py: prints every doc anchor next
to the line it lands on, which is what caught the semantic errors the
audit structurally cannot.
This commit is contained in:
anonsaber 2026-09-30 13:20:13 +08:00
parent e85863e6da
commit 655167e862
7 changed files with 259 additions and 105 deletions

View File

@ -116,7 +116,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
``` ```
**当前基线:88 passed / 0 failed / 4 ignored**(2026-09-30 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。 **当前基线:89 passed / 0 failed / 4 ignored**(2026-09-30 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
@ -235,7 +235,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | 仅用户明确允许后才向外部 AI 发正文 | 需求 | | `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | 仅用户明确允许后才向外部 AI 发正文 | 需求 |
| `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 | | `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 |
| `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 | | `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 |
| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:109 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 | | `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 |
| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | | `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 |
| `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | 通知的收件时间按业务配置 `timezone`(IANA,默认 `Asia/Shanghai`)渲染为 `%Y-%m-%d %H:%M`;仅接受 16 个无夏令时区域,未匹配返回 422,不做时区库推断 | 需求 | | `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | 通知的收件时间按业务配置 `timezone`(IANA,默认 `Asia/Shanghai`)渲染为 `%Y-%m-%d %H:%M`;仅接受 16 个无夏令时区域,未匹配返回 422,不做时区库推断 | 需求 |
| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 | | `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 |

View File

@ -74,11 +74,11 @@
`hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在 `hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在
`src/main.rs:217-264`。 `src/main.rs:217-264`。
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1742`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1765`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
**鉴权** **鉴权**
7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:47-52`),它委托生产同款 `worker_authorized`(`src/notify.rs:496`),因此令牌是**常数时间比较**(`src/notify.rs:1530`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:54-56`)。 7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:47-52`),它委托生产同款 `worker_authorized`(`src/notify.rs:510`),因此令牌是**常数时间比较**(`src/notify.rs:1553`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:54-56`)。
**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) **7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取)
@ -101,7 +101,7 @@
**三条约束,部署时务必确认** **三条约束,部署时务必确认**
- **绝不回显凭据值**:`debug_config`(`src/debug.rs:96-152`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。 - **绝不回显凭据值**:`debug_config`(`src/debug.rs:96-152`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。
- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1536`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:257-260`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:265-270`)。 - **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1559`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:257-260`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:265-270`)。
- **不在 Worker 白名单内,只能直连 origin**:网关的 16 条安全路由(`cloudflare-worker/src/backends.js:9-26`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:82-85`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 - **不在 Worker 白名单内,只能直连 origin**:网关的 16 条安全路由(`cloudflare-worker/src/backends.js:9-26`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:82-85`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
> **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性 > **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性
@ -183,13 +183,13 @@ curl -fsS -X POST "https://<你的平台URL>/api/push/register" \
``` ```
注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码, 注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码,
后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1277`)。 后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1221`)。
- `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request` - `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request`
(`src/notify.rs:1289-1291`)。 (`src/notify.rs:1324-1326`)。
- 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。 - 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。
- 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁 - 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁
(`src/notify.rs:1301` / `src/notify.rs:1304`),撞锁返回 409 `conflict`。 (`src/notify.rs:1324-1326`),撞锁返回 409 `conflict`。
- 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。 - 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。
反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。
- 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart - 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart
@ -383,12 +383,12 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
### 6.2 Redis Streams / worker(MOD-STREAMS) ### 6.2 Redis Streams / worker(MOD-STREAMS)
- Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 - Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞
- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:381`,路由 `src/notify.rs:1723`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 - worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:386`,路由 `src/notify.rs:1757`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:300`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。
- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:398`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:479`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。 - 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:403`;批量读取在 `src/state.rs:499`),逐条处理后确认消费(`src/state.rs:430`);处理失败转重试 / DLQ(`src/notify.rs:493`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。
- 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底) - 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)
### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE) ### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE)
- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。 - **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:416`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。
- **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。 - **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。
- 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。 - 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。
- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。 - 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。
@ -396,10 +396,10 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
#### 6.3.1 用户侧调度示例(copy-paste 可用) #### 6.3.1 用户侧调度示例(copy-paste 可用)
端点有两个:`POST /reconcile`(路由 `src/notify.rs:1722`)与 `POST /worker`(路由 端点有两个:`POST /reconcile`(路由 `src/notify.rs:1756`)与 `POST /worker`(路由
`src/notify.rs:1723`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、 `src/notify.rs:1757`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、
再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用 再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用
`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`。 `read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:416`。
**只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。 **只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。
最小可运行版本(推荐起点): 最小可运行版本(推荐起点):
@ -411,13 +411,13 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
| 鉴权 | Bearer + `reconcile_token` | Bearer + `worker_token` | | 鉴权 | Bearer + `reconcile_token` | Bearer + `worker_token` |
| 可走 Worker 吗 | 可以(在 `SAFE_ROUTES` 内) | 不行,必须直连后端 origin | | 可走 Worker 吗 | 可以(在 `SAFE_ROUTES` 内) | 不行,必须直连后端 origin |
| 幂等 | `SET NX lock:reconcile 300s` 单飞锁,撞锁回 409 | 无单飞锁,靠逐条 `delivery:*` 去重声明防重投 | | 幂等 | `SET NX lock:reconcile 300s` 单飞锁,撞锁回 409 | 无单飞锁,靠逐条 `delivery:*` 去重声明防重投 |
| 单次上限 | 100 条变更(`src/notify.rs:293`) | 10 条消息(`src/notify.rs:398`) | | 单次上限 | 100 条变更(`src/notify.rs:294`) | 10 条消息(`src/notify.rs:403`) |
| 常态返回 | 204 | 204(**不能当成功信号**) | | 常态返回 | 204 | 204(**不能当成功信号**) |
两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret 两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret
存储取同一个值即可(下方 k8s 示例即此做法)。`/worker` 只认 Bearer token、**没有 存储取同一个值即可(下方 k8s 示例即此做法)。`/worker` 只认 Bearer token、**没有
admin-session 兜底**(`src/notify.rs:386`),这是有意的:cron 不需要先登录拿 session。 admin-session 兜底**(`src/notify.rs:391`),这是有意的:cron 不需要先登录拿 session。
token 轮换后**无需重启**——worker 处理函数开头先 `refresh_business_config`(`src/notify.rs:381`), token 轮换后**无需重启**——worker 处理函数开头先 `refresh_business_config`(`src/notify.rs:386`),
再从热更新快照取值(`:385`,快照由 `auth_snapshot` 读取)。 再从热更新快照取值(`:385`,快照由 `auth_snapshot` 读取)。
`/worker` 不在 Worker 转发白名单里(`SAFE_ROUTES`:`/reconcile` 在内、`/worker` 不在), `/worker` 不在 Worker 转发白名单里(`SAFE_ROUTES`:`/reconcile` 在内、`/worker` 不在),
@ -454,8 +454,8 @@ curl -sS -o /dev/null -w '%{http_code}\n' \
- 两条之间用 `;` 而不是 `&&`:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账 - 两条之间用 `;` 而不是 `&&`:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账
入队的消息同样需要被消费。 入队的消息同样需要被消费。
- 排空那条必须是**后端 origin 的直连地址**,不是 Worker 域名。 - 排空那条必须是**后端 origin 的直连地址**,不是 Worker 域名。
- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:398`)。 - 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:403`)。
传非法 JSON 才是 400 `invalid_request`(`src/notify.rs:402`)。 传非法 JSON 才是 400 `invalid_request`(`src/notify.rs:407`)。
Kubernetes CronJob 示例: Kubernetes CronJob 示例:
@ -503,19 +503,20 @@ spec:
| 状态码 | 含义 | 处理 | | 状态码 | 含义 | 处理 |
| --- | --- | --- | | --- | --- | --- |
| `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 | | `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 |
| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:492` 无条件返回 | | `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:507` 无条件返回 |
| `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:402`) | 修 curl;空请求体合法,等价于取默认 10 条 | | `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:408`) | 修 curl;空请求体合法,等价于取默认 10 条 |
| `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:284`) | 检查 cron 的 token 值;重启不解决 | | `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:285`) | 检查 cron 的 token 值;重启不解决 |
| `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:393`) | 同上;`/worker` 无 admin-session 兜底 | | `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:398`) | 同上;`/worker` 无 admin-session 兜底 |
| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:290` 取锁,`src/notify.rs:346` 返回) | 直接跳过本次 tick,不要重试 | | `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:291` 取锁,`src/notify.rs:351` 返回) | 直接跳过本次 tick,不要重试 |
| 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:374`) | | 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:379`) |
重试建议: 重试建议:
- **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为: - **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为:
- 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile 300s` 单飞锁 - 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile` 单飞锁
(`src/notify.rs:290`,长对账中每 90s 续租 `src/notify.rs:303`,成功后释放 (`ttl::RECONCILE_LOCK_SECONDS` = 300s,`src/notify.rs:291`,长对账中每
`src/notify.rs:339`);`/worker` 靠逐条去重声明防重投,重复触发无害。 `ttl::RECONCILE_HEARTBEAT_SECONDS` = 90s 续租 `src/notify.rs:304`,成功后释放
`src/notify.rs:346`);`/worker` 靠逐条去重声明防重投,重复触发无害。
- 客户端拿到 `409 conflict` 时**不要重试**,直接跳过本次 tick——锁在 TTL 或任务完成后 - 客户端拿到 `409 conflict` 时**不要重试**,直接跳过本次 tick——锁在 TTL 或任务完成后
自然释放,下一次调度会重试。 自然释放,下一次调度会重试。
- 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过 - 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过
@ -523,7 +524,7 @@ spec:
- 如果你的调度器自带重试能力,把它限制为:**只对 5xx 与超时重试**,指数退避 - 如果你的调度器自带重试能力,把它限制为:**只对 5xx 与超时重试**,指数退避
2s / 4s / 8s,上限 60s,总次数 ≤ 3;`204` 与 `409` **永远不重试**。调度器没有自带能力 2s / 4s / 8s,上限 60s,总次数 ≤ 3;`204` 与 `409` **永远不重试**。调度器没有自带能力
就不要额外实现——下一个 tick 自然会重试。 就不要额外实现——下一个 tick 自然会重试。
- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:398`); - **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:403`);
积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,**不要**靠加长 积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,**不要**靠加长
curl 超时——那只会抬高挂死概率,不会让更多消息被消费。 curl 超时——那只会抬高挂死概率,不会让更多消息被消费。
- `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。 - `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。
@ -532,7 +533,7 @@ spec:
- **`/reconcile` 不会返回 400**:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 `since`。 - **`/reconcile` 不会返回 400**:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 `since`。
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见
`src/notify.rs:303`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 `src/notify.rs:304`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
`409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS` `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS`
(默认 `2`),不受影响。`POST /worker` 走不了 Worker,不受这些参数影响。 (默认 `2`),不受影响。`POST /worker` 走不了 Worker,不受这些参数影响。

View File

@ -277,7 +277,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。 当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。
仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:151` 注释:body text never reaches Telegram)。 仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:152` 注释:body text never reaches Telegram)。
曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。 曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。

View File

@ -4,92 +4,88 @@
## 你要做的事 ## 你要做的事
代码侧**没有**待你实现的项。剩下的这一件(TTL 实测)不能只靠我跑——它需要一个可连的测试 Redis 实例: **两件,都是交东西不是做判断。**
| # | 事项 | 谁做 | 你要动手吗 | 1. 一个能用的 Upstash `rediss://` URL(当前持有的两个 token 都已 `WRONGPASS`),用来把 TTL 断言那条真机绿灯补上。
| --- | --- | --- | --- | 2. 多账号要不要做——产品决策,见文末「后续」。
| 1 | TTL 实测 | 我 | 需要一个可连的测试 Redis 实例 |
## 阶段目标 ## 阶段目标
只剩一条,而且**只差一个 Redis 地址**:**TTL 实测**。`#[ignore]` 门控的集成测试已经写好在 `src/state.rs`(`real_redis_ttl_tests`,2 个用例),缺的只是 `REDIS_TEST_URL`——给我一个可连的测试 Redis 实例,跑一遍就能收掉,不需要停机窗口、不需要产品判断。 | # | 项 | 状态 |
| --- | --- | --- |
| 1 | `Email/changes` 的 `newState` 语义 | 已关闭(提交 `269c8f6`) |
| 2 | TTL 实测 | 代码侧已关闭(提交 `0890eb1` + 本轮);真机那条绿灯等一个可用的 `REDIS_TEST_URL` |
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `docs/deployment.md` §4.1) |
| 4 | `/worker` 未在部署文档里 | 已关闭(`docs/deployment.md` §6.3.1 已补) |
上一轮需要停机窗口的第 1 项(`Email/changes` 的 `newState` 语义)已由提交 `269c8f6` 在代码侧关掉,不再需要停机窗口。详见下文「已关闭」。 门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **89 passed / 0 failed / 4 ignored**(忽略的 4 个是需要 `REDIS_TEST_URL` 或 `JMAP_TEST_URL` 的联调用例);文档门 6/6 全绿,含 102 处行号锚点非空校验。
上一轮真机联调已经关掉了两条阻塞:
- **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。真实 Telegram 流量已驱动过 `/webhook/tg`。
- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1254`(JMAP 回调入队)与 `src/worker.rs:412`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。
两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。
顺带把原第 3 项里的「回调公网映射」也解掉了:当前联调环境前面没有 Cloudflare Worker,Stalwart 直接打到后端 origin 的 `/push/jmap` 且在真实投递;生产拓扑下 `/push/jmap` 本就在 Worker safelist 内(`docs/deployment.md` §10.4)。两种拓扑下映射都由现有部署解决,「用哪个组件做映射」不再需要回答。
### 本轮顺带发现并修掉的部署文档缺陷
`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。
同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:331` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:381`。
## 阻塞 ## 阻塞
### 已关闭:`Email/changes` 的 `newState` 语义(提交 `269c8f6`) **一个,不在代码里:TTL 的真机断言需要一个能认证通过的 `REDIS_TEST_URL`。** 当前持有的两个候选 token 都已被服务端拒绝(`-WRONGPASS invalid username-password pair`),网络与 TLS 握手本身正常。其余三条阻塞项均已关闭。
## 已关闭
### 1. TTL 实测(代码侧已关闭;真机断言待凭证)
原条目:全代码库 **12 个 TTL 写入点、17 个 TTL 值**,但只有三个真正落到 Redis 的原子命令(`set_nx_ex` 的 `SET … NX EX`、`retry_or_dlq` Lua 里的 `EXPIRE`、`set_ai_consent` 的 `SET … EX`)在真实 Redis 上被断言过实际 PTTL;其余全是裸字面量。测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数,所以单测覆盖的是调用路径,不是过期时长——把一个 24 小时去重窗口改成 `60_480` 仍能编译通过、单测全绿。
两步关掉:
1. **真实 Redis 断言**(提交 `0890eb1`)。`src/state.rs` 的 `real_redis_ttl_tests`,两个 `#[ignore]`-gated 用例,照 `src/domain/jmap/client.rs:522` 的模式:缺 `REDIS_TEST_URL` 时打 skipped 并返回,URL 不落日志。断言三个原子写入点的实际 PTTL。运行:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl`。
2. **消灭裸字面量**(本轮)。17 个值全部收进 `src/state.rs` 的 `pub(crate) mod ttl`,调用点改引常量,新增 `ttl_contract_is_pinned` 逐个断言常量表,并带两条顺序断言(心跳必须短于锁、同意档位必须严格递增)。原条目里够不到的 B 类 Lua 内嵌值也接上了——`retry_or_dlq` 现在用 `ttl::RETRY_COUNTER_SECONDS` 插值生成脚本字符串,脚本里不再有第二个字面量。改值现在 = 改常量 + 断言失败,两处都得过评审。
契约基线(键的完整语义见 `docs/reference.md` §1):
| 常量 | 值(秒) | 键 |
| --- | --- | --- |
| `ttl::RECONCILE_LOCK_SECONDS` | 300 | `lock:reconcile` |
| `ttl::RECONCILE_HEARTBEAT_SECONDS` | 90 | `lock:reconcile`(心跳续租) |
| `ttl::PUSH_REGISTER_LOCK_SECONDS` | 360 | `lock:push-register:{sha256(callback_url)}` |
| `ttl::PUSH_VERIFY_LIMIT_SECONDS` | 30 | `ratelimit:push-verify:{subscription_id}` |
| `ttl::DEDUP_TG_SECONDS` | 86_400 | `dedup:tg:{update_id}` |
| `ttl::DEDUP_JMAP_SECONDS` | 86_400 | `dedup:jmap:{account_id}:{email_id}`(回调入队与对账增量入队共用) |
| `ttl::DELIVERY_INFLIGHT_SECONDS` | 60 | `delivery:inflight:{stream}:{message.id}` |
| `ttl::DELIVERY_COMMITTED_SECONDS` | 604_800 | `delivery:committed:{stream}:{message.id}` |
| `ttl::PUSH_DISABLED_SECONDS` | 86_400 | `push:subscription:{id}:status`(`disabled`) |
| `ttl::PUSH_STATUS_PENDING_SECONDS` | 900 | `push:subscription:{id}:status`(`pending`) |
| `ttl::PUSH_STATUS_VERIFIED_SECONDS` | 300 | `push:subscription:{id}:status`(`verified`) |
| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` |
| `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` |
| `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` |
| `ttl::ADMIN_SESSION_SECONDS` | 900 | `admin-session:{token}`(同时是 `/admin/session` 返回的 `expires_in`) |
| `ttl::RETRY_COUNTER_SECONDS` | 86_400 | `retry:{stream}:{message.id}` |
| `ttl::CONSENT_TEMPORARY_SECONDS` | 3_600 | `consent:ai:{chat_id}`(「临时 / 一次」与显式 `/ai on` 共用) |
| `ttl::CONSENT_TODAY_SECONDS` | 86_400 | `consent:ai:{chat_id}`(「今天」) |
| `ttl::CONSENT_WEEK_SECONDS` | 604_800 | `consent:ai:{chat_id}`(「7天」) |
| `ttl::CONSENT_MAXIMUM_SECONDS` | 31_536_000 | `consent:ai:{chat_id}`(「直到撤销 / 长期」) |
| `ttl::CONSENT_REVOKED_SECONDS` | 0 | `consent:ai:{chat_id}`(撤销;落 `max(1)` = 1s,语义是「已过期」) |
生产代码里已经不存在裸 TTL 字面量;`worker.rs` 的同意档位测试断言仍保留数字字面量,那是**刻意的**——用独立于常量的期望值去验生产代码,否则两边一起改的话单测永远绿。
本轮顺手修掉测试自身的三个 bug:
- 单测二进制不会跑 `main()`,rustls 没装 crypto provider,`rediss://` 握手直接 panic;现在测试模块自己调 `install_rustls_provider()`。
- `retry:{stream}:{message.id}` 的键构造用消息 id 而不是 run id,断言读到一个从未写入的键,PTTL 返回 `-2`,测试第一句就失败。
- `worker.rs` 对账增量入队那一处 TTL 字面量原先漏收,仍是裸 `86_400`。
**代码侧的缺口已经关完,但真机那条绿灯还差一把钥匙。** 当前持有的 Upstash token 两个候选都已返回 `WRONGPASS`(裸 RESP+TLS 探针确认:握手 TLSv1.3 正常,`AUTH default <token>` 被服务端拒绝),所以 `connect()` 直接失败,断言根本没机会跑。给我一个新的 `rediss://default:<token>@evolved-chicken-297696.upstash.io:6379`,一条命令就能把它补上。在拿到之前,这项的结论是「断言已就绪 + 常量已收口 + 单测已钉死」,不是「真机已验证」——这个区别不该含糊过去。
### 2. `Email/changes` 的 `newState` 语义(提交 `269c8f6`)
原条目担心「服务器无法回放旧增量时返回 `newState`,要求客户端重新基线化」。但 jmap-client 0.4.2 的 `ChangesResponse` 只有 `accountId` / `oldState` / `newState` / `hasMoreChanges` / `created` / `updated` / `destroyed` 加展开的调用参数——**没有 reset / re-baseline 信号字段**。所以 `/changes` 失败在代码里就是一个普通的 `Err`,无法区分「临时故障」和「服务端不再回放这个 state」。这个条目因此无法通过任何观测手段闭环:停机窗口里能观测到的仍然是同一个 `Err`。 原条目担心「服务器无法回放旧增量时返回 `newState`,要求客户端重新基线化」。但 jmap-client 0.4.2 的 `ChangesResponse` 只有 `accountId` / `oldState` / `newState` / `hasMoreChanges` / `created` / `updated` / `destroyed` 加展开的调用参数——**没有 reset / re-baseline 信号字段**。所以 `/changes` 失败在代码里就是一个普通的 `Err`,无法区分「临时故障」和「服务端不再回放这个 state」。这个条目因此无法通过任何观测手段闭环:停机窗口里能观测到的仍然是同一个 `Err`。
代码的实际行为比条目描述的更糟:`Err` 被原样上抛成 `Err(())`,`reconcile:state` 原样保留——下一次 cron 用同一个 dead `sinceState` 重试,再失败,再 503。游标永久冻结,`/reconcile` 永远返回 `503 reconcile_retry` 且没有任何前进。 代码的实际行为比条目描述的更糟:`Err` 被原样上抛成 `Err(())`,`reconcile:state` 原样保留——下一次 cron 用同一个 dead `sinceState` 重试,再失败,再 503。游标永久冻结,`/reconcile` 永远返回 `503 reconcile_retry` 且没有任何前进。
**修法**:`/changes` 失败时改走重新基线——取一个新鲜的 `current_state()`,写回 `baseline:{fresh_state}:0` 并 `Ok` 返回。position walk 完全不依赖服务端 changelog 保留策略,所以下一轮从 position 0 重走一遍即可恢复;24h 去重键(`enqueue_reconcile_event`,86400s)保证重放最坏重复一次、不会漏。新增单测 `reconcile_rebaselines_when_changes_replay_is_stale` 锁定该行为。 **修法**:`/changes` 失败时改走重新基线——取一个新鲜的 `current_state()`,写回 `baseline:{fresh_state}:0` 并 `Ok` 返回。position walk 完全不依赖服务端 changelog 保留策略,所以下一轮从 position 0 重走一遍即可恢复;24h 去重键(`enqueue_reconcile_event`,`ttl::DEDUP_JMAP_SECONDS`)保证重放最坏重复一次、不会漏。新增单测 `reconcile_rebaselines_when_changes_replay_is_stale` 锁定该行为。
> position walk 路径(`list_emails_page(None, position, 100)`)本身不受影响——它把 `state` 传成 `None`,根本不发服务端 state。 > position walk 路径(`list_emails_page(None, position, 100)`)本身不受影响——它把 `state` 传成 `None`,根本不发服务端 state。
### 1. TTL 实测 ## 本轮顺带发现并修掉的部署文档缺陷
表里 **12 个 TTL 写入点、17 个 TTL 值**。它们分三类,可测性各不相同,不能混为一谈:A 类是调用点的字面量参数(不是模块级常量);B 类**内嵌在 Lua 脚本字符串里**,在调用点提常量够不到;C 类的值产生于 `worker.rs` 的意图构造处、只是被当作参数透传给写入点。 `docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。
> **2026-09-30 更正**:原表把 `7 * 86_400` 写成 `604_800`、`365 * 86_400` 写成 `31_536_000`(算术对、字面形式错),并凭空多出 `300`(5 天)与 `7200`(2 小时)两个代码里不存在的值——已删,C 类第六个值实际是 `0`(撤销)。另有 16 处行号锚点漂移,已按代码实际位置重指。 同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:336` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:386`。
共同点是:**A/C 两类的字面量本身没有任何测试断言**——测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数(`src/state.rs:948` 的 `_ttl_seconds`)。被测试覆盖的只是调用路径(`claim_dedup` 先 true 后 false、锁的 claim/renew/release 流程),不是过期时长本身。三个真正落到 Redis 的原子命令——`set_nx_ex` 的 `SET … NX EX`(`src/state.rs:905`,`ttl_seconds.max(1)` 兜 0)、`retry_or_dlq` 内嵌 Lua 里的 `EXPIRE KEYS[1] 86400`(`src/state.rs:523`)、`set_ai_consent` 的 `SET … EX`(`src/state.rs:542-551`,同样 `max(1)` 兜底)——**已由 `real_redis_ttl_tests` 在真实 Redis 上断言 PTTL**(见本节末尾「我来做的」)。真实 Redis 上这些命令的过期行为是 Redis 自身的既定语义,本仓库不依赖验证。
**A. 调用点字面量参数 — 10 个写入点 / 10 个值**
| 键 | 用途 | TTL | 写入点 |
| --- | --- | --- | --- |
| `lock:reconcile` | 对账锁 | 300s | `src/notify.rs:290` |
| `lock:reconcile` | 对账锁心跳续租 | 90s | `src/notify.rs:303` |
| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1301` |
| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1210` |
| `dedup:tg:{update_id}` | Telegram 更新去重 | 86_400s | `src/notify.rs:250` |
| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(回调入队) | 86_400s | `src/notify.rs:1249` |
| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(对账增量入队) | 86_400s | `src/worker.rs:412` |
| `delivery:inflight:{stream}:{message.id}` | 投递在途守卫 | 60s | `src/notify.rs:447` |
| `delivery:committed:{stream}:{message.id}` | 投递幂等 | 604_800s | `src/notify.rs:459` |
| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1426` |
**B. 内嵌 Lua 脚本 — 1 个写入点 / 1 个值**
| 键 | 用途 | TTL | 写入点 |
| --- | --- | --- | --- |
| `retry:{stream}:{message.id}` | 投递重试计数(键构造 `src/state.rs:514`) | 86400 | `src/state.rs:523` |
这格写在 `retry_or_dlq` 的 Lua 脚本字符串里(`src/state.rs:518-529`),不是在调用点传参——「把 TTL 提成模块级常量」这类改造对它够不到,得改脚本本身。
**C. `consent:ai:{chat_id}` — 1 个写入点 / 6 个值**
| 值 | 触发 | 说明 | 产生处 |
| --- | --- | --- | --- |
| `3600` | 「临时 / 一次」 | 1 小时 | `src/worker.rs:585-586` |
| `86_400` | 「今天」 | 1 天 | `src/worker.rs:591-592` |
| `7 * 86_400` | 「7天」 | 7 天 | `src/worker.rs:597-598` |
| `365 * 86_400` | 「直到撤销 / 长期」 | 最长 365 天 | `src/worker.rs:603-604` |
| `3600` | `/ai on` 等显式开启 | 1 小时 | `src/worker.rs:614-615` |
| `0` | `/ai off` 撤销 | 落到 `max(1)` = 1s,且已过期 | `src/worker.rs:624-625` |
写入点是 `src/worker.rs:450` 的 `set_ai_consent(chat.id, ttl)`,落 Redis 在 `src/state.rs:542-551`。这组的值由用户在 Telegram 里说的话决定,不是写死在 state 层。
我来做的:**已经补上集成测试**,`src/state.rs` 末尾新增 `real_redis_ttl_tests` 模块(两个 `#[ignore]`-gated 用例,照 `src/domain/jmap/client.rs:522` 的模式,缺 `REDIS_TEST_URL` 时打 skipped 并返回,URL 不落日志)。它用真实 Redis 断言三个原子写入点的实际 PTTL:`claim_dedup` 的 `.max(1)` 下限与 604_800 值、`set_ai_consent` 的 3600、`retry_or_dlq` Lua 里嵌入的 86400。运行:`REDIS_TEST_URL=… cargo test -- --ignored ttl`。
**残留风险(已收窄)**:上面 17 个数值里,三个原子写入点收到的值**已被测试断言**;A 类调用点字面量与 C 类 `Intent::Consent` 字面量仍未被断言——改成一个语法合法但语义错误的数字(例如 `60_480`)仍能编译通过、单测全绿。这是刻意取舍:把每个调用点字面量也搬进测试会让测试与生产代码逐行重复,反而更容易一起漂。真要收紧,做法是把 A 类字面量提成 `pub const` 再在测试里断言常量表。表中 17 个值已人工逐个核对无误,且本轮已修正 3 处数值写错(`7 * 86_400` / `365 * 86_400` 的字面形式)与 16 处行号锚点漂移。
## 后续(多账号) ## 后续(多账号)

View File

@ -75,10 +75,10 @@
|---|---|---|---| |---|---|---|---|
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 | | 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 | | `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1112`) | | `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1126`) |
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1292-1296`) | | `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1324-1326`) |
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 | | `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 |
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:381`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | | `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:386`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:491`)经 `?` 把 Redis 错误原样上抛(`state.rs:531`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:499-507`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
--- ---
@ -87,7 +87,7 @@
| 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 | | 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 |
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 | | `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 | | `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1261`)内联处理回调 URL 与验证码回写 | 无 | | `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1311`)内联处理回调 URL 与验证码回写 | 无 |
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
@ -96,7 +96,7 @@
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 | | 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 | | 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 | | 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:553`) | 无 | | `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:572`) | 无 |
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 | | `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
| 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 | | 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 | | docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
@ -127,7 +127,7 @@
| `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 7 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token` / `timezone`(末项镜像自 `BusinessConfig`,供 worker 渲染时间戳) | | `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 7 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token` / `timezone`(末项镜像自 `BusinessConfig`,供 worker 渲染时间戳) |
| 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 | | 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 |
| 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 | | 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 |
| `Channel` / `Notifier` / `MessageAdapter`(`src/channel.rs`)+ `UserCommand`(`src/domain.rs`) | 已删除占位 trait 与类型 | 三者**无实现、无调用方、无 dyn 绑定**,`#[expect(dead_code)]` 属性是仅有的引用来源;实际 Telegram 出站走 `channel::telegram::TelegramClient`,由 `worker.rs` 的 `MetadataWorker` 与 `notify.rs` 直接持有,从未经过它们。`UserCommand` 的唯一使用者是被删的 `Channel` | `channel.rs` 只留 `pub mod telegram`(`TelegramClient`,`reqwest` 自研);`domain.rs` 只留领域 `Notification`(worker.rs:234 在用)。这是 src/ 里最后 3 个 `#[expect(dead_code)]` | | `Channel` / `Notifier` / `MessageAdapter`(`src/channel.rs`)+ `UserCommand`(`src/domain.rs`) | 已删除占位 trait 与类型 | 三者**无实现、无调用方、无 dyn 绑定**,`#[expect(dead_code)]` 属性是仅有的引用来源;实际 Telegram 出站走 `channel::telegram::TelegramClient`,由 `worker.rs` 的 `MetadataWorker` 与 `notify.rs` 直接持有,从未经过它们。`UserCommand` 的唯一使用者是被删的 `Channel` | `channel.rs` 只留 `pub mod telegram`(`TelegramClient`,`reqwest` 自研);`domain.rs` 只留领域 `Notification`(worker.rs:235 在用)。这是 src/ 里最后 3 个 `#[expect(dead_code)]` |
> 注意 `LLM_MAX_RETRIES` 与 `LLM_SUMMARY_TARGET_CHARS` 的区别:前者从来不是环境变量 > 注意 `LLM_MAX_RETRIES` 与 `LLM_SUMMARY_TARGET_CHARS` 的区别:前者从来不是环境变量
> (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量 > (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量

View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Re-anchor docs line references after a code edit.
`git diff -U0 HEAD -- src/<file>` gives the exact add/del blocks, so old -> new
line numbers are cumulative and need no text matching. Every `foo.rs:N` /
`foo.rs:N-M` reference in docs/ is translated in place; unchanged references are
left alone.
Usage: reanchor.py [--check]
(default) apply fixes to docs/*.md and print what changed
--check report only; exit 1 if any anchor would move
"""
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
ANCHOR = re.compile(r"(?<![\w.:])([A-Za-z_][\w-]*\.rs):(\d+)(?:-(\d+))?")
HUNK = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
DOCS = sorted((REPO / "docs").glob("*.md"))
CHECK_ONLY = "--check" in sys.argv
def hunk_map(path: str) -> list[tuple[int, int, int, int]]:
raw = subprocess.run(
["git", "diff", "-U0", "HEAD", "--", f"src/{path}"],
cwd=REPO,
capture_output=True,
text=True,
)
return [
(int(m.group(1)), int(m.group(2) or 1), int(m.group(3)), int(m.group(4) or 1))
for m in HUNK.finditer(raw.stdout)
]
def old_to_new(hunks: list[tuple[int, int, int, int]], old_line: int) -> int:
"""Translate one old line number through the hunks above it."""
for o_start, o_len, n_start, n_len in hunks:
if old_line < o_start:
break
if old_line <= o_start + o_len - 1:
# Inside a replaced block: map by position; a pure deletion has no
# successor, so fall back to the hunk's new start.
return n_start + max(n_len - 1, 0) if n_len == 0 else n_start + (old_line - o_start)
delta = sum(
n_len - o_len for o_start, o_len, _n_start, n_len in hunks if o_start <= old_line
)
return old_line + delta
def head_text(doc: Path) -> list[str]:
"""The pre-edit version of a doc, so --check compares like with like."""
raw = subprocess.run(
["git", "show", f"HEAD:{doc.relative_to(REPO)}"],
cwd=REPO,
capture_output=True,
text=True,
)
return raw.stdout.splitlines() if raw.returncode == 0 else doc.read_text().splitlines()
def main() -> int:
caches: dict[str, list[tuple[int, int, int, int]]] = {}
changed_files = 0
changed_refs = 0
for doc in DOCS:
current = doc.read_text().splitlines()
baseline = head_text(doc) if CHECK_ONLY else current
lines = list(current)
dirty = False
for lineno, line in enumerate(lines):
if CHECK_ONLY:
if lineno < len(baseline):
line = baseline[lineno]
else:
continue
out: list[str] = []
pos = 0
for m in ANCHOR.finditer(line):
fname = m.group(1)
if not sorted((REPO / "src").rglob(fname)):
continue
hunks = caches.setdefault(fname, hunk_map(fname))
if not hunks:
continue
old_start = int(m.group(2))
old_end = int(m.group(3)) if m.group(3) else old_start
new_start, new_end = old_to_new(hunks, old_start), old_to_new(hunks, old_end)
if (new_start, new_end) == (old_start, old_end):
continue
replacement = f"{fname}:{new_start}" if new_start == new_end else f"{fname}:{new_start}-{new_end}"
if CHECK_ONLY:
# A doc may have been restructured since HEAD, so compare by
# value across the whole file instead of by line index.
if m.group(0) in "\n".join(current):
changed_refs += 1
print(f" STALE {doc.name} {m.group(0)} still present; want {replacement}")
continue
out.append(line[pos : m.start()])
out.append(replacement)
pos = m.end()
changed_refs += 1
print(f" {doc.name}:{lineno + 1} {m.group(0)} -> {replacement}")
dirty = True
if out:
out.append(line[pos:])
lines[lineno] = "".join(out)
dirty = True
if dirty:
doc.write_text("\n".join(lines) + "\n")
changed_files += 1
print(f"refs={changed_refs} files={changed_files}" + (" (check only)" if CHECK_ONLY else ""))
return 1 if changed_refs and CHECK_ONLY else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Print every `foo.rs:N[-M]` reference in docs/ together with the current
line text it points at, so a human can eyeball whether the anchor is still
semantically right (audit_anchors.py only proves the line is non-blank)."""
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
ANCHOR = re.compile(r"(?<![\w.:])([A-Za-z_][\w-]*\.rs):(\d+)(?:-(\d+))?")
DOCS = list((REPO / "docs").glob("*.md"))
def resolve(fname: str) -> Path | None:
hits = sorted((REPO / "src").rglob(fname))
return hits[0] if hits else None
for doc in sorted(DOCS):
for lineno, line in enumerate(doc.read_text().splitlines(), 1):
for m in ANCHOR.finditer(line):
fname = m.group(1)
start = int(m.group(2))
end = int(m.group(3)) if m.group(3) else start
path = resolve(fname)
if path is None:
print(f"{doc.name}:{lineno}\t{m.group(0)}\t<NONE>")
continue
src = path.read_text().splitlines()
if start > len(src):
print(f"{doc.name}:{lineno}\t{m.group(0)}\t<OUT OF RANGE, {len(src)} lines>")
continue
body = " ⏎ ".join(l.strip()[:60] for l in src[start - 1 : end])
print(f"{doc.name}:{lineno}\t{m.group(0)}\t{body}")