diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e34877e..24ea928 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,10 +35,10 @@ jobs: - name: Install root dependencies run: npm ci - - name: Build pinned WeChat adapter + - name: Build and patch pinned WeChat adapter run: | npm --prefix vendor/cli-in-wechat ci - npm --prefix vendor/cli-in-wechat run build + npm run vendor:build - name: Verify project run: | diff --git a/.gitignore b/.gitignore index af3f2fd..e1a291f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ qrcode* *.qr.png *.qr.svg .codex/ +.superpowers/ *.codex-output.json *.codex-output.md diff --git a/README.md b/README.md index 34bd049..22cd27d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ WeChatPilot 是一个运行在本机的微信 AI 自动化平台。当前内置 > [!IMPORTANT] > WeChatPilot 是独立、非微信或腾讯官方项目,与微信及腾讯不存在隶属、授权或背书关系。 -项目不会从微信执行命令,不会启动 `cli-in-wechat` 的 Router、Codex 适配器或远程控制入口,也不会使用 `--dangerously-bypass-approvals-and-sandbox`。微信凭据和 context token 只保存在本项目被 Git 忽略的 `.state/wechat/`,目录权限为 `0700`、文件权限为 `0600`;命令输出和日志只报告就绪状态,不显示具体值、用户 ID 或令牌。 +项目不会从微信执行命令,不会启动 `cli-in-wechat` 的 Router、Codex 适配器或远程控制入口,也不会使用 `--dangerously-bypass-approvals-and-sandbox`。微信不能启动、批准、恢复、停止或修改 Codex 任务;它在本项目中始终只是单向投递终点。微信凭据和 context token 只保存在本项目被 Git 忽略的 `.state/wechat/`,目录权限为 `0700`、文件权限为 `0600`;命令输出和日志只报告就绪状态,不显示具体值、用户 ID 或令牌。 ## 产品方向 @@ -18,7 +18,7 @@ WeChatPilot 将科技日报作为首个经过真实运行验证的内置工作 - 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 清单。 @@ -43,6 +43,8 @@ cd '/Users/kdnsna/Documents/微信 chat' ./scripts/health-check.sh ``` +输出中的 `observer` 只有配置/渲染器/Image 2 就绪布尔值,以及待恢复、待发送、不确定投递、待生成视觉、固定背景视觉和无效状态记录的安全计数,不包含 run ID、期号、提示词或路径。待处理计数会把整体状态标为 `pending`,不代表必需环境损坏;无效配置、编号/视觉账本损坏、渲染器不可用或 Image 2 能力标记不匹配则返回 `failed`。健康检查不会为自证就绪而现场生成图片。若微信登录过期,重新运行 `./scripts/init-wechat.sh` 扫码初始化,不要手工编辑私有状态文件。 + 只准备 Codex 输入、检查安全参数而不调用 Codex: ```sh @@ -79,6 +81,14 @@ GitHub Token 是可选项;不设置时使用公开 API 降级运行。若临 ./scripts/send-test.sh ``` +显式发送一张固定值守员测试卡(会真实向已绑定本人微信发送文字和 PNG): + +```sh +./scripts/send-run-report-test.sh +``` + +测试卡没有收件人、命令、附件、模型、提示词或消息覆盖参数。Codex 内置 Image 2 只生成不含文字的主视觉;所有中文、期号、信号、行动和审计字段由本地确定性模板渲染。 + 发送指定日期的完整日报: ```sh @@ -87,6 +97,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`: @@ -198,6 +239,8 @@ PATH=/opt/homebrew/opt/node@22/bin:/usr/bin:/bin \ ./scripts/run-scheduled.sh ``` +该脚本现在由 Observer 启动受审计的 `daily-tech-digest` 任务:日报投递完成后,再发送对应的文字运行报告和 PNG 卡片。如果上次 Observer 在任务运行期间被主机或进程中断,`interrupted` 报告只会在下一次 Observer 成功启动并完成恢复检查后上报。投递状态为 `indeterminate` 时永不自动重放,必须由运维人员核查,避免向微信重复发送。 + 新环境默认不会自动加载 LaunchAgent。安装脚本必须收到显式确认参数,否则退出且不改动 LaunchAgent: ```sh @@ -211,7 +254,7 @@ PATH=/opt/homebrew/opt/node@22/bin:/usr/bin:/bin \ ./scripts/health-check.sh ``` -卸载只停止任务并删除用户 `LaunchAgents` 中这一份精确 plist,不删除项目、日报、日志或微信状态: +要停用已存在的 LaunchAgent,使用专用卸载脚本。它只停止任务并删除用户 `LaunchAgents` 中这一份精确 plist,不删除项目、日报、日志或微信状态: ```sh ./scripts/uninstall-launchd.sh diff --git a/config/digest-output.schema.json b/config/digest-output.schema.json index d90f312..30b8ac4 100644 --- a/config/digest-output.schema.json +++ b/config/digest-output.schema.json @@ -1,7 +1,7 @@ { "type": "object", "additionalProperties": false, - "required": ["title", "date", "digest", "sourceUrls"], + "required": ["title", "date", "digest", "sourceUrls", "editorial"], "properties": { "title": { "type": "string" @@ -17,6 +17,62 @@ "items": { "type": "string" } + }, + "editorial": { + "type": "object", + "additionalProperties": false, + "required": ["executiveSummary", "highlights", "actions"], + "properties": { + "executiveSummary": { + "type": "string", + "minLength": 80, + "maxLength": 140 + }, + "highlights": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["title", "whyItMatters", "sourceUrls", "impact"], + "properties": { + "title": { + "type": "string", + "minLength": 8, + "maxLength": 36 + }, + "whyItMatters": { + "type": "string", + "minLength": 40, + "maxLength": 100 + }, + "sourceUrls": { + "type": "array", + "minItems": 1, + "maxItems": 6, + "items": { + "type": "string" + } + }, + "impact": { + "type": "string", + "enum": ["high", "medium-high", "medium", "exploratory"] + } + } + } + }, + "actions": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "string", + "minLength": 12, + "maxLength": 80 + } + } + } } } } diff --git a/config/observer-jobs.yaml b/config/observer-jobs.yaml new file mode 100644 index 0000000..f79c6d5 --- /dev/null +++ b/config/observer-jobs.yaml @@ -0,0 +1,15 @@ +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 diff --git a/config/report-image-output.schema.json b/config/report-image-output.schema.json new file mode 100644 index 0000000..0e1a656 --- /dev/null +++ b/config/report-image-output.schema.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "additionalProperties": false, + "required": ["status", "imagePath"], + "properties": { + "status": { + "type": "string", + "enum": ["generated"] + }, + "imagePath": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + } +} diff --git a/config/report-visual.yaml b/config/report-visual.yaml new file mode 100644 index 0000000..fc91bf6 --- /dev/null +++ b/config/report-visual.yaml @@ -0,0 +1,70 @@ +version: 1 +mode: editorial +canvas: + width: 1080 + height: 2400 + maximumBytes: 5242880 +generation: + maximumAttempts: 3 + timeoutMs: 180000 +style: + version: editorial-v1 + heroHeight: 480 +codex: + version: 0.144.4 + nodeExecutable: /opt/homebrew/opt/node@22/bin/node + javascriptEntrypoint: /opt/homebrew/lib/node_modules/@openai/codex/bin/codex.js + globalArgs: + - --sandbox + - read-only + - --ask-for-approval + - never + - --cd + - "{projectRoot}" + - --enable + - image_generation + - --disable + - apps + - --disable + - plugins + - --disable + - hooks + - --disable + - browser_use + - --disable + - computer_use + - --disable + - in_app_browser + - --disable + - shell_tool + - --disable + - unified_exec + - --disable + - shell_snapshot + - --disable + - multi_agent + - --disable + - goals + - --disable + - code_mode_host + - --disable + - workspace_dependencies + - --disable + - chronicle + - --disable + - memories + - --disable + - tool_suggest + - --disable + - auth_elicitation + command: exec + execArgs: + - --ephemeral + - --ignore-user-config + - --ignore-rules + - --color + - never + - --json + - --output-schema + - "{projectRoot}/config/report-image-output.schema.json" + - "-" diff --git a/config/vendor-wechat-artifacts.json b/config/vendor-wechat-artifacts.json index 4907e93..c6b9d9e 100644 --- a/config/vendor-wechat-artifacts.json +++ b/config/vendor-wechat-artifacts.json @@ -5,7 +5,12 @@ { "path": "ilink/auth.js", "bytes": 3140, - "sha256": "0743aeb829d774a3f67b277df757db234750d6b3ce9347eac4465d29691da707" + "sha256": "fb7953688a0393443a7847082b9aa36e04f4ddfcac13708e425e832e3b83a094" + }, + { + "path": "ilink/client.js", + "bytes": 29893, + "sha256": "d4374ca607458c44321c58a4d2a9af7e040fbd21f055319533a89ce33d4c171b" }, { "path": "utils/http.js", 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 `\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/editorial-config.test.ts b/tests/observer/editorial-config.test.ts new file mode 100644 index 0000000..6a28304 --- /dev/null +++ b/tests/observer/editorial-config.test.ts @@ -0,0 +1,120 @@ +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildEditorialImageInvocation, + loadEditorialConfig, +} from "../../src/observer/editorial-config.js"; + +const projectRoot = fileURLToPath(new URL("../..", import.meta.url)); +const temporaryRoots: string[] = []; + +function fixtureRoot(): string { + const root = mkdtempSync(join(tmpdir(), "wechatpilot-editorial-config-")); + const configDirectory = join(root, "config"); + mkdirSync(configDirectory); + for (const name of [ + "report-visual.yaml", + "report-image-output.schema.json", + ]) { + copyFileSync( + join(projectRoot, "config", name), + join(configDirectory, name), + ); + } + temporaryRoots.push(root); + return root; +} + +function mutateConfig(needle: string, replacement: string): string { + const root = fixtureRoot(); + const path = join(root, "config", "report-visual.yaml"); + const original = readFileSync(path, "utf8"); + expect(original).toContain(needle); + writeFileSync(path, original.replace(needle, replacement), "utf8"); + return root; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe("editorial image configuration", () => { + it("loads one exact image-only Codex profile", () => { + const config = loadEditorialConfig(projectRoot); + expect(config.mode).toBe("editorial"); + expect(config.canvas).toEqual({ + width: 1080, + height: 2400, + maximumBytes: 5_242_880, + }); + expect(config.generation).toEqual({ + maximumAttempts: 3, + timeoutMs: 180_000, + }); + expect(config.style).toEqual({ + version: "editorial-v1", + heroHeight: 480, + }); + + const invocation = buildEditorialImageInvocation(config, projectRoot, { + HOME: "/Users/example", + PATH: "/usr/bin:/bin", + OPENAI_API_KEY: "must-not-reach-child", + }); + expect(invocation.cwd).toBe(projectRoot); + expect(invocation.executable).toBe( + "/opt/homebrew/opt/node@22/bin/node", + ); + expect(invocation.args).toContain("image_generation"); + expect(invocation.args).toContain("read-only"); + expect(invocation.args).not.toContain("danger-full-access"); + expect(invocation.args).not.toContain("--add-dir"); + expect(invocation.env).not.toHaveProperty("OPENAI_API_KEY"); + }); + + it.each([ + [" - read-only\n", " - workspace-write\n"], + [" - image_generation\n", " - shell_tool\n"], + [ + " - \"{projectRoot}/config/report-image-output.schema.json\"\n", + " - \"{projectRoot}/config/other.schema.json\"\n", + ], + ["mode: editorial\n", "mode: another-profile\n"], + ])("rejects a mutated image profile", (needle, replacement) => { + const root = mutateConfig(needle, replacement); + expect(() => loadEditorialConfig(root)).toThrowError( + "Invalid editorial report configuration", + ); + }); + + it("rejects adding a sandbox bypass without echoing it", () => { + const secretLikeValue = "--dangerously-bypass-approvals-and-sandbox"; + const root = mutateConfig( + " command: exec\n", + ` - ${secretLikeValue}\n command: exec\n`, + ); + + let message = ""; + try { + loadEditorialConfig(root); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toBe("Invalid editorial report configuration"); + expect(message).not.toContain(secretLikeValue); + }); +}); diff --git a/tests/observer/job-adapters.test.ts b/tests/observer/job-adapters.test.ts new file mode 100644 index 0000000..2a2e2e1 --- /dev/null +++ b/tests/observer/job-adapters.test.ts @@ -0,0 +1,120 @@ +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(); + +describe("parseScheduledDigestResult", () => { + it("maps the exact sent result to a success draft", () => { + expect( + parseScheduledDigestResult( + `${JSON.stringify({ + ok: true, + status: "sent", + date: "2026-07-20", + chunks: 3, + jobAttempt: 2, + deliveryAttempt: 1, + editorial, + })}\n`, + ), + ).toEqual({ + statusOverride: "success", + draft: { + version: 1, + summary: ["今日日报已生成并发送"], + metrics: [ + { label: "日期", value: "2026-07-20" }, + { label: "消息分段", value: "3" }, + { label: "任务尝试", value: "2" }, + { label: "发送尝试", value: "1" }, + ], + failure: null, + artifacts: [], + editorial, + }, + }); + }); + + it("maps not-due and already-sent to skipped with status-specific fields", () => { + const notDue = parseScheduledDigestResult( + JSON.stringify({ ok: true, status: "not-due", date: "2026-07-20" }), + ); + expect(notDue).toMatchObject({ + statusOverride: "skipped", + draft: { + summary: ["尚未到每日发送时间,本次跳过"], + editorial: { + kind: "status", + highlights: expect.any(Array), + actions: expect.any(Array), + }, + }, + }); + 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: 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([ + "", + "{}", + '{"ok":false,"status":"sent","date":"2026-07-20","chunks":1,"jobAttempt":1,"deliveryAttempt":1}', + '{"ok":true,"status":"sent","date":"2026-02-30","chunks":1,"jobAttempt":1,"deliveryAttempt":1}', + '{"ok":true,"status":"sent","date":"2026-07-20","chunks":0,"jobAttempt":1,"deliveryAttempt":1}', + '{"ok":true,"status":"sent","date":"2026-07-20","chunks":1,"jobAttempt":1}', + '{"ok":true,"status":"not-due","date":"2026-07-20","jobAttempt":1}', + '{"ok":true,"status":"already-sent","date":"2026-07-20"}', + '{"ok":true,"status":"sent","date":"2026-07-20","chunks":1,"jobAttempt":1,"deliveryAttempt":1,"extra":"private"}', + ' {"ok":true,"status":"not-due","date":"2026-07-20"}', + '{"ok":true,"status":"not-due","date":"2026-07-20"}\n\n', + '{"ok":true,"status":"not-due","date":"2026-07-20"}\n{"private":true}', + ])("rejects malformed, unsafe, or non-exact stdout without echoing it: %j", (stdout) => { + let thrown: unknown; + try { + parseScheduledDigestResult(stdout); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe("Invalid scheduled digest result"); + expect("cause" in (thrown as Error)).toBe(false); + expect((thrown as Error).stack).toBe( + "Error: Invalid scheduled digest result", + ); + }); + + it("enforces the 64 KiB byte limit", () => { + expect(() => parseScheduledDigestResult("x".repeat(64 * 1024 + 1))).toThrow( + "Invalid scheduled digest result", + ); + }); +}); diff --git a/tests/observer/managed-job.test.ts b/tests/observer/managed-job.test.ts new file mode 100644 index 0000000..bf9311f --- /dev/null +++ b/tests/observer/managed-job.test.ts @@ -0,0 +1,408 @@ +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ObserverJob } from "../../src/observer/config.js"; +import { + runManagedJob, + type ManagedJobDependencies, +} from "../../src/observer/managed-job.js"; +import { validEditorialPayload } from "../helpers/editorial.js"; + +const job: ObserverJob = { + 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: 1_000, + resultAdapter: "scheduled-digest-json-v1", + allowedArtifactRoot: "output", + notify: "always", +}; + +const roots: string[] = []; + +function project(): string { + const root = mkdtempSync(join(tmpdir(), "wechatpilot-managed-job-")); + roots.push(root); + return root; +} + +class FakeChild extends EventEmitter { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly pid = 4321; + readonly kill = vi.fn((_signal?: NodeJS.Signals | number): boolean => true); +} + +function dependencies( + spawn: NonNullable, + overrides: Partial = {}, +): ManagedJobDependencies { + const startRunRecord = vi.fn(() => ({ + version: 1 as const, + status: "running" as const, + runId: "11111111-1111-4111-8111-111111111111", + taskId: job.id, + taskName: job.name, + projectLabel: job.projectLabel, + runnerPid: process.pid, + startedAt: "2026-07-20T00:00:00.000Z", + })); + return { + spawn, + randomUUID: () => "11111111-1111-4111-8111-111111111111", + now: (() => { + const values = [ + new Date("2026-07-20T00:00:00.000Z"), + new Date("2026-07-20T00:00:01.000Z"), + ]; + return () => values.shift() ?? new Date("2026-07-20T00:00:01.000Z"); + })(), + startRunRecord, + updateRunChildPid: vi.fn(), + acquireTaskLock: vi.fn(async () => ({ release: vi.fn(async () => undefined) })), + ...overrides, + }; +} + +function complete( + child: FakeChild, + result: unknown, + exitCode: number | null = 0, +): void { + queueMicrotask(() => { + child.emit("spawn"); + child.stdout.end(`${JSON.stringify(result)}\n`); + child.stderr.end(); + child.emit("exit", exitCode, null); + child.emit("close", exitCode, null); + }); +} + +afterEach(() => { + vi.useRealTimers(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("runManagedJob", () => { + it("spawns only the audited executable and arguments with shell false", async () => { + const root = project(); + const child = new FakeChild(); + const spawn = vi.fn((..._arguments: unknown[]) => { + complete(child, { + ok: true, + status: "sent", + date: "2026-07-20", + chunks: 2, + jobAttempt: 1, + deliveryAttempt: 1, + editorial: validEditorialPayload(), + }); + return child; + }); + const order: string[] = []; + const deps = dependencies(spawn as NonNullable, { + startRunRecord: vi.fn((options) => { + order.push("record"); + return { + version: 1, + status: "running", + runId: options.runId, + taskId: job.id, + taskName: job.name, + projectLabel: job.projectLabel, + runnerPid: options.runnerPid, + startedAt: options.startedAt.toISOString(), + }; + }), + }); + spawn.mockImplementationOnce((..._arguments: unknown[]) => { + order.push("spawn"); + complete(child, { + ok: true, + status: "sent", + date: "2026-07-20", + chunks: 2, + jobAttempt: 1, + deliveryAttempt: 1, + editorial: validEditorialPayload(), + }); + return child; + }); + + const outcome = await runManagedJob({ + projectRoot: root, + job, + environment: { + HOME: "/private/home", + CODEX_HOME: "/private/codex", + PATH: "/safe/bin", + GITHUB_TOKEN: "github-token", + PRIVATE_API_KEY: "must-not-pass", + }, + dependencies: deps, + }); + + expect(order).toEqual(["record", "spawn"]); + expect(spawn).toHaveBeenCalledWith( + job.executable, + [join(root, job.entrypoint), "--project-root", root], + expect.objectContaining({ + cwd: root, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + env: { + HOME: "/private/home", + CODEX_HOME: "/private/codex", + PATH: "/safe/bin", + GITHUB_TOKEN: "github-token", + }, + }), + ); + expect(outcome.authority.status).toBe("success"); + expect(outcome.draft?.summary).toEqual(["今日日报已生成并发送"]); + expect(deps.updateRunChildPid).toHaveBeenCalledWith({ + projectRoot: root, + runId: "11111111-1111-4111-8111-111111111111", + childPid: 4321, + }); + }); + + it("does not invent HOME or CODEX_HOME when the source omits them", async () => { + const root = project(); + const child = new FakeChild(); + const spawn = vi.fn((..._arguments: unknown[]) => { + complete(child, { ok: true, status: "not-due", date: "2026-07-20" }); + return child; + }); + await runManagedJob({ + projectRoot: root, + job, + environment: { PATH: "/safe/bin" }, + dependencies: dependencies(spawn as NonNullable), + }); + const options = spawn.mock.calls[0]![2] as { env: NodeJS.ProcessEnv }; + expect(options.env).toEqual({ PATH: "/safe/bin" }); + expect("HOME" in options.env).toBe(false); + expect("CODEX_HOME" in options.env).toBe(false); + }); + + it("rejects config overrides and path traversal before spawn", async () => { + const root = project(); + const spawn = vi.fn(); + for (const override of [ + { executable: "/bin/sh" }, + { entrypoint: "../private.js" }, + { workingDirectory: "../private" }, + { arguments: ["-c", "private"] }, + ]) { + await expect( + runManagedJob({ + projectRoot: root, + job: { ...job, ...override } as ObserverJob, + dependencies: dependencies(spawn as NonNullable), + }), + ).rejects.toThrow("Invalid managed observer job"); + } + expect(spawn).not.toHaveBeenCalled(); + }); + + it("records an abort before spawn as cancelled without spawning", async () => { + const root = project(); + const controller = new AbortController(); + controller.abort(); + const spawn = vi.fn(); + const deps = dependencies(spawn as NonNullable); + + const outcome = await runManagedJob({ + projectRoot: root, + job, + signal: controller.signal, + dependencies: deps, + }); + + expect(outcome.authority.status).toBe("cancelled"); + expect(spawn).not.toHaveBeenCalled(); + expect(deps.startRunRecord).toHaveBeenCalledOnce(); + }); + + it("fails safely and terminates the child when child identity persistence fails", async () => { + const root = project(); + const child = new FakeChild(); + const privateMarker = "PRIVATE_RAW_PROCESS_COMMAND"; + const deps = dependencies(vi.fn(() => child), { + updateRunChildPid: vi.fn(() => { throw new Error(privateMarker); }), + }); + const promise = runManagedJob({ projectRoot: root, job, dependencies: deps }); + await Promise.resolve(); + await Promise.resolve(); + child.emit("spawn"); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + child.emit("exit", null, "SIGTERM"); + + const outcome = await promise; + expect(outcome.authority.status).toBe("failed"); + expect(outcome.draft?.failure).toBe("受管任务进程发生错误"); + expect(JSON.stringify(outcome)).not.toContain(privateMarker); + }); + + it("uses fixed failures for spawn errors and non-zero exits", async () => { + const root = project(); + const spawnErrorChild = new FakeChild(); + const privateMarker = "PRIVATE_STDERR_SENTINEL"; + const spawnError = vi.fn(() => { + queueMicrotask(() => spawnErrorChild.emit("error", new Error(privateMarker))); + return spawnErrorChild; + }); + const failedToSpawn = await runManagedJob({ + projectRoot: root, + job, + dependencies: dependencies(spawnError as NonNullable), + }); + expect(failedToSpawn.authority.status).toBe("failed"); + expect(JSON.stringify(failedToSpawn)).not.toContain(privateMarker); + + const nonzeroChild = new FakeChild(); + const nonzeroSpawn = vi.fn(() => { + queueMicrotask(() => { + nonzeroChild.emit("spawn"); + nonzeroChild.stderr.end(privateMarker); + nonzeroChild.stdout.end(); + nonzeroChild.emit("exit", 7, null); + nonzeroChild.emit("close", 7, null); + }); + return nonzeroChild; + }); + const nonzero = await runManagedJob({ + projectRoot: root, + job, + dependencies: dependencies(nonzeroSpawn as NonNullable), + }); + expect(nonzero.authority.status).toBe("failed"); + expect(JSON.stringify(nonzero)).not.toContain(privateMarker); + }); + + it("terminates oversized combined output and returns no raw output", async () => { + const root = project(); + const child = new FakeChild(); + const privateMarker = "PRIVATE_OUTPUT_SENTINEL"; + const spawn = vi.fn(() => { + queueMicrotask(() => { + child.emit("spawn"); + child.stdout.write(Buffer.alloc(700_000, "x")); + child.stderr.write(Buffer.concat([ + Buffer.alloc(400_000, "y"), + Buffer.from(privateMarker), + ])); + child.emit("exit", null, "SIGTERM"); + child.emit("close", null, "SIGTERM"); + }); + return child; + }); + + const outcome = await runManagedJob({ + projectRoot: root, + job, + dependencies: dependencies(spawn as NonNullable), + }); + + expect(outcome.authority.status).toBe("failed"); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(JSON.stringify(outcome)).not.toContain(privateMarker); + }); + + it("keeps the outcome and task lock pending after KILL until exit or close", async () => { + vi.useFakeTimers(); + for (const reason of ["timeout", "abort"] as const) { + const root = project(); + const child = new FakeChild(); + const spawn = vi.fn(() => child); + const controller = new AbortController(); + const release = vi.fn(async () => undefined); + const promise = runManagedJob({ + projectRoot: root, + job, + signal: controller.signal, + dependencies: dependencies( + spawn as NonNullable, + { acquireTaskLock: vi.fn(async () => ({ release })) }, + ), + }); + let settled = false; + void promise.then(() => { + settled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + child.emit("spawn"); + if (reason === "timeout") { + await vi.advanceTimersByTimeAsync(job.timeoutMs); + } else { + controller.abort(); + } + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + await vi.advanceTimersByTimeAsync(1_999); + expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); + await vi.advanceTimersByTimeAsync(1); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + await Promise.resolve(); + expect(settled).toBe(false); + expect(release).not.toHaveBeenCalled(); + + if (reason === "timeout") { + child.emit("exit", null, "SIGKILL"); + } else { + child.emit("close", null, "SIGKILL"); + } + await expect(promise).resolves.toMatchObject({ + authority: { status: reason === "timeout" ? "timed-out" : "cancelled" }, + }); + expect(settled).toBe(true); + expect(release).toHaveBeenCalledOnce(); + expect(child.listenerCount("spawn")).toBe(0); + expect(child.listenerCount("error")).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + expect(child.listenerCount("close")).toBe(0); + expect(child.stdout.listenerCount("data")).toBe(0); + expect(child.stderr.listenerCount("data")).toBe(0); + } + }); + + it("cleans terminal races so only the first terminal result wins", async () => { + const root = project(); + const child = new FakeChild(); + const spawn = vi.fn(() => child); + const controller = new AbortController(); + const promise = runManagedJob({ + projectRoot: root, + job, + signal: controller.signal, + dependencies: dependencies(spawn as NonNullable), + }); + await Promise.resolve(); + await Promise.resolve(); + child.emit("spawn"); + child.stdout.end( + `${JSON.stringify({ ok: true, status: "not-due", date: "2026-07-20" })}\n`, + ); + child.stderr.end(); + child.emit("exit", 0, null); + child.emit("close", 0, null); + controller.abort(); + + await expect(promise).resolves.toMatchObject({ + authority: { status: "skipped" }, + }); + expect(child.kill).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/observer/report-card.test.ts b/tests/observer/report-card.test.ts new file mode 100644 index 0000000..a0c8ed0 --- /dev/null +++ b/tests/observer/report-card.test.ts @@ -0,0 +1,631 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Resvg } from "@resvg/resvg-js"; + +import * as projectFile from "../../src/lib/project-file.js"; +import type { RunReport } from "../../src/observer/run-report.js"; +import { + buildReportCardSvg, + renderReportCard, +} from "../../src/observer/report-card.js"; + +const roots: string[] = []; + +function projectRoot(): string { + const root = mkdtempSync(join(tmpdir(), "wechatpilot-report-card-")); + roots.push(root); + return root; +} + +function report( + status: RunReport["status"] = "success", + finishedAt = "2026-07-18T16:30:00.000Z", +): RunReport { + const startedAt = "2026-07-18T16:29:58.500Z"; + return { + version: 1, + runId: "018f47a8-72df-7d5e-8c1f-96eacc20f2c3", + taskId: "daily-digest", + taskName: "每日技术情报", + projectLabel: "WeChatPilot", + status, + startedAt, + finishedAt, + durationMs: Date.parse(finishedAt) - Date.parse(startedAt), + summary: ["已完成候选源收集", "报告已写入私有目录"], + metrics: [ + { label: "候选条目", value: "42" }, + { label: "入选条目", value: "10" }, + ], + failure: status === "success" ? null : "本次运行未完成", + artifacts: ["output/2026-07-19-digest.md"], + }; +} + +function pngHeader(width: number, height: number): Buffer { + const bytes = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes); + bytes.writeUInt32BE(13, 8); + bytes.write("IHDR", 12, "ascii"); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return bytes; +} + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function pngChunk(type: string, data = Buffer.alloc(0)): Buffer { + const typeBytes = Buffer.from(type, "ascii"); + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + typeBytes.copy(chunk, 4); + data.copy(chunk, 8); + chunk.writeUInt32BE( + crc32(Buffer.concat([typeBytes, data])), + 8 + data.length, + ); + return chunk; +} + +function structurallyCompletePng(): Buffer { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(1080, 0); + ihdr.writeUInt32BE(1440, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", ihdr), + pngChunk("IDAT"), + pngChunk("IEND"), + ]); +} + +function expectedPaths(root: string, value: RunReport) { + const directory = join(root, "output", "run-reports", "2026-07-19"); + return { + directory, + pngPath: join(directory, `${value.runId}.png`), + jsonPath: join(directory, `${value.runId}.json`), + }; +} + +function svgTextEntries(svg: string): readonly { y: number; text: string }[] { + return [...svg.matchAll(/]*\by="(\d+)"[^>]*>(.*?)<\/text>/gu)].map( + (match) => ({ y: Number(match[1]), text: match[2] ?? "" }), + ); +} + +function expectNoDynamicInkPastContentEdge( + svg: string, + verticalBands: readonly (readonly [number, number])[], +): void { + const image = new Resvg(svg, { + fitTo: { mode: "width", value: 1080 }, + font: { + loadSystemFonts: true, + defaultFontFamily: "PingFang SC", + }, + }).render(); + expect(image.asPng().subarray(0, 8)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + + const pixels = image.pixels; + const unexpectedInk: Array<{ + readonly x: number; + readonly y: number; + readonly rgba: readonly number[]; + }> = []; + for (const [top, bottom] of verticalBands) { + for (let y = top; y <= bottom; y += 1) { + for (let x = 1017; x < image.width; x += 1) { + const offset = (y * image.width + x) * 4; + const rgba = [...pixels.subarray(offset, offset + 4)]; + if (rgba.join(",") !== "241,245,249,255") { + unexpectedInk.push({ x, y, rgba }); + if (unexpectedInk.length === 5) { + expect(unexpectedInk).toEqual([]); + } + } + } + } + } + expect(unexpectedInk).toEqual([]); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("report card SVG", () => { + it("uses a fixed canvas and the required status colors", () => { + const colors: Record = { + success: "#15803d", + failed: "#b91c1c", + "timed-out": "#b91c1c", + cancelled: "#c2410c", + interrupted: "#c2410c", + skipped: "#64748b", + }; + + for (const [status, color] of Object.entries(colors)) { + const svg = buildReportCardSvg(report(status as RunReport["status"])); + expect(svg).toContain('viewBox="0 0 1080 1440"'); + expect(svg).toContain(`fill="${color}"`); + expect(svg.match(/font-family=/gu)).toHaveLength(1); + expect(svg).toContain('font-family="PingFang SC"'); + } + }); + + 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"), + taskName: '', + 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(/