From 954ad5c6036c1ddb7d0c5765a4c3d3457a464daf Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:46:15 +0800 Subject: [PATCH 01/56] docs: design Codex WeChat observer --- ...2026-07-19-codex-wechat-observer-design.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-codex-wechat-observer-design.md diff --git a/docs/superpowers/specs/2026-07-19-codex-wechat-observer-design.md b/docs/superpowers/specs/2026-07-19-codex-wechat-observer-design.md new file mode 100644 index 0000000..b89659b --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-codex-wechat-observer-design.md @@ -0,0 +1,315 @@ +# Codex 微信值守员设计 + +**日期:** 2026-07-19 +**状态:** 产品方向已批准;等待用户审阅本书面规格 +**实施范围:** WeChatPilot v1.1 的第一个独立子项目 + +## 1. 产品决策 + +WeChatPilot 下一阶段优先建设 **Codex Observer Gateway(Codex 微信值守员)**:让家中电脑上的 Codex 任务在每次运行结束后,主动向用户本人的微信发送一条简短状态消息和一张可核验的 PNG 报告卡。 + +产品定位是: + +> 家中 Codex 的只读微信值守员——任务做完主动汇报,失败及时提醒,结果附带证据。 + +它不是微信里的 Codex 终端。微信始终只是单向报告目的地,不接收任务指令、不批准权限、不恢复会话,也不暴露本机命令、文件浏览或远程控制能力。 + +本设计收窄了上一份品牌规格中的近期平台方向:通用入站聊天机器人、意图路由和远程工作流触发不进入 v1.1。可复用的平台能力将围绕“本地任务事件、结构化报告、可靠出站通知”建设。 + +## 2. 调研依据 + +设计基于以下已经验证的能力边界: + +- Codex 官方 Scheduled tasks 可以在本地项目或隔离 worktree 中后台运行;项目级任务要求电脑保持开机、桌面端运行且项目仍可访问。官方入口目前提供 Codex 内部的 Scheduled 收件箱,没有公开的微信 webhook 配置。 +- Codex 官方 Hooks 可以在生命周期事件上运行确定性本地脚本,包含 `Stop`、`PermissionRequest` 等事件;它适合作为后续通知适配器,但单独依赖 Hook 不能保证覆盖进程崩溃、强制终止或未写出业务摘要的情况。 +- Codex App Server 提供 `turn/started`、`turn/completed`、失败状态和 turn diff 等结构化事件,但部分传输和接口仍属于 experimental,第一版不以它作为生产依赖。 +- 腾讯微信 iLink 的 `sendMessage` 与 CDN 上传流程支持文本、图片和文件,足以承载报告卡。 +- `weixin-codex-bridge`、`cli-in-wechat` 和 `wechat-acp` 已验证微信连接 Codex 的市场需求,但它们主要把微信作为控制面;`codex-telegram-notifier` 则验证了独立任务通知网关的产品模式。 + +参考资料: + +- [Codex Scheduled tasks](https://learn.chatgpt.com/docs/automations) +- [Codex Hooks](https://learn.chatgpt.com/docs/hooks) +- [Codex App Server](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) +- [腾讯微信 iLink](https://github.com/Tencent/openclaw-weixin/blob/main/README.zh_CN.md) +- [weixin-codex-bridge](https://github.com/zktww/weixin-codex-bridge) +- [codex-telegram-notifier](https://github.com/Menwitz/codex-telegram-notifier) + +## 3. v1 目标与非目标 + +### 3.1 目标 + +第一版必须完成一个真实、可重复验证的闭环: + +1. 一个本机定时任务通过 WeChatPilot 管理的外层运行器启动。 +2. 运行器记录任务状态、开始时间、结束时间、耗时和退出结果。 +3. 任务可选择提交结构化业务摘要和产物清单;未提交时仍能生成最小报告。 +4. WeChatPilot 在本地完成脱敏、校验和 PNG 报告卡渲染。 +5. 每次最终运行向本人微信发送文字摘要和报告卡。 +6. 发送过程具有按运行实例幂等、有限重试、不确定态停止和私有审计记录。 + +首个参考集成使用现有 `daily-tech-digest` 定时流水线。设计出的运行报告协议必须与摘要业务解耦,使后续代码检查、测试、依赖审计、PPT 检查或文档处理任务可以复用。 + +### 3.2 非目标 + +v1 不实现: + +- 微信消息触发 Codex、Shell、脚本或任意本机操作。 +- 微信端 `/approve`、`/deny`、`/retry`、`/resume`、`/stop` 等控制命令。 +- 扫描、解析或修改 `~/.codex` 会话数据库、rollout 文件或桌面端私有索引。 +- 自动发现所有既有 Codex Desktop Scheduled tasks。 +- 实时转发 Codex 思考、工具调用、终端输出或完整 diff。 +- 从任意路径发送任意附件。 +- 群聊、多用户、可选接收人或公网 webhook。 + +## 4. 交付分层 + +### 4.1 v1:受管任务运行器 + +v1 只保证监控 **WeChatPilot-managed jobs**。每个任务由本地、版本化、人工审核的任务定义注册,调度器调用 WeChatPilot 外层运行器,运行器再启动固定入口。因为外层进程掌握子任务退出和超时状态,所以即使 Codex 没有生成最终回复,也能形成失败或超时报告。 + +运行器必须在启动子任务之前持久化 `running` 记录。电脑断电、进程被强制终止或系统重启时无法即时发微信;下次启动时,恢复流程检查由本项目创建的未完成记录和进程身份,将确认已经失活的运行归类为 `interrupted`,再补发一份恢复报告。它不扫描 Codex 的全局会话或桌面端私有状态。 + +现有 `launchd -> scripts/run-scheduled.sh -> scheduled CLI` 是第一个受管任务基础,不改变其固定绝对路径、锁、重试和每日发送账本。 + +### 4.2 v1 兼容模式:任务主动上报 + +原生 Codex Scheduled task 可以在结束步骤调用一个只接受结构化报告的本地 reporter CLI。此模式能生成丰富报告,但 Codex 进程在调用 reporter 之前崩溃时可能漏报。因此它属于兼容能力,不计入 v1 的“总能报告最终状态”承诺。 + +### 4.3 v2:Codex 生命周期适配器 + +后续增加独立适配器: + +- 优先评估官方项目级 `Stop` / `PermissionRequest` Hooks。 +- App Server 仅在协议和桌面端连接方式稳定后,用于更丰富的运行状态、失败详情和 diff 指标。 +- `PermissionRequest` 只生成“请回到电脑处理”的提醒,不允许微信返回审批决定。 + +适配器只能生产统一的 `RunReport`,不得直接调用微信传输层。这样 Codex 协议升级不会影响报告渲染和发送可靠性。 + +## 5. 总体架构 + +```text +launchd / approved local scheduler + -> ManagedJobRunner + -> fixed task entrypoint + -> optional structured report draft + -> exit / timeout / abort / recovery classification + -> RunReportNormalizer + -> ReportPolicy and redaction + -> ReportCardRenderer (SVG -> PNG) + -> RunDeliveryOutbox + -> text sender + -> audited WeChat image uploader + -> private run ledger and health status +``` + +组件边界: + +1. **ManagedJobRunner** 只负责运行一个已注册的本地入口并观测生命周期,不了解微信。 +2. **RunReportNormalizer** 把运行器结果和可选业务摘要合并为唯一、严格校验的报告对象。 +3. **ReportPolicy** 执行字段长度、脱敏、项目别名和附件路径策略,不负责排版。 +4. **ReportCardRenderer** 只接收已经安全化的报告视图模型,生成固定尺寸 PNG。 +5. **RunDeliveryOutbox** 负责编排文字和图片发送、幂等与重试,不读取 Codex 会话。 +6. **WechatMediaSender** 只实现审计过的图片上传和发送协议,不加载 vendor 的 Router、Codex adapter 或主入口。 + +## 6. 运行报告协议 + +`RunReport` 使用严格版本化 JSON Schema。v1 字段如下: + +```text +version 固定为 1 +runId 本次运行的随机 UUID +taskId 版本化任务 ID,例如 daily-tech-digest +taskName 面向用户的固定中文名称 +projectLabel 配置中的安全别名,不是绝对路径 +status success | failed | timed-out | cancelled | interrupted | skipped +startedAt ISO 8601 时间 +finishedAt ISO 8601 时间 +durationMs 非负安全整数 +summary 1–3 条安全化摘要,每条有长度上限 +metrics 最多 6 个 label/value 指标,均为字符串白名单 +failure 可选的稳定错误分类与安全提示 +artifacts 最多 3 个通过策略检查的本地产物引用 +``` + +协议禁止包含: + +- 原始 prompt、完整模型回复、reasoning、完整 stderr 或完整命令行。 +- 绝对用户目录、用户名、微信 user ID、context token 或 Codex session ID。 +- Authorization、Cookie、环境变量、API key 或任何凭据。 +- 未经策略确认的 URL、Markdown/HTML、控制字符和双向文本覆盖字符。 + +任务业务代码可以写入一个 `RunReportDraft`,但不能指定 `runId`、时间、接收人、微信凭据、最终状态或任意附件路径。这些权威字段由外层运行器填写。draft 中的产物只能使用相对于任务定义中 `allowedArtifactRoot` 的路径,由外层策略解析和验证。 + +## 7. 报告卡设计 + +v1 报告卡使用本地确定性模板,不调用图片生成模型。 + +- 输出:PNG,1080 × 1440,sRGB。 +- 视觉状态:成功为绿色、失败/超时为红色、跳过为灰色、取消/中断为橙色。 +- 固定区域:任务名称、状态、完成时间、耗时、三条摘要、关键指标、下一步和短运行编号。 +- 字体:macOS 中文系统字体链;缺字时回退到通用 sans-serif。 +- 长文本:在进入渲染器前截断并添加省略标记,模板不自动缩小到不可读字号。 +- 可访问性:状态同时使用文字和图标表达,不只依赖颜色。 + +渲染器先生成受控 SVG,再在本地栅格化为 PNG。SVG 不接受业务提供的原始标签、样式、脚本、外链、data URL 或远程字体。 + +文字消息是报告的可访问性与失败兜底,格式固定为: + +```text +Codex 任务完成:<任务名> +状态:<状态> · 耗时:<耗时> +结果:<第一条摘要> +运行:<短 runId> +``` + +## 8. 微信图片发送 + +当前第一方 WeChat adapter 只实现受限文本发送。v1 新增最小图片发送能力: + +1. 只接受由报告渲染器产生、位于项目 `output/run-reports/` 下的 PNG。 +2. 拒绝 symlink、路径穿越、非普通文件、扩展名与 MIME 不一致、尺寸不符或超过配置上限的文件。 +3. 按腾讯 iLink 流程计算摘要、AES 加密、获取上传 URL、上传密文并发送 `image_item`。 +4. 媒体实现从固定上游提交中单独审计所需代码;不得实例化或启动 vendor 的 `bridge/router`、CLI、Codex adapter 或会话逻辑。 +5. 日志只记录 `runId`、阶段和安全错误分类,不记录 URL 查询参数、AES key、token、用户 ID 或响应正文。 + +发送顺序固定为先文字、后图片。这样图片失败时用户仍能收到最小结果。每个部分单独记账: + +```text +pending -> text-sending -> text-sent -> image-sending -> sent + \-> partial / indeterminate +``` + +只有微信明确拒绝且尚未确认该部分送达时,才允许有限重试该部分。超时、连接中断、无法解析响应或图片发送结果不确定时,标记 `indeterminate` 并停止自动重放,避免重复消息。 + +## 9. 本地状态与幂等 + +新增状态布局: + +```text +.state/observer/runs/.json 私有运行状态 +.state/delivery/runs/.json 私有发送 ledger +.state/locks/observer-.lock 每任务串行锁 +output/run-reports//.png 已脱敏报告卡 +output/run-reports//.json 已脱敏报告副本 +``` + +- `.state` 目录保持 `0700`,文件保持 `0600`。 +- 每次调度先创建 `runId`;同一 `runId` 永远只能形成一份最终报告和一条发送 ledger。 +- `running` 记录必须在子任务启动前原子写入并同步落盘;恢复流程只处理本项目记录且确认原进程已经失活的运行。 +- 每任务锁阻止同一任务重叠运行,沿用现有 live/stale PID 与文件身份校验策略。 +- 报告卡和安全化 JSON 可保留用于本地审计;原始子进程输出不进入报告目录。 +- 默认保留最近 30 天报告;清理只能删除明确位于上述报告目录、通过路径检查且早于保留期的文件。自动清理不纳入首个 POC。 + +## 10. 错误分类 + +用户可见失败只使用稳定分类: + +- `task-failed`:任务以非零状态退出。 +- `task-timeout`:超过任务定义的固定期限。 +- `task-cancelled`:本机调度器收到终止信号。 +- `task-interrupted`:恢复流程确认此前的运行因进程或主机中断而未完成。 +- `report-invalid`:业务摘要未通过 schema 或策略校验,回退到最小报告。 +- `render-failed`:PNG 生成失败,仍发送文字报告。 +- `wechat-login-expired`:要求用户回到电脑重新扫码。 +- `wechat-rejected`:微信明确拒绝,可有限重试未确认部分。 +- `wechat-indeterminate`:结果无法确认,为避免重复而停止。 + +错误卡不展示原始异常堆栈。详细诊断只进入现有脱敏日志,并继续接受大小、权限和路径限制。 + +## 11. 配置边界 + +任务定义必须位于仓库内的版本化配置,且每个任务明确声明: + +- 固定 `taskId`、名称和项目别名。 +- 固定入口和参数;配置值不能来自微信消息。 +- 固定工作目录,必须位于允许的项目根下。 +- 超时、调度入口、报告摘要路径和允许产物根。 +- 是否每次发送;v1 默认每次最终运行都发送,包括 `skipped`。 + +任务运行器不得接受 `--command`、`--cwd`、`--user`、`--recipient` 或任意附件路径等临时覆盖参数。新增或变更任务必须通过本机代码审查和版本控制完成。 + +运行器必须使用参数数组和 `shell: false` 启动固定入口,使用环境变量白名单,不设置、清空或改写 `HOME` / `CODEX_HOME`,也不得向任务追加沙箱绕过、自动批准或权限提升参数。WeChatPilot 只观察任务实际采用的本机安全配置,不替任务扩大权限。 + +## 12. 测试策略 + +### 12.1 单元测试 + +- `RunReport` / `RunReportDraft` schema、长度、枚举和权威字段覆盖。 +- 对 token、Cookie、URL 查询参数、绝对路径、控制字符和嵌套错误对象的脱敏。 +- 报告视图模型的截断、状态颜色、日期和耗时格式。 +- PNG 签名、尺寸和非空渲染;模板输入不得产生脚本、外链或未转义标签。 +- 附件根目录、symlink、普通文件、大小、扩展名和 MIME 检查。 +- iLink 图片上传请求形状、摘要和加密参数使用固定无密钥 fixture 测试。 +- 文字成功/图片失败、明确拒绝、不确定响应和重复 `runId` 的 ledger 状态机。 + +### 12.2 集成测试 + +- 使用无秘密的假任务覆盖成功、非零退出、超时、取消、中断恢复和无效 draft。 +- 假任务不输出业务摘要时仍生成最小报告。 +- 模拟报告渲染失败时只发送文字。 +- 模拟两次并发运行时只有一个获得锁。 +- 模拟外层进程被强制终止后,下一次启动只生成一份 `interrupted` 恢复报告。 +- 模拟发送进程中断后不会整组重发。 +- 现有日报、Codex 子进程安全参数、文本微信发送、每日 ledger 和 LaunchAgent 测试保持通过。 + +### 12.3 真实验证门槛 + +真实微信验证必须是显式的本机检查点: + +1. 先发送不含私人数据的测试报告卡。 +2. 证明文字和图片均得到微信明确接受。 +3. 再执行一次真实 `daily-tech-digest` 集成。 +4. 最后模拟一个受控失败,确认红色失败卡和不重复行为。 + +不得把“本地函数返回成功”当作微信真实送达证明。 + +## 13. 分阶段交付 + +### Phase 1:报告闭环 + +- `RunReport` schema、脱敏策略和固定报告卡渲染。 +- 审计后的微信 PNG 上传与发送。 +- 运行级发送 ledger。 +- 手工触发成功/失败测试报告。 + +### Phase 2:首个受管 Codex 任务 + +- 通用 `ManagedJobRunner`。 +- 将现有 `daily-tech-digest` 接入运行报告,但不改变它的日报正文发送语义。 +- 成功、失败、超时和跳过报告。 +- 保持现有 launchd 兼容性和每日幂等。 + +### Phase 3:Codex 官方适配器 + +- 评估项目级 Hooks 的稳定事件和任务相关性。 +- 对原生 Scheduled task 提供主动 reporter 集成。 +- 在不读取 Codex 私有状态、不开放微信控制的前提下,评估 App Server 观察能力。 + +实时进度、更多附件、日报/周报汇总和多项目总览均在前三阶段验证稳定后单独设计。 + +## 14. 验收标准 + +v1 完成时必须满足: + +- 至少一个受管 Codex 任务在成功、失败、超时和中断恢复情况下都能形成最终 `RunReport`。 +- 在电脑在线、微信登录有效且服务明确接受请求时,最终状态产生后两分钟内发送文字结果;渲染成功时同时发送 1080 × 1440 PNG 报告卡。主机中断的运行在恢复流程启动后遵循同一时限。 +- 同一 `runId` 不会重复发送已经确认的文字或图片。 +- 微信发送不确定时停止自动重放,并在本地健康状态中明确显示。 +- 报告、图片、ledger、日志和错误中不出现凭据、微信身份、Codex 会话 ID、绝对个人路径或原始命令输出。 +- 微信无法触发、批准、恢复、停止或修改任何 Codex 任务。 +- vendor 远程控制入口从未启动或复用;新增媒体代码通过固定提交、大小和 SHA-256 审计。 +- 根测试、vendor 测试、类型检查、构建、LaunchAgent 检查和秘密扫描全部通过。 +- 真实微信验证同时证明成功报告、受控失败报告和重复保护。 + +## 15. 产品扩展方向 + +当 v1 稳定后,WeChatPilot 可以把同一报告协议扩展到: + +- 夜间测试、依赖与安全检查。 +- GitHub issue、PR、CI 和 release 状态巡检。 +- PPT 质量检查和可视化评分卡。 +- 长文、小说或文档处理的进度与成果报告。 +- 多个家庭项目的每日总览和每周趋势图。 + +这些扩展共享一个原则:任务在本机按预先授权的方式运行,微信只接收经过脱敏和验证的结果。 From 4ece9371e0a973eb2c5954197acc7264749a9975 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:02:56 +0800 Subject: [PATCH 02/56] docs: plan Codex WeChat observer implementation --- ...19-codex-wechat-observer-implementation.md | 959 ++++++++++++++++++ 1 file changed, 959 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-codex-wechat-observer-implementation.md diff --git a/docs/superpowers/plans/2026-07-19-codex-wechat-observer-implementation.md b/docs/superpowers/plans/2026-07-19-codex-wechat-observer-implementation.md new file mode 100644 index 0000000..73ad675 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-codex-wechat-observer-implementation.md @@ -0,0 +1,959 @@ +# Codex WeChat Observer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a safe, local Codex task observer that always records managed-job outcomes and sends a concise text summary plus a deterministic PNG report card to the single bound WeChat owner. + +**Architecture:** A versioned managed-job registry launches one audited project-local task through `spawn(..., { shell: false })`, journals the run before child start, classifies completion or recovery, and normalizes an optional adapter result into a strict `RunReport`. A deterministic SVG-to-PNG renderer and a run-level delivery ledger then send text first and image second through the existing owner-bound WeChat adapter, failing closed on uncertain delivery. + +**Tech Stack:** Node.js 22.23.1, TypeScript 7, Zod 4, YAML, Vitest 4, `@resvg/resvg-js` 2.6.2, macOS launchd, Tencent iLink media protocol, pinned `sgaofen/cli-in-wechat` commit `75d7db9338e133080fef9914b92ceb5e3ebd0dfc`. + +--- + +## Scope boundary + +This plan implements design phases 1 and 2: the report pipeline and the first WeChatPilot-managed Codex job (`daily-tech-digest`). It does not implement transparent discovery of Codex Desktop Scheduled tasks, Codex Hooks such as `Stop` or `PermissionRequest`, App Server subscriptions, WeChat approvals, inbound commands, or arbitrary job registration. Those require a separate approved specification and implementation plan after this managed-job path is proven. + +Before Task 1, create a dedicated worktree and branch from commit `954ad5c` using the repository's worktree workflow. Use worktree `.worktrees/codex-wechat-observer` and branch `codex/codex-wechat-observer`; do not implement these tasks directly in the currently deployed checkout. + +## File map + +### New observer modules + +- `src/observer/config.ts` — strict loading of `config/observer-jobs.yaml`; only the audited v1 job shape is accepted. +- `src/observer/report-policy.ts` — removes secrets, local paths, URLs, control characters, and bidirectional overrides from report text. +- `src/observer/run-report.ts` — authoritative `RunReport` and untrusted `RunReportDraft` schemas plus normalization. +- `src/observer/run-state.ts` — private run journal, atomic `running`/recovery-pending/final transitions, final-report listing, and stale-run recovery. +- `src/observer/task-lock.ts` — per-task live/stale process lock. +- `src/observer/report-card.ts` — fixed SVG layout and 1080 × 1440 PNG rendering. +- `src/observer/job-adapters.ts` — converts bounded `scheduled` CLI JSON into a safe draft without persisting stdout. +- `src/observer/managed-job.ts` — bounded child lifecycle with `shell: false`, safe environment propagation, timeout, and cancellation. + +### New delivery and orchestration modules + +- `src/delivery/wechat-media.ts` — audited iLink image preparation, AES upload, and final `image_item` send. +- `src/delivery/run-report-delivery.ts` — text-first/image-second run ledger and retry state machine. +- `src/app/observer.ts` — recovery, run, normalize, render, and deliver orchestration. +- `src/cli/observer.ts` — fixed `--project-root` and `--job daily-tech-digest` entry point. +- `src/cli/send-run-report-test.ts` — explicit live test-card sender with no recipient override. + +### Configuration, scripts, and tests + +- `config/observer-jobs.yaml` — one fixed v1 managed job. +- `scripts/send-run-report-test.sh` — explicit test-card command. +- `tests/observer/*.test.ts` — report, state, renderer, adapter, runner, and lock tests. +- `tests/delivery/wechat-media.test.ts` — media protocol and path boundary tests. +- `tests/delivery/run-report-delivery.test.ts` — run delivery ledger tests. +- `tests/app/observer.test.ts` and `tests/cli/observer.test.ts` — end-to-end orchestration without live WeChat. + +### Existing files to modify + +- `package.json`, `package-lock.json` — add the fixed renderer dependency and observer scripts. +- `src/lib/project-file.ts`, `tests/lib/project-file.test.ts` — add atomic private binary output. +- `src/delivery/wechat.ts`, `tests/delivery/wechat.test.ts` — prepare the image sender without exposing credentials. +- `config/vendor-wechat-artifacts.json`, `tests/vendor/artifact-integrity.test.ts` — audit upstream `ilink/client.js` media reference. +- `src/app/health.ts`, `tests/app/health.test.ts` — report observer config, renderer, and interrupted/outbox state without paths. +- `scripts/run-scheduled.sh`, `launchd/com.kdnsna.daily-tech-digest.plist`, `tests/launchd/*.test.ts` — route the existing schedule through the observer while preserving the service label. +- `README.md` — document the read-only observer, test command, health states, and recovery behavior. + +--- + +### Task 1: Strict job configuration and safe run-report contract + +**Files:** +- Create: `config/observer-jobs.yaml` +- Create: `src/observer/config.ts` +- Create: `src/observer/report-policy.ts` +- Create: `src/observer/run-report.ts` +- Test: `tests/observer/config.test.ts` +- Test: `tests/observer/run-report.test.ts` + +- [ ] **Step 1: Write failing configuration tests** + +Create `tests/observer/config.test.ts` with cases that load the real file, reject an absolute/parent-traversing entry point, reject any second job in v1, and reject a command override: + +```ts +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadObserverConfig } from "../../src/observer/config.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(yaml: string): string { + const root = mkdtempSync(join(tmpdir(), "wechatpilot-observer-config-")); + roots.push(root); + mkdirSync(join(root, "config")); + writeFileSync(join(root, "config", "observer-jobs.yaml"), yaml); + return root; +} + +describe("observer job configuration", () => { + it("loads only the audited daily digest job", () => { + const config = loadObserverConfig(process.cwd()); + expect(config.jobs).toEqual([ + expect.objectContaining({ + id: "daily-tech-digest", + entrypoint: "dist/src/cli/scheduled.js", + resultAdapter: "scheduled-digest-json-v1", + }), + ]); + }); + + it.each(["/tmp/evil.js", "../evil.js"])("rejects entrypoint %s", (entrypoint) => { + const root = fixture(`version: 1\njobs:\n - id: daily-tech-digest\n name: 每日科技情报\n projectLabel: WeChatPilot\n executable: /opt/homebrew/opt/node@22/bin/node\n entrypoint: ${entrypoint}\n arguments: [--project-root, \"{projectRoot}\"]\n workingDirectory: .\n timeoutMs: 1200000\n resultAdapter: scheduled-digest-json-v1\n allowedArtifactRoot: output\n notify: always\n`); + expect(() => loadObserverConfig(root)).toThrow(/observer|entrypoint|configuration/iu); + }); +}); +``` + +- [ ] **Step 2: Run the focused tests and verify the missing module failure** + +Run: + +```bash +PATH=/opt/homebrew/opt/node@22/bin:/usr/bin:/bin \ + /opt/homebrew/opt/node@22/bin/node \ + /opt/homebrew/opt/node@22/lib/node_modules/npm/bin/npm-cli.js \ + test -- tests/observer/config.test.ts +``` + +Expected: FAIL because `src/observer/config.ts` does not exist. + +- [ ] **Step 3: Add the exact v1 job file and loader** + +Create `config/observer-jobs.yaml`: + +```yaml +version: 1 +jobs: + - id: daily-tech-digest + name: 每日科技情报 + projectLabel: WeChatPilot + executable: /opt/homebrew/opt/node@22/bin/node + entrypoint: dist/src/cli/scheduled.js + arguments: + - --project-root + - "{projectRoot}" + workingDirectory: . + timeoutMs: 1200000 + resultAdapter: scheduled-digest-json-v1 + allowedArtifactRoot: output + notify: always +``` + +Implement `ObserverConfigSchema` so v1 accepts exactly one job, literal executable/entrypoint/arguments/adapter values, `timeoutMs` from 1 second through 30 minutes, `workingDirectory: "."`, and `allowedArtifactRoot: "output"`. Export: + +```ts +export type ObserverJob = z.infer; +export interface ObserverConfig { readonly version: 1; readonly jobs: readonly [ObserverJob]; } +export function loadObserverConfig(projectRoot: string): ObserverConfig; +export function findObserverJob(config: ObserverConfig, jobId: string): ObserverJob; +``` + +`findObserverJob` must throw the fixed message `Unknown observer job` without echoing the supplied ID. + +- [ ] **Step 4: Write failing run-report policy tests** + +Create `tests/observer/run-report.test.ts` and require these outcomes: + +```ts +import { describe, expect, it } from "vitest"; +import { buildRunReport, RunReportSchema } from "../../src/observer/run-report.js"; + +const authority = { + runId: "11111111-1111-4111-8111-111111111111", + taskId: "daily-tech-digest", + taskName: "每日科技情报", + projectLabel: "WeChatPilot", + status: "success" as const, + startedAt: new Date("2026-07-19T00:00:00.000Z"), + finishedAt: new Date("2026-07-19T00:01:30.000Z"), +}; + +describe("RunReport", () => { + it("keeps authority fields and removes unsafe draft content", () => { + const report = buildRunReport({ + authority, + projectRoot: "/Users/example/private-project", + draft: { + version: 1, + summary: ["完成 /Users/example/private-project token=secret-value"], + metrics: [{ label: "结果", value: "https://private.invalid/path" }], + artifacts: [], + }, + }); + expect(RunReportSchema.parse(report)).toEqual(report); + expect(JSON.stringify(report)).not.toContain("/Users/example"); + expect(JSON.stringify(report)).not.toContain("secret-value"); + expect(JSON.stringify(report)).not.toContain("https://"); + expect(report.durationMs).toBe(90_000); + }); + + it("falls back to a minimal summary when the draft is invalid", () => { + const report = buildRunReport({ authority, projectRoot: process.cwd(), draft: null }); + expect(report.summary).toEqual(["任务已完成,未提供结构化业务摘要"]); + expect(report.metrics).toEqual([]); + }); +}); +``` + +- [ ] **Step 5: Implement report policy and schemas** + +`RunReportDraftSchema` must contain only `version`, `summary`, `metrics`, `failure`, and project-relative `artifacts`. `RunReportSchema` must add authority fields and the enum: + +```ts +export const RunStatusSchema = z.enum([ + "success", + "failed", + "timed-out", + "cancelled", + "interrupted", + "skipped", +]); +``` + +Implement `sanitizeReportText(value, { projectRoot, maximumCharacters })` with this order: call `redactSecrets`, remove C0/C1 controls except ordinary spaces, remove `U+202A`–`U+202E` and `U+2066`–`U+2069`, replace the exact project root and `/Users//...` or `/home//...` paths with `[LOCAL_PATH]`, replace URL-shaped text with `[URL]`, collapse whitespace, trim, and truncate with `…`. Reject an empty result rather than passing it to the card. + +- [ ] **Step 6: Run focused and full tests, then commit** + +Run the two focused files, then `npm test`. Expected: both focused files PASS and the existing suite has zero failures. + +```bash +git add config/observer-jobs.yaml src/observer tests/observer/config.test.ts tests/observer/run-report.test.ts +git commit -m "feat: define observer jobs and run reports" +``` + +--- + +### Task 2: Private run journal, binary writes, and interrupted recovery + +**Files:** +- Create: `src/observer/run-state.ts` +- Modify: `src/lib/project-file.ts` +- Test: `tests/observer/run-state.test.ts` +- Test: `tests/lib/project-file.test.ts` + +- [ ] **Step 1: Add a failing private-binary-write test** + +Append to `tests/lib/project-file.test.ts`: + +```ts +it("atomically writes private binary content without following symlinks", () => { + const root = project(); + const path = join(root, "output", "card.png"); + writeProjectBytesAtomic(root, path, Uint8Array.from([0x89, 0x50, 0x4e, 0x47])); + expect([...readFileSync(path)]).toEqual([0x89, 0x50, 0x4e, 0x47]); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(statSync(dirname(path)).mode & 0o777).toBe(0o700); +}); +``` + +Run only this test file. Expected: FAIL because `writeProjectBytesAtomic` is not exported. + +- [ ] **Step 2: Implement `writeProjectBytesAtomic` and reuse it for text** + +Add: + +```ts +export function writeProjectBytesAtomic( + projectRoot: string, + filePath: string, + content: Uint8Array, +): void; +``` + +Use the same project/symlink checks, unique temporary file, `O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW`, `0600`, `fsync`, rename, and cleanup behavior as the current text writer. Make `writeProjectTextAtomic` call the binary helper with `Buffer.from(content, "utf8")` so both paths retain one implementation. + +- [ ] **Step 3: Write failing journal and recovery tests** + +Create `tests/observer/run-state.test.ts` with a fixed clock and require: + +```ts +const running = startRunRecord({ + projectRoot: root, + runId: "22222222-2222-4222-8222-222222222222", + job, + runnerPid: 1234, + startedAt: new Date("2026-07-19T01:00:00.000Z"), +}); +expect(running.status).toBe("running"); +updateRunChildPid({ projectRoot: root, runId: running.runId, childPid: 5678 }); + +expect(recoverInterruptedRuns({ + projectRoot: root, + now: () => new Date("2026-07-19T01:05:00.000Z"), + isProcessAlive: () => false, +})).toEqual({ + recovered: [ + expect.objectContaining({ status: "interrupted", taskId: "daily-tech-digest" }), + ], + activeTaskIds: [], + invalidRecords: 0, +}); +expect(recoverInterruptedRuns({ + projectRoot: root, + now: () => new Date("2026-07-19T01:06:00.000Z"), + isProcessAlive: () => false, +})).toEqual({ recovered: [], activeTaskIds: [], invalidRecords: 0 }); +``` + +Also prove: a live runner or child PID appears in `activeTaskIds` and prevents recovery; a symlink, group-readable file, malformed JSON, oversized file, unknown task ID, or invalid UUID increments `invalidRecords`, is never rewritten, and never exposes its path or content. + +- [ ] **Step 4: Implement strict state transitions** + +Export these exact APIs: + +```ts +export interface RunningRunRecord { + readonly version: 1; + readonly status: "running"; + readonly runId: string; + readonly taskId: string; + readonly taskName: string; + readonly projectLabel: string; + readonly runnerPid: number; + readonly childPid?: number; + readonly startedAt: string; +} + +export function startRunRecord(options: StartRunRecordOptions): RunningRunRecord; +export function updateRunChildPid(options: UpdateRunChildPidOptions): RunningRunRecord; +export function finishRunRecord(projectRoot: string, report: RunReport): void; +export interface RecoverInterruptedRunsResult { + readonly recovered: readonly RunAuthority[]; + readonly activeTaskIds: readonly string[]; + readonly invalidRecords: number; +} +export function recoverInterruptedRuns(options: RecoverInterruptedRunsOptions): RecoverInterruptedRunsResult; +export function listFinalRunReports(projectRoot: string): readonly RunReport[]; +export function inspectRunState(projectRoot: string): { + readonly recoveryPending: number; + readonly invalidRecords: number; +}; +``` + +Store only under `.state/observer/runs/.json`. Each update must parse the existing private record and use `writeProjectTextAtomic`. Recovery may convert only a valid `running` record whose runner and child PIDs are both absent or confirmed dead; it writes `status: "recovery-pending"` before returning the authority so a second recovery cannot duplicate it. `finishRunRecord` replaces `running` or `recovery-pending` with the strict final `RunReport`. `listFinalRunReports` returns only valid private final reports and never raw running records. + +- [ ] **Step 5: Run focused tests and commit** + +Expected: `tests/lib/project-file.test.ts` and `tests/observer/run-state.test.ts` PASS; `git diff --check` emits no output. + +```bash +git add src/lib/project-file.ts src/observer/run-state.ts tests/lib/project-file.test.ts tests/observer/run-state.test.ts +git commit -m "feat: journal observer runs and recover interruptions" +``` + +--- + +### Task 3: Deterministic SVG-to-PNG report card + +**Files:** +- Modify: `package.json` +- Modify: `package-lock.json` +- Create: `src/observer/report-card.ts` +- Test: `tests/observer/report-card.test.ts` + +- [ ] **Step 1: Install the pinned renderer dependency** + +Run: + +```bash +PATH=/opt/homebrew/opt/node@22/bin:/usr/bin:/bin \ + /opt/homebrew/opt/node@22/bin/node \ + /opt/homebrew/opt/node@22/lib/node_modules/npm/bin/npm-cli.js \ + install --save-exact @resvg/resvg-js@2.6.2 +``` + +Expected: `package.json` and `package-lock.json` contain exactly `2.6.2`; no unrelated dependency is upgraded. + +- [ ] **Step 2: Write the failing renderer tests** + +Create `tests/observer/report-card.test.ts`. Use a real normalized report and assert: + +```ts +const artifact = renderReportCard({ projectRoot: root, report }); +const png = readFileSync(artifact.pngPath); +expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); +expect(png.readUInt32BE(16)).toBe(1080); +expect(png.readUInt32BE(20)).toBe(1440); +expect(statSync(artifact.pngPath).mode & 0o777).toBe(0o600); +expect(JSON.parse(readFileSync(artifact.jsonPath, "utf8"))).toEqual(report); +``` + +Add a hostile summary containing `', + projectLabel: "A & B href=https://evil.invalid", + summary: [' & href=data:image/png;base64,AA\u202e'], + metrics: [ + { + label: "href=", + value: "data: https://evil.invalid/x?a=1&b=2\u202e", + }, + ], + failure: '', + artifacts: ["href=data:evil"], + } as RunReport; + + const svg = buildReportCardSvg(hostile); + + expect(svg).toContain("<script"); + expect(svg).toContain("&"); + expect(svg).not.toMatch(/\u202e', + editorial: { + ...base.editorial!, + executiveSummary: + '\u202e', + highlights: base.editorial!.highlights.map((item) => ({ + ...item, + title: '\u202e', + })), + }, + } as RunReport; + const svg = buildEditorialCardSvg({ + report: hostile, + heroPng: hero(), + visualMode: "generated", + }); + + expect(svg.match(/data:image\/png;base64,/gu)).toHaveLength(1); + expect(svg.match(/\shref=/gu)).toHaveLength(1); + expect(svg).not.toMatch(/ { + it.each(["generated", "fallback"] as const)( + "renders and reuses a private 1080x2400 %s card", + (mode) => { + const root = project(); + const value = report(); + const visual = preparedVisual(root, mode); + const artifact = renderEditorialReportCard({ + projectRoot: root, + report: value, + visual, + }); + const png = readFileSync(artifact.pngPath); + + expect(pngDimensions(png)).toEqual({ width: 1_080, height: 2_400 }); + expect(png.byteLength).toBeLessThanOrEqual(5 * 1_024 * 1_024); + expect(statSync(artifact.pngPath).mode & 0o777).toBe(0o600); + expect(statSync(artifact.jsonPath).mode & 0o777).toBe(0o600); + expect(JSON.parse(readFileSync(artifact.jsonPath, "utf8"))).toEqual(value); + expect(artifact).toMatchObject({ issueLabel: ISSUE.label, visualMode: mode }); + expect(renderEditorialReportCard({ projectRoot: root, report: value, visual })).toEqual( + artifact, + ); + }, + 30_000, + ); +}); diff --git a/tests/observer/report-card.test.ts b/tests/observer/report-card.test.ts index 73e46cd..a0c8ed0 100644 --- a/tests/observer/report-card.test.ts +++ b/tests/observer/report-card.test.ts @@ -181,6 +181,16 @@ describe("report card SVG", () => { } }); + it("includes an assigned issue in the compatibility footer", () => { + const value = { + ...report(), + issueNumber: 127, + issueLabel: "WP-000127", + } as RunReport; + + expect(buildReportCardSvg(value)).toContain("WP-000127"); + }); + it("keeps hostile dynamic fields in escaped text nodes without resource hooks", () => { const hostile = { ...report("failed"), From 5da2bcab83928d859c3d84ff932164454ee38357 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:14:30 +0800 Subject: [PATCH 49/56] fix: separate fallback hero labels --- src/observer/editorial-card.ts | 4 +++- tests/observer/editorial-card.test.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/observer/editorial-card.ts b/src/observer/editorial-card.ts index de2fd93..2f3e7b6 100644 --- a/src/observer/editorial-card.ts +++ b/src/observer/editorial-card.ts @@ -145,7 +145,9 @@ export function buildEditorialCardSvg(options: { textNode(64, 292, 62, 700, "编辑部情报", "#ffffff"), textNode(66, 336, 24, 500, singleLine(report.taskName, 24, 66, 760), "#eef2f7"), textNode(64, 420, 20, 500, `${finished.date} · ${finished.time} CST`, "#ffffff"), - textNode(768, 420, 18, 600, options.visualMode === "generated" ? "AI VISUAL" : "FIXED VISUAL", "#ffffff"), + ...(options.visualMode === "generated" + ? [textNode(768, 420, 18, 600, "AI VISUAL", "#ffffff")] + : []), ); if (options.visualMode === "fallback") { nodes.push( diff --git a/tests/observer/editorial-card.test.ts b/tests/observer/editorial-card.test.ts index 6560bf5..9e785ff 100644 --- a/tests/observer/editorial-card.test.ts +++ b/tests/observer/editorial-card.test.ts @@ -149,6 +149,7 @@ describe("fixed editorial card SVG", () => { expect(svg).toContain(marker); expect(svg).toContain("AI 主视觉暂不可用"); expect(svg).toContain("报告图:固定背景"); + expect(svg).not.toContain("FIXED VISUAL"); if (status !== "success") { expect(svg).not.toContain("日报正文:确认成功"); expect(svg).not.toContain("技术信号 1"); From 2ea926908c5cd006bcbba122401566534b85ba6e Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:20:32 +0800 Subject: [PATCH 50/56] feat: orchestrate numbered Image 2 reports --- src/app/observer.ts | 163 ++++++++++++++++++++++--- src/observer/run-state.ts | 40 ++++++ tests/app/observer.test.ts | 203 ++++++++++++++++++++++++++++++- tests/observer/run-state.test.ts | 33 +++++ 4 files changed, 418 insertions(+), 21 deletions(-) diff --git a/src/app/observer.ts b/src/app/observer.ts index 8df9fb4..201885d 100644 --- a/src/app/observer.ts +++ b/src/app/observer.ts @@ -29,11 +29,28 @@ import { type ManagedJobOutcome, type RunManagedJobOptions, } from "../observer/managed-job.js"; +import { + renderEditorialReportCard, + type EditorialCardArtifact, +} from "../observer/editorial-card.js"; +import { + loadEditorialConfig, + type EditorialConfig, +} from "../observer/editorial-config.js"; import { renderReportCard, type ReportCardArtifact, } from "../observer/report-card.js"; import { + prepareReportVisual, + type PreparedReportVisual, +} from "../observer/report-visual.js"; +import { + assignReportIssue, + type ReportIssue, +} from "../observer/report-sequence.js"; +import { + attachIssue, buildRunReport, type BuildRunReportOptions, type RunAuthority, @@ -44,6 +61,7 @@ import { finishRunRecord, listFinalRunReports, recoverInterruptedRuns, + replaceFinalRunReport, type RecoverInterruptedRunsOptions, type RecoverInterruptedRunsResult, } from "../observer/run-state.js"; @@ -58,6 +76,7 @@ import { shanghaiDateKey } from "../lib/time.js"; const RECOVERY_SUMMARY = "任务因主机或进程中断,已在恢复后补记"; const RENDER_FAILURE_CODE = "OBSERVER_REPORT_RENDER_FAILED"; +const SAFE_RENDER_ERROR = "Unable to prepare observer report image"; interface PrepareObserverSenderOptions { readonly projectRoot: string; @@ -81,6 +100,12 @@ export interface ObserverDependencies { readonly runJob?: (options: RunManagedJobOptions) => Promise; readonly normalize?: (options: BuildRunReportOptions) => RunReport; readonly finishRecord?: (projectRoot: string, report: RunReport) => void; + readonly loadEditorial?: typeof loadEditorialConfig; + readonly assignIssue?: typeof assignReportIssue; + readonly attachIssue?: typeof attachIssue; + readonly replaceFinalRecord?: typeof replaceFinalRunReport; + readonly prepareVisual?: typeof prepareReportVisual; + readonly renderEditorial?: typeof renderEditorialReportCard; readonly render?: (options: { readonly projectRoot: string; readonly report: RunReport; @@ -126,6 +151,15 @@ interface ResolvedObserverDependencies { readonly runJob: (options: RunManagedJobOptions) => Promise; readonly normalize: (options: BuildRunReportOptions) => RunReport; readonly finishRecord: (projectRoot: string, report: RunReport) => void; + readonly loadEditorial: (projectRoot: string) => EditorialConfig; + readonly assignIssue: (options: { + readonly projectRoot: string; + readonly runId: string; + }) => Promise; + readonly attachIssue: typeof attachIssue; + readonly replaceFinalRecord: typeof replaceFinalRunReport; + readonly prepareVisual: typeof prepareReportVisual; + readonly renderEditorial: typeof renderEditorialReportCard; readonly render: (options: { readonly projectRoot: string; readonly report: RunReport; @@ -155,6 +189,14 @@ function resolvedDependencies( runJob: supplied?.runJob ?? runManagedJob, normalize: supplied?.normalize ?? buildRunReport, finishRecord: supplied?.finishRecord ?? finishRunRecord, + loadEditorial: supplied?.loadEditorial ?? loadEditorialConfig, + assignIssue: supplied?.assignIssue ?? assignReportIssue, + attachIssue: supplied?.attachIssue ?? attachIssue, + replaceFinalRecord: + supplied?.replaceFinalRecord ?? replaceFinalRunReport, + prepareVisual: supplied?.prepareVisual ?? prepareReportVisual, + renderEditorial: + supplied?.renderEditorial ?? renderEditorialReportCard, render: supplied?.render ?? renderReportCard, prepareText: supplied?.prepareText ?? @@ -201,42 +243,121 @@ async function logRenderFailure( }); await logger.error(RENDER_FAILURE_CODE); } catch { - // A logging failure must not prevent the text-only audited report. + // A logging failure must not expose or replace the safe render failure. + } +} + +interface PreparedFinalReportArtifacts { + readonly report: RunReport; + readonly artifact: ReportCardArtifact | EditorialCardArtifact; +} + +function issueFromReport(report: RunReport): ReportIssue | null { + if (report.issueNumber === undefined || report.issueLabel === undefined) { + return null; + } + return { number: report.issueNumber, label: report.issueLabel }; +} + +async function prepareFinalReportArtifacts( + options: RunObserverJobOptions, + dependencies: ResolvedObserverDependencies, + report: RunReport, + disposition: RunDeliveryDisposition, +): Promise { + if (report.editorial === undefined) { + return { + report, + artifact: dependencies.render({ + projectRoot: options.projectRoot, + report, + }), + }; + } + + let finalReport = report; + let issue = issueFromReport(finalReport); + if (issue === null) { + if (disposition !== "missing") { + return { + report, + artifact: dependencies.render({ + projectRoot: options.projectRoot, + report, + }), + }; + } + issue = await dependencies.assignIssue({ + projectRoot: options.projectRoot, + runId: report.runId, + }); + finalReport = dependencies.attachIssue(report, issue); + dependencies.replaceFinalRecord(options.projectRoot, finalReport); + } + + const imageConfig = dependencies.loadEditorial(options.projectRoot); + if (imageConfig.mode === "legacy") { + return { + report: finalReport, + artifact: dependencies.render({ + projectRoot: options.projectRoot, + report: finalReport, + }), + }; } + + const visual: PreparedReportVisual = await dependencies.prepareVisual({ + projectRoot: options.projectRoot, + report: finalReport, + issue, + config: imageConfig, + }); + return { + report: finalReport, + artifact: dependencies.renderEditorial({ + projectRoot: options.projectRoot, + report: finalReport, + visual, + }), + }; } async function deliverFinalReport( options: RunObserverJobOptions, dependencies: ResolvedObserverDependencies, report: RunReport, -): Promise { - let imagePath: string | undefined; + disposition: RunDeliveryDisposition, +): Promise { + let prepared: PreparedFinalReportArtifacts; try { - imagePath = dependencies.render({ projectRoot: options.projectRoot, report }) - .pngPath; + prepared = await prepareFinalReportArtifacts( + options, + dependencies, + report, + disposition, + ); } catch { await logRenderFailure(options, dependencies); + throw new Error(SAFE_RENDER_ERROR); } const senderOptions = { projectRoot: options.projectRoot, - report, + report: prepared.report, ...(options.signal === undefined ? {} : { signal: options.signal }), }; const sendText = await dependencies.prepareText(senderOptions); - const sendImage = - imagePath === undefined - ? async (): Promise<{ readonly status: "sent" }> => ({ status: "sent" }) - : await dependencies.prepareImage(senderOptions); + const sendImage = await dependencies.prepareImage(senderOptions); await dependencies.deliver({ projectRoot: options.projectRoot, - report, + report: prepared.report, sendText, sendImage, - ...(imagePath === undefined ? {} : { imagePath }), + imagePath: prepared.artifact.pngPath, ...(options.now === undefined ? {} : { now: options.now }), }); + return prepared.report; } function recoveredReport( @@ -277,7 +398,13 @@ export async function runObserverJob( for (const authority of recovery.recovered) { const report = recoveredReport(options, dependencies, authority); - await deliverFinalReport(options, dependencies, report); + const disposition = dependencies.deliveryDisposition({ + projectRoot: options.projectRoot, + report, + }); + if (disposition === "missing" || disposition === "retryable") { + await deliverFinalReport(options, dependencies, report, disposition); + } } for (const report of dependencies.listFinalReports(options.projectRoot)) { @@ -286,7 +413,7 @@ export async function runObserverJob( report, }); if (disposition === "missing" || disposition === "retryable") { - await deliverFinalReport(options, dependencies, report); + await deliverFinalReport(options, dependencies, report, disposition); } } @@ -315,8 +442,12 @@ export async function runObserverJob( draft: outcome.draft, }); dependencies.finishRecord(options.projectRoot, report); - await deliverFinalReport(options, dependencies, report); - return report; + return await deliverFinalReport( + options, + dependencies, + report, + "missing", + ); } finally { await lock.release(); } diff --git a/src/observer/run-state.ts b/src/observer/run-state.ts index 590cfaf..d5aab25 100644 --- a/src/observer/run-state.ts +++ b/src/observer/run-state.ts @@ -545,6 +545,46 @@ export function finishRunRecord(projectRoot: string, report: RunReport): void { } } +export function replaceFinalRunReport( + projectRoot: string, + report: RunReport, +): void { + try { + const replacement = RunReportSchema.parse(report); + if ( + !isAuditedAuthority(replacement) || + replacement.issueNumber === undefined || + replacement.issueLabel === undefined + ) { + throw safeError(); + } + const current = recordForUpdate(projectRoot, replacement.runId); + if ( + current.kind !== "final" || + current.value.issueNumber !== undefined || + current.value.issueLabel !== undefined + ) { + throw safeError(); + } + const { + issueNumber: _replacementIssueNumber, + issueLabel: _replacementIssueLabel, + ...replacementContent + } = replacement; + const { + issueNumber: _currentIssueNumber, + issueLabel: _currentIssueLabel, + ...currentContent + } = current.value; + if (JSON.stringify(replacementContent) !== JSON.stringify(currentContent)) { + throw safeError(); + } + writeRecord(projectRoot, replacement.runId, replacement); + } catch { + throw safeError(); + } +} + export function recoverInterruptedRuns( options: RecoverInterruptedRunsOptions, ): RecoverInterruptedRunsResult { diff --git a/tests/app/observer.test.ts b/tests/app/observer.test.ts index 15df585..9a462e2 100644 --- a/tests/app/observer.test.ts +++ b/tests/app/observer.test.ts @@ -14,7 +14,9 @@ import { } from "../../src/delivery/run-report-delivery.js"; import { WechatDeliveryRejectedError } from "../../src/delivery/wechat.js"; import type { ObserverConfig, ObserverJob } from "../../src/observer/config.js"; +import type { EditorialConfig } from "../../src/observer/editorial-config.js"; import { ObserverTaskBusyError } from "../../src/observer/task-lock.js"; +import type { PreparedReportVisual } from "../../src/observer/report-visual.js"; import type { RunAuthority, RunReport, @@ -26,6 +28,7 @@ import { updateRunChildPid, } from "../../src/observer/run-state.js"; import { renderReportCard } from "../../src/observer/report-card.js"; +import { validEditorialPayload } from "../helpers/editorial.js"; const ROOT = "/private/tmp/wechat-observer-test"; const RECOVERED_ID = "018f514c-1a2b-7abc-8def-1234567890ab"; @@ -63,6 +66,11 @@ const job: ObserverJob = { }; const config: ObserverConfig = { version: 1, jobs: [job] }; +const editorialConfig = { + mode: "editorial", + generation: { timeoutMs: 180_000, maximumAttempts: 3 }, + style: { version: "editorial-v1", heroHeight: 480 }, +} as EditorialConfig; function authority( runId: string, @@ -95,6 +103,30 @@ function report( }; } +function editorialReport(runId: string): RunReport { + return { + ...report(runId), + editorial: validEditorialPayload(), + }; +} + +function numberedEditorialReport(runId: string): RunReport { + return { + ...editorialReport(runId), + issueNumber: 7, + issueLabel: "WP-000007", + }; +} + +const preparedVisual: PreparedReportVisual = { + mode: "generated", + attempts: 1, + heroPath: `${ROOT}/output/${CURRENT_ID}.hero.png`, + heroSha256: "a".repeat(64), + styleVersion: "editorial-v1", + promptSha256: "b".repeat(64), +}; + function dependencies( overrides: Partial = {}, ): ObserverDependencies { @@ -122,6 +154,21 @@ function dependencies( })), normalize: vi.fn(({ authority: value }) => report(value.runId, value.status)), finishRecord: vi.fn(() => undefined), + loadEditorial: vi.fn(() => editorialConfig), + assignIssue: vi.fn(async () => ({ number: 7, label: "WP-000007" })), + attachIssue: vi.fn((value) => ({ + ...value, + issueNumber: 7, + issueLabel: "WP-000007", + })), + replaceFinalRecord: vi.fn(() => undefined), + prepareVisual: vi.fn(async () => preparedVisual), + renderEditorial: vi.fn(({ report: value, visual }) => ({ + pngPath: `${ROOT}/output/${value.runId}.png`, + jsonPath: `${ROOT}/output/${value.runId}.json`, + issueLabel: value.issueLabel!, + visualMode: visual.mode, + })), render: vi.fn(({ report: value }) => ({ pngPath: `${ROOT}/output/${value.runId}.png`, jsonPath: `${ROOT}/output/${value.runId}.json`, @@ -151,6 +198,97 @@ function dependencies( } describe("observer orchestration", () => { + it("orders numbering, visual preparation, rendering, and delivery after finalization", async () => { + const calls: string[] = []; + const deps = dependencies({ + normalize: vi.fn(() => editorialReport(CURRENT_ID)), + finishRecord: vi.fn(() => calls.push("finish-record")), + assignIssue: vi.fn(async () => { + calls.push("assign-issue"); + return { number: 7, label: "WP-000007" }; + }), + attachIssue: vi.fn((value, issue) => ({ + ...value, + issueNumber: issue.number, + issueLabel: issue.label, + })), + replaceFinalRecord: vi.fn(() => calls.push("persist-numbered-report")), + prepareVisual: vi.fn(async () => { + calls.push("prepare-visual"); + return preparedVisual; + }), + renderEditorial: vi.fn(({ report: value, visual }) => { + calls.push("render-card"); + return { + pngPath: `${ROOT}/output/${value.runId}.png`, + jsonPath: `${ROOT}/output/${value.runId}.json`, + issueLabel: value.issueLabel!, + visualMode: visual.mode, + }; + }), + prepareText: vi.fn(async () => { + calls.push("prepare-text"); + return async () => ({ status: "sent" as const, chunks: 1 }); + }), + prepareImage: vi.fn(async () => { + calls.push("prepare-image"); + return async () => ({ status: "sent" as const }); + }), + deliver: vi.fn(async () => { + calls.push("deliver"); + return { + status: "sent" as const, + textChunks: 1, + image: "sent" as const, + textAttempt: 1, + imageAttempt: 1, + }; + }), + }); + + const result = await runObserverJob({ + projectRoot: ROOT, + jobId: job.id, + dependencies: deps, + }); + + expect(result).toEqual(numberedEditorialReport(CURRENT_ID)); + expect(calls).toEqual([ + "finish-record", + "assign-issue", + "persist-numbered-report", + "prepare-visual", + "render-card", + "prepare-text", + "prepare-image", + "deliver", + ]); + }); + + it("assigns an issue but skips Image 2 in emergency legacy mode", async () => { + const legacyConfig = { ...editorialConfig, mode: "legacy" as const }; + const deps = dependencies({ + normalize: vi.fn(() => editorialReport(CURRENT_ID)), + loadEditorial: vi.fn(() => legacyConfig), + }); + + const result = await runObserverJob({ + projectRoot: ROOT, + jobId: job.id, + dependencies: deps, + }); + + expect(result).toEqual(numberedEditorialReport(CURRENT_ID)); + expect(deps.assignIssue).toHaveBeenCalledOnce(); + expect(deps.replaceFinalRecord).toHaveBeenCalledOnce(); + expect(deps.prepareVisual).not.toHaveBeenCalled(); + expect(deps.renderEditorial).not.toHaveBeenCalled(); + expect(deps.render).toHaveBeenCalledWith({ + projectRoot: ROOT, + report: numberedEditorialReport(CURRENT_ID), + }); + }); + it("runs recovery, the managed job, finalization, rendering, delivery, and one outer lock release in exact order", async () => { const calls: string[] = []; const release = vi.fn(async () => { @@ -358,7 +496,7 @@ describe("observer orchestration", () => { expect(deps.runJob).not.toHaveBeenCalled(); }); - it("continues with text-only delivery when rendering fails and logs only a stable code", async () => { + it("keeps delivery missing when rendering fails and logs only a stable code", async () => { const logger = { log: vi.fn(async () => undefined), debug: vi.fn(async () => undefined), @@ -373,13 +511,68 @@ describe("observer orchestration", () => { createLogger: vi.fn(() => logger), }); - await runObserverJob({ projectRoot: ROOT, jobId: job.id, dependencies: deps }); + let thrown: unknown; + try { + await runObserverJob({ projectRoot: ROOT, jobId: job.id, dependencies: deps }); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + message: "Unable to prepare observer report image", + }); + expect(JSON.stringify(thrown)).not.toContain("must-not-enter-log"); expect(logger.error).toHaveBeenCalledWith("OBSERVER_REPORT_RENDER_FAILED"); + expect(deps.prepareText).not.toHaveBeenCalled(); expect(deps.prepareImage).not.toHaveBeenCalled(); - expect(deps.deliver).toHaveBeenCalledWith( - expect.not.objectContaining({ imagePath: expect.anything() }), - ); + expect(deps.deliver).not.toHaveBeenCalled(); + }); + + it("reuses a numbered editorial visual for retryable outbox delivery", async () => { + const value = numberedEditorialReport(RECOVERED_ID); + const deps = dependencies({ + recover: vi.fn(() => ({ + recovered: [], + activeTaskIds: [job.id], + invalidRecords: 0, + })), + listFinalReports: vi.fn(() => [value]), + deliveryDisposition: vi.fn(() => "retryable" as const), + }); + + await expect( + runObserverJob({ projectRoot: ROOT, jobId: job.id, dependencies: deps }), + ).rejects.toBeInstanceOf(ObserverTaskBusyError); + + expect(deps.assignIssue).not.toHaveBeenCalled(); + expect(deps.replaceFinalRecord).not.toHaveBeenCalled(); + expect(deps.prepareVisual).toHaveBeenCalledOnce(); + expect(deps.renderEditorial).toHaveBeenCalledOnce(); + expect(deps.deliver).toHaveBeenCalledOnce(); + }); + + it("does not regenerate, render, or send an indeterminate outbox report", async () => { + const value = numberedEditorialReport(RECOVERED_ID); + const deps = dependencies({ + recover: vi.fn(() => ({ + recovered: [], + activeTaskIds: [job.id], + invalidRecords: 0, + })), + listFinalReports: vi.fn(() => [value]), + deliveryDisposition: vi.fn(() => "indeterminate" as const), + }); + + await expect( + runObserverJob({ projectRoot: ROOT, jobId: job.id, dependencies: deps }), + ).rejects.toBeInstanceOf(ObserverTaskBusyError); + + expect(deps.assignIssue).not.toHaveBeenCalled(); + expect(deps.prepareVisual).not.toHaveBeenCalled(); + expect(deps.renderEditorial).not.toHaveBeenCalled(); + expect(deps.prepareText).not.toHaveBeenCalled(); + expect(deps.prepareImage).not.toHaveBeenCalled(); + expect(deps.deliver).not.toHaveBeenCalled(); }); it("reuses the real card after an image rejection and reconciles without resending text", async () => { diff --git a/tests/observer/run-state.test.ts b/tests/observer/run-state.test.ts index 342d3d6..0a12c0b 100644 --- a/tests/observer/run-state.test.ts +++ b/tests/observer/run-state.test.ts @@ -21,6 +21,7 @@ import { inspectRunState, listFinalRunReports, recoverInterruptedRuns, + replaceFinalRunReport, startRunRecord as startRunRecordRaw, updateRunChildPid as updateRunChildPidRaw, type StartRunRecordOptions, @@ -550,6 +551,38 @@ describe("observer run-state journal", () => { ); }); + it("atomically enriches a final report only with its first matching issue", () => { + const projectRoot = root(); + startRunRecord({ + projectRoot, + runId: ids.main, + job, + runnerPid: 1234, + startedAt: new Date("2026-07-19T01:00:00.000Z"), + }); + const final = report(ids.main); + finishRunRecord(projectRoot, final); + const enriched = { + ...final, + issueNumber: 7, + issueLabel: "WP-000007", + } as RunReport; + + for (const mutation of [ + { ...enriched, summary: ["PRIVATE_RUN_STATE_SENTINEL"] }, + { ...enriched, durationMs: enriched.durationMs + 1 }, + { ...enriched, issueNumber: 8, issueLabel: "WP-000007" }, + ]) { + expectSafeFailure(() => replaceFinalRunReport(projectRoot, mutation)); + expect(listFinalRunReports(projectRoot)).toEqual([final]); + } + + replaceFinalRunReport(projectRoot, enriched); + expect(listFinalRunReports(projectRoot)).toEqual([enriched]); + expect(statSync(runPath(projectRoot, ids.main)).mode & 0o777).toBe(0o600); + expectSafeFailure(() => replaceFinalRunReport(projectRoot, enriched)); + }); + it("lists only private final reports in stable order and inspects without details", () => { const projectRoot = root(); const later = report( From 1935b4d2abf0c1e9abd9a7b6663bda09b0d246ec Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:37:50 +0800 Subject: [PATCH 51/56] feat: verify editorial image readiness --- package.json | 4 +- src/app/health.ts | 96 ++++++++++ src/cli/probe-report-image.ts | 197 ++++++++++++++++++++ src/cli/render-run-report-preview.ts | 152 +++++++++++++++ src/cli/send-run-report-test.ts | 104 +++++++---- src/observer/fixed-editorial-report.ts | 98 ++++++++++ src/observer/report-image-capability.ts | 71 +++++++ src/observer/report-sequence.ts | 20 ++ src/observer/report-visual-state.ts | 165 ++++++++++++++++ src/observer/report-visual.ts | 139 ++------------ tests/app/health.test.ts | 96 ++++++++++ tests/cli/probe-report-image.test.ts | 122 ++++++++++++ tests/cli/render-run-report-preview.test.ts | 106 +++++++++++ tests/cli/send-run-report-test.test.ts | 65 +++++++ tests/launchd/scripts.test.ts | 23 ++- 15 files changed, 1295 insertions(+), 163 deletions(-) create mode 100644 src/cli/probe-report-image.ts create mode 100644 src/cli/render-run-report-preview.ts create mode 100644 src/observer/fixed-editorial-report.ts create mode 100644 src/observer/report-image-capability.ts create mode 100644 src/observer/report-visual-state.ts create mode 100644 tests/cli/probe-report-image.test.ts create mode 100644 tests/cli/render-run-report-preview.test.ts diff --git a/package.json b/package.json index 6911e80..e0d0298 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "send:digest": "tsx src/cli/send-digest.ts", "scheduled": "tsx src/cli/scheduled.ts --project-root \"/Users/kdnsna/Documents/微信 chat\"", "observer": "tsx src/cli/observer.ts --project-root \"/Users/kdnsna/Documents/微信 chat\" --job daily-tech-digest", - "report:test": "tsx src/cli/send-run-report-test.ts", + "report:image-probe": "tsx src/cli/probe-report-image.ts --project-root \"/Users/kdnsna/Documents/微信 chat\"", + "report:preview": "tsx src/cli/render-run-report-preview.ts --project-root \"/Users/kdnsna/Documents/微信 chat\"", + "report:test": "tsx src/cli/send-run-report-test.ts --project-root \"/Users/kdnsna/Documents/微信 chat\"", "health": "tsx src/cli/health.ts" }, "dependencies": { diff --git a/src/app/health.ts b/src/app/health.ts index 97bde08..380377c 100644 --- a/src/app/health.ts +++ b/src/app/health.ts @@ -25,6 +25,13 @@ import { loadObserverConfig, type ObserverConfig, } from "../observer/config.js"; +import { + loadEditorialConfig, + type EditorialConfig, +} from "../observer/editorial-config.js"; +import { reportImageCapabilityReady } from "../observer/report-image-capability.js"; +import { inspectReportSequence } from "../observer/report-sequence.js"; +import { inspectReportVisuals } from "../observer/report-visual-state.js"; import type { RunReport } from "../observer/run-report.js"; import { inspectRunState, @@ -78,6 +85,17 @@ export interface HealthProbes { readonly verifyVendorArtifacts: (projectRoot: string) => void; readonly loadObserverConfig: (projectRoot: string) => ObserverConfig; readonly rendererReady: () => boolean; + readonly loadEditorialConfig: (projectRoot: string) => EditorialConfig; + readonly imageCapabilityReady: (options: { + readonly projectRoot: string; + readonly config: EditorialConfig; + }) => boolean; + readonly inspectReportSequence: (projectRoot: string) => boolean; + readonly inspectReportVisuals: (projectRoot: string) => { + readonly pending: number; + readonly fallback: number; + readonly invalid: number; + }; readonly inspectRunState: (projectRoot: string) => { readonly recoveryPending: number; readonly invalidRecords: number; @@ -136,6 +154,12 @@ export interface ProjectHealthReport { readonly status: CheckStatus; readonly configValid: boolean; readonly rendererReady: boolean; + readonly imageConfigValid: boolean; + readonly imageCapabilityReady: boolean; + readonly sequenceLedgerValid: boolean; + readonly pendingVisuals: number; + readonly fallbackVisuals: number; + readonly invalidVisualRecords: number; readonly pendingRecoveryReports: number; readonly pendingDeliveries: number; readonly indeterminateDeliveries: number; @@ -218,6 +242,10 @@ const defaultProbes: HealthProbes = { verifyVendorArtifacts: verifyVendorWechatArtifacts, loadObserverConfig, rendererReady: defaultRendererReady, + loadEditorialConfig, + imageCapabilityReady: reportImageCapabilityReady, + inspectReportSequence, + inspectReportVisuals, inspectRunState, listFinalRunReports, inspectRunDeliveryState, @@ -482,6 +510,62 @@ export function inspectProjectHealth( diagnostics.push("observer renderer check failed"); } + let imageConfig: EditorialConfig | undefined; + let imageConfigValid = false; + try { + imageConfig = probes.loadEditorialConfig(paths.root); + imageConfigValid = true; + } catch { + diagnostics.push("observer image configuration check failed"); + } + + const imageCapabilityReady = + imageConfig !== undefined && + safeProbe(() => + probes.imageCapabilityReady({ + projectRoot: paths.root, + config: imageConfig!, + }), + ); + if (!imageCapabilityReady) { + diagnostics.push("observer image capability unavailable"); + } + + const sequenceLedgerValid = safeProbe(() => + probes.inspectReportSequence(paths.root), + ); + if (!sequenceLedgerValid) { + diagnostics.push("observer sequence state check failed"); + } + + let pendingVisuals = 0; + let fallbackVisuals = 0; + let invalidVisualRecords = 0; + let visualStateValid = true; + try { + const visual = probes.inspectReportVisuals(paths.root); + const pending = safeCount(visual.pending); + const fallback = safeCount(visual.fallback); + const invalid = safeCount(visual.invalid); + if (pending === null || fallback === null || invalid === null) { + visualStateValid = false; + invalidVisualRecords = 1; + } else { + pendingVisuals = pending; + fallbackVisuals = fallback; + invalidVisualRecords = invalid; + } + } catch { + visualStateValid = false; + invalidVisualRecords = 1; + } + if (pendingVisuals > 0) { + diagnostics.push("observer visual pending"); + } + if (!visualStateValid || invalidVisualRecords > 0) { + diagnostics.push("observer visual state check failed"); + } + let pendingRecoveryReports = 0; let pendingDeliveries = 0; let indeterminateDeliveries = 0; @@ -548,12 +632,23 @@ export function inspectProjectHealth( const observerReady = observerConfigValid && rendererReady && + imageConfigValid && + imageCapabilityReady && + sequenceLedgerValid && + visualStateValid && + invalidVisualRecords === 0 && observerStateValid && invalidStateRecords === 0; const observer = { status: status(observerReady), configValid: observerConfigValid, rendererReady, + imageConfigValid, + imageCapabilityReady, + sequenceLedgerValid, + pendingVisuals, + fallbackVisuals, + invalidVisualRecords, pendingRecoveryReports, pendingDeliveries, indeterminateDeliveries, @@ -561,6 +656,7 @@ export function inspectProjectHealth( } as const; const observerPending = pendingRecoveryReports > 0 || + pendingVisuals > 0 || pendingDeliveries > 0 || indeterminateDeliveries > 0; diff --git a/src/cli/probe-report-image.ts b/src/cli/probe-report-image.ts new file mode 100644 index 0000000..0e180e2 --- /dev/null +++ b/src/cli/probe-report-image.ts @@ -0,0 +1,197 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstatSync, unlinkSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { pngDimensions } from "../lib/png.js"; +import { readProjectBytes } from "../lib/project-file.js"; +import { generateCodexHero } from "../observer/codex-image.js"; +import { + loadEditorialConfig, + type EditorialConfig, +} from "../observer/editorial-config.js"; +import { buildFixedEditorialReport } from "../observer/fixed-editorial-report.js"; +import { + writeReportImageCapability, + type ReportImageCapability, +} from "../observer/report-image-capability.js"; + +const MAXIMUM_HERO_BYTES = 20 * 1_024 * 1_024; +const PROBE_ISSUE = { number: 999_999, label: "WP-999999" } as const; + +interface ProbeImageArtifact { + readonly pngPath: string; + readonly sha256: string; + readonly promptSha256: string; +} + +export interface ProbeReportImageDependencies { + readonly now?: () => Date; + readonly createRunId?: () => string; + readonly loadConfig?: (projectRoot: string) => EditorialConfig; + readonly generate?: (options: { + readonly projectRoot: string; + readonly report: ReturnType; + readonly issue: typeof PROBE_ISSUE; + readonly config: EditorialConfig; + }) => Promise; + readonly writeMarker?: (options: { + readonly projectRoot: string; + readonly marker: ReportImageCapability; + }) => void; +} + +export interface ProbeReportImageCliIo { + readonly stdout: (value: string) => void; + readonly stderr: (value: string) => void; +} + +class ProbeReportImageUsageError extends Error { + override readonly name = "ProbeReportImageUsageError"; +} + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function expectedHeroPath(projectRoot: string, runId: string): string { + return join( + projectRoot, + "output", + "run-reports", + "2026-07-20", + `${runId}.hero.png`, + ); +} + +function sameIdentity( + left: { readonly dev: number; readonly ino: number }, + right: { readonly dev: number; readonly ino: number }, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +export async function probeReportImage(options: { + readonly projectRoot: string; + readonly dependencies?: ProbeReportImageDependencies; +}): Promise { + const dependencies = options.dependencies ?? {}; + const config = (dependencies.loadConfig ?? loadEditorialConfig)( + options.projectRoot, + ); + const runId = dependencies.createRunId?.() ?? randomUUID(); + const report = buildFixedEditorialReport({ + runId, + finishedAt: "2026-07-20T01:00:00.000Z", + issue: PROBE_ISSUE, + taskId: "codex-image-capability-probe", + taskName: "Codex Image 2 能力探测", + }); + const generate = dependencies.generate ?? generateCodexHero; + const artifact = await generate({ + projectRoot: options.projectRoot, + report, + issue: PROBE_ISSUE, + config, + }); + const expectedPath = expectedHeroPath(options.projectRoot, runId); + if (resolve(artifact.pngPath) !== expectedPath) { + throw new Error("Report image capability probe failed"); + } + const bytes = readProjectBytes( + options.projectRoot, + artifact.pngPath, + MAXIMUM_HERO_BYTES, + ); + const dimensions = bytes === null ? null : pngDimensions(bytes); + if ( + bytes === null || + dimensions === null || + dimensions.width < 768 || + dimensions.height < 768 || + sha256(bytes) !== artifact.sha256 + ) { + throw new Error("Report image capability probe failed"); + } + const identity = lstatSync(artifact.pngPath); + if (identity.isSymbolicLink() || !identity.isFile()) { + throw new Error("Report image capability probe failed"); + } + (dependencies.writeMarker ?? writeReportImageCapability)({ + projectRoot: options.projectRoot, + marker: { + codexVersion: config.codex.version, + styleVersion: config.style.version, + succeededAt: (dependencies.now?.() ?? new Date()).toISOString(), + artifactSha256: artifact.sha256, + }, + }); + const finalIdentity = lstatSync(artifact.pngPath); + if ( + finalIdentity.isSymbolicLink() || + !finalIdentity.isFile() || + !sameIdentity(identity, finalIdentity) || + identity.size !== finalIdentity.size || + identity.mtimeMs !== finalIdentity.mtimeMs || + identity.ctimeMs !== finalIdentity.ctimeMs + ) { + throw new Error("Report image capability probe failed"); + } + unlinkSync(artifact.pngPath); +} + +function projectRootFromArguments(arguments_: readonly string[]): string { + if (arguments_.length === 0) { + return resolve(process.cwd()); + } + if ( + arguments_.length === 2 && + arguments_[0] === "--project-root" && + arguments_[1] !== undefined && + isAbsolute(arguments_[1]) + ) { + return resolve(arguments_[1]); + } + throw new ProbeReportImageUsageError(); +} + +const processIo: ProbeReportImageCliIo = { + stdout: (value) => process.stdout.write(value), + stderr: (value) => process.stderr.write(value), +}; + +export async function runProbeReportImageCli( + arguments_: readonly string[], + io: ProbeReportImageCliIo = processIo, + dependencies: { readonly probe?: typeof probeReportImage } = {}, +): Promise<0 | 1> { + try { + const projectRoot = projectRootFromArguments(arguments_); + await (dependencies.probe ?? probeReportImage)({ projectRoot }); + io.stdout(`${JSON.stringify({ ok: true, status: "ready" })}\n`); + return 0; + } catch (error) { + io.stderr( + `${JSON.stringify({ + error: + error instanceof ProbeReportImageUsageError + ? error.name + : "ProbeReportImageError", + message: "inspect project logs", + })}\n`, + ); + return 1; + } +} + +async function main(): Promise { + process.exitCode = await runProbeReportImageCli(process.argv.slice(2)); +} + +const entrypoint = process.argv[1]; +if ( + entrypoint !== undefined && + pathToFileURL(resolve(entrypoint)).href === import.meta.url +) { + void main(); +} diff --git a/src/cli/render-run-report-preview.ts b/src/cli/render-run-report-preview.ts new file mode 100644 index 0000000..3722587 --- /dev/null +++ b/src/cli/render-run-report-preview.ts @@ -0,0 +1,152 @@ +import { isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + renderEditorialReportCard, + type EditorialCardArtifact, +} from "../observer/editorial-card.js"; +import { + loadEditorialConfig, + type EditorialConfig, +} from "../observer/editorial-config.js"; +import { + buildFixedEditorialReport, + FIXED_PREVIEW_FINISHED_AT, + FIXED_PREVIEW_RUN_ID, +} from "../observer/fixed-editorial-report.js"; +import { + prepareReportVisual, + type PreparedReportVisual, +} from "../observer/report-visual.js"; +import { + assignReportIssue, + type ReportIssue, +} from "../observer/report-sequence.js"; + +export interface RenderRunReportPreviewDependencies { + readonly assignIssue?: (options: { + readonly projectRoot: string; + readonly runId: string; + }) => Promise; + readonly loadConfig?: (projectRoot: string) => EditorialConfig; + readonly prepareVisual?: (options: { + readonly projectRoot: string; + readonly report: ReturnType; + readonly issue: ReportIssue; + readonly config: EditorialConfig; + }) => Promise; + readonly render?: typeof renderEditorialReportCard; +} + +export interface RenderRunReportPreviewCliIo { + readonly stdout: (value: string) => void; + readonly stderr: (value: string) => void; +} + +class RenderRunReportPreviewUsageError extends Error { + override readonly name = "RenderRunReportPreviewUsageError"; +} + +export async function renderFixedEditorialPreview(options: { + readonly projectRoot: string; + readonly dependencies?: RenderRunReportPreviewDependencies; +}): Promise { + const dependencies = options.dependencies ?? {}; + const issue = await (dependencies.assignIssue ?? assignReportIssue)({ + projectRoot: options.projectRoot, + runId: FIXED_PREVIEW_RUN_ID, + }); + const config = (dependencies.loadConfig ?? loadEditorialConfig)( + options.projectRoot, + ); + if (config.mode !== "editorial") { + throw new Error("Unable to render editorial report preview"); + } + const report = buildFixedEditorialReport({ + runId: FIXED_PREVIEW_RUN_ID, + finishedAt: FIXED_PREVIEW_FINISHED_AT, + issue, + taskId: "codex-wechat-editorial-preview", + taskName: "Codex 编辑部情报固定预览", + }); + const visual = await (dependencies.prepareVisual ?? prepareReportVisual)({ + projectRoot: options.projectRoot, + report, + issue, + config, + }); + const artifact = (dependencies.render ?? renderEditorialReportCard)({ + projectRoot: options.projectRoot, + report, + visual, + }); + const expected = join( + options.projectRoot, + "output", + "run-reports", + "2026-07-20", + `${FIXED_PREVIEW_RUN_ID}.png`, + ); + if (resolve(artifact.pngPath) !== expected) { + throw new Error("Unable to render editorial report preview"); + } + return artifact; +} + +function projectRootFromArguments(arguments_: readonly string[]): string { + if (arguments_.length === 0) { + return resolve(process.cwd()); + } + if ( + arguments_.length === 2 && + arguments_[0] === "--project-root" && + arguments_[1] !== undefined && + isAbsolute(arguments_[1]) + ) { + return resolve(arguments_[1]); + } + throw new RenderRunReportPreviewUsageError(); +} + +const processIo: RenderRunReportPreviewCliIo = { + stdout: (value) => process.stdout.write(value), + stderr: (value) => process.stderr.write(value), +}; + +export async function runRenderRunReportPreviewCli( + arguments_: readonly string[], + io: RenderRunReportPreviewCliIo = processIo, + dependencies: { + readonly render?: typeof renderFixedEditorialPreview; + } = {}, +): Promise<0 | 1> { + try { + const projectRoot = projectRootFromArguments(arguments_); + await (dependencies.render ?? renderFixedEditorialPreview)({ projectRoot }); + io.stdout(`${JSON.stringify({ ok: true, status: "rendered" })}\n`); + return 0; + } catch (error) { + io.stderr( + `${JSON.stringify({ + error: + error instanceof RenderRunReportPreviewUsageError + ? error.name + : "RenderRunReportPreviewError", + message: "inspect project logs", + })}\n`, + ); + return 1; + } +} + +async function main(): Promise { + process.exitCode = await runRenderRunReportPreviewCli(process.argv.slice(2)); +} + +const entrypoint = process.argv[1]; +if ( + entrypoint !== undefined && + pathToFileURL(resolve(entrypoint)).href === import.meta.url +) { + void main(); +} diff --git a/src/cli/send-run-report-test.ts b/src/cli/send-run-report-test.ts index f3b121b..f195e43 100644 --- a/src/cli/send-run-report-test.ts +++ b/src/cli/send-run-report-test.ts @@ -31,11 +31,21 @@ import { } from "../delivery/wechat.js"; import type { ReportCardArtifact } from "../observer/report-card.js"; import { - buildRunReport, ProjectRelativeArtifactPathSchema, RunReportSchema, type RunReport, } from "../observer/run-report.js"; +import type { EditorialCardArtifact } from "../observer/editorial-card.js"; +import { + loadEditorialConfig, + type EditorialConfig, +} from "../observer/editorial-config.js"; +import { buildFixedEditorialReport } from "../observer/fixed-editorial-report.js"; +import type { PreparedReportVisual } from "../observer/report-visual.js"; +import { + assignReportIssue, + type ReportIssue, +} from "../observer/report-sequence.js"; import { writeProjectTextAtomic } from "../lib/project-file.js"; import { assertNoSymlinkComponents, @@ -77,6 +87,22 @@ type TestCardCheckpoint = z.infer; export interface SendRunReportTestDependencies { readonly now?: () => Date; readonly createRunId?: () => string; + readonly assignIssue?: (options: { + readonly projectRoot: string; + readonly runId: string; + }) => Promise; + readonly loadEditorial?: (projectRoot: string) => EditorialConfig; + readonly prepareVisual?: (options: { + readonly projectRoot: string; + readonly report: RunReport; + readonly issue: ReportIssue; + readonly config: EditorialConfig; + }) => Promise; + readonly renderEditorial?: (options: { + readonly projectRoot: string; + readonly report: RunReport; + readonly visual: PreparedReportVisual; + }) => EditorialCardArtifact; readonly render?: (options: { readonly projectRoot: string; readonly report: RunReport; @@ -267,32 +293,16 @@ function writeCheckpoint( } function fixedReport( - projectRoot: string, now: Date, runId: string, + issue: ReportIssue, ): RunReport { - const timestamp = now.toISOString(); - return buildRunReport({ - projectRoot, - authority: { - runId, - taskId: TEST_TASK_ID, - taskName: TEST_TASK_NAME, - projectLabel: "WeChatPilot", - status: "success", - startedAt: timestamp, - finishedAt: timestamp, - }, - draft: { - version: 1, - summary: ["值守员文字与 PNG 报告卡发送链路验证成功"], - metrics: [ - { label: "报告类型", value: "确定性本地渲染" }, - { label: "发送目标", value: "已绑定本人微信" }, - ], - failure: null, - artifacts: [], - }, + return buildFixedEditorialReport({ + runId, + finishedAt: now.toISOString(), + issue, + taskId: TEST_TASK_ID, + taskName: TEST_TASK_NAME, }); } @@ -301,15 +311,43 @@ async function createCheckpoint(options: { readonly now: Date; readonly dependencies: SendRunReportTestDependencies; }): Promise { - const report = fixedReport( - options.projectRoot, - options.now, - options.dependencies.createRunId?.() ?? randomUUID(), - ); - const render = - options.dependencies.render ?? - (await import("../observer/report-card.js")).renderReportCard; - const artifact = render({ projectRoot: options.projectRoot, report }); + const runId = options.dependencies.createRunId?.() ?? randomUUID(); + const issue = await (options.dependencies.assignIssue ?? assignReportIssue)({ + projectRoot: options.projectRoot, + runId, + }); + const report = fixedReport(options.now, runId, issue); + let artifact: ReportCardArtifact | EditorialCardArtifact; + if (options.dependencies.render !== undefined) { + artifact = options.dependencies.render({ + projectRoot: options.projectRoot, + report, + }); + } else { + const config = ( + options.dependencies.loadEditorial ?? loadEditorialConfig + )(options.projectRoot); + if (config.mode !== "editorial") { + throw checkpointError(); + } + const prepareVisual = + options.dependencies.prepareVisual ?? + (await import("../observer/report-visual.js")).prepareReportVisual; + const visual = await prepareVisual({ + projectRoot: options.projectRoot, + report, + issue, + config, + }); + const renderEditorial = + options.dependencies.renderEditorial ?? + (await import("../observer/editorial-card.js")).renderEditorialReportCard; + artifact = renderEditorial({ + projectRoot: options.projectRoot, + report, + visual, + }); + } const imagePath = canonicalImagePath(report); const expectedImagePath = resolveProjectPath(options.projectRoot, imagePath); if (resolve(artifact.pngPath) !== expectedImagePath) { diff --git a/src/observer/fixed-editorial-report.ts b/src/observer/fixed-editorial-report.ts new file mode 100644 index 0000000..e5c1fd4 --- /dev/null +++ b/src/observer/fixed-editorial-report.ts @@ -0,0 +1,98 @@ +import { RunReportSchema, type RunReport } from "./run-report.js"; +import type { ReportIssue } from "./report-sequence.js"; + +const SIGNALS = [ + { + title: "Codex 定时任务进入可审计值守阶段", + whyItMatters: + "固定入口、只读执行和完成后报告,让家庭电脑上的自动任务可以被远程了解,同时不开放命令控制能力。", + sourceCount: 3, + confidence: "high" as const, + impact: "high" as const, + }, + { + title: "Image 2 负责主视觉而非文字排版", + whyItMatters: + "生成式图片专注表达当天主题,标题、信号、行动和审计信息继续由确定性排版生成,兼顾美观与可读性。", + sourceCount: 2, + confidence: "medium-high" as const, + impact: "high" as const, + }, + { + title: "微信成为单向通知与监控终端", + whyItMatters: + "任务完成后主动发送长图和摘要,用户在移动端即可掌握结果,同时消息入口不会触发本机命令或远程控制。", + sourceCount: 2, + confidence: "medium-high" as const, + impact: "medium-high" as const, + }, + { + title: "编号与账本支持失败恢复和去重", + whyItMatters: + "每份报告获得稳定期号,视觉生成和微信投递分别记录状态,使明确失败可以重试而不重复发送已经成功的部分。", + sourceCount: 2, + confidence: "medium-high" as const, + impact: "medium-high" as const, + }, + { + title: "固定长图版式提升每日信息密度", + whyItMatters: + "一张长图同时容纳今日结论、五项重点信号、三项行动和执行审计,避免过去只有一句话且版式单薄的问题。", + sourceCount: 1, + confidence: "medium" as const, + impact: "medium" as const, + }, +] as const; + +export const FIXED_PREVIEW_RUN_ID = "33333333-3333-4333-8333-333333333333"; +export const FIXED_PREVIEW_FINISHED_AT = "2026-07-20T01:00:00.000Z"; + +export function buildFixedEditorialReport(options: { + readonly runId: string; + readonly finishedAt: string; + readonly issue: ReportIssue; + readonly taskId?: string; + readonly taskName?: string; +}): RunReport { + const finished = new Date(options.finishedAt); + const startedAt = new Date(finished.getTime() - 82_000).toISOString(); + return RunReportSchema.parse({ + version: 1, + runId: options.runId, + taskId: options.taskId ?? "codex-wechat-editorial-test", + taskName: options.taskName ?? "Codex 编辑部情报链路测试", + projectLabel: "WeChatPilot", + status: "success", + startedAt, + finishedAt: finished.toISOString(), + durationMs: 82_000, + summary: ["编辑部情报长图、编号、审计与微信单向投递链路验证完成"], + metrics: [ + { label: "报告版式", value: "1080 × 2400 单张长图" }, + { label: "主视觉", value: "Codex 内置 Image 2" }, + { label: "安全边界", value: "微信仅接收通知,不执行命令" }, + ], + failure: null, + artifacts: [], + editorial: { + version: 1, + kind: "digest", + executiveSummary: + "本次固定报告验证 Codex 本地任务完成后,能够生成详尽、编号清晰且版式稳定的编辑部情报长图,并通过已绑定微信进行单向通知。主视觉由内置 Image 2 生成,正文和审计信息保持确定性排版。", + highlights: SIGNALS, + actions: [ + "先运行能力探测,确认当前 Codex 与编辑部视觉版本可用", + "检查本地预览的文字密度、编号、主视觉裁切和审计信息", + "确认微信实发后再让 LaunchAgent 执行每日自动值守任务", + ], + audit: { + candidateCount: 28, + selectedCount: 5, + sourceCategoryCount: 4, + scheduledDate: "2026-07-20", + }, + }, + issueNumber: options.issue.number, + issueLabel: options.issue.label, + }); +} diff --git a/src/observer/report-image-capability.ts b/src/observer/report-image-capability.ts new file mode 100644 index 0000000..180aeda --- /dev/null +++ b/src/observer/report-image-capability.ts @@ -0,0 +1,71 @@ +import { join } from "node:path"; + +import { z } from "zod"; + +import { + readProjectBytes, + writeProjectTextAtomic, +} from "../lib/project-file.js"; +import type { EditorialConfig } from "./editorial-config.js"; + +const MAXIMUM_MARKER_BYTES = 8 * 1_024; +const HASH = /^[a-f0-9]{64}$/u; + +const ReportImageCapabilitySchema = z + .object({ + codexVersion: z.string().min(1).max(64), + styleVersion: z.literal("editorial-v1"), + succeededAt: z.iso.datetime({ offset: true }), + artifactSha256: z.string().regex(HASH), + }) + .strict(); + +export type ReportImageCapability = z.infer< + typeof ReportImageCapabilitySchema +>; + +function markerPath(projectRoot: string): string { + return join( + projectRoot, + ".state", + "observer", + "report-image-capability.json", + ); +} + +export function writeReportImageCapability(options: { + readonly projectRoot: string; + readonly marker: ReportImageCapability; +}): void { + const marker = ReportImageCapabilitySchema.parse(options.marker); + writeProjectTextAtomic( + options.projectRoot, + markerPath(options.projectRoot), + `${JSON.stringify(marker)}\n`, + ); +} + +export function reportImageCapabilityReady(options: { + readonly projectRoot: string; + readonly config: EditorialConfig; +}): boolean { + try { + const bytes = readProjectBytes( + options.projectRoot, + markerPath(options.projectRoot), + MAXIMUM_MARKER_BYTES, + ); + if (bytes === null) { + return false; + } + const marker = ReportImageCapabilitySchema.parse( + JSON.parse(bytes.toString("utf8")) as unknown, + ); + return ( + marker.codexVersion === options.config.codex.version && + marker.styleVersion === options.config.style.version + ); + } catch { + return false; + } +} diff --git a/src/observer/report-sequence.ts b/src/observer/report-sequence.ts index fc07b51..ca781ce 100644 --- a/src/observer/report-sequence.ts +++ b/src/observer/report-sequence.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { z } from "zod"; import { + readProjectBytes, readProjectText, writeProjectTextAtomic, } from "../lib/project-file.js"; @@ -98,3 +99,22 @@ export async function assignReportIssue(options: { await lock.release(); } } + +export function inspectReportSequence(projectRoot: string): boolean { + try { + const path = join( + projectRoot, + ".state", + "observer", + "report-sequence.json", + ); + const bytes = readProjectBytes(projectRoot, path, MAXIMUM_LEDGER_BYTES); + if (bytes === null) { + return true; + } + ReportSequenceSchema.parse(JSON.parse(bytes.toString("utf8")) as unknown); + return true; + } catch { + return false; + } +} diff --git a/src/observer/report-visual-state.ts b/src/observer/report-visual-state.ts new file mode 100644 index 0000000..d387cba --- /dev/null +++ b/src/observer/report-visual-state.ts @@ -0,0 +1,165 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { z } from "zod"; + +import { pngDimensions } from "../lib/png.js"; +import { readProjectBytes } from "../lib/project-file.js"; +import { ProjectRelativeArtifactPathSchema } from "./run-report.js"; + +export const REPORT_VISUAL_MAXIMUM_LEDGER_BYTES = 64 * 1_024; +export const REPORT_VISUAL_MAXIMUM_HERO_BYTES = 20 * 1_024 * 1_024; +export const REPORT_VISUAL_MAXIMUM_ATTEMPTS = 3; +export const REPORT_VISUAL_STYLE_VERSION = "editorial-v1" as const; +const HASH = /^[a-f0-9]{64}$/u; + +const baseShape = { + version: z.literal(1), + runId: z.uuid(), + issueNumber: z.number().int().positive().max(999_999), + reportSha256: z.string().regex(HASH), + attempts: z + .number() + .int() + .min(0) + .max(REPORT_VISUAL_MAXIMUM_ATTEMPTS), +} as const; + +export const ReportVisualLedgerSchema = z.discriminatedUnion("status", [ + z.object({ ...baseShape, status: z.literal("pending") }).strict(), + z + .object({ + ...baseShape, + status: z.literal("generating"), + attempts: z.number().int().min(1).max(REPORT_VISUAL_MAXIMUM_ATTEMPTS), + promptSha256: z.string().regex(HASH), + }) + .strict(), + z + .object({ + ...baseShape, + status: z.literal("generated"), + attempts: z.number().int().min(1).max(REPORT_VISUAL_MAXIMUM_ATTEMPTS), + promptSha256: z.string().regex(HASH), + heroPath: ProjectRelativeArtifactPathSchema, + heroSha256: z.string().regex(HASH), + styleVersion: z.literal(REPORT_VISUAL_STYLE_VERSION), + }) + .strict(), + z + .object({ + ...baseShape, + status: z.literal("fallback"), + stableCode: z.literal("IMAGE_GENERATION_UNAVAILABLE"), + heroPath: ProjectRelativeArtifactPathSchema, + heroSha256: z.string().regex(HASH), + styleVersion: z.literal(REPORT_VISUAL_STYLE_VERSION), + }) + .strict(), +]); + +export type ReportVisualLedger = z.infer; + +export function readReportVisualLedger( + projectRoot: string, + path: string, +): ReportVisualLedger | null { + const bytes = readProjectBytes( + projectRoot, + path, + REPORT_VISUAL_MAXIMUM_LEDGER_BYTES, + ); + if (bytes === null) { + return null; + } + return ReportVisualLedgerSchema.parse( + JSON.parse(bytes.toString("utf8")) as unknown, + ); +} + +function completedArtifactValid( + projectRoot: string, + ledger: ReportVisualLedger, +): boolean { + if (ledger.status !== "generated" && ledger.status !== "fallback") { + return true; + } + try { + const bytes = readProjectBytes( + projectRoot, + join(projectRoot, ledger.heroPath), + REPORT_VISUAL_MAXIMUM_HERO_BYTES, + ); + if ( + bytes === null || + createHash("sha256").update(bytes).digest("hex") !== ledger.heroSha256 + ) { + return false; + } + const dimensions = pngDimensions(bytes); + return ( + dimensions !== null && + (ledger.status === "generated" + ? dimensions.width >= 768 && dimensions.height >= 768 + : dimensions.width === 1_080 && dimensions.height === 480) + ); + } catch { + return false; + } +} + +export function inspectReportVisuals(projectRoot: string): { + readonly pending: number; + readonly fallback: number; + readonly invalid: number; +} { + let pending = 0; + let fallback = 0; + let invalid = 0; + const directory = join(projectRoot, ".state", "observer", "visuals"); + let entries; + try { + const metadata = lstatSync(directory); + if ( + metadata.isSymbolicLink() || + !metadata.isDirectory() || + (metadata.mode & 0o777) !== 0o700 + ) { + return { pending, fallback, invalid: 1 }; + } + entries = readdirSync(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { pending, fallback, invalid }; + } + return { pending, fallback, invalid: 1 }; + } + + for (const entry of entries) { + try { + if (!entry.isFile() || !entry.name.endsWith(".json")) { + throw new Error("invalid entry"); + } + const ledger = readReportVisualLedger( + projectRoot, + join(directory, entry.name), + ); + if ( + ledger === null || + entry.name !== `${ledger.runId}.json` || + !completedArtifactValid(projectRoot, ledger) + ) { + throw new Error("invalid record"); + } + if (ledger.status === "pending" || ledger.status === "generating") { + pending += 1; + } else if (ledger.status === "fallback") { + fallback += 1; + } + } catch { + invalid += 1; + } + } + return { pending, fallback, invalid }; +} diff --git a/src/observer/report-visual.ts b/src/observer/report-visual.ts index 030b197..7ac3f66 100644 --- a/src/observer/report-visual.ts +++ b/src/observer/report-visual.ts @@ -1,9 +1,7 @@ import { createHash } from "node:crypto"; -import { lstatSync, readdirSync } from "node:fs"; import { isAbsolute, join, relative, sep } from "node:path"; import { Resvg } from "@resvg/resvg-js"; -import { z } from "zod"; import { pngDimensions, isValidPng } from "../lib/png.js"; import { @@ -21,62 +19,19 @@ import { import type { EditorialConfig } from "./editorial-config.js"; import { ProjectRelativeArtifactPathSchema, RunReportSchema, type RunReport } from "./run-report.js"; import type { ReportIssue } from "./report-sequence.js"; +import { + inspectReportVisuals, + readReportVisualLedger, + ReportVisualLedgerSchema, + type ReportVisualLedger, + REPORT_VISUAL_MAXIMUM_ATTEMPTS as MAXIMUM_ATTEMPTS, + REPORT_VISUAL_MAXIMUM_HERO_BYTES as MAXIMUM_HERO_BYTES, + REPORT_VISUAL_STYLE_VERSION as STYLE_VERSION, +} from "./report-visual-state.js"; -const MAXIMUM_LEDGER_BYTES = 64 * 1_024; -const MAXIMUM_HERO_BYTES = 20 * 1_024 * 1_024; -const MAXIMUM_ATTEMPTS = 3; const RETRY_DELAYS_MS = [5_000, 15_000] as const; -const STYLE_VERSION = "editorial-v1" as const; const HASH = /^[a-f0-9]{64}$/u; -const baseShape = { - version: z.literal(1), - runId: z.uuid(), - issueNumber: z.number().int().positive().max(999_999), - reportSha256: z.string().regex(HASH), - attempts: z.number().int().min(0).max(MAXIMUM_ATTEMPTS), -} as const; - -const ReportVisualLedgerSchema = z.discriminatedUnion("status", [ - z - .object({ - ...baseShape, - status: z.literal("pending"), - }) - .strict(), - z - .object({ - ...baseShape, - status: z.literal("generating"), - attempts: z.number().int().min(1).max(MAXIMUM_ATTEMPTS), - promptSha256: z.string().regex(HASH), - }) - .strict(), - z - .object({ - ...baseShape, - status: z.literal("generated"), - attempts: z.number().int().min(1).max(MAXIMUM_ATTEMPTS), - promptSha256: z.string().regex(HASH), - heroPath: ProjectRelativeArtifactPathSchema, - heroSha256: z.string().regex(HASH), - styleVersion: z.literal(STYLE_VERSION), - }) - .strict(), - z - .object({ - ...baseShape, - status: z.literal("fallback"), - stableCode: z.literal("IMAGE_GENERATION_UNAVAILABLE"), - heroPath: ProjectRelativeArtifactPathSchema, - heroSha256: z.string().regex(HASH), - styleVersion: z.literal(STYLE_VERSION), - }) - .strict(), -]); - -type ReportVisualLedger = z.infer; - export interface PreparedReportVisual { readonly mode: "generated" | "fallback"; readonly attempts: number; @@ -149,13 +104,7 @@ function readLedger( projectRoot: string, path: string, ): ReportVisualLedger | null { - const bytes = readProjectBytes(projectRoot, path, MAXIMUM_LEDGER_BYTES); - if (bytes === null) { - return null; - } - return ReportVisualLedgerSchema.parse( - JSON.parse(bytes.toString("utf8")) as unknown, - ); + return readReportVisualLedger(projectRoot, path); } function relativeHeroPath(projectRoot: string, absolutePath: string): string { @@ -417,70 +366,4 @@ export async function prepareReportVisual(options: { ); } -function recordArtifactValid( - projectRoot: string, - ledger: ReportVisualLedger, -): boolean { - if (ledger.status !== "generated" && ledger.status !== "fallback") { - return true; - } - try { - preparedFromLedger(projectRoot, ledger); - return true; - } catch { - return false; - } -} - -export function inspectReportVisuals(projectRoot: string): { - readonly pending: number; - readonly fallback: number; - readonly invalid: number; -} { - let pending = 0; - let fallback = 0; - let invalid = 0; - const directory = join(projectRoot, ".state", "observer", "visuals"); - let entries; - try { - const metadata = lstatSync(directory); - if ( - metadata.isSymbolicLink() || - !metadata.isDirectory() || - (metadata.mode & 0o777) !== 0o700 - ) { - return { pending, fallback, invalid: 1 }; - } - entries = readdirSync(directory, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { pending, fallback, invalid }; - } - return { pending, fallback, invalid: 1 }; - } - - for (const entry of entries) { - try { - if (!entry.isFile() || !entry.name.endsWith(".json")) { - throw new Error("invalid entry"); - } - const path = join(directory, entry.name); - const ledger = readLedger(projectRoot, path); - if ( - ledger === null || - entry.name !== `${ledger.runId}.json` || - !recordArtifactValid(projectRoot, ledger) - ) { - throw new Error("invalid record"); - } - if (ledger.status === "pending" || ledger.status === "generating") { - pending += 1; - } else if (ledger.status === "fallback") { - fallback += 1; - } - } catch { - invalid += 1; - } - } - return { pending, fallback, invalid }; -} +export { inspectReportVisuals }; diff --git a/tests/app/health.test.ts b/tests/app/health.test.ts index 5ac4bc4..e3b9e69 100644 --- a/tests/app/health.test.ts +++ b/tests/app/health.test.ts @@ -82,6 +82,13 @@ function healthyProbes( }), loadObserverConfig: () => OBSERVER_CONFIG, rendererReady: () => true, + loadEditorialConfig: () => ({ + codex: { version: "0.144.4" }, + style: { version: "editorial-v1" }, + }) as never, + imageCapabilityReady: () => true, + inspectReportSequence: () => true, + inspectReportVisuals: () => ({ pending: 0, fallback: 0, invalid: 0 }), inspectRunState: () => ({ recoveryPending: 0, invalidRecords: 0 }), listFinalRunReports: () => [], inspectRunDeliveryState: () => ({ @@ -178,6 +185,12 @@ describe("project health", () => { status: "ok", configValid: true, rendererReady: true, + imageConfigValid: true, + imageCapabilityReady: true, + sequenceLedgerValid: true, + pendingVisuals: 0, + fallbackVisuals: 0, + invalidVisualRecords: 0, pendingRecoveryReports: 0, pendingDeliveries: 0, indeterminateDeliveries: 0, @@ -377,6 +390,12 @@ describe("project health", () => { status: "ok", configValid: true, rendererReady: true, + imageConfigValid: true, + imageCapabilityReady: true, + sequenceLedgerValid: true, + pendingVisuals: 0, + fallbackVisuals: 0, + invalidVisualRecords: 0, pendingRecoveryReports: 2, pendingDeliveries: 3, indeterminateDeliveries: 4, @@ -393,6 +412,83 @@ describe("project health", () => { expect(healthExitCode(report)).toBe(0); }); + it("reports safe Image 2 capability and visual ledger diagnostics", () => { + const root = project(); + const secretRunId = "019c7714-3b77-74d1-9866-e1f484aae2ab"; + const secretIssue = "WP-654321"; + const secretPrompt = "PRIVATE_IMAGE_PROMPT"; + const secretPath = "/private/generated/hero.png"; + const rawFailure = `${secretRunId} ${secretIssue} ${secretPrompt} ${secretPath}`; + const report = inspectProjectHealth({ + projectRoot: root, + probes: healthyProbes({ + wechatHealth: () => ({ + credentialsReady: true, + ownerBound: true, + contextReady: true, + }), + imageCapabilityReady: () => { + throw new Error(rawFailure); + }, + inspectReportVisuals: () => ({ pending: 2, fallback: 3, invalid: 1 }), + }), + }); + + expect(report.status).toBe("failed"); + expect(report.requiredReady).toBe(false); + expect(report.observer).toMatchObject({ + imageConfigValid: true, + imageCapabilityReady: false, + sequenceLedgerValid: true, + pendingVisuals: 2, + fallbackVisuals: 3, + invalidVisualRecords: 1, + }); + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + "observer image capability unavailable", + "observer visual pending", + "observer visual state check failed", + ]), + ); + const serialized = JSON.stringify(report); + for (const secret of [secretRunId, secretIssue, secretPrompt, secretPath, rawFailure]) { + expect(serialized).not.toContain(secret); + } + }); + + it("keeps completed fallbacks ready and marks only pending visuals pending", () => { + const root = project(); + const fallbackOnly = inspectProjectHealth({ + projectRoot: root, + probes: healthyProbes({ + wechatHealth: () => ({ + credentialsReady: true, + ownerBound: true, + contextReady: true, + }), + inspectReportVisuals: () => ({ pending: 0, fallback: 4, invalid: 0 }), + }), + }); + expect(fallbackOnly.status).toBe("ready"); + expect(fallbackOnly.observer.fallbackVisuals).toBe(4); + + const pending = inspectProjectHealth({ + projectRoot: root, + probes: healthyProbes({ + wechatHealth: () => ({ + credentialsReady: true, + ownerBound: true, + contextReady: true, + }), + inspectReportVisuals: () => ({ pending: 1, fallback: 0, invalid: 0 }), + }), + }); + expect(pending.status).toBe("pending"); + expect(pending.requiredReady).toBe(true); + expect(pending.diagnostics).toContain("observer visual pending"); + }); + it("uses one reports-aware delivery inspection without separate disposition probes", () => { const root = project(); const missingRunId = "018f514c-1a2b-7abc-8def-1234567890ac"; diff --git a/tests/cli/probe-report-image.test.ts b/tests/cli/probe-report-image.test.ts new file mode 100644 index 0000000..e133c7a --- /dev/null +++ b/tests/cli/probe-report-image.test.ts @@ -0,0 +1,122 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Resvg } from "@resvg/resvg-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + probeReportImage, + runProbeReportImageCli, +} from "../../src/cli/probe-report-image.js"; +import { writeProjectBytesAtomic } from "../../src/lib/project-file.js"; + +const RUN_ID = "44444444-4444-4444-8444-444444444444"; +const NOW = new Date("2026-07-20T02:00:00.000Z"); +const roots: string[] = []; + +function project(): string { + const root = mkdtempSync(join(tmpdir(), "report-image-probe-")); + roots.push(root); + return root; +} + +function png(): Buffer { + return Buffer.from( + new Resvg( + '', + ) + .render() + .asPng(), + ); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("report image capability probe", () => { + it("writes a minimal private marker and removes only the verified copied hero", async () => { + const root = project(); + const heroPath = join( + root, + "output", + "run-reports", + "2026-07-20", + `${RUN_ID}.hero.png`, + ); + const bytes = png(); + const hash = createHash("sha256").update(bytes).digest("hex"); + + await probeReportImage({ + projectRoot: root, + dependencies: { + now: () => NOW, + createRunId: () => RUN_ID, + loadConfig: () => ({ + codex: { version: "0.144.4" }, + style: { version: "editorial-v1" }, + }) as never, + generate: async () => { + writeProjectBytesAtomic(root, heroPath, bytes); + return { + pngPath: heroPath, + sha256: hash, + promptSha256: "b".repeat(64), + }; + }, + }, + }); + + expect(existsSync(heroPath)).toBe(false); + const markerPath = join( + root, + ".state", + "observer", + "report-image-capability.json", + ); + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as object; + expect(marker).toEqual({ + codexVersion: "0.144.4", + styleVersion: "editorial-v1", + succeededAt: NOW.toISOString(), + artifactSha256: hash, + }); + expect(Object.keys(marker)).toHaveLength(4); + }); + + it("prints only stable JSON and rejects every override", async () => { + const stdout = vi.fn(); + const stderr = vi.fn(); + const probe = vi.fn(async () => undefined); + await expect( + runProbeReportImageCli([], { stdout, stderr }, { probe }), + ).resolves.toBe(0); + expect(stdout).toHaveBeenCalledWith('{"ok":true,"status":"ready"}\n'); + expect(stderr).not.toHaveBeenCalled(); + + for (const arguments_ of [ + ["--prompt", "PRIVATE_PROMPT"], + ["--model", "gpt-image-2"], + ["--path", "/private/hero.png"], + ["--project-root", "relative"], + ]) { + stdout.mockClear(); + stderr.mockClear(); + probe.mockClear(); + await expect( + runProbeReportImageCli(arguments_, { stdout, stderr }, { probe }), + ).resolves.toBe(1); + expect(probe).not.toHaveBeenCalled(); + const output = stderr.mock.calls.flat().join(""); + expect(output).toBe( + '{"error":"ProbeReportImageUsageError","message":"inspect project logs"}\n', + ); + expect(output).not.toContain(arguments_.at(-1)); + } + }); +}); diff --git a/tests/cli/render-run-report-preview.test.ts b/tests/cli/render-run-report-preview.test.ts new file mode 100644 index 0000000..fd4640d --- /dev/null +++ b/tests/cli/render-run-report-preview.test.ts @@ -0,0 +1,106 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + renderFixedEditorialPreview, + runRenderRunReportPreviewCli, +} from "../../src/cli/render-run-report-preview.js"; +import { + FIXED_PREVIEW_RUN_ID, +} from "../../src/observer/fixed-editorial-report.js"; + +const ROOT = "/private/tmp/editorial-preview-test"; + +describe("fixed editorial report preview", () => { + it("uses numbering, production visual preparation, and the editorial compositor without delivery", async () => { + const calls: string[] = []; + const issue = { number: 12, label: "WP-000012" }; + const result = await renderFixedEditorialPreview({ + projectRoot: ROOT, + dependencies: { + assignIssue: vi.fn(async () => { + calls.push("assign"); + return issue; + }), + loadConfig: vi.fn(() => { + calls.push("config"); + return { mode: "editorial" } as never; + }), + prepareVisual: vi.fn(async ({ report }) => { + calls.push("visual"); + expect(report.issueLabel).toBe(issue.label); + return { + mode: "generated" as const, + attempts: 1, + heroPath: `${ROOT}/output/preview.hero.png`, + heroSha256: "a".repeat(64), + styleVersion: "editorial-v1" as const, + promptSha256: "b".repeat(64), + }; + }), + render: vi.fn(({ report }) => { + calls.push("render"); + return { + pngPath: `${ROOT}/output/run-reports/2026-07-20/${report.runId}.png`, + jsonPath: `${ROOT}/output/run-reports/2026-07-20/${report.runId}.json`, + issueLabel: report.issueLabel!, + visualMode: "generated" as const, + }; + }), + }, + }); + + expect(calls).toEqual(["assign", "config", "visual", "render"]); + expect(result.pngPath).toBe( + `${ROOT}/output/run-reports/2026-07-20/${FIXED_PREVIEW_RUN_ID}.png`, + ); + }); + + it("prints only the stable rendered status and accepts no overrides", async () => { + const stdout = vi.fn(); + const stderr = vi.fn(); + const render = vi.fn(async () => ({ + pngPath: `${ROOT}/output/run-reports/2026-07-20/${FIXED_PREVIEW_RUN_ID}.png`, + jsonPath: `${ROOT}/output/run-reports/2026-07-20/${FIXED_PREVIEW_RUN_ID}.json`, + issueLabel: "WP-000012", + visualMode: "generated" as const, + })); + await expect( + runRenderRunReportPreviewCli( + ["--project-root", ROOT], + { stdout, stderr }, + { render }, + ), + ).resolves.toBe(0); + expect(stdout).toHaveBeenCalledWith( + '{"ok":true,"status":"rendered"}\n', + ); + expect(stderr).not.toHaveBeenCalled(); + + for (const arguments_ of [ + ["--recipient", "owner"], + ["--prompt", "PRIVATE_PROMPT"], + ["--image", "/private/image.png"], + ["--project-root", "relative"], + ]) { + stdout.mockClear(); + stderr.mockClear(); + render.mockClear(); + await expect( + runRenderRunReportPreviewCli(arguments_, { stdout, stderr }, { render }), + ).resolves.toBe(1); + expect(render).not.toHaveBeenCalled(); + expect(stderr.mock.calls.flat().join("")).not.toContain(arguments_.at(-1)); + } + }); + + it("does not import or expose any WeChat sender", () => { + const source = readFileSync( + resolve(process.cwd(), "src", "cli", "render-run-report-preview.ts"), + "utf8", + ); + expect(source).not.toMatch(/delivery\/wechat|prepareWechat|sendWechat|deliverRunReport/u); + }); +}); diff --git a/tests/cli/send-run-report-test.test.ts b/tests/cli/send-run-report-test.test.ts index dc1e257..1c55049 100644 --- a/tests/cli/send-run-report-test.test.ts +++ b/tests/cli/send-run-report-test.test.ts @@ -73,6 +73,71 @@ afterEach(() => { }); describe("fixed run-report test checkpoint", () => { + it("builds five editorial signals, assigns a real issue, and uses the production visual path", async () => { + const root = project(); + const calls: string[] = []; + const issue = { number: 21, label: "WP-000021" }; + const deliver = vi.fn(async ({ report, imagePath }) => { + calls.push("deliver"); + expect(report.issueLabel).toBe(issue.label); + expect(report.editorial?.highlights).toHaveLength(5); + expect(report.editorial?.actions).toHaveLength(3); + expect(imagePath).toBe( + join(root, "output", "run-reports", "2026-07-20", `${FIRST_ID}.png`), + ); + return { + status: "sent" as const, + textChunks: 1, + image: "sent" as const, + textAttempt: 1, + imageAttempt: 1, + }; + }); + + await expect( + sendFixedRunReportTest({ + projectRoot: root, + dependencies: { + now: () => NOW, + createRunId: () => FIRST_ID, + assignIssue: vi.fn(async () => { + calls.push("assign"); + return issue; + }), + loadEditorial: vi.fn(() => { + calls.push("config"); + return { mode: "editorial" } as never; + }), + prepareVisual: vi.fn(async ({ report }) => { + calls.push("visual"); + expect(report.issueLabel).toBe(issue.label); + return { + mode: "generated" as const, + attempts: 1, + heroPath: join(root, "output", "preview.hero.png"), + heroSha256: "a".repeat(64), + styleVersion: "editorial-v1" as const, + promptSha256: "b".repeat(64), + }; + }), + renderEditorial: vi.fn(({ report }) => { + calls.push("render"); + return { + ...artifact(root, report), + issueLabel: report.issueLabel!, + visualMode: "generated" as const, + }; + }), + prepareText: async () => async () => ({ status: "sent", chunks: 1 }), + prepareImage: async () => async () => ({ status: "sent" }), + deliver, + }, + }), + ).resolves.toMatchObject({ status: "sent", image: "sent" }); + + expect(calls).toEqual(["assign", "config", "visual", "render", "deliver"]); + }); + it("rejects a concurrent first invocation before duplicate delivery", async () => { const root = project(); let startFirst!: () => void; diff --git a/tests/launchd/scripts.test.ts b/tests/launchd/scripts.test.ts index ca6fa02..45bbf1a 100644 --- a/tests/launchd/scripts.test.ts +++ b/tests/launchd/scripts.test.ts @@ -7,6 +7,20 @@ import { runSendRunReportTestCli } from "../../src/cli/send-run-report-test.js"; const projectRoot = resolve(process.cwd()); describe("LaunchAgent lifecycle scripts", () => { + it("provides fixed package commands for capability, preview, and owner-bound live testing", () => { + const packageJson = JSON.parse( + readFileSync(resolve(projectRoot, "package.json"), "utf8"), + ) as { scripts: Record }; + expect(packageJson.scripts).toMatchObject({ + "report:image-probe": + 'tsx src/cli/probe-report-image.ts --project-root "/Users/kdnsna/Documents/微信 chat"', + "report:preview": + 'tsx src/cli/render-run-report-preview.ts --project-root "/Users/kdnsna/Documents/微信 chat"', + "report:test": + 'tsx src/cli/send-run-report-test.ts --project-root "/Users/kdnsna/Documents/微信 chat"', + }); + }); + it("delays native report renderer loading until guarded runtime probes", () => { const health = readFileSync(resolve(projectRoot, "src", "app", "health.ts"), "utf8"); const testCard = readFileSync( @@ -19,7 +33,14 @@ describe("LaunchAgent lifecycle scripts", () => { expect(testCard).not.toMatch( /import\s+\{[^}]*renderReportCard[^}]*\}\s+from\s+"\.\.\/observer\/report-card\.js"/u, ); - expect(testCard).toContain('await import("../observer/report-card.js")'); + expect(testCard).not.toMatch( + /import\s+\{[^}]*renderEditorialReportCard[^}]*\}\s+from\s+"\.\.\/observer\/editorial-card\.js"/u, + ); + expect(testCard).not.toMatch( + /import\s+\{[^}]*prepareReportVisual[^}]*\}\s+from\s+"\.\.\/observer\/report-visual\.js"/u, + ); + expect(testCard).toContain('await import("../observer/report-visual.js")'); + expect(testCard).toContain('await import("../observer/editorial-card.js")'); }); it("sanitizes a delayed renderer loading failure", async () => { From 6defd3bb17d30df2f83a66b5f488943d1e8dd3d8 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:42:43 +0800 Subject: [PATCH 52/56] test: align managed job editorial result --- tests/observer/managed-job.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/observer/managed-job.test.ts b/tests/observer/managed-job.test.ts index 2f619a8..bf9311f 100644 --- a/tests/observer/managed-job.test.ts +++ b/tests/observer/managed-job.test.ts @@ -11,6 +11,7 @@ import { runManagedJob, type ManagedJobDependencies, } from "../../src/observer/managed-job.js"; +import { validEditorialPayload } from "../helpers/editorial.js"; const job: ObserverJob = { id: "daily-tech-digest", @@ -105,6 +106,7 @@ describe("runManagedJob", () => { chunks: 2, jobAttempt: 1, deliveryAttempt: 1, + editorial: validEditorialPayload(), }); return child; }); @@ -133,6 +135,7 @@ describe("runManagedJob", () => { chunks: 2, jobAttempt: 1, deliveryAttempt: 1, + editorial: validEditorialPayload(), }); return child; }); From 72023cfc3987ff4ca910e1b9ade3b1e663ec9938 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:44:09 +0800 Subject: [PATCH 53/56] docs: operate Codex editorial image reports --- README.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0aa5aff..0761e99 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ cd '/Users/kdnsna/Documents/微信 chat' ./scripts/health-check.sh ``` -输出中的 `observer` 只有配置/渲染器就绪布尔值以及待恢复、待发送、不确定投递、无效状态记录的安全计数,不包含 run ID 或路径。待处理计数会把整体状态标为 `pending`,不代表必需环境损坏;无效配置、渲染器不可用或无效状态记录则返回 `failed`。若微信登录过期,重新运行 `./scripts/init-wechat.sh` 扫码初始化,不要手工编辑私有状态文件。 +输出中的 `observer` 只有配置/渲染器/Image 2 就绪布尔值,以及待恢复、待发送、不确定投递、待生成视觉、固定背景视觉和无效状态记录的安全计数,不包含 run ID、期号、提示词或路径。待处理计数会把整体状态标为 `pending`,不代表必需环境损坏;无效配置、编号/视觉账本损坏、渲染器不可用或 Image 2 能力标记不匹配则返回 `failed`。健康检查不会为自证就绪而现场生成图片。若微信登录过期,重新运行 `./scripts/init-wechat.sh` 扫码初始化,不要手工编辑私有状态文件。 只准备 Codex 输入、检查安全参数而不调用 Codex: @@ -78,7 +78,7 @@ GitHub Token 是可选项;不设置时使用公开 API 降级运行。若临 ./scripts/send-run-report-test.sh ``` -测试卡没有收件人、命令、附件或消息覆盖参数。报告内容与 PNG 卡片由本地确定性模板渲染,不由 AI 生成。 +测试卡没有收件人、命令、附件、模型、提示词或消息覆盖参数。Codex 内置 Image 2 只生成不含文字的主视觉;所有中文、期号、信号、行动和审计字段由本地确定性模板渲染。 发送指定日期的完整日报: @@ -88,6 +88,37 @@ GitHub Token 是可选项;不设置时使用公开 API 降级运行。若临 长文本遵循固定上游逻辑:每段最多 2000 个 JavaScript 字符单元,依次优先在段落、换行、空格处分段,最后才硬切,段间等待 300ms。主动发送 POST 不做未经证明的网络重放;响应丢失或部分分段成功会记为 `indeterminate`,当天停止自动重发,避免重复。完整日报使用原子锁和每日 ledger;成功后同一天任何再次调用都会被拒绝。确定失败最多允许初次加两次重试。 +## 编辑部情报长图与 Image 2 + +每个新的最终 Observer 运行报告会获得一个连续期号,例如 `WP-000021`,并对应唯一的视觉账本、主视觉和最终 PNG。成功、跳过、失败、超时、取消、中断和恢复报告共用同一条期号序列。同一 run 重试时复用原期号、主视觉、报告内容和最终长图,不再分配新期号或重复生成。 + +长图固定为 1080 × 2400 PNG,从上到下包含五个区域: + +1. 无文字主视觉、报告身份、期号、状态与时间。 +2. `EXECUTIVE BRIEF` 今日结论。 +3. 最多五项带 `01`–`05` 编号的重点信号、为何重要、来源数、置信度和影响。 +4. 三项可执行的下一步行动。 +5. Codex 执行与交付审计,包含候选/入选数、来源覆盖、耗时、视觉模式和短 run ID。 + +Image 2 使用本机已登录的 Codex 内置认证,项目不读取、保存或要求 OpenAI API key。图片子进程仅接收经脱敏的视觉摘要,从 stdin 输入,且固定为临时、非交互、项目根目录、`read-only`、`approval never`。它只启用 `image_generation`,显式关闭 shell、unified exec、browser、computer use、Apps、plugins、hooks、多代理和额外目录;不接收微信消息、收件人、凭据、源 URL、完整日报、本机私有路径或可执行指令。 + +先验证能力,再生成本地预览,最后才实发: + +```sh +npm run report:image-probe +npm run health +npm run report:preview +npm run report:test +``` + +`report:image-probe` 会调用一次相同的生产图片路径,完成后只保存 Codex 版本、样式版本、成功时间和产物哈希,并删除经文件身份核对的项目内探测图。`report:preview` 生成固定长图但不加载任何微信发送器。`report:test` 生成五项固定信号与三项行动,获得真实期号,然后向已绑定本人发送一次文字和长图。 + +主视觉最多尝试三次;三次都失败后生成确定性品牌背景,图上如实标注 `AI 主视觉暂不可用`,详细正文仍可投递。如果最终合成失败,文字和图片都不发送,最终报告与视觉账本保留供恢复;只有微信明确拒绝才在次数上限内重试,响应丢失则记为 `indeterminate` 且永不自动重放。 + +图片在微信发送之前已完成渲染,因此它只能声明 `报告图:已生成`,不得声明自己未来的微信投递已经成功。真实投递结果只由图片之外的私有 delivery ledger 确认。 + +紧急回滚无需更换代码或破坏新账本:只将 `config/report-visual.yaml` 中的 `mode: editorial` 改为 `mode: legacy`,再重新加载同一份 schema-compatible Observer 构建。legacy 模式仍会分配期号,但跳过 Image 2 并使用旧报告卡渲染器。保留所有最终报告、期号、视觉账本和不确定投递记录,不要重置状态或自动补发。 + ## 数据源与降级 首期配置位于 `config/sources.yaml`: From ff504ff4ec9684f2dce576f1345a112fdaf50cfa Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:25:13 +0800 Subject: [PATCH 54/56] fix: outlive WeChat QR long polling --- README.md | 2 +- config/vendor-wechat-artifacts.json | 2 +- package.json | 3 +- patches/cli-in-wechat-qr-status-timeout.patch | 11 ++++++++ scripts/init-project.sh | 2 +- tests/vendor/qr-status-timeout-patch.test.ts | 28 +++++++++++++++++++ 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 patches/cli-in-wechat-qr-status-timeout.patch create mode 100644 tests/vendor/qr-status-timeout-patch.test.ts diff --git a/README.md b/README.md index 0761e99..0e5d0c2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - macOS 当前用户权限,不需要 `sudo`。 - 固定运行时:`/opt/homebrew/opt/node@22/bin/node`(已验证为 Node 22.23.1)。 - Codex:由 `config/digest.yaml` 固定为检测到的 Node 可执行文件与官方 CLI JavaScript 入口。 -- 微信依赖:`vendor/cli-in-wechat` Git submodule,固定提交 `75d7db9338e133080fef9914b92ceb5e3ebd0dfc`,不跟随任何分支。 +- 微信依赖:`vendor/cli-in-wechat` Git submodule,固定提交 `75d7db9338e133080fef9914b92ceb5e3ebd0dfc`,不跟随任何分支。构建后仅对未跟踪的 QR 运行时产物应用已审计的 45 秒长轮询补丁,上游源码和 submodule 指针保持不变。 不要运行 vendor 内的 `npm start`、`npm run dev` 或其主入口。项目只校验并动态加载审计过的 QR、HTTP 和加密辅助模块;构建产物必须同时通过 `config/vendor-wechat-artifacts.json` 的大小和 SHA-256 清单。 diff --git a/config/vendor-wechat-artifacts.json b/config/vendor-wechat-artifacts.json index 98d4532..c6b9d9e 100644 --- a/config/vendor-wechat-artifacts.json +++ b/config/vendor-wechat-artifacts.json @@ -5,7 +5,7 @@ { "path": "ilink/auth.js", "bytes": 3140, - "sha256": "0743aeb829d774a3f67b277df757db234750d6b3ce9347eac4465d29691da707" + "sha256": "fb7953688a0393443a7847082b9aa36e04f4ddfcac13708e425e832e3b83a094" }, { "path": "ilink/client.js", diff --git a/package.json b/package.json index e0d0298..98b9e13 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "digest": "tsx src/cli/digest.ts", "digest:dry": "tsx src/cli/digest.ts --dry-run", "vendor:install": "npm --prefix vendor/cli-in-wechat ci", - "vendor:build": "npm --prefix vendor/cli-in-wechat run build", + "vendor:build": "npm --prefix vendor/cli-in-wechat run build && npm run vendor:patch", + "vendor:patch": "/usr/bin/patch --batch --forward -p1 < patches/cli-in-wechat-qr-status-timeout.patch", "wechat:init": "tsx src/cli/init-wechat.ts", "send:test": "tsx src/cli/send-test.ts", "send:digest": "tsx src/cli/send-digest.ts", diff --git a/patches/cli-in-wechat-qr-status-timeout.patch b/patches/cli-in-wechat-qr-status-timeout.patch new file mode 100644 index 0000000..b24ec0f --- /dev/null +++ b/patches/cli-in-wechat-qr-status-timeout.patch @@ -0,0 +1,11 @@ +--- a/vendor/cli-in-wechat/dist/ilink/auth.js ++++ b/vendor/cli-in-wechat/dist/ilink/auth.js +@@ -11,7 +11,7 @@ export async function getQRCode() { + } + export async function pollQRCodeStatus(qrcode) { + // Polled every 2s in a loop, so keep retries light — the loop itself is the outer retry. +- const res = await fetchWithRetry(`${DEFAULT_BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, { headers: { 'iLink-App-ClientVersion': '1' }, label: 'QR-status', retries: 1, timeoutMs: 15_000 }); ++ const res = await fetchWithRetry(`${DEFAULT_BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, { headers: { 'iLink-App-ClientVersion': '1' }, label: 'QR-status', retries: 1, timeoutMs: 45_000 }); + if (!res.ok) + throw new Error(`轮询二维码状态失败: HTTP ${res.status}`); + return res.json(); diff --git a/scripts/init-project.sh b/scripts/init-project.sh index b27fc1f..49f63c3 100755 --- a/scripts/init-project.sh +++ b/scripts/init-project.sh @@ -11,7 +11,7 @@ cd "${PROJECT_ROOT}" "${NODE22}" "${NPM22}" ci "${NODE22}" "${NPM22}" --prefix "${PROJECT_ROOT}/vendor/cli-in-wechat" ci -"${NODE22}" "${NPM22}" --prefix "${PROJECT_ROOT}/vendor/cli-in-wechat" run build +"${NODE22}" "${NPM22}" run vendor:build "${NODE22}" "${NPM22}" run test "${NODE22}" "${NPM22}" run typecheck "${NODE22}" "${NPM22}" run build diff --git a/tests/vendor/qr-status-timeout-patch.test.ts b/tests/vendor/qr-status-timeout-patch.test.ts new file mode 100644 index 0000000..bc0b76d --- /dev/null +++ b/tests/vendor/qr-status-timeout-patch.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function read(relativePath: string): string { + return readFileSync(resolve(repositoryRoot, relativePath), "utf8"); +} + +it("applies the reviewed QR long-poll timeout patch after every vendor build", () => { + const packageJson = JSON.parse(read("package.json")) as { + readonly scripts: Record; + }; + const upstreamSource = read("vendor/cli-in-wechat/src/ilink/auth.ts"); + const runtimeSource = read("vendor/cli-in-wechat/dist/ilink/auth.js"); + const patch = read("patches/cli-in-wechat-qr-status-timeout.patch"); + + expect(packageJson.scripts["vendor:build"]).toContain("npm run vendor:patch"); + expect(upstreamSource).toContain("timeoutMs: 15_000"); + expect(runtimeSource).toContain("timeoutMs: 45_000"); + expect(runtimeSource).not.toContain("timeoutMs: 15_000"); + expect(patch).toContain("- const res = await fetchWithRetry"); + expect(patch).toContain("+ const res = await fetchWithRetry"); + expect(patch).toContain("timeoutMs: 45_000"); +}); From 7f4fb41682ec534f6fceb7438d082e77c03127d1 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:51:10 +0800 Subject: [PATCH 55/56] fix: fill status reports with audit evidence --- src/observer/editorial-card.ts | 13 ++--- src/report/editorial.ts | 76 +++++++++++++++++++++++++-- tests/app/observer.test.ts | 9 +++- tests/observer/editorial-card.test.ts | 17 ++++++ tests/observer/job-adapters.test.ts | 42 ++++++++++----- tests/observer/run-report.test.ts | 6 ++- tests/report/editorial.test.ts | 33 ++++++++++++ 7 files changed, 171 insertions(+), 25 deletions(-) diff --git a/src/observer/editorial-card.ts b/src/observer/editorial-card.ts index 2f3e7b6..75e78dd 100644 --- a/src/observer/editorial-card.ts +++ b/src/observer/editorial-card.ts @@ -125,6 +125,7 @@ export function buildEditorialCardSvg(options: { const finished = shanghaiParts(report.finishedAt); const issueLabel = report.issueLabel ?? "WP-UNASSIGNED"; const editorial = report.editorial; + const isStatusReceipt = editorial?.kind === "status"; const highlights = (editorial?.highlights ?? []).slice(0, 5); const actions = (editorial?.actions ?? []).slice(0, 3); const audit = editorial?.audit; @@ -212,7 +213,7 @@ export function buildEditorialCardSvg(options: { top + itemHeight - 24, 18, 600, - `来源 ${highlight.sourceCount} · 置信度 ${CONFIDENCE[highlight.confidence]} · ${IMPACT[highlight.impact]}`, + `${isStatusReceipt ? "证据" : "来源"} ${highlight.sourceCount} · 置信度 ${CONFIDENCE[highlight.confidence]} · ${IMPACT[highlight.impact]}`, MUTED, ), ); @@ -259,13 +260,13 @@ export function buildEditorialCardSvg(options: { nodes.push( textNode(48, 2140, 20, 700, "CODEX AUDIT / 执行与交付审计", NAVY), ``, - textNode(48, 2204, 18, 600, `候选 ${auditNumber(audit?.candidateCount)}`, MUTED), - textNode(218, 2204, 18, 600, `入选 ${auditNumber(selectedCount)}`, MUTED), - textNode(388, 2204, 18, 600, `来源覆盖 ${auditNumber(audit?.sourceCategoryCount)}`, MUTED), + textNode(48, 2204, 18, 600, `${isStatusReceipt ? "审计证据" : "候选"} ${auditNumber(audit?.candidateCount)}`, MUTED), + textNode(218, 2204, 18, 600, `${isStatusReceipt ? "确认项" : "入选"} ${auditNumber(selectedCount)}`, MUTED), + textNode(388, 2204, 18, 600, `${isStatusReceipt ? "检查域" : "来源覆盖"} ${auditNumber(audit?.sourceCategoryCount)}`, MUTED), textNode(620, 2204, 18, 600, `耗时 ${report.durationMs} ms`, MUTED), - textNode(48, 2244, 18, 700, bodyConfirmed(report) ? "日报正文:确认成功" : "日报正文:本次未确认", bodyConfirmed(report) ? "#2f6f55" : MUTED), + textNode(48, 2244, 18, 700, isStatusReceipt ? "状态回执:已生成" : bodyConfirmed(report) ? "日报正文:确认成功" : "日报正文:本次未确认", isStatusReceipt || bodyConfirmed(report) ? "#2f6f55" : MUTED), textNode(360, 2244, 18, 700, options.visualMode === "generated" ? "报告图:已生成" : "报告图:固定背景", options.visualMode === "generated" ? "#2f6f55" : ORANGE), - textNode(48, 2288, 17, 600, "01 收集 → 02 核验 → 03 编排 → 04 图像合成", INK), + textNode(48, 2288, 17, 600, isStatusReceipt ? "01 识别 → 02 取证 → 03 审计 → 04 回执合成" : "01 收集 → 02 核验 → 03 编排 → 04 图像合成", INK), ``, textNode(48, 2362, 17, 600, `RUN ${report.runId.slice(0, 8).toUpperCase()} · ${escapeText(options.visualMode.toUpperCase())} · EDITORIAL-V1`, MUTED), textNode(908, 2362, 17, 700, "PAGE 01 / 01", NAVY), diff --git a/src/report/editorial.ts b/src/report/editorial.ts index 1205649..61a5d40 100644 --- a/src/report/editorial.ts +++ b/src/report/editorial.ts @@ -178,12 +178,82 @@ export function buildStatusEditorialReport(options: { failed: "本次情报任务未能完成,系统只记录可审计的失败状态,不会用未核验内容填充或伪造今日信号。", interrupted: "任务因主机或进程中断而结束,恢复流程已补记状态,不自动重放任何可能已发送的内容。", } as const; + const receipts = { + "not-due": { + highlights: [ + ["调度日期已完成核对", "系统只执行了计划时间检查,当前运行不满足新一轮情报任务的启动条件。"], + ["未启动新的候选采集", "本次没有读取或编排新的技术候选,因此不会把过时数据包装成当日信号。"], + ["未调用日报正文投递", "微信正文通道未被触发,这避免了在计划时间之外产生额外消息。"], + ["现有编号与账本保持不变", "本次状态检查不改写已完成的日报、投递记录或历史审计信息。"], + ["下一计划周期仍然有效", "本次跳过不会停用定时任务,后续仍由 LaunchAgent 按既定时间再次检查。"], + ], + actions: [ + "无需手工重跑,等待下一计划周期自动检查", + "如需临时发送,先确认调度时间和当日去重状态", + "保留本次跳过回执,用于核对定时器是否按预期唤醒", + ], + }, + "already-sent": { + highlights: [ + ["幂等账本确认今日已发送", "当日投递记录已处于完成状态,因此这次唤醒不会再创建一份相同日报。"], + ["重复执行在生成前被拦截", "去重判断发生在新正文和新图片投递之前,避免消耗生成配额或打扰微信会话。"], + ["原始微信投递结果保持不变", "系统保留既有的已发文本、图片和尝试次数,不覆盖也不伪造新的成功记录。"], + ["本次不制造新的技术信号", "由于正文已经完成,状态回执只展示可验证的执行事实,不为填满版面而补写情报。"], + ["后续定时调度继续保留", "这次去重跳过只影响当前唤醒,不会关闭下一个计划周期的自动值守。"], + ], + actions: [ + "查看先前已发送的日报和对应期号,不以本次状态回执替代正文", + "保留幂等与投递账本,不手工清除已发送标记", + "等待下一计划周期,再由 LaunchAgent 触发新的核验和发送", + ], + }, + failed: { + highlights: [ + ["任务未达到完成终态", "运行账本已记录失败状态,本次回执不会把未完成结果标注为成功。"], + ["未验证内容不进入情报区", "系统没有使用失败过程中的中间产物填充今日信号,避免误导后续判断。"], + ["微信投递不会无限重试", "只有明确可重试的拒绝才使用有限尝试,不确定或登录失效会立即阻断。"], + ["失败证据保留供后续诊断", "状态、阶段和尝试次数会写入私有账本,同时不记录令牌、凭据或个人上下文。"], + ["下一次执行需要先通过健康检查", "在重新执行前应确认运行时、配置、微信上下文和视觉生成能力均为就绪。"], + ], + actions: [ + "查看安全失败分类和对应阶段,不从日志中追索私密上下文", + "先运行健康检查,仅在必需能力恢复后启动一次明确重试", + "保留失败账本与期号,避免新任务覆盖待审计记录", + ], + }, + interrupted: { + highlights: [ + ["中断运行已由恢复流程识别", "系统根据运行身份与存活检查确认原进程不再活动,然后补记中断状态。"], + ["不自动重放可能已发送的内容", "中断点可能位于外部投递前后,因此系统不假设未发送,也不自动重复全份报告。"], + ["恢复回执不伪造技术信号", "本次只展示中断和恢复证据,没有把不完整的候选、正文或视觉产物当作日报。"], + ["旧运行账本保持可审计", "原运行的开始时间、任务身份和中断结果会被保留,新任务不会覆盖它。"], + ["后续恢复必须使用新运行身份", "只有在锁、进程和微信投递状态均明确后,才能安全启动下一次独立运行。"], + ], + actions: [ + "先核对原运行是否已经产生外部投递,不根据本地猜测盲目重发", + "清理前先确认锁文件对应的进程已消失,保留恢复审计记录", + "通过健康检查后再启动新运行,并使用新期号区分两次执行", + ], + }, + } as const; + const receipt = receipts[options.reason]; return EditorialReportPayloadSchema.parse({ version: 1, kind: "status", executiveSummary: descriptions[options.reason], - highlights: [], - actions: [], - audit: { scheduledDate: options.date }, + highlights: receipt.highlights.map(([title, whyItMatters]) => ({ + title, + whyItMatters, + sourceCount: 1, + confidence: "high", + impact: "medium", + })), + actions: receipt.actions, + audit: { + candidateCount: 5, + selectedCount: 5, + sourceCategoryCount: 4, + scheduledDate: options.date, + }, }); } diff --git a/tests/app/observer.test.ts b/tests/app/observer.test.ts index 9a462e2..6836aa0 100644 --- a/tests/app/observer.test.ts +++ b/tests/app/observer.test.ts @@ -330,8 +330,13 @@ describe("observer orchestration", () => { artifacts: [], editorial: { kind: "status", - highlights: [], - actions: [], + highlights: Array.from({ length: 5 }, () => + expect.objectContaining({ + confidence: "high", + impact: "medium", + }), + ), + actions: Array.from({ length: 3 }, () => expect.any(String)), audit: { scheduledDate: "2026-07-20" }, }, }); diff --git a/tests/observer/editorial-card.test.ts b/tests/observer/editorial-card.test.ts index 9e785ff..9792615 100644 --- a/tests/observer/editorial-card.test.ts +++ b/tests/observer/editorial-card.test.ts @@ -156,6 +156,23 @@ describe("fixed editorial card SVG", () => { } }); + it("labels status receipts as operational evidence instead of digest sources", () => { + const svg = buildEditorialCardSvg({ + report: report("skipped", 0), + heroPng: hero(), + visualMode: "generated", + }); + + expect(svg).toContain("证据 1 · 置信度 高 · 中影响"); + expect(svg).toContain("审计证据 5"); + expect(svg).toContain("确认项 5"); + expect(svg).toContain("检查域 4"); + expect(svg).toContain("状态回执:已生成"); + expect(svg).toContain("01 识别 → 02 取证 → 03 审计 → 04 回执合成"); + expect(svg).not.toContain("日报正文:本次未确认"); + expect(svg).not.toContain("来源 1 ·"); + }); + it("allows one trusted embedded hero and neutralizes report-controlled resources", () => { const base = report(); const hostile = { diff --git a/tests/observer/job-adapters.test.ts b/tests/observer/job-adapters.test.ts index a698e4d..2a2e2e1 100644 --- a/tests/observer/job-adapters.test.ts +++ b/tests/observer/job-adapters.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseScheduledDigestResult } from "../../src/observer/job-adapters.js"; +import { EditorialReportPayloadSchema } from "../../src/report/editorial.js"; import { validEditorialPayload } from "../helpers/editorial.js"; const editorial = validEditorialPayload(); @@ -45,25 +46,42 @@ describe("parseScheduledDigestResult", () => { statusOverride: "skipped", draft: { summary: ["尚未到每日发送时间,本次跳过"], - editorial: { kind: "status", highlights: [], actions: [] }, + editorial: { + kind: "status", + highlights: expect.any(Array), + actions: expect.any(Array), + }, }, }); - expect( - parseScheduledDigestResult( - JSON.stringify({ - ok: true, - status: "already-sent", - date: "2026-07-20", - jobAttempt: 2, - }), - ), - ).toMatchObject({ + const notDueEditorial = EditorialReportPayloadSchema.parse( + notDue.draft.editorial, + ); + expect(notDueEditorial.highlights).toHaveLength(5); + expect(notDueEditorial.actions).toHaveLength(3); + const alreadySent = parseScheduledDigestResult( + JSON.stringify({ + ok: true, + status: "already-sent", + date: "2026-07-20", + jobAttempt: 2, + }), + ); + expect(alreadySent).toMatchObject({ statusOverride: "skipped", draft: { summary: ["今日日报已发送,本次跳过"], - editorial: { kind: "status", highlights: [], actions: [] }, + editorial: { + kind: "status", + highlights: expect.any(Array), + actions: expect.any(Array), + }, }, }); + const alreadySentEditorial = EditorialReportPayloadSchema.parse( + alreadySent.draft.editorial, + ); + expect(alreadySentEditorial.highlights).toHaveLength(5); + expect(alreadySentEditorial.actions).toHaveLength(3); }); it.each([ diff --git a/tests/observer/run-report.test.ts b/tests/observer/run-report.test.ts index b87c625..f7dfdef 100644 --- a/tests/observer/run-report.test.ts +++ b/tests/observer/run-report.test.ts @@ -441,8 +441,10 @@ describe("buildRunReport", () => { }); expect(fallback.editorial).toMatchObject({ kind: "status", - highlights: [], - actions: [], + highlights: Array.from({ length: 5 }, () => + expect.objectContaining({ confidence: "high", impact: "medium" }), + ), + actions: Array.from({ length: 3 }, () => expect.any(String)), audit: { scheduledDate: "2026-07-19" }, }); expect(JSON.stringify(fallback)).not.toContain("secret.test"); diff --git a/tests/report/editorial.test.ts b/tests/report/editorial.test.ts index b7618de..69db9d0 100644 --- a/tests/report/editorial.test.ts +++ b/tests/report/editorial.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + buildStatusEditorialReport, DigestEditorialSchema, EditorialReportPayloadSchema, toEditorialReportPayload, @@ -30,6 +31,38 @@ function validEditorial() { } describe("editorial report payload", () => { + it.each([ + "not-due", + "already-sent", + "failed", + "interrupted", + ] as const)("builds a detailed, evidence-only %s status receipt", (reason) => { + const receipt = buildStatusEditorialReport({ + reason, + date: "2026-07-20", + }); + + expect(receipt.kind).toBe("status"); + expect(receipt.highlights).toHaveLength(5); + expect(receipt.actions).toHaveLength(3); + expect(receipt.audit).toEqual({ + candidateCount: 5, + selectedCount: 5, + sourceCategoryCount: 4, + scheduledDate: "2026-07-20", + }); + expect( + receipt.highlights.every( + (item) => + item.sourceCount >= 1 && + item.confidence === "high" && + item.impact === "medium", + ), + ).toBe(true); + expect(JSON.stringify(receipt)).not.toMatch(/https?:|token|cookie/iu); + expect(EditorialReportPayloadSchema.parse(receipt)).toEqual(receipt); + }); + it("converts verified source URLs into counts and deterministic confidence", () => { const safe = toEditorialReportPayload(validEditorial(), { allowedUrls: ["https://one.test/a", "https://two.test/b"], From c5f80aa281eab0baed521eda63c7d2190caf49f5 Mon Sep 17 00:00:00 2001 From: kdnsna <39900121+kdnsna@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:53:47 +0800 Subject: [PATCH 56/56] test: isolate observer process identities --- tests/observer/run-state.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/observer/run-state.test.ts b/tests/observer/run-state.test.ts index 0a12c0b..072233c 100644 --- a/tests/observer/run-state.test.ts +++ b/tests/observer/run-state.test.ts @@ -310,6 +310,7 @@ describe("observer run-state journal", () => { projectRoot, now: () => new Date("2026-07-19T01:05:00.000Z"), isProcessAlive: (pid) => pid === 101 || pid === 202 || pid === 302, + processIdentity: identity, }); expect(result).toEqual({