Commit 4e3e086a authored by 谢宇轩's avatar 谢宇轩

feat: new feature

parent faf1233d
# Repository Guidelines
## Project Structure & Module Organization
All Bash scripts live in `bin/`: `harvest.sh` performs one source fetch, `daily-digest.sh` builds the daily report, and `provider.sh` is a shared library; `add-source.sh` and `modify-source.sh` manage per-source timers and conf, while `migrate.sh` and `deploy.sh` manage installation and deployment (repo-level tools, not copied into the runtime directory). Keep systemd unit and timer definitions in `systemd/`, and keep only redacted configuration examples in `config/`. The optional Flask administration UI lives in `web/app.py`; its Jinja templates belong in `web/templates/`.
The deployed layout matters: runtime scripts (everything in `bin/` except `deploy.sh`/`migrate.sh`) and the web app are copied to `~/Documents/News-Harvester/bin/` and `~/Documents/News-Harvester/web/`, while systemd units and live configuration reside under `/etc/systemd/system/` and `/etc/news-harvester/`.
## Build, Test, and Development Commands
There is no build step or automated test suite. Before committing script changes, run:
```bash
bash -n bin/*.sh
python3 -m py_compile web/app.py
```
On an Arch Linux server, install the optional panel dependency with `sudo pacman -S python-flask`. For an end-to-end smoke test after installation, run `sudo systemctl start news-harvester@nikkei-world.service` and inspect its journal. Use `./bin/deploy.sh <host> --no-systemd` only when the change does not require updating units.
## Coding Style & Naming Conventions
Write Bash for Bash (`#!/bin/bash`), use `set -euo pipefail` for mutating scripts, quote expansions, and expose installation paths through the existing `${VAR:-default}` pattern. `status.sh` intentionally tolerates read-only command failures; preserve that behavior. Use lowercase, hyphenated source names such as `nikkei-world`; the management scripts accept only letters, digits, `_`, and `-`.
Use four spaces and `snake_case` in Python. Keep Flask route handlers small, validate source/provider input before invoking subprocesses, and place UI changes in the matching template. Preserve existing systemd naming: `news-harvester@<source>.timer` pairs with the template service.
## Testing & Change Validation
No coverage threshold is configured. Run the syntax checks above for every change. For timer or configuration changes, validate with `systemctl daemon-reload` and `systemctl list-timers`; for UI changes, start the panel in a configured server environment and exercise the changed route. Record the command and result in the pull request.
## Commits, Pull Requests & Security
Existing history uses concise Conventional Commit-style subjects, e.g. `feat: add provider support`; use `fix:`, `feat:`, or `docs:` with an imperative summary. Keep pull requests focused, explain any deployment-path or systemd impact, link relevant issues, and include screenshots for template/UI changes.
Never commit live provider JSON, SMTP credentials, passwords, logs, or generated output. Add sanitized `*.example` files instead and keep secret files permission-restricted (typically `600`).
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
基于 systemd timer + Claude 无头模式的多栏目新闻抓取与监控方案。 基于 systemd timer + Claude 无头模式的多栏目新闻抓取与监控方案。
每源抓取通过 `claude -p --output-format json` 捕获完整运行元数据(成本、耗时、turns、token、session_id、权限拒绝),落盘为结构化 JSON;独立的每日汇总服务再用 Claude 无头模式 + `--json-schema` 对当天所有抓取记录做智能分析,产出 Markdown 汇总并判断是否需要告警。 每源抓取通过 `claude -p --output-format json --json-schema` 捕获运行元数据及文章列表,由本地程序评估结果质量并校验结构后落盘;独立的每日汇总服务再用 Claude 无头模式 + `--json-schema` 对当天所有抓取记录做智能分析,产出 Markdown 汇总并判断是否需要告警。
--- ---
...@@ -85,7 +85,7 @@ news-harvester-pro/ ...@@ -85,7 +85,7 @@ news-harvester-pro/
| 本地文件 | 服务器路径 | | 本地文件 | 服务器路径 |
|---------|-----------| |---------|-----------|
| `bin/*.sh` | `/home/yichu/Documents/News-Harvester/bin/` | | `bin/*.sh`、`bin/*.py`、`bin/*.schema.json` | `/home/yichu/Documents/News-Harvester/bin/` |
| `web/app.py` + `web/templates/` | `/home/yichu/Documents/News-Harvester/web/` | | `web/app.py` + `web/templates/` | `/home/yichu/Documents/News-Harvester/web/` |
| `systemd/news-harvester@.service` | `/etc/systemd/system/` | | `systemd/news-harvester@.service` | `/etc/systemd/system/` |
| `systemd/news-harvester@<source>.timer` | `/etc/systemd/system/` | | `systemd/news-harvester@<source>.timer` | `/etc/systemd/system/` |
...@@ -167,7 +167,7 @@ sudo ./migrate.sh ...@@ -167,7 +167,7 @@ sudo ./migrate.sh
deploy.sh 做的事: deploy.sh 做的事:
1. `rsync` 整个项目到 `~/news-harvester-pro/`(`--delete` 保持镜像,排除 logs/.git) 1. `rsync` 整个项目到 `~/news-harvester-pro/`(`--delete` 保持镜像,排除 logs/.git)
2. 复制 `bin/*.sh` → `~/Documents/News-Harvester/bin/` 并赋可执行权限 2. 复制 `bin/*.sh`、`bin/*.py`、`bin/*.schema.json` → `~/Documents/News-Harvester/bin/` 并赋可执行权限
3. 同步 systemd 模板 service + digest unit → `/etc/systemd/system/` 并 `daemon-reload` 3. 同步 systemd 模板 service + digest unit → `/etc/systemd/system/` 并 `daemon-reload`
4. 同步本地 `config/providers/*.json` → `/etc/systemd/system/providers/`(需要 sudo,不含 `.example.json`,不用 `--delete` 保留服务器端新增的 provider) 4. 同步本地 `config/providers/*.json` → `/etc/systemd/system/providers/`(需要 sudo,不含 `.example.json`,不用 `--delete` 保留服务器端新增的 provider)
5. 检查 web.conf + 重启 web 面板(可选) 5. 检查 web.conf + 重启 web 面板(可选)
...@@ -192,8 +192,10 @@ service 模板按顺序加载两个 `EnvironmentFile`,后者覆盖前者(systemd ...@@ -192,8 +192,10 @@ service 模板按顺序加载两个 `EnvironmentFile`,后者覆盖前者(systemd
| `COUNT` | 抓取条数 | 20(脚本兜底) | harvest | | `COUNT` | 抓取条数 | 20(脚本兜底) | harvest |
| `MAIL_TO` | 收件人;**留空不发邮件**;支持多个(空格或逗号分隔) | 空 | harvest + digest | | `MAIL_TO` | 收件人;**留空不发邮件**;支持多个(空格或逗号分隔) | 空 | harvest + digest |
| `MAIL_ON_SUCCESS` | 成功是否发邮件:1=发,0=仅失败发 | 1 | harvest | | `MAIL_ON_SUCCESS` | 成功是否发邮件:1=发,0=仅失败发 | 1 | harvest |
| `MAX_BUDGET_USD` | 正常采集和续采/修复的 Claude 单次预算上限(美元) | 15 | harvest + continue |
| `PROVIDER_BUDGET_OVERRIDES` | provider 独立预算,格式 `name=金额,name=金额` | 空 | harvest + continue |
| `ALERT_COST_OVER` | 单次成本超此值(美元)则标题标 `[ALERT]` | 空(不判断) | harvest | | `ALERT_COST_OVER` | 单次成本超此值(美元)则标题标 `[ALERT]` | 空(不判断) | harvest |
| `PROVIDER` | Claude provider 名称;空或 `default` = 系统全局配置 | 空 | harvest + digest | | `PROVIDER` | Claude provider 名称;栏目未写此字段时继承全局配置 | 空 | harvest + digest |
| `FALLBACK_PROVIDER` | 备用 provider;continue.sh 续采/修复时默认使用 | 空 | continue | | `FALLBACK_PROVIDER` | 备用 provider;continue.sh 续采/修复时默认使用 | 空 | continue |
| `DIGEST_MAIL_ALWAYS` | 每日汇总:1=每次发,0=仅告警时发 | 1 | digest | | `DIGEST_MAIL_ALWAYS` | 每日汇总:1=每次发,0=仅告警时发 | 1 | digest |
| `DIGEST_RETAIN_DAYS` | 旧日志保留天数 | 30 | digest | | `DIGEST_RETAIN_DAYS` | 旧日志保留天数 | 30 | digest |
...@@ -220,9 +222,11 @@ DIGEST_BUDGET_USD=2 ...@@ -220,9 +222,11 @@ DIGEST_BUDGET_USD=2
```ini ```ini
COUNT=30 COUNT=30
MAIL_ON_SUCCESS=0 # 成功不单独发邮件,只在 digest 里看 MAIL_ON_SUCCESS=0 # 成功不单独发邮件,只在 digest 里看
MAX_BUDGET_USD=15 # 正常采集和续采/修复使用同一预算上限
PROVIDER_BUDGET_OVERRIDES=easyrouter-claude=25,glm=30
ALERT_COST_OVER=2 ALERT_COST_OVER=2
PROVIDER=easyrouter # 此栏目用 easyrouter provider(覆盖全局默认) PROVIDER=easyrouter # 此栏目用 easyrouter provider(覆盖全局默认)
FALLBACK_PROVIDER=default # 续采时退回系统默认配置 FALLBACK_PROVIDER=backup-router # 续采时使用备用 provider
``` ```
--- ---
...@@ -247,7 +251,7 @@ Provider 机制允许不同数据源使用不同的 Claude API 端点和认证( ...@@ -247,7 +251,7 @@ Provider 机制允许不同数据源使用不同的 Claude API 端点和认证(
``` ```
3. 数据源通过 conf 中的 `PROVIDER=<name>` 引用;脚本启动 Claude 时自动加上 `--settings '{"env": {...}}'` 注入这些环境变量 3. 数据源通过 conf 中的 `PROVIDER=<name>` 引用;脚本启动 Claude 时自动加上 `--settings '{"env": {...}}'` 注入这些环境变量
4. `PROVIDER` 留空或设为 `default` → 不加 `--settings`,使用系统全局配置(Claude CLI 自带的认证和模型) 4. 栏目没有 `PROVIDER` 时继承 `default.conf`;最终值为空或 `default` 时不加 `--settings`,使用 Claude CLI 的系统全局配置
5. `FALLBACK_PROVIDER` → continue.sh 续采/修复时**默认使用**此 provider(因为 continue 本身就是失败重试) 5. `FALLBACK_PROVIDER` → continue.sh 续采/修复时**默认使用**此 provider(因为 continue 本身就是失败重试)
6. continue.sh 也支持 `--provider <name>` 参数手动指定,优先级最高 6. continue.sh 也支持 `--provider <name>` 参数手动指定,优先级最高
...@@ -330,7 +334,7 @@ status.sh 概览和详情模式都会显示 Provider 列/行。Web 面板的栏 ...@@ -330,7 +334,7 @@ status.sh 概览和详情模式都会显示 Provider 列/行。Web 面板的栏
| 文件 | 说明 | 保留 | | 文件 | 说明 | 保留 |
|------|------|------| |------|------|------|
| `logs/<source>.<YYYYMMDD>.<HHMMSS>.json` | 完整 claude JSON 输出(digest 分析的数据源) | 按 `DIGEST_RETAIN_DAYS` 自动清理 | | `logs/<source>.<YYYYMMDD>.<HHMMSS>.json` | Claude 元数据 + 校验后的 `result` 对象(digest 分析的数据源) | 按 `DIGEST_RETAIN_DAYS` 自动清理 |
| `logs/<source>.<YYYYMMDD>.<HHMMSS>.raw` | claude 的 stderr(排错用) | 同上 | | `logs/<source>.<YYYYMMDD>.<HHMMSS>.raw` | claude 的 stderr(排错用) | 同上 |
| `logs/<source>.last.log` | 人类可读的上次运行摘要(覆盖) | 始终保留最新 | | `logs/<source>.last.log` | 人类可读的上次运行摘要(覆盖) | 始终保留最新 |
| `logs/digest.<YYYYMMDD>.md` | 每日汇总 Markdown | 不自动清理 | | `logs/digest.<YYYYMMDD>.md` | 每日汇总 Markdown | 不自动清理 |
...@@ -593,8 +597,8 @@ sudo ./modify-source.sh <source-name> --time HH:MM [选项] ...@@ -593,8 +597,8 @@ sudo ./modify-source.sh <source-name> --time HH:MM [选项]
| `--count N` | 更新抓取条数,写入/覆盖 `<source>.conf` | | `--count N` | 更新抓取条数,写入/覆盖 `<source>.conf` |
| `--mail-to ADDR` | 更新收件人 | | `--mail-to ADDR` | 更新收件人 |
| `--mail-on-success 0\|1` | 更新成功是否发邮件 | | `--mail-on-success 0\|1` | 更新成功是否发邮件 |
| `--provider NAME` | 更新 Claude provider,写入 `PROVIDER=` | | `--provider NAME` | 更新 Claude provider;传 `default` 时删除栏目级 `PROVIDER` |
| `--fallback-provider NAME` | 更新备用 provider,写入 `FALLBACK_PROVIDER=` | | `--fallback-provider NAME` | 更新备用 provider;传 `default` 时删除栏目级 `FALLBACK_PROVIDER` |
| `--yes` / `-y` | 跳过确认 | | `--yes` / `-y` | 跳过确认 |
示例: 示例:
...@@ -646,6 +650,7 @@ sudo ./add-source.sh <source-name> [选项] ...@@ -646,6 +650,7 @@ sudo ./add-source.sh <source-name> [选项]
| `--mail-to ADDR` | 收件人,写入 `<source>.conf` | | `--mail-to ADDR` | 收件人,写入 `<source>.conf` |
| `--mail-on-success 0\|1` | 成功是否发邮件,写入 `<source>.conf` | | `--mail-on-success 0\|1` | 成功是否发邮件,写入 `<source>.conf` |
| `--provider NAME` | Claude provider 名称,写入 `PROVIDER=` | | `--provider NAME` | Claude provider 名称,写入 `PROVIDER=` |
| `--fallback-provider NAME` | 备用 provider 名称,写入 `FALLBACK_PROVIDER=` |
| `--yes` / `-y` | 跳过确认 | | `--yes` / `-y` | 跳过确认 |
| `--no-enable` | 只创建文件不启用 | | `--no-enable` | 只创建文件不启用 |
| `--force` | 同名 timer 已存在时覆盖 | | `--force` | 同名 timer 已存在时覆盖 |
...@@ -724,11 +729,79 @@ sudo ./migrate.sh --uninstall ...@@ -724,11 +729,79 @@ sudo ./migrate.sh --uninstall
- `usage`(input/output/cache token) - `usage`(input/output/cache token)
- `permission_denials`(权限拒绝列表) - `permission_denials`(权限拒绝列表)
- `is_error`、`subtype`、`terminal_reason` - `is_error`、`subtype`、`terminal_reason`
- `--json-schema` 强制返回结构化输出(digest 用此功能) - `--json-schema` 返回 `structured_output` 对象;正常采集、续采/修复和 digest 均使用此功能
- `--max-budget-usd` 硬性预算上限 - `--max-budget-usd` 硬性预算上限:续采的 `--budget` 临时值优先,其次匹配 `PROVIDER_BUDGET_OVERRIDES` 中当前实际 provider,再使用 `MAX_BUDGET_USD`;正常采集和续采/修复共用此规则
- `--permission-mode bypassPermissions` + `--allowed-tools` 控制工具权限 - `--permission-mode bypassPermissions` + `--allowed-tools` 控制工具权限
harvest.sh 用 `jq` 解析 JSON,提取元数据写入日志和邮件。digest.sh 用 `--json-schema` 让 Claude 返回 `{alert, alert_reasons, digest, total_cost, failed_sources}` 结构化结果。 harvest.sh 和 continue.sh 使用同一份 [JSON Schema](bin/harvest-result.schema.json)。`harvest-result.py` 校验 Claude 的 `structured_output`,再读取每篇文章的 Obsidian 原文归档,计算真实正文长度与统计,输出到日志的 `result` 对象。原始文本保留为 `result_text`,完整 stdout 保存在同名 `.claude.raw` 文件中。原始 Claude 错误标志保留为 `claude_is_error`,顶层 `is_error` 表示最终业务失败,`process_exit_code` 和 `exit_code` 分别记录 Claude 与最终任务退出码;成本、token 和 session 元数据仍在顶层。
此行为对应 [Claude 官方结构化输出说明](https://code.claude.com/docs/en/headless#get-structured-output)(结构化内容位于 `structured_output`,不是原始 `result` 文本)。
### 统一结果与正文质量
| 字段 | 含义 |
| --- | --- |
| `schema_version` / `source` / `requested_count` | 契约版本 1、信息源、目标条数 |
| `status` | `SUCCESS` 或 `FAILED` |
| `attempted_count` / `succeeded_count` / `failed_count` | 本地根据文章列表重新计算的已处理、成功、失败篇数 |
| `source_exhausted` / `exhaustion_reason` | 已核实没有更多新文章及依据;访问受限、任务中断不能视为数据取尽 |
| `failures[]` | 固定枚举 `type`、可读 `message`、关联文章 `url`(任务级失败可为空) |
| `warnings[]` | 可读质量提示 |
| `articles[]` | 成功和失败文章都必须列出 |
模型对每篇文章只返回 `url`、`title`、`published_at`、`archive_path`、`status`、`failure_type`、`failure_reason` 和 `quality_evidence[]`。未知发布时间、未归档路径用空字符串;成功文章的失败类型为 `null`,失败原因允许 Claude 返回 `null` 或空字符串,落盘时统一为空字符串。Schema 不再要求模型重复输出正文、摘要、分类、标签或长度,以减少输出 token 和字段误填。
落盘前,本地程序打开 `archive_path` 指向的 `*原文.md`,确认 frontmatter 中 `type: 原文`,只提取 `## 正文` 到下一个分隔线之间的内容。路径读取兼容模型常见的 shell 反斜杠转义、Markdown 链接包装,以及文件名中的 `/` 被归档程序清洗为 `-`;直接路径不存在时,会在对应归档目录中按 frontmatter 的 `source` URL 定位原文件,并将 `archive_path` 修正为真实路径。最终日志在每篇文章下增加 `archive` 对象:`loaded` 表示读取是否成功,`path` 是确认后的真实路径,`properties` 保存 Obsidian frontmatter,`body` 保存归档正文,`body_length` 是归档正文去除空白后的 Unicode 字符数,`error` 保存读取失败原因。成功文章的归档缺失、不可读、不是原文文件或正文区段缺失时,文章改判为 `ARCHIVE_FAILED`。
可在 `default.conf` 中设置 `OBSIDIAN_VAULT_PATH`。设置后,归档路径必须位于该 Vault 内;未设置时仍要求文件名以 `原文.md` 结尾。相对路径按 `WORK_DIR` 解析,新的结构化输出约定要求返回绝对路径。
失败类型枚举:
| 枚举 | 含义 |
| --- | --- |
| `PAYWALL` / `LOGIN_REQUIRED` / `CAPTCHA` | 订阅付费限制 / 需要登录 / 验证码 |
| `EMPTY_BODY` / `BODY_TOO_SHORT` / `CONTENT_INCOMPLETE` | 正文为空 / 过短 / 仅导语或正文截断 |
| `FETCH_FAILED` / `ARCHIVE_FAILED` / `PUSH_FAILED` | 抓取 / 归档 / 推送失败 |
| `RUNTIME_ERROR` / `PERMISSION_DENIED` | Claude 进程、模型等运行异常 / 工具权限拒绝 |
| `INVALID_OUTPUT` / `UNKNOWN` | 输出缺失、不符合 Schema 或业务字段矛盾 / 原因未知 |
外层不介入 `news-harvester` skill 的抓取、正文识别、归档和推送方式,也不按关键词逐篇判断付费墙。skill 按自身规则填写文章状态和失败原因;外层只校验结果结构,并从已经归档的原文文件读取正文。
在本次可读取的归档原文中,如果 `## 正文` 少于 500 个非空白 Unicode 字符的文章达到 50%(含)以上,外层追加任务级 `PAYWALL` 失败和可读提示,将整次运行判为失败。该规则不改写单篇文章的状态和失败原因;不足 50% 不触发。归档读取失败会走 `ARCHIVE_FAILED`,没有可读取原文时不额外触发 `PAYWALL`。
单篇失败会保留在 `articles[]`、`failures[]` 和 `warnings[]` 中,但不会自动把整次运行判为失败。已处理文章中成功篇数严格多于失败篇数时允许整次成功;失败达到一半时追加任务级 `FETCH_FAILED`。运行异常、权限拒绝、输出格式错误、未说明原因的数量不足及上述 50% 短正文规则仍直接导致整次失败。例如目标 10 篇、成功 9 篇、1 篇付费墙时,最终状态为 `SUCCESS`,同时保留该篇 `PAYWALL` 记录。
Web 详情页提供逐篇预览;历史文本日志继续展示并标注未经新版核验。每日汇总只发送文章元数据、失败与质量提示给 Claude,不重复发送全文;确定性的采集失败一定触发告警。
单次采集和续采邮件使用与 Web 详情一致的文章列表,不再截取并粘贴“result 末尾 60 行”。每行格式为 `英文标题 | 中文标题 | 归档 ✓ | 推送 updated`;中文标题从原文属性的 `related` 读取,推送状态优先从该篇 `quality_evidence` 中提取,并兼容整批 `synced` 状态。普通说明集中在一个列表中,不再逐行添加“提示:”前缀。
```bash
jq '.result | {status, succeeded_count, failed_count, failures}' logs/<source>.<date>.<time>.json
jq '.result.articles[] | {title, body_length:.archive.body_length, category:.archive.properties.category, tags:.archive.properties.tags, status, failure_type}' logs/<source>.<date>.<time>.json
```
### 同一信息源每天运行多次
```bash
sudo ./bin/add-source.sh sample --time 09:00,14:30,20:00
sudo ./bin/modify-source.sh sample --time 09:00 --time 18:30
```
`--time` 支持逗号分隔或重复传入,排序去重;修改时替换完整时间列表,单个时间仍兼容。Web 添加和编辑页支持相同的逗号输入。仍使用一个 `news-harvester@<source>.timer` 和一个 service,每个时间写一条 `OnCalendar=*-*-* HH:MM:00`。自动推算会扫描所有已有时间,按最晚时间加 15 分钟并在午夜回绕。修改和停用会清理实例 drop-in 中的日历项,保留其他设置,重新设置时间会启用 timer。
时间按服务器本地时区执行。同一 service 尚未结束时,systemd 不会并发启动同名实例;运行跨过下次时间时,结束后可能立即再次触发,请为较长采集留足间隔。参见 [systemd timer 官方说明](https://github.com/systemd/systemd/blob/main/man/systemd.timer.xml)。手动 `continue.sh` 不属于 timer 的防重入范围。
### 部署与验证
正常采集现在需要 Python 3 和 jq;Web 另外需要 Flask。部署脚本会把新增 Python 程序与 Schema 一同复制到运行目录,不需要迁移旧日志或修改现有单时间 timer。设置新时间后由管理脚本执行 daemon-reload 并重启/启用 timer。
```bash
for file in bin/*.sh; do bash -n "$file" || exit; done
python3 -m py_compile web/app.py bin/*.py
python3 -m unittest discover -s tests -v
```
回归测试使用临时目录和假的 Claude/systemctl/邮件程序,不访问真实信息源;安装 Flask 后同时执行路由与模板测试。服务器验收仍需运行一次真实源采集,检查逐篇质量结果,并用 `systemctl list-timers` 验证后续触发时间。
--- ---
...@@ -742,7 +815,7 @@ harvest.sh 用 `jq` 解析 JSON,提取元数据写入日志和邮件。digest.sh ...@@ -742,7 +815,7 @@ harvest.sh 用 `jq` 解析 JSON,提取元数据写入日志和邮件。digest.sh
``` ```
若 Node 版本升级导致路径变化,需同步修改这三个脚本中的 `v24.14.0`。 若 Node 版本升级导致路径变化,需同步修改这三个脚本中的 `v24.14.0`。
2. **jq 必需**:新版依赖 `jq` 解析 JSON 输出。未安装时 harvest 会降级为无法提取元数据,digest 直接报错退出。 2. **运行依赖**:正常采集及续采要求 Python 3 和 jq,缺失时直接退出;Web 额外需要 Flask。
3. **错开时间**:多栏目不要全设同一时刻,避免同时起多个 claude 进程撞限流。每个栏目预算独立(默认 `$10`),digest 预算 `$2`。 3. **错开时间**:多栏目不要全设同一时刻,避免同时起多个 claude 进程撞限流。每个栏目预算独立(默认 `$10`),digest 预算 `$2`。
...@@ -755,3 +828,4 @@ harvest.sh 用 `jq` 解析 JSON,提取元数据写入日志和邮件。digest.sh ...@@ -755,3 +828,4 @@ harvest.sh 用 `jq` 解析 JSON,提取元数据写入日志和邮件。digest.sh
7. **成本是估算**:文档明确说明 `total_cost_usd` 是客户端估算,可能与实际账单有差异。 7. **成本是估算**:文档明确说明 `total_cost_usd` 是客户端估算,可能与实际账单有差异。
8. **日志清理**:digest 每次运行后自动删除超过 `DIGEST_RETAIN_DAYS` 天的 `.json` 和 `.raw` 文件,`digest.*.md` 和 `*.last.log` 不自动清理。 8. **日志清理**:digest 每次运行后自动删除超过 `DIGEST_RETAIN_DAYS` 天的 `.json` 和 `.raw` 文件,`digest.*.md` 和 `*.last.log` 不自动清理。
...@@ -7,8 +7,8 @@ ...@@ -7,8 +7,8 @@
# 工作流程: # 工作流程:
# 1. 扫描 LOG_DIR 下今天的 *.json 结果文件 # 1. 扫描 LOG_DIR 下今天的 *.json 结果文件
# 2. jq 提取每源元数据 → 紧凑 JSON 数组 # 2. jq 提取每源元数据 → 紧凑 JSON 数组
# 3. 脚本计算确定性概况(源数/成功/失败/总成本/总turns/总耗时/总token) # 3. 脚本计算确定性概况、文章数和按 provider 归组的成本
# 4. 调 claude --json-schema,只让它返回 anomalies/recommendations/alert # 4. 调 claude --json-schema,只让它返回问题诊断、排查和编排建议
# 5. 写 logs/digest.<date>.md(纯文本存档) # 5. 写 logs/digest.<date>.md(纯文本存档)
# 6. 发送 HTML 汇总邮件(全内联样式,无外部 CSS) # 6. 发送 HTML 汇总邮件(全内联样式,无外部 CSS)
# 7. 清理超期旧日志 # 7. 清理超期旧日志
...@@ -91,21 +91,26 @@ build_summary() { ...@@ -91,21 +91,26 @@ build_summary() {
if ! jq empty "$f" 2>/dev/null; then if ! jq empty "$f" 2>/dev/null; then
[ "$first" = "1" ] || echo -n ","; first=0 [ "$first" = "1" ] || echo -n ","; first=0
jq -nc --arg src "$src" --arg ts "$ts" --arg file "$f" \ jq -nc --arg src "$src" --arg ts "$ts" --arg file "$f" \
'{source:$src,time:$ts,status:"PARSE_ERROR",cost:0,turns:0,duration_ms:0,input_tokens:0,output_tokens:0,denials:0,result:"(JSON 解析失败)",file:$file}' '{source:$src,time:$ts,provider:"unknown",status:"PARSE_ERROR",cost:0,max_budget_usd:0,duration_ms:0,
requested_count:0,attempted_count:0,succeeded_count:0,failed_count:0,
subtype:"unknown",terminal:"n/a",is_error:true,denials:0,result:"(JSON 解析失败)",file:$file}'
continue continue
fi fi
[ "$first" = "1" ] || echo -n ","; first=0 [ "$first" = "1" ] || echo -n ","; first=0
jq -c --arg src "$src" --arg ts "$ts" '{ jq -c --arg src "$src" --arg ts "$ts" '{
source:$src,time:$ts, source:$src,time:$ts,
provider:(.provider//"unknown"),
is_error:(.is_error//false), is_error:(.is_error//false),
status:(if (.result | type) == "object" then .result.status // "FAILED" status:(if (.result | type) == "object" then .result.status // "FAILED"
elif (.is_error//false) then "FAILED" else "SUCCESS" end), elif (.is_error//false) then "FAILED" else "SUCCESS" end),
subtype:(.subtype//"unknown"), subtype:(.subtype//"unknown"),
cost:(.total_cost_usd//0), cost:(.total_cost_usd//0),
turns:(.num_turns//0), max_budget_usd:(.max_budget_usd//0),
duration_ms:(.duration_api_ms//0), duration_ms:(.duration_ms//.duration_api_ms//0),
input_tokens:(.usage.input_tokens//0), requested_count:(if (.result|type)=="object" then .result.requested_count//0 else 0 end),
output_tokens:(.usage.output_tokens//0), attempted_count:(if (.result|type)=="object" then .result.attempted_count//0 else 0 end),
succeeded_count:(if (.result|type)=="object" then .result.succeeded_count//0 else 0 end),
failed_count:(if (.result|type)=="object" then .result.failed_count//0 else 0 end),
denials:((.permission_denials//[])|length), denials:((.permission_denials//[])|length),
terminal:(.terminal_reason//"n/a"), terminal:(.terminal_reason//"n/a"),
result:(if (.result | type) == "object" then .result | .articles |= map( result:(if (.result | type) == "object" then .result | .articles |= map(
...@@ -138,12 +143,16 @@ fi ...@@ -138,12 +143,16 @@ fi
TOTAL_COST=$(jq '[.[].cost]|add // 0' "$SUMMARY_JSON" | awk '{printf "%.2f", $0}') TOTAL_COST=$(jq '[.[].cost]|add // 0' "$SUMMARY_JSON" | awk '{printf "%.2f", $0}')
SUCCESS_COUNT=$(jq '[.[]|select(.status=="SUCCESS")]|length' "$SUMMARY_JSON" 2>/dev/null || echo 0) SUCCESS_COUNT=$(jq '[.[]|select(.status=="SUCCESS")]|length' "$SUMMARY_JSON" 2>/dev/null || echo 0)
FAILED_COUNT=$((SOURCE_COUNT - SUCCESS_COUNT)) FAILED_COUNT=$((SOURCE_COUNT - SUCCESS_COUNT))
TOTAL_TURNS=$(jq '[.[].turns]|add // 0' "$SUMMARY_JSON") TOTAL_ARTICLES=$(jq '[.[].attempted_count]|add // 0' "$SUMMARY_JSON")
TOTAL_DURATION_MS=$(jq '[.[].duration_ms]|add // 0' "$SUMMARY_JSON") TOTAL_ARTICLE_SUCCESS=$(jq '[.[].succeeded_count]|add // 0' "$SUMMARY_JSON")
TOTAL_IN_TOK=$(jq '[.[].input_tokens]|add // 0' "$SUMMARY_JSON") TOTAL_ARTICLE_FAILED=$(jq '[.[].failed_count]|add // 0' "$SUMMARY_JSON")
TOTAL_OUT_TOK=$(jq '[.[].output_tokens]|add // 0' "$SUMMARY_JSON") PROVIDER_COSTS=$(jq -c 'sort_by(.provider) | group_by(.provider) | map({
TOTAL_DENIALS=$(jq '[.[].denials]|add // 0' "$SUMMARY_JSON") provider:.[0].provider,
TOTAL_DURATION_S=$(awk -v ms="$TOTAL_DURATION_MS" 'BEGIN{printf "%.1f", ms/1000}') runs:length,
succeeded_runs:(map(select(.status=="SUCCESS"))|length),
articles:(map(.attempted_count)|add // 0),
cost:(map(.cost)|add // 0)
})' "$SUMMARY_JSON")
# 唯一源数(同源可能跑多次) # 唯一源数(同源可能跑多次)
UNIQUE_SOURCES=$(jq '[.[].source]|unique|length' "$SUMMARY_JSON") UNIQUE_SOURCES=$(jq '[.[].source]|unique|length' "$SUMMARY_JSON")
...@@ -151,7 +160,7 @@ UNIQUE_SOURCES=$(jq '[.[].source]|unique|length' "$SUMMARY_JSON") ...@@ -151,7 +160,7 @@ UNIQUE_SOURCES=$(jq '[.[].source]|unique|length' "$SUMMARY_JSON")
# ============================================================ # ============================================================
# 4. 调 claude --json-schema(只分析异常和建议) # 4. 调 claude --json-schema(只分析异常和建议)
# ============================================================ # ============================================================
SCHEMA='{"type":"object","properties":{"alert":{"type":"boolean"},"alert_reasons":{"type":"array","items":{"type":"string"}},"anomalies":{"type":"array","items":{"type":"string"},"description":"异常项说明。每条简短。不要重复表格数据,只描述需关注的模式或问题"},"recommendations":{"type":"array","items":{"type":"string"},"description":"建议操作。每条简短可执行。无异常则空数组"},"failed_sources":{"type":"array","items":{"type":"string"}}},"required":["alert","alert_reasons","anomalies","recommendations","failed_sources"]}' SCHEMA='{"type":"object","properties":{"alert":{"type":"boolean"},"alert_reasons":{"type":"array","items":{"type":"string"}},"issues":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string"},"category":{"type":"string","enum":["BUDGET_EXCEEDED","PROVIDER_ERROR","ACCESS_STATE","ARCHIVE_ERROR","PUSH_ERROR","OUTPUT_ERROR","SCHEDULING","OTHER"]},"summary":{"type":"string"},"evidence":{"type":"string"},"recommendation":{"type":"string"}},"required":["source","category","summary","evidence","recommendation"],"additionalProperties":false}},"orchestration_recommendations":{"type":"array","items":{"type":"string"}},"failed_sources":{"type":"array","items":{"type":"string"}}},"required":["alert","alert_reasons","issues","orchestration_recommendations","failed_sources"],"additionalProperties":false}'
SUMMARY_CONTENT="$(cat "$SUMMARY_JSON")" SUMMARY_CONTENT="$(cat "$SUMMARY_JSON")"
...@@ -165,23 +174,20 @@ claude -p \ ...@@ -165,23 +174,20 @@ claude -p \
--max-budget-usd "$DIGEST_BUDGET_USD" \ --max-budget-usd "$DIGEST_BUDGET_USD" \
"你是新闻抓取系统的监控助手。下面是今天($TODAY_HUMAN)各数据源的抓取运行数据(JSON 数组)。 "你是新闻抓取系统的监控助手。下面是今天($TODAY_HUMAN)各数据源的抓取运行数据(JSON 数组)。
重要:概况数字和每源表格由脚本自动生成并展示在邮件中,你**不需要**重复这些。你只负责: 概况、文章数量、provider 成本和任务表由脚本生成,不要重复。你只负责输出清楚的问题诊断与建议:
1. anomalies:分析需要关注的异常模式(不是简单重复表格): 1. issues:每个实际问题一项,必须写清:涉及的数据源、问题类别、发生了什么、判断证据、下一步如何排查。
- 旧版 result 文本是否暗示未完成,新版检查 result.failures、warnings 及各文章 status(正文已省略)(如只说"正在后台抓取"但无归档结果) 2. orchestration_recommendations:只有存在跨任务调度问题时才填写,例如同一 provider 的长任务过于集中、同一源重试时间过近;没有则返回空数组。
- 同一源重复失败或修复失败(正常定时多次运行不算异常) 3. alert:是否需要人工介入;alert_reasons 每条用一句话说明最重要的原因。
- 成本/耗时/token 异常偏高的源 4. failed_sources:失败的数据源名称,去重。
- 权限拒绝、解析失败等
2. recommendations:可执行建议(如有)
3. alert:是否存在需要人工介入的异常
4. alert_reasons:告警原因(简短,每条一句话)
5. failed_sources:失败的源名称列表
判断参考: 判断参考:
- status 为 FAILED/PARSE_ERROR → 告警 - status 为 FAILED/PARSE_ERROR → 告警,并从 subtype、terminal、result.failures、文章 failure_type 和 warnings 判断根因。
- 单源成本超 \$$COST_ALERT_THRESHOLD → 标注 - 失败运行的 cost 接近 max_budget_usd,或 terminal/subtype 指向预算限制 → BUDGET_EXCEEDED;说明预算导致任务中断,并建议按 provider 调整预算或减少单批文章数。
- 权限拒绝 > 0 → 标注 - 同一 provider 多个任务出现 RUNTIME_ERROR、连接或模型错误 → PROVIDER_ERROR;建议检查 provider 端点、额度、限流和模型可用性。
- result 提到"失败""错误""error""未完成""后台"等 → 关注 - PAYWALL、LOGIN_REQUIRED、CAPTCHA、短正文集中出现 → ACCESS_STATE;说明可能登录态丢失、订阅失效或触发验证,建议先检查浏览器会话再重跑。
- ARCHIVE_FAILED / PUSH_FAILED / INVALID_OUTPUT 分别给出文件路径、推送链路或结构化输出的针对性检查。
- 单次成本超 \$$COST_ALERT_THRESHOLD 要指出,但仅成本较高且任务成功时可不告警。
- 同源当日多次运行属于正常支持的多时间调度,不因次数本身告警 - 同源当日多次运行属于正常支持的多时间调度,不因次数本身告警
汇总数据: 汇总数据:
...@@ -200,21 +206,46 @@ if [ "$RC" -ne 0 ] || ! jq empty "$CLAUDE_JSON" 2>/dev/null; then ...@@ -200,21 +206,46 @@ if [ "$RC" -ne 0 ] || ! jq empty "$CLAUDE_JSON" 2>/dev/null; then
[ -z "$FAILED" ] && ALERT="false" [ -z "$FAILED" ] && ALERT="false"
if [ "$ALERT" = "true" ]; then if [ "$ALERT" = "true" ]; then
ALERT_REASONS='["有源失败: '"${FAILED}"'"]' ALERT_REASONS='["有源失败: '"${FAILED}"'"]'
ANOMALIES_JSON='["脚本兜底:检测到失败的源,请查看日志排查"]' ISSUES_JSON='[]'
else else
ALERT_REASONS='[]'; ANOMALIES_JSON='[]' ALERT_REASONS='[]'; ISSUES_JSON='[]'
fi fi
RECOMMENDATIONS_JSON='[]' ORCHESTRATION_JSON='[]'
AI_NOTE="(claude 分析失败,以下为脚本规则兜底结果)" AI_NOTE="(claude 分析失败,以下为脚本规则兜底结果)"
else else
ALERT=$(jq -r '.structured_output.alert // false' "$CLAUDE_JSON") ALERT=$(jq -r '.structured_output.alert // false' "$CLAUDE_JSON")
ALERT_REASONS=$(jq -c '.structured_output.alert_reasons // []' "$CLAUDE_JSON") ALERT_REASONS=$(jq -c '.structured_output.alert_reasons // []' "$CLAUDE_JSON")
ANOMALIES_JSON=$(jq -c '.structured_output.anomalies // []' "$CLAUDE_JSON") ISSUES_JSON=$(jq -c '.structured_output.issues // []' "$CLAUDE_JSON")
RECOMMENDATIONS_JSON=$(jq -c '.structured_output.recommendations // []' "$CLAUDE_JSON") ORCHESTRATION_JSON=$(jq -c '.structured_output.orchestration_recommendations // []' "$CLAUDE_JSON")
FAILED=$(jq -r '.structured_output.failed_sources // []|join(",")' "$CLAUDE_JSON") FAILED=$(jq -r '.structured_output.failed_sources // []|join(",")' "$CLAUDE_JSON")
AI_NOTE="" AI_NOTE=""
fi fi
# 即使 Claude 分析失败或遗漏,也为每个失败任务生成可执行的基础诊断。
RULE_ISSUES=$(jq -c '[.[] | select(.status != "SUCCESS") |
((if (.result|type)=="object" then [.result.failures[]?.type] else [] end) | unique) as $types |
((if (.result|type)=="object" then [.result.failures[]?.message] else [] end) | map(select(length>0)) | join(";")) as $messages |
(if .status=="PARSE_ERROR" or ($types|index("INVALID_OUTPUT")) then "OUTPUT_ERROR"
elif (($types|index("LOGIN_REQUIRED")) or ($types|index("PAYWALL")) or ($types|index("CAPTCHA")) or ($types|index("BODY_TOO_SHORT")) or ($types|index("CONTENT_INCOMPLETE"))) then "ACCESS_STATE"
elif ($types|index("ARCHIVE_FAILED")) then "ARCHIVE_ERROR"
elif ($types|index("PUSH_FAILED")) then "PUSH_ERROR"
elif (($types|index("RUNTIME_ERROR")) and (.max_budget_usd>0) and (.cost >= .max_budget_usd*0.95)) then "BUDGET_EXCEEDED"
elif ($types|index("RUNTIME_ERROR")) then "PROVIDER_ERROR"
else "OTHER" end) as $category |
{source:.source,category:$category,
summary:(.source + " 本次任务未成功完成"),
evidence:(if $messages!="" then $messages else ("状态=" + .status + ",subtype=" + .subtype + ",terminal=" + .terminal) end),
recommendation:(if $category=="BUDGET_EXCEEDED" then "检查该 provider 的实际成本和 MAX_BUDGET_USD,必要时提高独立预算或减少单批文章数后续采。"
elif $category=="PROVIDER_ERROR" then "检查 provider 端点、额度、限流和模型可用性,并查看对应 .raw 日志后重试。"
elif $category=="ACCESS_STATE" then "检查该数据源的浏览器登录、订阅和验证码状态,恢复会话后执行修复采集。"
elif $category=="ARCHIVE_ERROR" then "核对 archive_path、文件名转义和 Vault 路径权限,再执行修复。"
elif $category=="PUSH_ERROR" then "检查 News Hub 连接与返回状态,对未推送文章补推。"
elif $category=="OUTPUT_ERROR" then "查看 Claude 原始输出和 Schema 错误,确认结果文件完整后重跑。"
else "查看 result.failures、warnings 和原始日志,确认根因后重试。" end)}
]' "$SUMMARY_JSON")
ISSUES_JSON=$(jq -nc --argjson ai "$ISSUES_JSON" --argjson rules "$RULE_ISSUES" '
reduce $rules[] as $rule ($ai; if any(.[]; .source==$rule.source) then . else .+[$rule] end)')
# Business failures always alert, even if the model overlooks them. # Business failures always alert, even if the model overlooks them.
if [ "$FAILED_COUNT" -gt 0 ]; then if [ "$FAILED_COUNT" -gt 0 ]; then
ALERT="true" ALERT="true"
...@@ -231,25 +262,25 @@ fi ...@@ -231,25 +262,25 @@ fi
echo "" echo ""
echo "数据源: ${UNIQUE_SOURCES} 个(共 ${SOURCE_COUNT} 次运行)" echo "数据源: ${UNIQUE_SOURCES} 个(共 ${SOURCE_COUNT} 次运行)"
echo "成功/失败: ${SUCCESS_COUNT}/${FAILED_COUNT}" echo "成功/失败: ${SUCCESS_COUNT}/${FAILED_COUNT}"
echo "Provider: ${USED_PROVIDER}" echo "文章: 成功 ${TOTAL_ARTICLE_SUCCESS} / 失败 ${TOTAL_ARTICLE_FAILED} / 共处理 ${TOTAL_ARTICLES}"
echo "总成本: \$${TOTAL_COST}"
echo "总 Turns: ${TOTAL_TURNS} 总耗时: ${TOTAL_DURATION_S}s"
echo "总 Token: 输入 ${TOTAL_IN_TOK} / 输出 ${TOTAL_OUT_TOK}"
echo "权限拒绝: ${TOTAL_DENIALS}"
echo "告警: ${ALERT}" echo "告警: ${ALERT}"
[ -n "$AI_NOTE" ] && echo "(${AI_NOTE})" [ -n "$AI_NOTE" ] && echo "(${AI_NOTE})"
echo "" echo ""
echo "## 各源详情" echo "## Provider 成本"
jq -r '.[] | "- \(.source) \(.time) \(.status) | $\(.cost) | \(.turns)turns | \(.duration_ms)ms | 拒绝\(.denials)"' "$SUMMARY_JSON" echo "$PROVIDER_COSTS" | jq -r '.[] | "- \(.provider): $\(.cost) | \(.runs) 次运行 | \(.articles) 篇文章"'
echo ""
echo "## 数据源任务执行情况"
jq -r '.[] | (.time[0:2]+":"+.time[2:4]) as $clock | (.duration_ms/60000) as $minutes |
"- \(.source) | \($clock) | \(.provider) | \(.status) | 文章 \(.succeeded_count)/\(.failed_count)/\(.requested_count)(成功/失败/目标) | $\(.cost) | \($minutes*10|round/10) 分钟"' "$SUMMARY_JSON"
echo "" echo ""
if [ "$(echo "$ANOMALIES_JSON" | jq 'length')" -gt 0 ]; then if [ "$(echo "$ISSUES_JSON" | jq 'length')" -gt 0 ]; then
echo "## 异常项" echo "## 异常分析与排查建议"
echo "$ANOMALIES_JSON" | jq -r '.[] | "- \(.)"' echo "$ISSUES_JSON" | jq -r '.[] | "- [\(.category)] \(.source):\(.summary)\n 证据:\(.evidence)\n 建议:\(.recommendation)"'
echo "" echo ""
fi fi
if [ "$(echo "$RECOMMENDATIONS_JSON" | jq 'length')" -gt 0 ]; then if [ "$(echo "$ORCHESTRATION_JSON" | jq 'length')" -gt 0 ]; then
echo "## 建议" echo "## 调度编排建议"
echo "$RECOMMENDATIONS_JSON" | jq -r '.[] | "- \(.)"' echo "$ORCHESTRATION_JSON" | jq -r '.[] | "- \(.)"'
echo "" echo ""
fi fi
if [ "$ALERT" = "true" ]; then if [ "$ALERT" = "true" ]; then
...@@ -267,28 +298,62 @@ fi ...@@ -267,28 +298,62 @@ fi
# 生成表格行 HTML # 生成表格行 HTML
build_table_rows() { build_table_rows() {
jq -r '.[] | [.source,.time,.status,.cost,.turns,.duration_ms,.input_tokens,.output_tokens,.denials] | @tsv' "$SUMMARY_JSON" \ jq -r '.[] | [.source,.time,.provider,.status,.succeeded_count,.failed_count,.requested_count,.cost,.duration_ms] | @tsv' "$SUMMARY_JSON" \
| while IFS=$'\t' read -r src time status cost turns dur_ms in_tok out_tok denials; do | while IFS=$'\t' read -r src time provider status article_ok article_fail article_target cost dur_ms; do
src_e=$(printf '%s' "$src" | html_esc) src_e=$(printf '%s' "$src" | html_esc)
provider_e=$(printf '%s' "$provider" | html_esc)
case "$status" in case "$status" in
FAILED|PARSE_ERROR) bg="#fde8e8"; icon="❌" ;; FAILED|PARSE_ERROR) bg="#fde8e8"; icon="❌" ;;
*) if [ "${denials:-0}" -gt 0 ]; then bg="#fff4e0"; icon="⚠️"; else bg="#ffffff"; icon="✅"; fi ;; *) bg="#ffffff"; icon="✅" ;;
esac esac
dur_s=$(awk -v ms="${dur_ms:-0}" 'BEGIN{printf "%.1f", ms/1000}') friendly_time="$time"
[[ "$time" =~ ^[0-9]{6}$ ]] && friendly_time="${time:0:2}:${time:2:2}"
dur_min=$(awk -v ms="${dur_ms:-0}" 'BEGIN{printf "%.1f", ms/60000}')
cost_fmt=$(awk -v c="${cost:-0}" 'BEGIN{printf "%.2f", c}') cost_fmt=$(awk -v c="${cost:-0}" 'BEGIN{printf "%.2f", c}')
printf '<tr style="background:%s;">' "$bg" printf '<tr style="background:%s;">' "$bg"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;font-weight:600;">%s</td>' "$src_e" printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;font-weight:600;">%s</td>' "$src_e"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;color:#718096;font-size:12px;">%s</td>' "$time" printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;color:#718096;font-size:12px;white-space:nowrap;">%s</td>' "$friendly_time"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;font-size:12px;">%s</td>' "$provider_e"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;">%s %s</td>' "$icon" "$status" printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;">%s %s</td>' "$icon" "$status"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:center;white-space:nowrap;">%s / %s / %s</td>' "$article_ok" "$article_fail" "$article_target"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;">$%s</td>' "$cost_fmt" printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;">$%s</td>' "$cost_fmt"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;">%s</td>' "$turns" printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;white-space:nowrap;">%s 分钟</td>' "$dur_min"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;">%ss</td>' "$dur_s" printf '</tr>\n'
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-size:12px;">%s/%s</td>' "$in_tok" "$out_tok" done
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;">%s</td>' "$denials" }
build_provider_cost_rows() {
echo "$PROVIDER_COSTS" | jq -r '.[] | [.provider,.runs,.succeeded_runs,.articles,.cost] | @tsv' \
| while IFS=$'\t' read -r provider runs succeeded_runs articles cost; do
provider_e=$(printf '%s' "$provider" | html_esc)
cost_fmt=$(awk -v c="${cost:-0}" 'BEGIN{printf "%.2f", c}')
printf '<tr>'
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;font-weight:600;">%s</td>' "$provider_e"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;">%s</td>' "$runs"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;">%s</td>' "$succeeded_runs"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;">%s</td>' "$articles"
printf '<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:700;">$%s</td>' "$cost_fmt"
printf '</tr>\n' printf '</tr>\n'
done done
} }
build_issue_cards() {
echo "$ISSUES_JSON" | jq -r '.[] | [.source,.category,.summary,.evidence,.recommendation] | @tsv' \
| while IFS=$'\t' read -r source category summary evidence recommendation; do
source_e=$(printf '%s' "$source" | html_esc)
category_e=$(printf '%s' "$category" | html_esc)
summary_e=$(printf '%s' "$summary" | html_esc)
evidence_e=$(printf '%s' "$evidence" | html_esc)
recommendation_e=$(printf '%s' "$recommendation" | html_esc)
printf '<div style="margin-bottom:12px;padding:14px 16px;background:#fffaf0;border-left:4px solid #dd6b20;border-radius:4px;">'
printf '<div style="font-weight:700;color:#9c4221;">%s · %s</div>' "$source_e" "$category_e"
printf '<div style="margin-top:6px;line-height:1.5;">%s</div>' "$summary_e"
printf '<div style="margin-top:6px;color:#718096;font-size:12px;line-height:1.5;">依据:%s</div>' "$evidence_e"
printf '<div style="margin-top:8px;color:#2b6cb0;font-size:13px;line-height:1.5;"><strong>建议:</strong>%s</div>' "$recommendation_e"
printf '</div>\n'
done
}
# JSON 数组 → HTML 列表 # JSON 数组 → HTML 列表
json_to_list() { json_to_list() {
local json="$1" empty_msg="$2" local json="$1" empty_msg="$2"
...@@ -317,6 +382,7 @@ if [ -n "$MAIL_TO" ]; then ...@@ -317,6 +382,7 @@ if [ -n "$MAIL_TO" ]; then
SUBJECT="[News Harvester] ${ALERT_PREFIX}每日汇总: ${TODAY_HUMAN}" SUBJECT="[News Harvester] ${ALERT_PREFIX}每日汇总: ${TODAY_HUMAN}"
TABLE_ROWS=$(build_table_rows) TABLE_ROWS=$(build_table_rows)
PROVIDER_COST_ROWS=$(build_provider_cost_rows)
# 告警框 # 告警框
ALERT_BOX="" ALERT_BOX=""
...@@ -328,26 +394,26 @@ if [ -n "$MAIL_TO" ]; then ...@@ -328,26 +394,26 @@ if [ -n "$MAIL_TO" ]; then
</div>" </div>"
fi fi
# 异常项 # 异常诊断
ANO_COUNT=$(echo "$ANOMALIES_JSON" | jq 'length' 2>/dev/null || echo 0) ISSUE_COUNT=$(echo "$ISSUES_JSON" | jq 'length' 2>/dev/null || echo 0)
if [ "$ANO_COUNT" -gt 0 ]; then if [ "$ISSUE_COUNT" -gt 0 ]; then
ANO_LIST=$(json_to_list "$ANOMALIES_JSON" "") ISSUE_CARDS=$(build_issue_cards)
ANO_SECTION="<div style=\"margin:0 0 20px;\"> ISSUE_SECTION="<div style=\"margin:0 0 20px;\">
<div style=\"font-size:15px;font-weight:700;margin-bottom:10px;color:#dd6b20;\">🔍 异常分析${AI_NOTE:+ <span style=\"font-size:12px;font-weight:400;color:#a0aec0;\">${AI_NOTE}</span>}</div> <div style=\"font-size:15px;font-weight:700;margin-bottom:10px;color:#dd6b20;\">🔍 异常分析与排查建议${AI_NOTE:+ <span style=\"font-size:12px;font-weight:400;color:#a0aec0;\">${AI_NOTE}</span>}</div>
${ANO_LIST} ${ISSUE_CARDS}
</div>" </div>"
else else
ANO_SECTION="<div style=\"margin:0 0 20px;padding:12px 16px;background:#f0fff4;border-radius:4px;border-left:4px solid #38a169;\"> ISSUE_SECTION="<div style=\"margin:0 0 20px;padding:12px 16px;background:#f0fff4;border-radius:4px;border-left:4px solid #38a169;\">
<div style=\"color:#276749;font-weight:600;\">✅ 未发现异常,所有源正常运行</div> <div style=\"color:#276749;font-weight:600;\">✅ 未发现异常,所有源正常运行</div>
</div>" </div>"
fi fi
# 建议 # 调度建议
REC_COUNT=$(echo "$RECOMMENDATIONS_JSON" | jq 'length' 2>/dev/null || echo 0) ORCHESTRATION_COUNT=$(echo "$ORCHESTRATION_JSON" | jq 'length' 2>/dev/null || echo 0)
if [ "$REC_COUNT" -gt 0 ]; then if [ "$ORCHESTRATION_COUNT" -gt 0 ]; then
REC_LIST=$(json_to_list "$RECOMMENDATIONS_JSON" "") REC_LIST=$(json_to_list "$ORCHESTRATION_JSON" "")
REC_SECTION="<div style=\"margin:0 0 20px;\"> REC_SECTION="<div style=\"margin:0 0 20px;\">
<div style=\"font-size:15px;font-weight:700;margin-bottom:10px;color:#2b6cb0;\">💡 建议操作</div> <div style=\"font-size:15px;font-weight:700;margin-bottom:10px;color:#2b6cb0;\">🗓️ 调度编排建议</div>
${REC_LIST} ${REC_LIST}
</div>" </div>"
else else
...@@ -390,44 +456,47 @@ if [ -n "$MAIL_TO" ]; then ...@@ -390,44 +456,47 @@ if [ -n "$MAIL_TO" ]; then
<div style="font-size:11px;color:#718096;margin-top:2px;">失败</div> <div style="font-size:11px;color:#718096;margin-top:2px;">失败</div>
</div> </div>
<div style="flex:1;min-width:100px;background:#f7fafc;border-radius:6px;padding:14px;text-align:center;"> <div style="flex:1;min-width:100px;background:#f7fafc;border-radius:6px;padding:14px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#805ad5;">\$${TOTAL_COST}</div> <div style="font-size:22px;font-weight:700;color:#805ad5;">${TOTAL_ARTICLE_SUCCESS}/${TOTAL_ARTICLES}</div>
<div style="font-size:11px;color:#718096;margin-top:2px;">总成本</div> <div style="font-size:11px;color:#718096;margin-top:2px;">成功文章 / 已处理</div>
</div>
<div style="flex:1;min-width:100px;background:#f7fafc;border-radius:6px;padding:14px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#4a5568;">${TOTAL_DURATION_S}s</div>
<div style="font-size:11px;color:#718096;margin-top:2px;">总耗时</div>
</div> </div>
</div> </div>
<!-- Provider 成本 -->
<div style="font-size:15px;font-weight:700;margin-bottom:10px;color:#2d3748;">Provider 成本</div>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:24px;">
<thead><tr style="background:#edf2f7;">
<th style="padding:9px 12px;text-align:left;border-bottom:2px solid #cbd5e0;">Provider</th>
<th style="padding:9px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">运行</th>
<th style="padding:9px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">成功</th>
<th style="padding:9px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">文章</th>
<th style="padding:9px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">成本</th>
</tr></thead>
<tbody>${PROVIDER_COST_ROWS}</tbody>
<tfoot><tr><td colspan="4" style="padding:9px 12px;text-align:right;font-weight:600;">合计</td><td style="padding:9px 12px;text-align:right;font-weight:700;">\$${TOTAL_COST}</td></tr></tfoot>
</table>
<!-- 每源表格 --> <!-- 每源表格 -->
<div style="font-size:15px;font-weight:700;margin-bottom:10px;color:#2d3748;">各源运行详情</div> <div style="font-size:15px;font-weight:700;margin-bottom:10px;color:#2d3748;">数据源任务执行情况</div>
<table style="width:100%;border-collapse:collapse;font-size:13px;"> <table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead> <thead>
<tr style="background:#edf2f7;"> <tr style="background:#edf2f7;">
<th style="padding:10px 12px;text-align:left;border-bottom:2px solid #cbd5e0;">栏目</th> <th style="padding:10px 12px;text-align:left;border-bottom:2px solid #cbd5e0;">栏目</th>
<th style="padding:10px 12px;text-align:left;border-bottom:2px solid #cbd5e0;">时间</th> <th style="padding:10px 12px;text-align:left;border-bottom:2px solid #cbd5e0;">时间</th>
<th style="padding:10px 12px;text-align:left;border-bottom:2px solid #cbd5e0;">Provider</th>
<th style="padding:10px 12px;text-align:left;border-bottom:2px solid #cbd5e0;">状态</th> <th style="padding:10px 12px;text-align:left;border-bottom:2px solid #cbd5e0;">状态</th>
<th style="padding:10px 12px;text-align:center;border-bottom:2px solid #cbd5e0;">文章 成功/失败/目标</th>
<th style="padding:10px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">成本</th> <th style="padding:10px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">成本</th>
<th style="padding:10px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">Turns</th>
<th style="padding:10px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">耗时</th> <th style="padding:10px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">耗时</th>
<th style="padding:10px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">Token</th>
<th style="padding:10px 12px;text-align:right;border-bottom:2px solid #cbd5e0;">拒绝</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${TABLE_ROWS} ${TABLE_ROWS}
</tbody> </tbody>
</table> </table>
<!-- AI 异常分析 --> <!-- 异常分析 -->
<div style="margin-top:24px;"> <div style="margin-top:24px;">
${ANO_SECTION} ${ISSUE_SECTION}
</div> </div>
<!-- 建议 --> <!-- 调度建议 -->
${REC_SECTION} ${REC_SECTION}
<!-- 明细 -->
<details style="margin-top:20px;border-top:1px solid #e2e8f0;padding-top:16px;">
<summary style="cursor:pointer;font-size:13px;color:#718096;font-weight:600;">📄 查看完整存档(Markdown)</summary>
<pre style="margin-top:12px;background:#f7fafc;border-radius:6px;padding:14px;white-space:pre-wrap;word-wrap:break-word;font-size:12px;line-height:1.5;overflow-x:auto;">$(cat "$DIGEST_MD" | html_esc)</pre>
</details>
</div> </div>
<!-- 页脚 --> <!-- 页脚 -->
<div style="padding:14px 28px;background:#f7fafc;border-top:1px solid #e2e8f0;font-size:12px;color:#a0aec0;text-align:center;"> <div style="padding:14px 28px;background:#f7fafc;border-top:1px solid #e2e8f0;font-size:12px;color:#a0aec0;text-align:center;">
......
...@@ -256,7 +256,8 @@ def format_summary(result): ...@@ -256,7 +256,8 @@ def format_summary(result):
return "\n".join(lines) return "\n".join(lines)
def normalize(envelope, source, count, process_rc, work_dir=None, vault_root=None): def normalize(envelope, source, count, process_rc, work_dir=None, vault_root=None,
provider="default", max_budget_usd=15):
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
result = empty_result(source, count) result = empty_result(source, count)
task_failed = False task_failed = False
...@@ -359,6 +360,8 @@ def normalize(envelope, source, count, process_rc, work_dir=None, vault_root=Non ...@@ -359,6 +360,8 @@ def normalize(envelope, source, count, process_rc, work_dir=None, vault_root=Non
output["is_error"] = result["status"] == "FAILED" output["is_error"] = result["status"] == "FAILED"
output["process_exit_code"] = process_rc output["process_exit_code"] = process_rc
output["exit_code"] = process_rc if process_rc else (1 if output["is_error"] else 0) output["exit_code"] = process_rc if process_rc else (1 if output["is_error"] else 0)
output["provider"] = provider
output["max_budget_usd"] = max_budget_usd
return output return output
...@@ -371,6 +374,8 @@ def main(): ...@@ -371,6 +374,8 @@ def main():
parser.add_argument("--exit-code", type=int, default=0) parser.add_argument("--exit-code", type=int, default=0)
parser.add_argument("--work-dir", type=Path) parser.add_argument("--work-dir", type=Path)
parser.add_argument("--vault-root", type=Path) parser.add_argument("--vault-root", type=Path)
parser.add_argument("--provider", default="default")
parser.add_argument("--max-budget-usd", type=float, default=15)
args = parser.parse_args() args = parser.parse_args()
if args.count < 1: if args.count < 1:
parser.error("采集数量必须为正整数") parser.error("采集数量必须为正整数")
...@@ -387,7 +392,7 @@ def main(): ...@@ -387,7 +392,7 @@ def main():
except (ValueError, OSError): except (ValueError, OSError):
envelope = {} envelope = {}
output = normalize(envelope, args.source, args.count, args.exit_code, output = normalize(envelope, args.source, args.count, args.exit_code,
args.work_dir, args.vault_root) args.work_dir, args.vault_root, args.provider, args.max_budget_usd)
temporary = args.path.with_suffix(".tmp") temporary = args.path.with_suffix(".tmp")
temporary.write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") temporary.write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(args.path) temporary.replace(args.path)
......
...@@ -24,6 +24,8 @@ normalize_result() { ...@@ -24,6 +24,8 @@ normalize_result() {
--count "$COUNT" --count "$COUNT"
--exit-code "$RC" --exit-code "$RC"
--work-dir "$WORK_DIR" --work-dir "$WORK_DIR"
--provider "${USED_PROVIDER:-default}"
--max-budget-usd "${RESOLVED_MAX_BUDGET_USD:-${MAX_BUDGET_USD:-15}}"
) )
if [ -n "${OBSIDIAN_VAULT_PATH:-}" ]; then if [ -n "${OBSIDIAN_VAULT_PATH:-}" ]; then
normalize_args+=(--vault-root "$OBSIDIAN_VAULT_PATH") normalize_args+=(--vault-root "$OBSIDIAN_VAULT_PATH")
......
# News Harvester 控制平面:基础概念、Kind 与 Schema
> 状态:实施基线(v1alpha1)
>
> 适用范围:Manager、Agent、节点 Runtime、etcd 数据模型
>
> 本文是四份架构文档中的总规范。Agent、Manager、Runtime 的实现必须遵守本文的资源模型和所有权边界。
## 1. 目标与非目标
### 1.1 目标
News Harvester 从单机脚本演进为“一个 Manager 管理多台 Agent 节点”的声明式系统,同时保留现有采集方式:
- Manager 只描述“哪台机器、什么时候、采集什么、失败后如何自愈”。
- Agent 监听 etcd,将期望状态协调成本机配置、source 注册和 systemd timer。
- systemd timer 仍是目标节点上的实际定时触发器。
- Claude 仍通过 `news-harvester` Skill 完成流程编排和中文摘要。
- 采集、归档、推送继续由 Skills 中的确定性脚本执行。
- Manager 不提供“远程手动执行、重试、修复”作为核心能力。
- etcd 不保存任务历史,只保存节点状态和每个 source 的最后一次结果。
### 1.2 非目标
v1 不建设以下能力:
- 中央任务队列或分布式调度器。
- Manager 直接 SSH、调用 systemctl 或执行节点脚本。
- 采集文章级历史数据库。
- 跨节点迁移运行中的采集任务。
- 在 etcd 中存放 Skills Git 仓库或 source bundle 内容。
- 自动处理 CAPTCHA、登录失效、订阅墙等必须人工介入的场景。
- 用 etcd 锁替代同一节点上的本地 source 锁。
## 2. 核心设计原则
### 2.1 期望状态,不是命令队列
Manager 写入的是资源最终应该达到的状态。例如:
```yaml
kind: SourceSchedule
metadata:
name: nikkei-world
spec:
enabled: true
schedule:
onCalendar: "*-*-* 09:00:00"
harvest:
count: 20
```
这不是一次 `add` 或 `update` 命令。Agent 无论首次看到、重启后重新读取,还是 Watch 断线后全量恢复,都应得到相同的本机结果。
### 2.2 控制面与执行面分离
| 层次 | 权威数据 | 责任 |
|---|---|---|
| 控制面 | etcd desired keys | 节点、source timer、Provider、Runtime 版本、自愈策略 |
| 节点协调面 | Agent | Watch、校验、安装 source、渲染配置、协调 systemd、报告状态 |
| 节点诊断面 | Agent `web` + 本机 Runtime 事实 | 只读展示已落地配置、timer、结果、运行证据和依赖状态 |
| 执行面 | systemd + Runtime | 定时触发、同 source 加锁、运行 Claude、归档、推送、记录结果 |
| 采集实现 | news-harvester Skill | 网页抓取、去重、摘要协议、finalize、News Hub 推送 |
### 2.3 两套 source 配置是有意保留的
- `SourceSchedule` / systemd timer 是主配置,决定是否运行以及何时运行。
- Skill `source-manager` 是执行能力注册表,决定 source 如何抓取。
Agent 在启用 timer 前检查 Skill 中是否存在 source:
1. 已安装:继续协调。
2. 未安装但 Git 仓库中存在对应 bundle:自动安装。
3. bundle 不存在或安装失败:报告 `SOURCE_NOT_FOUND` 或 `SOURCE_INSTALL_FAILED`,timer 保持 disabled。
source 安装发生在 Agent 协调阶段,绝不放进 `harvest.sh` 的采集运行阶段。
### 2.4 数据所有权单向明确
| Key 类型 | 写入者 | 读取者 |
|---|---|---|
| desired | Manager | Agent、Manager |
| presence | Agent(带 Lease) | Manager |
| status | Agent | Manager |
| audit | Manager | Manager |
Agent 不修改 desired;Manager 不伪造 Agent status。
## 3. 整体运行原理
```text
管理员
│
▼
Manager(Next.js 全栈)
│ 校验 Schema + etcd Txn + generation++
▼
etcd /news-harvester/v1/desired/...
│
│ Watch + 周期性全量同步
▼
Agent(Go,每节点一个)
├─ 同步 Runtime / Skills Git ref
├─ 检查或安装 source bundle
├─ 解密并落地 Provider
├─ 渲染 source conf + timer
├─ 通过受限提权助手执行 systemd 操作
├─ 写 reconcile status / heartbeat
└─ 可选只读 Web:展示本机已落实的 Runtime 事实
│
▼
systemd timer
│
▼
Runtime wrapper → 同 source flock → harvest.sh → Claude → Skill
│
├─ 本地日志与 Skill 状态文件
└─ 结构化 last-result.json
│
▼
Agent 读取并写回 etcd SourceLastResult
```
Manager 页面读取 etcd 中的集群期望与汇总状态;节点 Web 不读取 etcd desired,只查看 Agent 协调后的本机实际产物。节点 Web 不提供 source、Provider、timer 或执行操作的写入口。
### 3.1 Watch 不是唯一事实来源
Agent 必须同时采用:
- etcd Watch:低延迟接收变化。
- 启动时全量 List:恢复当前事实。
- 每 60 秒全量协调:处理 Watch 断线、compaction、本机人工漂移。
Watch 事件携带 revision。发生断线时从最后确认 revision 恢复;若 revision 已被压缩,则重新全量 List,再从新 revision 建立 Watch。
### 3.2 generation 与 observedGeneration
- Manager 只在 `spec` 发生变化时递增 `metadata.generation`。
- Agent 成功或失败协调后,将看到的 generation 写入 `status.observedGeneration`。
- `observedGeneration < generation` 表示节点尚未处理最新期望。
- 即使协调失败,`observedGeneration` 也要更新;失败原因由 Conditions 表达。
### 3.3 删除语义
删除 `SourceSchedule` desired key 表示该资源不再期望存在。Agent 应:
1. disable/stop 对应 timer。
2. 删除由 Agent 管理的 timer 和 source conf。
3. daemon-reload。
4. 保留采集日志、Obsidian 内容和 Skill source 注册,避免数据破坏。
5. 写入一次 `Deleted=True` 协调状态后,可由垃圾回收策略清理该状态。
只有带 Agent 管理标记的本机文件才允许被自动删除。
## 4. etcd Key 空间
固定前缀:
```text
/news-harvester/v1
```
### 4.1 Desired keys
```text
/news-harvester/v1/desired/nodes/{nodeId}/node
/news-harvester/v1/desired/nodes/{nodeId}/schedules/{scheduleName}
/news-harvester/v1/desired/providers/{providerName}
/news-harvester/v1/desired/runtime-releases/{releaseName}
```
将 schedule 放在 node 子树下有三个目的:
- Agent 只 Watch 自己的前缀。
- etcd RBAC 可以只授予本节点读取权限。
- source 从节点 A 迁移到节点 B 时,语义就是删除 A key、创建 B key。
### 4.2 Status keys
```text
/news-harvester/v1/status/nodes/{nodeId}/presence
/news-harvester/v1/status/nodes/{nodeId}/snapshot
/news-harvester/v1/status/nodes/{nodeId}/reconcile
/news-harvester/v1/status/nodes/{nodeId}/schedules/{scheduleName}
/news-harvester/v1/status/nodes/{nodeId}/sources/{sourceId}/last-result
```
- `presence` 绑定 30 秒 Lease,Agent 每 10 秒 keepalive;key 消失即判离线。
- `snapshot` 持久保存最后已知节点状态和 `lastSeenAt`,不绑定 Lease。
- `last-result` 只覆盖同 source 的上一条结果,不作为历史库。
### 4.3 其他 keys
```text
/news-harvester/v1/audit/{yyyyMMdd}/{timestamp}-{uuid}
/news-harvester/v1/system/schema-version
```
v1 不在 etcd 中创建 `/commands`、`/jobs` 或 `/tasks`。
## 5. 通用资源信封
所有 desired/status value 使用 UTF-8 JSON。YAML 只作为 Manager 导入导出格式,写入 etcd 前必须转成规范 JSON。
```ts
type ApiVersion = "news-harvester.io/v1alpha1";
interface ObjectMeta {
name: string; // ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$, <= 63
uid: string; // UUID,创建后不可变
generation: number; // >= 1,仅 spec 变化时递增
createdAt: string; // RFC3339 UTC
updatedAt: string; // RFC3339 UTC
labels?: Record<string, string>;
annotations?: Record<string, string>;
}
interface Resource<K extends string, S> {
apiVersion: ApiVersion;
kind: K;
metadata: ObjectMeta;
spec: S;
}
```
etcd 的 `create_revision`、`mod_revision` 和 `version` 不复制进 value。Manager API 将它们以 `resourceVersion` 返回给浏览器,并用 `mod_revision` 做乐观并发控制。
### 5.1 Schema 落地规则
- 机器 Schema 使用 JSON Schema Draft 2020-12。
- TypeScript Manager 中的 TypeBox 定义是 Schema 单一源码,构建时输出 `schemas/v1alpha1/*.schema.json`。
- Go Agent 将同一批 JSON Schema 嵌入二进制并在协调前再次校验。
- 每层 object 默认 `additionalProperties: false`,版本兼容字段必须先进入 Schema 才能使用。
- default 只由 Manager 在创建/导入时补齐;Agent 不通过回写 desired 补默认值。
- `format: date-time` 统一要求 RFC3339 UTC;duration 不混用字符串和数字。
- Manager 与 Agent 使用共享合法/非法 fixtures 做跨语言契约测试。
- 文中的 TypeScript interface 是字段语义规范;生成的 JSON Schema 是运行时校验规范,两者由同一 TypeBox 源码产生,禁止手工维护两份。
### 5.2 通用 Condition
```ts
interface Condition {
type: string;
status: "True" | "False" | "Unknown";
reason: string; // 稳定机器码
message: string; // 面向运维人员,<= 1024 字符
observedGeneration: number;
lastTransitionTime: string; // 只有 status 变化时更新
}
```
条件采用正向名称,例如 `Ready`、`RuntimeReady`、`SourceReady`、`TimerReady`,不要使用 `HasError`。
## 6. Kind 定义
### 6.1 Node
Node 是 Manager 对一台已注册 Agent 节点的期望配置。
```ts
interface NodeSpec {
enabled: boolean;
displayName?: string;
runtimeReleaseRef: string;
labels?: Record<string, string>;
limits: {
maxConcurrentSources: number; // 1..32,默认 2
maxConcurrentClaude: number; // 1..16,默认 2
maxConcurrentBrowserTabs: number;// 1..32,默认 5
};
defaults: {
count: number; // 1..100,默认 20
budgetUsd: number; // >0,默认 15
timeoutSeconds: number; // 60..86400,默认 1800
providerRef?: string;
fallbackProviderRef?: string;
};
reconcileIntervalSeconds: number; // 30..3600,默认 60
}
```
Node 中不保存绝对路径。工作目录、HOME、vault、sudo helper 路径属于安装时生成的本机 `agent.yaml`,避免 Manager 对异构节点强行套用路径。
示例:
```yaml
apiVersion: news-harvester.io/v1alpha1
kind: Node
metadata:
name: collector-shanghai-01
uid: 31e54455-c92e-4759-ac39-1155591b0108
generation: 3
createdAt: "2026-09-02T02:00:00Z"
updatedAt: "2026-09-02T03:00:00Z"
spec:
enabled: true
displayName: 上海采集节点 01
runtimeReleaseRef: stable-2026-09
limits:
maxConcurrentSources: 2
maxConcurrentClaude: 2
maxConcurrentBrowserTabs: 5
defaults:
count: 20
budgetUsd: 15
timeoutSeconds: 1800
providerRef: primary
fallbackProviderRef: fallback
reconcileIntervalSeconds: 60
```
### 6.2 SourceSchedule
SourceSchedule 是系统最核心的期望资源。所在 etcd 路径已经表达 `nodeId`,`spec` 中不重复存储。
v1 强制同一 node 下一个 `sourceId` 只能存在一个 SourceSchedule,并要求 `metadata.name == spec.sourceId`。如果未来需要同一 source 一天多次运行,应直接使用包含多个时间点的 systemd OnCalendar 表达式,而不是创建两个会竞争同一 source 状态文件的资源。
```ts
type RecoveryAction =
| "none"
| "resume-session"
| "resume-summary"
| "retry-push"
| "rerun-harvest";
interface RecoveryRule {
matchCodes: string[];
action: RecoveryAction;
maxAttempts: number; // 0..10
backoffSeconds: number; // 0..86400
}
interface SourceScheduleSpec {
sourceId: string;
enabled: boolean;
schedule: {
onCalendar: string; // systemd OnCalendar 原生表达式
timeZone?: string; // IANA TZ;默认节点时区
persistent: boolean; // 默认 true
accuracySeconds: number; // 1..3600,默认 60
randomizedDelaySeconds: number; // 0..86400,默认 0
};
harvest: {
count?: number; // 未设置则继承 Node defaults
budgetUsd?: number;
timeoutSeconds?: number;
providerRef?: string;
fallbackProviderRef?: string;
};
recoveryPolicy: {
enabled: boolean;
rules: RecoveryRule[];
};
notifications?: {
mailTo?: string[];
mailOnSuccess?: boolean;
alertCostOverUsd?: number;
};
}
```
推荐默认自愈策略:
```yaml
recoveryPolicy:
enabled: true
rules:
- matchCodes: [CLAUDE_INTERRUPTED, MODEL_TIMEOUT, NETWORK_TRANSIENT]
action: resume-session
maxAttempts: 2
backoffSeconds: 300
- matchCodes: [SUMMARY_INCOMPLETE]
action: resume-summary
maxAttempts: 2
backoffSeconds: 120
- matchCodes: [PUSH_PARTIAL, PUSH_UNAVAILABLE]
action: retry-push
maxAttempts: 5
backoffSeconds: 300
- matchCodes: [HUMAN_REQUIRED, SOURCE_NOT_FOUND, PROVIDER_INVALID]
action: none
maxAttempts: 0
backoffSeconds: 0
```
`rerun-harvest` 可能重复访问数据源,默认不启用。现有 `repair` 模式包含删除和覆盖,不允许作为无人值守默认动作。
完整示例(存储于 `/news-harvester/v1/desired/nodes/collector-shanghai-01/schedules/nikkei-world`):
```yaml
apiVersion: news-harvester.io/v1alpha1
kind: SourceSchedule
metadata:
name: nikkei-world
uid: 302bfec2-e419-41cf-ad9f-709665074842
generation: 7
createdAt: "2026-09-02T02:10:00Z"
updatedAt: "2026-09-02T03:20:00Z"
labels:
region: asia
annotations:
news-harvester.io/managed-by: manager-ui
spec:
sourceId: nikkei-world
enabled: true
schedule:
onCalendar: "*-*-* 09:00:00"
timeZone: Asia/Shanghai
persistent: true
accuracySeconds: 60
randomizedDelaySeconds: 120
harvest:
count: 20
budgetUsd: 15
timeoutSeconds: 1800
providerRef: primary
fallbackProviderRef: fallback
recoveryPolicy:
enabled: true
rules:
- matchCodes: [CLAUDE_INTERRUPTED, MODEL_TIMEOUT, NETWORK_TRANSIENT]
action: resume-session
maxAttempts: 2
backoffSeconds: 300
- matchCodes: [SUMMARY_INCOMPLETE]
action: resume-summary
maxAttempts: 2
backoffSeconds: 120
- matchCodes: [PUSH_PARTIAL, PUSH_UNAVAILABLE]
action: retry-push
maxAttempts: 5
backoffSeconds: 300
- matchCodes: [HUMAN_REQUIRED, SOURCE_NOT_FOUND, PROVIDER_INVALID]
action: none
maxAttempts: 0
backoffSeconds: 0
notifications:
mailTo:
- ops@example.com
mailOnSuccess: false
alertCostOverUsd: 5
```
### 6.3 Provider
Provider 表示 Claude CLI 的 Provider,不与 Skill 中 News Hub push 凭证混用。
```ts
interface ProviderSpec {
enabled: boolean;
type: "claude";
publicEnv: Record<string, string>;
secretEnvelope?: {
algorithm: "age-x25519+aes-256-gcm";
ciphertext: string; // base64
nonce: string; // base64
wrappedKeys: Record<string, string>; // nodeId -> age 加密后的 DEK
keyVersion: number;
};
}
```
规则:
- token、key、secret、password、auth 类字段必须进入 `secretEnvelope`。
- 浏览器永远拿不到解密后的 secret。
- Agent 只能解开为自己 nodeId 包装的 DEK。
- Provider 删除前必须检查是否仍被 Node 或 SourceSchedule 引用。
- Runtime 只允许将白名单环境变量传给 Claude,拒绝 `PATH`、`HOME`、`LD_*`、`NODE_OPTIONS` 等可改变执行边界的变量。
完整示例:
```yaml
apiVersion: news-harvester.io/v1alpha1
kind: Provider
metadata:
name: primary
uid: 68d5ce81-fe74-4481-96ea-e269f138de43
generation: 4
createdAt: "2026-09-02T01:00:00Z"
updatedAt: "2026-09-02T03:30:00Z"
labels:
purpose: harvest
spec:
enabled: true
type: claude
publicEnv:
ANTHROPIC_BASE_URL: https://api.example.com
ANTHROPIC_MODEL: claude-sonnet
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet
CLAUDE_CODE_SUBAGENT_MODEL: claude-sonnet
secretEnvelope:
algorithm: age-x25519+aes-256-gcm
ciphertext: ZW5jcnlwdGVkLXByb3ZpZGVyLWpzb24=
nonce: AAECAwQFBgcICQoL
wrappedKeys:
collector-shanghai-01: AGE-ENCRYPTED-DEK-FOR-COLLECTOR-SHANGHAI-01
collector-beijing-01: AGE-ENCRYPTED-DEK-FOR-COLLECTOR-BEIJING-01
keyVersion: 2
```
示例中的 `ciphertext`、`nonce` 和 `wrappedKeys` 只是格式占位,不是有效凭证。
### 6.4 RuntimeRelease
RuntimeRelease 描述 Agent 要部署的 Runtime 与 Skills 版本。Git 仓库保存代码和所有 source bundles;etcd 只保存引用。
```ts
interface GitArtifact {
repository: string;
ref: string; // tag 或 commit;生产环境禁止浮动 branch
commit?: string; // 解析后的 40 位 commit
subdirectory?: string;
}
interface RuntimeReleaseSpec {
enabled: boolean;
runtime: GitArtifact; // news-harvester-pro
skills: GitArtifact; // news-hub-skills
agent?: {
version: string;
downloadUrl: string;
sha256: string;
};
minimumAgentVersion?: string;
}
```
Agent 自身升级与 Runtime Git 更新分开:
- Runtime/Skills 可由当前 Agent 协调。
- Agent 二进制采用下载、校验、原子替换、systemd 重启的专门升级流程。
- v1 可以先只报告 Agent 版本不一致,不自动升级 Agent。
完整示例:
```yaml
apiVersion: news-harvester.io/v1alpha1
kind: RuntimeRelease
metadata:
name: stable-2026-09
uid: 1dc56455-0306-472f-8cdb-f5f116fc74a6
generation: 2
createdAt: "2026-09-01T08:00:00Z"
updatedAt: "2026-09-02T01:30:00Z"
labels:
channel: stable
spec:
enabled: true
runtime:
repository: ssh://git@example.com/news/news-harvester-pro.git
ref: refs/tags/runtime-v1.0.0
commit: 0123456789abcdef0123456789abcdef01234567
skills:
repository: ssh://git@example.com/news/news-hub-skills.git
ref: refs/tags/skills-v1.0.0
commit: 89abcdef0123456789abcdef0123456789abcdef
subdirectory: skills/news-harvester
agent:
version: 1.0.0
downloadUrl: https://releases.example.com/news-harvester-agent/1.0.0/linux-amd64
sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
minimumAgentVersion: 1.0.0
```
### 6.5 NodeStatus
```ts
interface NodeStatus {
apiVersion: ApiVersion;
kind: "NodeStatus";
metadata: ObjectMeta;
status: {
nodeId: string;
agentVersion: string;
hostname: string;
os: string;
arch: string;
bootId: string;
startedAt: string;
lastSeenAt: string;
etcdRevision: string;
runtimeRelease?: string;
runtimeCommit?: string;
skillsCommit?: string;
conditions: Condition[];
capacity: {
sourceSlots: number;
claudeSlots: number;
browserTabs: number;
};
};
}
```
在线状态以 `presence` Lease 是否存在为准,不能只根据 `lastSeenAt` 推断。
完整示例(由 Agent 写入):
```yaml
apiVersion: news-harvester.io/v1alpha1
kind: NodeStatus
metadata:
name: collector-shanghai-01
uid: e9b64a30-a8dc-4938-99fa-68757e49091a
generation: 1
createdAt: "2026-09-02T02:01:00Z"
updatedAt: "2026-09-02T03:31:10Z"
status:
nodeId: collector-shanghai-01
agentVersion: 1.0.0
hostname: collector01
os: linux
arch: amd64
bootId: f3233bb1-8aad-462c-9e5f-905c78d37937
startedAt: "2026-09-02T02:01:00Z"
lastSeenAt: "2026-09-02T03:31:10Z"
etcdRevision: "18452"
runtimeRelease: stable-2026-09
runtimeCommit: 0123456789abcdef0123456789abcdef01234567
skillsCommit: 89abcdef0123456789abcdef0123456789abcdef
conditions:
- type: Ready
status: "True"
reason: ReconcileSucceeded
message: Node runtime and all managed schedules are ready
observedGeneration: 3
lastTransitionTime: "2026-09-02T03:30:10Z"
- type: EtcdConnected
status: "True"
reason: LeaseActive
message: Heartbeat lease is active
observedGeneration: 3
lastTransitionTime: "2026-09-02T02:01:02Z"
capacity:
sourceSlots: 2
claudeSlots: 2
browserTabs: 5
```
### 6.6 ScheduleStatus
```ts
interface ScheduleStatus {
apiVersion: ApiVersion;
kind: "ScheduleStatus";
metadata: ObjectMeta;
status: {
observedGeneration: number;
desiredHash: string;
appliedHash?: string;
sourceInstalled: boolean;
timerUnit: string;
timerEnabled: boolean;
timerActiveState: string;
nextElapseAt?: string;
lastTriggerAt?: string;
conditions: Condition[];
};
}
```
完整示例(由 Agent 写入):
```yaml
apiVersion: news-harvester.io/v1alpha1
kind: ScheduleStatus
metadata:
name: nikkei-world
uid: c63174f1-5c5c-422e-8993-76d6ae8ff613
generation: 1
createdAt: "2026-09-02T02:12:00Z"
updatedAt: "2026-09-02T03:31:00Z"
status:
observedGeneration: 7
desiredHash: 4c763bd124e576b1dc43f95da07550dc45d3ab7b217937b5bd063e0bc4b52a55
appliedHash: 4c763bd124e576b1dc43f95da07550dc45d3ab7b217937b5bd063e0bc4b52a55
sourceInstalled: true
timerUnit: news-harvester@nikkei-world.timer
timerEnabled: true
timerActiveState: active
nextElapseAt: "2026-09-03T01:00:00Z"
lastTriggerAt: "2026-09-02T01:00:00Z"
conditions:
- type: SourceReady
status: "True"
reason: SourceInstalled
message: Source is registered in the news-harvester skill
observedGeneration: 7
lastTransitionTime: "2026-09-02T02:12:00Z"
- type: TimerReady
status: "True"
reason: TimerApplied
message: Timer is enabled and active
observedGeneration: 7
lastTransitionTime: "2026-09-02T03:30:10Z"
```
### 6.7 SourceLastResult
SourceLastResult 是 Manager 需要展示的唯一采集结果对象。它不是任务记录,不累积历史。
```ts
type ResultOutcome =
| "SUCCEEDED"
| "DEGRADED"
| "FAILED"
| "NEEDS_HUMAN"
| "SKIPPED";
interface SourceLastResult {
apiVersion: ApiVersion;
kind: "SourceLastResult";
metadata: ObjectMeta;
status: {
nodeId: string;
scheduleName: string;
sourceId: string;
observedGeneration: number;
runId: string;
trigger: "timer" | "recovery" | "local-manual";
outcome: ResultOutcome;
startedAt: string;
finishedAt: string;
durationMs: number;
requestedCount: number;
archive: {
status: "SUCCESS" | "PARTIAL" | "FAILED" | "NOT_RUN";
newCount: number;
skippedCount: number;
failedCount: number;
exhausted: boolean;
dayDir?: string;
};
summary: {
status: "SUCCESS" | "INCOMPLETE" | "FAILED" | "NOT_RUN";
finalizedCount: number;
missingCount: number;
};
push: {
status: "SUCCESS" | "PARTIAL" | "FAILED" | "DISABLED" | "NOT_RUN";
syncedCount: number;
failedCount: number;
};
claude: {
processExitCode?: number;
isError?: boolean;
sessionId?: string;
provider: string;
costUsd?: number;
turns?: number;
inputTokens?: number;
outputTokens?: number;
terminalReason?: string;
};
error?: {
code: string;
class: "CONFIG" | "TRANSIENT" | "PERMANENT" | "HUMAN" | "CONCURRENCY";
retryable: boolean;
phase: "RECONCILE" | "HARVEST" | "SUMMARY" | "FINALIZE" | "PUSH" | "UNKNOWN";
message: string;
};
recovery?: {
action: RecoveryAction;
attempt: number;
maxAttempts: number;
nextAttemptAt?: string;
};
localArtifacts?: {
resultPath?: string;
rawLogPath?: string;
journalUnit?: string;
};
};
}
```
完整示例(本地归档成功、News Hub 推送部分失败,等待 Agent 自愈):
```yaml
apiVersion: news-harvester.io/v1alpha1
kind: SourceLastResult
metadata:
name: nikkei-world
uid: 4eae7fd8-5fa3-4675-8f38-5518857fe546
generation: 1
createdAt: "2026-09-02T01:12:00Z"
updatedAt: "2026-09-02T01:12:00Z"
status:
nodeId: collector-shanghai-01
scheduleName: nikkei-world
sourceId: nikkei-world
observedGeneration: 7
runId: 0199a6ef-5570-7af3-b506-33d6be56d820
trigger: timer
outcome: DEGRADED
startedAt: "2026-09-02T01:00:00Z"
finishedAt: "2026-09-02T01:12:00Z"
durationMs: 720000
requestedCount: 20
archive:
status: SUCCESS
newCount: 8
skippedCount: 12
failedCount: 0
exhausted: true
dayDir: /var/lib/news-harvester/vault/日经亚洲/世界/20260902
summary:
status: SUCCESS
finalizedCount: 8
missingCount: 0
push:
status: PARTIAL
syncedCount: 7
failedCount: 1
claude:
processExitCode: 0
isError: false
sessionId: 8fbd586a-c764-409a-abaf-6389580d78c8
provider: primary
costUsd: 1.42
turns: 12
inputTokens: 18420
outputTokens: 6320
terminalReason: end_turn
error:
code: PUSH_PARTIAL
class: TRANSIENT
retryable: true
phase: PUSH
message: 1 article remains unsynced after News Hub push retries
recovery:
action: retry-push
attempt: 1
maxAttempts: 5
nextAttemptAt: "2026-09-02T01:17:00Z"
localArtifacts:
resultPath: /var/lib/news-harvester/runs/nikkei-world/0199a6ef-5570-7af3-b506-33d6be56d820/result.json
rawLogPath: /var/lib/news-harvester/runs/nikkei-world/0199a6ef-5570-7af3-b506-33d6be56d820/claude-stderr.log
journalUnit: news-harvester@nikkei-world.service
```
判定规则:
- `actual < requested` 且 `archive.exhausted=true` 可以是 `SUCCEEDED`。
- 本地归档成功但 push 部分失败为 `DEGRADED`,不是归档失败。
- Skill exit 70 映射为 `NEEDS_HUMAN/HUMAN_REQUIRED`,禁止自动重试。
- 同 source 锁冲突映射为 `SKIPPED/SOURCE_LOCKED`。
- Claude 外层退出 0 不能单独证明整个采集成功;必须结合 Runtime 生成的阶段结果。
## 7. 错误码规范
| 错误码 | 分类 | 默认可重试 | 含义 |
|---|---|---:|---|
| `SOURCE_NOT_FOUND` | CONFIG | 否 | Skill 未注册且 bundle 不存在 |
| `SOURCE_INSTALL_FAILED` | CONFIG | 否 | bundle 安装或校验失败 |
| `PROVIDER_NOT_FOUND` | CONFIG | 否 | 引用 Provider 不存在 |
| `PROVIDER_INVALID` | CONFIG | 否 | Provider 解密、Schema 或白名单校验失败 |
| `RUNTIME_SYNC_FAILED` | TRANSIENT | 是 | Runtime/Skills Git 同步失败 |
| `TIMER_APPLY_FAILED` | CONFIG | 否 | systemd 配置未能应用 |
| `SOURCE_LOCKED` | CONCURRENCY | 是 | 同 source 已在运行 |
| `CLAUDE_START_FAILED` | CONFIG | 否 | Claude CLI 不存在或无法启动 |
| `CLAUDE_INTERRUPTED` | TRANSIENT | 是 | Claude 会话非业务性中断 |
| `MODEL_TIMEOUT` | TRANSIENT | 是 | 模型或 Runtime 超时 |
| `NETWORK_TRANSIENT` | TRANSIENT | 是 | 可恢复网络错误 |
| `SUMMARY_INCOMPLETE` | TRANSIENT | 是 | manifest 仍有未摘要文章 |
| `FINALIZE_FAILED` | PERMANENT | 否 | finalize 数据或文件错误 |
| `PUSH_PARTIAL` | TRANSIENT | 是 | News Hub 部分文章未同步 |
| `PUSH_UNAVAILABLE` | TRANSIENT | 是 | News Hub 暂时不可用 |
| `HUMAN_REQUIRED` | HUMAN | 否 | CAPTCHA、登录或订阅状态失效 |
| `RESULT_UNCERTAIN` | PERMANENT | 否 | 外层成功但无法确认 Skill 阶段结果 |
“是否重试”最终由 SourceSchedule 的 recoveryPolicy 决定;表中只是 Manager 创建规则时的默认建议。
## 8. 并发与锁
### 8.1 不使用全局 Claude 锁
不同 source 允许并行。资源控制分为:
- `maxConcurrentSources`:Agent 节点级同时运行的 source 数。
- `maxConcurrentClaude`:Claude CLI 进程数。
- `maxConcurrentBrowserTabs`:Skills Chromium 全局页签数。
### 8.2 同 source 本地锁
同一个 node/source 必须从采集开始一直锁到摘要、finalize 和可选 push 完成。使用本地 `flock`:
```text
/run/lock/news-harvester/{sourceId}.lock
```
systemd timer、Agent 自愈和本机人工执行必须调用同一个 Runtime wrapper,因此共享同一把锁。
不使用 etcd 分布式锁:同 source 的可变文件都位于单节点,本地文件锁更简单且不会受 etcd Lease 时钟语义影响。
## 9. Provider、凭证与访问控制
### 9.1 etcd 传输与身份
- etcd client 和 peer 全部启用 TLS。
- Agent 使用每节点独立客户端证书,身份为 `agent:{nodeId}`。
- Agent 只读自己的 desired node 子树和所引用的全局资源。
- Agent 只写自己的 status 子树。
- Manager 使用独立管理身份,拥有 desired/audit 读写权限及 status 只读权限。
### 9.2 敏感数据
- etcd 快照可能包含 value,因此 Provider secret 必须应用层信封加密。
- Agent 私钥只保存在本机,权限 `0600`。
- Manager 日志、审计和浏览器响应不得包含明文 secret。
- Runtime Provider JSON 权限 `0600`,且只对固定运行用户可读。
## 10. 一致性与事务
Manager 修改资源时使用 etcd Txn:
```text
IF mod_revision(resourceKey) == submittedResourceVersion
THEN put(resourceKey, newValue), put(auditKey, auditValue)
ELSE conflict
```
- 更新冲突返回 HTTP 409,要求用户刷新后重试。
- 新建使用 `version(key) == 0`。
- 删除使用 `mod_revision` 比较,防止误删新版本。
- source 跨节点迁移使用一个 Txn 同时删除旧 key、创建新 key并写审计。
etcd Watch 用于通知而非读后写一致性判断;需要并发保护的更新必须使用 Txn/CAS。
## 11. YAML 导入语义
Manager 支持导入单个或多个 YAML 文档。导入不是执行命令,而是提交完整期望:
1. YAML 解析禁止重复 key、alias 炸弹和自定义 tag。
2. 按 Kind Schema 校验。
3. 展示 create/update/delete diff。
4. 用户确认后以 etcd Txn 写入。
5. 默认只 create/update,不隐式删除未出现在 YAML 中的资源。
6. 只有显式 `--prune`/UI 勾选时才删除同一管理集合中的缺失资源。
7. `metadata.annotations["news-harvester.io/managed-by"]` 标记 YAML 管理集合。
## 12. 版本演进
- 首版 `apiVersion` 固定为 `news-harvester.io/v1alpha1`。
- Manager 只写当前版本;Agent 至少支持当前版本和前一个兼容版本。
- Schema 变更遵循“新增可选字段优先”。
- 删除或改变字段语义时新增 apiVersion,不原地破坏。
- etcd `/system/schema-version` 只用于启动兼容性检查,不能替代每个对象自身的 apiVersion。
## 13. 验收标准
- Manager 修改 SourceSchedule 后,Agent 在一个协调周期内生成一致的本机 timer/conf。
- Agent 离线时 Manager 仍可修改 desired;Agent 恢复后自动追平。
- Watch 断线或 etcd compaction 后,Agent 可通过全量协调恢复。
- 重复协调不产生额外变化,不重复安装 source,不重复 daemon-reload。
- 删除 schedule 不删除采集数据。
- 同 source 的 timer、自愈、本机手动运行不能并发。
- Manager 能区分节点离线、协调失败、采集失败、需要人工介入、归档成功但推送失败。
- Provider secret 不以明文出现在 etcd、日志、审计和浏览器中。
## 14. 相关实现文档
- [02-agent-go.md](./02-agent-go.md):Go Agent 模块、协调算法、状态上报和自愈。
- [03-manager-typescript-etcd.md](./03-manager-typescript-etcd.md):TypeScript Manager、UI/API、etcd 集群与 Docker Compose。
- [04-runtime-framework.md](./04-runtime-framework.md):固定用户、目录、sudoer、systemd、Runtime wrapper 和现有 Demo 改造。
## 15. 官方技术依据
- [etcd v3 API:KV、Txn、Watch、Lease](https://etcd.io/docs/v3.6/learning/api/)
- [etcd API guarantees](https://etcd.io/docs/v3.5/learning/api_guarantees/)
- [etcd container deployment](https://etcd.io/docs/v3.6/op-guide/container/)
# News Harvester Agent:Go 实现方案
> 状态:实施基线(v1alpha1)
>
> 目标:编译为单一 Linux 二进制,在每台采集节点长期运行,将 etcd 期望状态协调成本机 Runtime。
## 1. Agent 的职责边界
Agent 负责:
- 首次注册并获取节点身份。
- 与 etcd 建立 mTLS 连接。
- 维护节点 heartbeat Lease。
- Watch 本节点 desired 前缀,并周期性全量协调。
- 同步 Runtime 和 Skills Git 版本。
- 检查/安装 source bundle。
- 解密、校验并落地 Claude Provider。
- 渲染并应用 source conf、systemd timer。
- 检测本机配置漂移并恢复期望状态。
- 读取 Runtime 结构化结果,上报每个 source 的 last result。
- 根据 recoveryPolicy 执行本机自愈。
- 按需提供只读的本机 Runtime Web 看板,用于节点现场排障。
Agent 不负责:
- 自己实现网页采集或中文摘要。
- 取代 systemd timer 进行中央调度。
- 接收 Manager 的远程执行命令。
- 保存文章、任务历史或业务数据库。
- 对 CAPTCHA/登录失效无限重试。
- 修改 Manager 写入的 desired 数据。
## 2. 进程与部署模型
每个节点恰好运行一个:
```text
news-harvester-agent.service
└─ /usr/bin/news-harvester-agent run --config /etc/news-harvester/agent.yaml
```
节点排障时可选运行一个独立进程:
```text
news-harvester-agent-web.service
└─ /usr/bin/news-harvester-agent web \
--config /etc/news-harvester/agent.yaml \
--listen 127.0.0.1:8080
```
- 二进制:Go,关闭 CGO,产出 `linux/amd64` 与 `linux/arm64`。
- 运行用户:固定 `news-harvest`。
- 启动方式:systemd,`Restart=always`。
- 本地状态:bbolt 单文件数据库。
- 提权方式:调用 root-owned `nh-root` 受限助手;Agent 本身不以 root 运行。
- `run` 守护进程不开放 HTTP 端口;健康、状态仍通过 etcd 与本机 CLI 上报和查看。
- `web` 是可选、只读且独立生命周期的本机看板;默认仅监听 loopback,故障不得影响 `run`、timer 或采集任务。
## 3. 推荐代码结构
```text
agent/
├── cmd/
│ ├── news-harvester-agent/
│ │ └── main.go
│ └── nh-root/
│ └── main.go
├── internal/
│ ├── buildinfo/ # version / commit / build time
│ ├── config/ # agent.yaml 加载与校验
│ ├── enrollment/ # CSR、首次注册、证书落盘
│ ├── etcdstore/ # client、Watch、Txn、Lease
│ ├── contracts/ # v1alpha1 类型与 JSON Schema 校验
│ ├── controller/ # 控制器生命周期与工作队列
│ ├── reconcile/
│ │ ├── node.go
│ │ ├── schedule.go
│ │ ├── provider.go
│ │ └── runtime_release.go
│ ├── runtime/
│ │ ├── git.go
│ │ ├── source_manager.go
│ │ ├── renderer.go
│ │ ├── systemd.go
│ │ └── result_reader.go
│ ├── recovery/
│ │ ├── classifier.go
│ │ ├── policy.go
│ │ └── scheduler.go
│ ├── status/ # conditions、presence、last-result
│ ├── localstate/ # bbolt applied hash / attempts / revision
│ ├── privilege/ # nh-root 协议客户端/服务端
│ ├── web/ # 本机 Runtime 事实看板
│ │ ├── server.go
│ │ ├── routes.go
│ │ ├── viewmodel.go
│ │ ├── redaction.go
│ │ └── templates/ # go:embed 静态模板与资源
│ └── logging/ # slog JSON / journald fields
├── schemas/ # 从 contracts 包同步的 JSON Schema
├── packaging/
│ ├── news-harvester-agent.service
│ ├── news-harvester-agent-web.service
│ ├── sudoers-news-harvester
│ └── install.sh
├── go.mod
└── Makefile
```
## 4. 主要依赖
保持依赖数量少且可静态编译:
| 依赖 | 用途 |
|---|---|
| `go.etcd.io/etcd/client/v3` | etcd KV、Txn、Watch、Lease |
| `github.com/santhosh-tekuri/jsonschema/v6` | 按共享 JSON Schema 校验资源 |
| `go.etcd.io/bbolt` | 本地 applied state、revision、自愈计数 |
| `gopkg.in/yaml.v3` | 读取本机 agent.yaml |
| `filippo.io/age` | 解密分配给本节点的 Provider DEK |
| Go 标准库 `log/slog` | 结构化日志 |
不嵌入 Docker、Node.js、Claude SDK 或浏览器库。Agent 只协调已经安装的 Runtime。
## 5. 本机配置 agent.yaml
`/etc/news-harvester/agent.yaml` 由 root 安装脚本生成,权限 `0640 root:news-harvest`。
```yaml
version: 1
nodeId: collector-shanghai-01
etcd:
endpoints:
- https://manager.example.com:2379
- https://manager.example.com:22379
- https://manager.example.com:32379
caFile: /etc/news-harvester/pki/ca.pem
certFile: /etc/news-harvester/pki/agent.pem
keyFile: /etc/news-harvester/pki/agent-key.pem
dialTimeout: 5s
requestTimeout: 10s
paths:
stateDir: /var/lib/news-harvester/agent
workDir: /var/lib/news-harvester/work
logDir: /var/log/news-harvester
runtimeRoot: /opt/news-harvester/runtime
skillsRoot: /var/lib/news-harvester/skills/news-hub-skills
vaultPath: /var/lib/news-harvester/vault
sourceConfigDir: /etc/news-harvester/sources
providerDir: /etc/news-harvester/providers
runtime:
claudeBin: /usr/local/bin/claude
nodeBin: /usr/bin/node
rootHelper: /usr/libexec/news-harvester/nh-root
identity:
ageIdentityFile: /etc/news-harvester/pki/age-identity.txt
heartbeat:
ttlSeconds: 30
keepAliveSeconds: 10
reconcile:
fullInterval: 60s
debounce: 500ms
commandTimeout: 30s
web:
enabled: false
listen: 127.0.0.1:8080
authFile: /etc/news-harvester/web-auth.yaml
maxLogBytes: 262144
maxRunsPerSource: 20
```
本机路径不由 Manager 下发。这些路径在安装时一次确定,使不同发行版、磁盘挂载和 vault 布局可以独立处理。
## 6. 启动生命周期
```text
加载并校验 agent.yaml
→ 检查文件权限、nodeId、证书和 age identity
→ 打开本地 bbolt(加进程独占锁)
→ 连接任一 etcd endpoint
→ 线性读取 Node desired + RuntimeRelease + schedules
→ 建立 presence Lease
→ 执行首次全量协调
→ 从读取 revision+1 建立 Watch
→ 启动周期性全量协调、结果扫描、自愈控制器
```
如果 etcd 暂时不可达:
- 已经存在的 systemd timer 保持运行,不因控制面故障停止采集。
- Agent 指数退避重连,最大退避 60 秒并增加随机抖动。
- 本机结果继续落盘;重连后再覆盖上报 last-result。
- 不使用过期缓存主动创建新 timer,但可以继续维护已有 Runtime。
如果 Node `spec.enabled=false`:
- disable/stop 所有由 Agent 管理的 timer。
- 不删除配置和本地数据。
- Agent 本身保持在线,等待重新启用。
## 7. Watch 与工作队列
### 7.1 Watch 范围
Agent 只监听:
```text
/news-harvester/v1/desired/nodes/{nodeId}/
/news-harvester/v1/desired/runtime-releases/
/news-harvester/v1/desired/providers/
```
全局资源事件先检查是否被本节点引用;未引用资源不触发协调。
### 7.2 队列模型
使用内存去重队列,key 为:
```text
node
runtime:{releaseName}
provider:{providerName}
schedule:{scheduleName}
full
```
规则:
- 同 key 在队列中只保留一次。
- 500ms debounce 合并连续 UI 编辑。
- 每节点只允许一个“配置应用事务”进入 privileged helper。
- source 独立的只读检查可以并行,实际 apply 串行,减少 daemon-reload 次数。
- 多个 schedule 变化在一次全量协调中批量渲染,最后只执行一次 daemon-reload。
### 7.3 Watch 恢复
- 每个成功处理的 Watch response revision 写入 bbolt。
- 断线后从最后 revision + 1 重建 Watch。
- 收到 compacted revision 时停止增量恢复,执行线性全量 List,保存新 revision,再建立 Watch。
- 每 60 秒强制全量比对,即使没有事件也检查本机漂移。
## 8. SourceSchedule 协调算法
### 8.1 计算有效配置
字段优先级:
```text
SourceSchedule.spec.harvest > Node.spec.defaults > 程序安全默认值
```
拒绝以下期望:
- sourceId 或 scheduleName 不符合名称规范。
- count、预算、timeout 越界。
- OnCalendar 无法通过 `systemd-analyze calendar` 验证。
- 引用不存在/disabled 的 Provider 或 RuntimeRelease。
- RuntimeRelease 与当前 Agent 版本不兼容。
### 8.2 协调步骤
```text
1. 读取并校验最新 desired
2. 解析 RuntimeRelease
3. 确保 Runtime Git ref 已就绪
4. 确保 Skills Git ref 已就绪
5. 检查 source-manager 是否已有 sourceId
6. 缺失时查找 bundles/{sourceId}.nhsource.json 并安装
7. 验证引用 Provider,解密后生成 provider JSON
8. 生成 source conf、timer、状态采集 drop-in
9. 计算 desiredHash,与 appliedHash 和磁盘 hash 比较
10. 有变化时提交给 nh-root 一次性 apply
11. daemon-reload,按 enabled 状态 enable/disable timer
12. 读取 systemd 实际状态并写 ScheduleStatus
```
任何步骤失败均停止后续会扩大影响的操作。例如 source 安装失败时不启用 timer。
### 8.3 幂等
每份生成文件头部包含:
```text
# Managed by news-harvester-agent
# resource: SourceSchedule/nikkei-world
# generation: 7
# desired-sha256: ...
```
Agent 比较:
- etcd generation。
- 规范化 desired SHA-256。
- 本机生成文件 SHA-256。
- systemd enable/active 状态。
四者一致时协调为 no-op,不运行 source-manager、不写文件、不 daemon-reload。
### 8.4 删除和垃圾回收
全量协调时列出本机所有带管理标记的 schedule 文件:
- desired 中不存在:disable/stop,删除 timer/conf。
- 没有管理标记:视为人工配置,不修改,只报告 `UnmanagedConflict`。
- 日志、vault、registry、Skills source 配置不自动删除。
## 9. Runtime 与 Skills Git 同步
### 9.1 版本规则
- 生产 RuntimeRelease 必须使用不可变 tag 或 commit。
- branch 只允许开发环境使用。
- Agent `git fetch` 后解析 ref,验证得到的 commit 与 `spec.commit`(若提供)一致。
- 所有命令使用参数数组执行,不拼接 shell 字符串。
- 禁止 Git hooks,配置 `core.hooksPath` 指向空目录。
### 9.2 Runtime 发布目录
```text
/opt/news-harvester/runtime/
├── releases/{commit}/
└── current -> releases/{commit}
```
Agent 在自己的 staging 目录准备 release,由 `nh-root` 校验目标路径并原子切换 `current` symlink。已运行的服务不被中断,新任务使用新版本。
### 9.3 Skills 仓库
Skills 使用独立仓库:
```text
/var/lib/news-harvester/skills/news-hub-skills
```
source bundle 固定来自:
```text
skills/news-harvester/bundles/{sourceId}.nhsource.json
```
Agent 不接受 desired 中的任意 bundle 路径或 URL,避免路径穿越和非预期代码来源。
## 10. Provider 协调
流程:
1. 读取 Provider 资源。
2. 查找 `wrappedKeys[nodeId]`。
3. 使用本机 age identity 解密 DEK。
4. 使用 AES-256-GCM 解密 secretData 并验证认证标签。
5. 合并 publicEnv 与 secret env。
6. 只保留 Claude Provider 白名单 key。
7. 生成 `/etc/news-harvester/providers/{name}.json` staging 内容。
8. 通过 `nh-root` 安装为 `0600 news-harvest:news-harvest`。
Provider 发生变化不打断正在运行的 Claude;下一次任务读取新配置。
当 Provider 无法解密时,所有引用它的 schedule 条件变为 `ProviderReady=False`,timer 默认保持 disabled。不得静默回退到默认 Provider,因为在批量节点环境中静默回退会导致成本和模型不可控。
## 11. 状态上报
### 11.1 Presence
- TTL:30 秒。
- keepalive:每 10 秒。
- value 只包含 `nodeId`、`agentVersion`、`bootId`、`startedAt`。
- Lease 失效后重新 Grant;不能继续向旧 Lease 写入。
### 11.2 Node snapshot
以下事件立即更新:
- Agent 启动或版本变化。
- 首次/全量协调完成。
- Runtime/Skills commit 变化。
- Node Ready condition 变化。
正常心跳时每 60 秒最多更新一次 `lastSeenAt`,避免无意义 etcd 写放大。
### 11.3 Schedule status
每次协调后覆盖写入,包含 observedGeneration、hash、source/timer 实际状态和 Conditions。message 截断到 1024 字符,完整错误留在 journald。
### 11.4 Last result
Agent 使用两种方式发现 Runtime 新结果:
- fsnotify 监听 `/var/lib/news-harvester/results/`。
- 每 30 秒按 mtime 扫描兜底,避免漏事件。
只接受满足以下条件的 JSON:
- 文件 owner 是 `news-harvest`。
- 非符号链接,普通文件,大小不超过 1 MiB。
- 路径在固定 results 目录内。
- 通过 SourceLastResult Schema。
- runId 尚未处理,或 finishedAt 新于 etcd 当前结果。
上报成功后在 bbolt 记录 runId。文件仍按本地保留策略保留,不由 Agent立即删除。
## 12. 自愈控制器
### 12.1 输入
- 最新 SourceLastResult。
- 对应 SourceSchedule recoveryPolicy。
- 本地 recovery attempt 记录。
- 当前 source 锁、timer/service 状态。
### 12.2 决策
```text
outcome SUCCEEDED/SKIPPED(normal exhausted) → 不处理
error.code 没有匹配 rule → 不处理
rule.action=none → 不处理,保留告警
attempt >= maxAttempts → 标记 RecoveryExhausted
尚未到 nextAttemptAt → 等待
source 当前运行 → 延后
否则 → 创建本地 recovery request 并启动 recovery service
```
### 12.3 动作映射
| action | Runtime 行为 |
|---|---|
| `resume-session` | 使用上次 sessionId 调用受控 resume;无 sessionId 时失败,不自动转 rerun |
| `resume-summary` | 根据 `.manifest.json/.summaries.json/registry sentinel` 补摘要并 finalize |
| `retry-push` | 对 dayDir 调用 Skill `push.js`,利用 `.syncstate.json` 续推 |
| `rerun-harvest` | 重新进入完整流程;默认策略不启用 |
| `none` | 只上报 |
自愈由固定 `news-harvester-recovery@.service` 执行,仍经过相同 source flock。Agent 不把长时间 Claude 子进程直接挂在自身进程下。
### 12.4 必须停止的错误
以下错误不允许自动升级为其他动作:
- `HUMAN_REQUIRED`
- `SOURCE_NOT_FOUND`
- `PROVIDER_INVALID`
- `FINALIZE_FAILED`(数据不一致)
- 连续达到 maxAttempts
只有 Manager 修改期望或本机人工处理后产生新结果,才重新评估。
## 13. 受限提权协议
Agent 通过:
```text
sudo /usr/libexec/news-harvester/nh-root
```
在 stdin 发送有版本的 JSON 请求。`nh-root` 只支持固定动作:
```ts
type RootAction =
| "apply-runtime-release"
| "apply-schedule-files"
| "delete-schedule-files"
| "daemon-reload"
| "enable-timer"
| "disable-timer"
| "start-recovery"
| "inspect-systemd";
```
安全要求:
- source/unit 名称再次校验。
- 所有源和目标目录使用固定 allowlist。
- 拒绝绝对路径参数、`..`、符号链接目标和未知 JSON 字段。
- staging 文件通过已打开 fd 读取,避免校验后替换。
- 使用临时文件、fsync、rename 原子安装。
- 每次调用记录 action、nodeId、source、hash,不记录 secret。
- sudoers 只允许无参数执行这一个 root-owned 二进制。
详细权限模型见 [04-runtime-framework.md](./04-runtime-framework.md)。
## 14. 本机 Runtime Web 看板
### 14.1 定位和启动方式
Web 看板展示 Agent 已经协调到本机后的**实际产物和运行事实**,不展示 etcd 中尚未落实的期望,也不把 etcd 当作页面数据源。即使 Manager 或 etcd 临时不可用,看板仍应能够完成节点排障。
手动启动:
```text
news-harvester-agent web \
--config /etc/news-harvester/agent.yaml \
--listen 127.0.0.1:8080
```
长期启用时,由 installer 根据 `web.enabled` 启用独立的 `news-harvester-agent-web.service`;该字段只控制 service 是否自动启用,不限制人工执行 `web` 子命令。Web 不能嵌入 `agent run` 进程,避免页面、模板或端口故障影响协调控制器。
默认通过 SSH tunnel 访问:
```text
ssh -L 8080:127.0.0.1:8080 collector-shanghai-01
```
然后访问 `http://127.0.0.1:8080`。绑定非 loopback 地址必须显式配置认证;生产环境建议置于带 TLS 和访问控制的反向代理后,不能直接暴露匿名面板。
### 14.2 本机事实来源
看板只通过与 Agent 协调器共用的只读 adapter 读取:
- `/etc/news-harvester/sources/*.conf` 的实际受管配置,敏感值脱敏。
- systemd 中 service/timer 的 enabled、active、last/next trigger 和退出状态。
- `/var/lib/news-harvester/results/{source}.json` 的 SourceLastResult。
- `/var/lib/news-harvester/runs/{source}/{runId}/` 中有大小和行数上限的执行证据与日志。
- Runtime/Skills 当前 symlink、版本和 commit。
- source-manager 实际已安装 source、依赖检查、目录权限和磁盘状态。
- Agent 本机保存的 applied generation/hash;它表示已经应用的事实,不代表 etcd 当前期望。
不得从页面直接查询、合并或渲染 etcd desired。页面需要明确区分“本机已应用版本”和“本机实际运行状态”。
### 14.3 页面与接口
```text
GET / 节点 Runtime 总览
GET /sources/{source} source 配置、timer、最近结果
GET /runs/{source}/{runId} 有界且脱敏的运行证据
GET /runtime 版本、路径、依赖、权限检查
GET /api/local/v1/status 本机只读 JSON 状态
GET /healthz Web 进程自身健康
```
`healthz` 只代表 Web 能响应,不等价于 Agent Ready 或 source 健康。
### 14.4 严格只读边界
看板不提供:
- add/modify/delete source。
- enable/disable timer。
- run/retry/recover/continue。
- Provider 增删改或 secret 查看。
- Runtime 升级、回滚或任何 systemctl 写操作。
除认证会话所需请求外,所有写方法和旧控制路由返回 `405 Method Not Allowed`。Source 和 Provider 的期望变更只能由 Manager 写入 etcd;紧急人工操作使用节点 CLI 或受控的系统命令,不能借 Web 绕过权限边界。
### 14.5 安全和数据最小化
- 默认仅监听 `127.0.0.1`;非 loopback 监听时 `authFile` 必填且启动失败要 fail closed。
- 不显示 Provider secret、Claude settings、etcd 证书/私钥、Cookie、文章正文或摘要正文。
- source/runId 必须按白名单校验,拒绝 `..`、绝对路径、符号链接和越界文件。
- 日志按 `maxLogBytes`、最大行数和 `maxRunsPerSource` 截断,并统一脱敏。
- HTML 模板默认转义,配置 CSP、`X-Content-Type-Options` 和安全的 session cookie。
- 记录访问日志,但不记录 query 中的敏感内容。
现有 Flask 页面在迁移期作为行为参考和临时只读面板保留;Go Web 达到页面和诊断能力等价后,再删除 Flask/Python 运行依赖。
## 15. 日志与可观测性
使用 JSON `slog` 输出到 journald,公共字段:
```text
component, node_id, controller, resource_kind, resource_name,
generation, reconcile_id, run_id, error_code, duration_ms
```
不得记录:
- Provider secret。
- Claude 完整 settings JSON。
- etcd 客户端私钥。
- 文章正文和摘要正文。
本机诊断命令:
```text
news-harvester-agent validate-config
news-harvester-agent doctor
news-harvester-agent reconcile --dry-run
news-harvester-agent status
news-harvester-agent web --help
```
这些命令只读;正常生产协调仍由 daemon 完成。
## 16. 优雅退出
收到 SIGTERM:
1. 停止接收新协调和自愈工作。
2. 取消 Watch。
3. 等待正在执行的短协调步骤,最长 20 秒。
4. 撤销 presence Lease或关闭 keepalive,让节点尽快显示离线。
5. flush bbolt 并退出。
不会停止已经由 systemd 启动的采集任务。
## 17. 测试策略
### 17.1 单元测试
- Schema 与默认值。
- generation/hash/no-op 判断。
- Provider 白名单和解密失败。
- 错误分类和 recoveryPolicy。
- 路径、unit/source name 防注入。
- Condition transition time。
- Web view model 脱敏、日志截断和路径越界拒绝。
### 17.2 集成测试
- 临时单节点 etcd:Watch、compaction 恢复、Lease 失效。
- fake `nh-root`:协调顺序和失败回滚。
- 临时 Git 仓库:ref/commit、source bundle 查找。
- Runtime 结果目录:fsnotify 漏失后的扫描恢复。
- Agent 重启:bbolt revision、自愈 attempt 不丢失。
- etcd 完全不可达时,Web 仍可展示本机 timer、配置和最后结果。
- Web 进程退出时,Agent daemon 和 timer 不受影响。
### 17.3 节点验收
- 安装后无人工编辑即可上线。
- 断开 etcd 不影响已安装 timer。
- 恢复网络后配置自动追平。
- 人工篡改受管 timer 后一个周期内恢复。
- 同 source timer 与 recovery 同时触发时只有一个进入执行。
- Agent 用户无法写任意 `/etc` 文件或启动任意 systemd unit。
- Web 默认只监听 loopback,且不存在 source/provider/systemd 写接口。
- 页面和 JSON 接口不泄漏 Provider、Cookie、证书或 Claude 配置。
## 18. 实施顺序
1. contracts、config、etcd client、presence。
2. Node/SourceSchedule 全量协调和 status。
3. `nh-root` 与 Runtime 文件渲染。
4. Runtime/Skills Git 版本协调和 source 自动安装。
5. Provider 信封加密与节点解密。
6. Runtime result ingestion。
7. 本机只读 Runtime Web 子命令和独立 service。
8. recoveryPolicy 与 recovery service。
9. Agent 二进制升级能力。
每阶段都保持 Agent 可运行,不用等全部功能完成才首次部署。
# News Harvester Manager:TypeScript 全栈与 etcd 部署方案
> 状态:实施基线(v1alpha1)
>
> 目标:一个 TypeScript 项目、一个 Manager 容器完成 UI、服务端操作、Agent 注册和状态查询,不拆独立前后端。
## 1. 技术选型
### 1.1 Manager
采用 Next.js App Router 全栈应用:
- UI:React Server Components + 少量 Client Components。
- 读取:Server Components 直接调用 server-only etcd repository。
- 修改:Server Actions,统一做身份、Schema、引用和 CAS 校验。
- API:Route Handlers,仅用于 Agent enrollment、健康检查、状态轮询和 YAML 导入导出。
- 部署:`output: "standalone"`,构建为单一 Docker 镜像。
- 运行方式:长驻 Node.js 容器,不使用 serverless/lambda。
Manager 不需要独立 API 服务。浏览器不直接连接 etcd,也不接触 etcd 证书或 Provider secret。
### 1.2 数据与校验
| 能力 | 选择 |
|---|---|
| etcd client | `etcd3`,封装在 repository adapter 后,锁定精确版本 |
| Schema | TypeBox 作为 TypeScript 类型与 JSON Schema 的单一来源 |
| runtime validation | Ajv |
| YAML | `yaml`,关闭自定义 tag、限制 alias 和文档大小 |
| 表单 | React Hook Form + 从共享 Schema 派生的校验 |
| 样式 | Tailwind CSS + 项目内组件,不单独部署 UI 库 |
| 认证 | 内置账号会话;v1 单管理员,后续可替换 OIDC adapter |
| 日志 | JSON 结构化日志到 stdout/journald/Docker logs |
不引入 PostgreSQL、Redis、消息队列。etcd 保存期望、状态和轻量审计;文章与任务历史不进入 Manager。
## 2. 代码结构
```text
manager/
├── app/
│ ├── (auth)/login/page.tsx
│ ├── (console)/
│ │ ├── page.tsx
│ │ ├── nodes/
│ │ ├── schedules/
│ │ ├── providers/
│ │ ├── runtime-releases/
│ │ ├── import/
│ │ └── audit/
│ └── api/
│ ├── health/route.ts
│ ├── readiness/route.ts
│ ├── status/route.ts
│ ├── agent/enroll/route.ts
│ └── export/route.ts
├── src/
│ ├── contracts/
│ │ ├── metadata.ts
│ │ ├── node.ts
│ │ ├── source-schedule.ts
│ │ ├── provider.ts
│ │ ├── runtime-release.ts
│ │ └── status.ts
│ ├── auth/
│ ├── dal/ # 认证后的数据访问入口
│ ├── etcd/
│ │ ├── client.ts
│ │ ├── keys.ts
│ │ ├── repository.ts
│ │ ├── transactions.ts
│ │ └── auth-admin.ts
│ ├── services/
│ │ ├── node-service.ts
│ │ ├── schedule-service.ts
│ │ ├── provider-service.ts
│ │ ├── release-service.ts
│ │ ├── enrollment-service.ts
│ │ └── import-service.ts
│ ├── crypto/
│ │ ├── envelope.ts
│ │ └── redaction.ts
│ ├── audit/
│ └── ui/
├── public/
├── scripts/
│ ├── bootstrap-etcd.ts
│ ├── create-admin.ts
│ └── generate-schema.ts
├── dist-cli/ # 构建阶段产出的运维 CLI
├── Dockerfile
├── next.config.ts
├── package.json
└── pnpm-lock.yaml
```
`src/etcd`、`src/crypto` 和所有凭证环境变量必须标记为 server-only,禁止被客户端 bundle 引用。
## 3. Manager 页面与使用方式
### 3.1 Dashboard
只展示运维真正需要的信息:
- 在线/离线 Agent 数。
- 协调失败节点和 source。
- 最近采集失败、降级、需要人工介入的 source。
- Runtime/Skills 版本分布。
- 最近 24 小时配置变更。
不提供“立即采集”“远程修复”“执行 systemctl”按钮。
### 3.2 Nodes
- 注册状态、在线 Lease、最后心跳。
- Agent、Runtime、Skills 版本。
- 容量限制。
- 节点上的 SourceSchedule 列表。
- 协调 Conditions 和最近错误。
- 启用/停用节点。
### 3.3 Source schedules
- 选择 node 和 sourceId。
- 编辑 systemd OnCalendar、时区、count、Provider、预算、timeout。
- 编辑 recoveryPolicy。
- 显示 desired generation / observedGeneration。
- 显示 timer 实际状态、下次触发时间和 SourceLastResult。
- 支持复制 schedule 到另一节点;迁移使用原子 Txn。
### 3.4 Providers
- 新建、修改、禁用 Provider。
- secret 输入后只允许覆盖,不允许重新显示明文。
- 展示引用它的 Node/SourceSchedule。
- 保存时为每个被分配节点生成 wrapped key。
- 节点新增引用时为该节点补一份 wrapped key,不重新加密其他节点数据。
### 3.5 Runtime releases
- 配置 Runtime Git repo/ref/commit。
- 配置 Skills Git repo/ref/commit。
- 查看各节点 observed commit。
- 浮动 branch 显示警告,生产发布要求 tag 或 commit。
### 3.6 YAML import/export
- 浏览器中预览解析结果和 diff。
- 默认 create/update,不 prune。
- 冲突资源逐条显示 resourceVersion 变化。
- secret 导出永远只包含 `redacted: true`,不输出 ciphertext 或 wrapped key。
## 4. 服务端数据访问规则
### 4.1 统一 DAL
Page、Server Action、Route Handler 都必须通过 DAL:
```text
verifySession
→ authorize(action, resource)
→ validate(input)
→ service business checks
→ etcd repository Txn
→ sanitized DTO
```
不能只在页面布局中检查登录,因为 Route Handler 和 Server Action 都是独立入口。
### 4.2 读取
- desired 使用线性一致读。
- dashboard status 可使用普通线性读,不需要 Watch 常驻连接。
- 浏览器每 5 秒轮询 `/api/status?scope=dashboard`;页面隐藏时降到 30 秒。
- 节点详情按需读取对应 key 前缀,避免每次拉取整个集群。
- 返回 `resourceVersion=mod_revision`,用于后续更新。
### 4.3 写入
每次 mutation 必须在一个 etcd Txn 中完成:
- 比较 mod_revision/version。
- 写 desired。
- 写同一操作的 audit entry。
- 涉及引用关系时,在事务前再次线性读取并校验。
更新冲突返回 409,不做 last-write-wins 覆盖。
### 4.4 引用完整性
- SourceSchedule.providerRef 必须引用 enabled Provider。
- Node.runtimeReleaseRef 必须引用 enabled RuntimeRelease。
- Provider/RuntimeRelease 被引用时禁止硬删除,只允许 disabled 或解除引用后删除。
- Node 删除前必须迁移或删除其 SourceSchedule。
etcd 没有外键,这些规则由 Manager service 强制执行;Agent仍需再次校验,不能盲信控制面。
## 5. Agent Enrollment
Enrollment 是 Manager 唯一直接面向 Agent 的控制接口,之后 Agent 直接连接 etcd。
### 5.1 一次性 token
管理员在 Manager 创建:
- token 只显示一次。
- 绑定可选 nodeId 前缀/标签。
- 默认 15 分钟过期。
- 默认只能使用一次。
- etcd 中只保存 token 的 Argon2id hash。
### 5.2 注册请求
```json
{
"nodeId": "collector-shanghai-01",
"hostname": "collector01",
"agentVersion": "1.0.0",
"csrPem": "-----BEGIN CERTIFICATE REQUEST-----...",
"ageRecipient": "age1...",
"bootstrapToken": "..."
}
```
### 5.3 Manager 操作
1. 校验 token、nodeId 和 CSR 公钥。
2. 拒绝已被其他机器占用的 nodeId。
3. 使用 etcd client CA 签发短期客户端证书,CN=`agent:{nodeId}`。
4. 创建同名 etcd user 和 node-scoped role。
5. 授权读取本节点 desired、读取所需全局资源、写本节点 status。
6. 创建初始 Node desired(默认 disabled,等待管理员确认)。
7. 保存 ageRecipient 到 Node annotation 或独立受控字段。
8. 消耗一次性 token并写审计。
9. 返回 CA、agent cert、etcd endpoints 和 Node bootstrap 配置。
Agent 私钥和 age identity 在节点本地生成,绝不上传 Manager。
### 5.4 证书轮换
- 客户端证书建议 90 天有效。
- Agent 在剩余 30 天时用当前证书请求轮换。
- Manager 只允许相同 nodeId 和公钥,或要求管理员批准公钥变化。
- etcd 每次新 TLS 连接读取证书文件,Agent 原子替换后重连即可。
## 6. Provider 信封加密
### 6.1 写入
```text
浏览器提交 secret
→ Manager 生成随机 256-bit DEK
→ AES-256-GCM 加密 secret JSON
→ 针对每个目标 node 的 ageRecipient 包装 DEK
→ 清空请求内明文 buffer(best effort)
→ etcd Txn 写 Provider + audit(redacted)
```
Manager 自己也需要一个 wrapped key,便于新增节点引用或轮换 secret。Manager age identity 通过 Docker secret 挂载,不进入镜像、compose 文件或 etcd。
### 6.2 展示
Provider API 返回:
```json
{
"name": "primary",
"enabled": true,
"publicEnv": {"ANTHROPIC_BASE_URL": "https://..."},
"secretKeys": ["ANTHROPIC_AUTH_TOKEN"],
"secretConfigured": true,
"recipients": ["collector-shanghai-01"]
}
```
不返回 ciphertext、nonce、wrappedKeys 和任何明文片段。
## 7. Manager 认证与授权
v1 采用内置单管理员模式,适合当前内网部署:
- 管理员密码只保存 Argon2id hash。
- 会话使用 HttpOnly、Secure、SameSite=Lax cookie。
- 会话服务端签名和加密,密钥来自 Docker secret。
- 登录、Provider 修改、节点删除、YAML prune 增加速率限制和二次确认。
- 所有 mutation 重新验证 session 和角色。
- 反向代理部署时配置可信 host/proxy,Server Actions 只允许同源。
接口仍按 `admin`、`viewer` 两种角色设计,v1 UI 可以只创建 admin;后续接 OIDC 时不改业务 service。
## 8. 审计
审计对象包含:
```ts
interface AuditEntry {
id: string;
at: string;
actor: { type: "user" | "system"; id: string };
action: "create" | "update" | "delete" | "move" | "enroll" | "rotate";
resource: { kind: string; key: string; name: string };
beforeGeneration?: number;
afterGeneration?: number;
changedPaths: string[];
requestId: string;
clientIp?: string;
}
```
- 不记录完整 before/after value,避免 secret 泄漏和存储膨胀。
- `changedPaths` 例如 `spec.schedule.onCalendar`。
- 默认保留 180 天,由 Manager maintenance 命令清理。
## 9. HTTP 接口
### 9.1 面向浏览器
| 接口 | 方法 | 用途 |
|---|---|---|
| `/api/health` | GET | 进程健康,不检查 etcd |
| `/api/readiness` | GET | 检查 etcd quorum 可访问与 schema compatible |
| `/api/status` | GET | UI 轮询的脱敏状态 DTO |
| `/api/export` | GET | YAML 导出 |
| `/api/import/preview` | POST | 校验并返回 diff,不写入 |
| `/api/import/apply` | POST | 确认后事务写入 |
普通 CRUD 优先使用 Server Actions,减少重复 API 层。
### 9.2 面向 Agent
| 接口 | 方法 | 用途 |
|---|---|---|
| `/api/agent/enroll` | POST | 首次注册 |
| `/api/agent/rotate-certificate` | POST | 客户端证书轮换 |
没有 run/retry/repair endpoint。
## 10. Docker 镜像
Manager 使用多阶段构建:
```dockerfile
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build && pnpm build:cli
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S manager && adduser -S manager -G manager
COPY --from=build --chown=manager:manager /app/.next/standalone ./
COPY --from=build --chown=manager:manager /app/.next/static ./.next/static
COPY --from=build --chown=manager:manager /app/public ./public
COPY --from=build --chown=manager:manager /app/dist-cli ./bin
USER manager
EXPOSE 3000
CMD ["node", "server.js"]
```
构建时锁定 Node、pnpm 和依赖版本;生产镜像不包含源码、开发依赖、CA 私钥生成工具。
## 11. 单机三成员 etcd Docker Compose
### 11.1 定位
一个物理机运行三个 etcd member:
- 可以容忍一个 etcd 容器/进程/volume 暂时故障。
- 可以验证真实 quorum、member 和客户端多 endpoint 行为。
- 不能容忍物理机、电源、磁盘控制器或 Docker daemon 整体故障。
真正高可用时将三个 member 迁移到三台机器,Manager 和 Agent 数据模型不变。
### 11.2 目录
```text
deploy/manager/
├── compose.yaml
├── .env.example
├── pki/
│ ├── ca.pem
│ ├── etcd/
│ │ ├── server.pem
│ │ ├── server-key.pem
│ │ ├── peer.pem
│ │ └── peer-key.pem
│ ├── health/
│ │ ├── client.pem
│ │ └── client-key.pem
│ └── manager/
│ ├── client.pem
│ └── client-key.pem
└── secrets/
├── session-key
├── manager-age-identity
├── etcd-client-ca-key
└── admin-password-hash
```
PKI 初始化脚本必须把 `etcd1`、`etcd2`、`etcd3`、外部 `ETCD_PUBLIC_HOST` 和健康检查地址写入 etcd server/peer 证书 SAN。Manager 与 health 使用独立 client 证书,容器只挂载自己需要的私钥。
### 11.3 compose 基线
```yaml
services:
etcd1:
image: gcr.io/etcd-development/etcd:${ETCD_VERSION:-v3.6.0}
restart: unless-stopped
command:
- /usr/local/bin/etcd
- --name=etcd1
- --data-dir=/etcd-data
- --listen-client-urls=https://0.0.0.0:2379
- --advertise-client-urls=https://${ETCD_PUBLIC_HOST}:2379
- --listen-peer-urls=https://0.0.0.0:2380
- --initial-advertise-peer-urls=https://etcd1:2380
- --initial-cluster=etcd1=https://etcd1:2380,etcd2=https://etcd2:2380,etcd3=https://etcd3:2380
- --initial-cluster-state=new
- --initial-cluster-token=${ETCD_CLUSTER_TOKEN}
- --cert-file=/pki/etcd/server.pem
- --key-file=/pki/etcd/server-key.pem
- --trusted-ca-file=/pki/ca.pem
- --client-cert-auth=true
- --peer-cert-file=/pki/etcd/peer.pem
- --peer-key-file=/pki/etcd/peer-key.pem
- --peer-trusted-ca-file=/pki/ca.pem
- --peer-client-cert-auth=true
- --tls-min-version=TLS1.2
ports:
- "${ETCD_BIND_IP:-0.0.0.0}:2379:2379"
volumes:
- etcd1-data:/etcd-data
- ./pki/ca.pem:/pki/ca.pem:ro
- ./pki/etcd:/pki/etcd:ro
- ./pki/health:/pki/health:ro
networks: [control]
healthcheck:
test: ["CMD", "/usr/local/bin/etcdctl", "--endpoints=https://etcd1:2379", "--cacert=/pki/ca.pem", "--cert=/pki/health/client.pem", "--key=/pki/health/client-key.pem", "endpoint", "health"]
interval: 10s
timeout: 5s
retries: 12
etcd2:
image: gcr.io/etcd-development/etcd:${ETCD_VERSION:-v3.6.0}
restart: unless-stopped
command:
- /usr/local/bin/etcd
- --name=etcd2
- --data-dir=/etcd-data
- --listen-client-urls=https://0.0.0.0:2379
- --advertise-client-urls=https://${ETCD_PUBLIC_HOST}:22379
- --listen-peer-urls=https://0.0.0.0:2380
- --initial-advertise-peer-urls=https://etcd2:2380
- --initial-cluster=etcd1=https://etcd1:2380,etcd2=https://etcd2:2380,etcd3=https://etcd3:2380
- --initial-cluster-state=new
- --initial-cluster-token=${ETCD_CLUSTER_TOKEN}
- --cert-file=/pki/etcd/server.pem
- --key-file=/pki/etcd/server-key.pem
- --trusted-ca-file=/pki/ca.pem
- --client-cert-auth=true
- --peer-cert-file=/pki/etcd/peer.pem
- --peer-key-file=/pki/etcd/peer-key.pem
- --peer-trusted-ca-file=/pki/ca.pem
- --peer-client-cert-auth=true
- --tls-min-version=TLS1.2
ports:
- "${ETCD_BIND_IP:-0.0.0.0}:22379:2379"
volumes:
- etcd2-data:/etcd-data
- ./pki/ca.pem:/pki/ca.pem:ro
- ./pki/etcd:/pki/etcd:ro
- ./pki/health:/pki/health:ro
networks: [control]
healthcheck:
test: ["CMD", "/usr/local/bin/etcdctl", "--endpoints=https://etcd2:2379", "--cacert=/pki/ca.pem", "--cert=/pki/health/client.pem", "--key=/pki/health/client-key.pem", "endpoint", "health"]
interval: 10s
timeout: 5s
retries: 12
etcd3:
image: gcr.io/etcd-development/etcd:${ETCD_VERSION:-v3.6.0}
restart: unless-stopped
command:
- /usr/local/bin/etcd
- --name=etcd3
- --data-dir=/etcd-data
- --listen-client-urls=https://0.0.0.0:2379
- --advertise-client-urls=https://${ETCD_PUBLIC_HOST}:32379
- --listen-peer-urls=https://0.0.0.0:2380
- --initial-advertise-peer-urls=https://etcd3:2380
- --initial-cluster=etcd1=https://etcd1:2380,etcd2=https://etcd2:2380,etcd3=https://etcd3:2380
- --initial-cluster-state=new
- --initial-cluster-token=${ETCD_CLUSTER_TOKEN}
- --cert-file=/pki/etcd/server.pem
- --key-file=/pki/etcd/server-key.pem
- --trusted-ca-file=/pki/ca.pem
- --client-cert-auth=true
- --peer-cert-file=/pki/etcd/peer.pem
- --peer-key-file=/pki/etcd/peer-key.pem
- --peer-trusted-ca-file=/pki/ca.pem
- --peer-client-cert-auth=true
- --tls-min-version=TLS1.2
ports:
- "${ETCD_BIND_IP:-0.0.0.0}:32379:2379"
volumes:
- etcd3-data:/etcd-data
- ./pki/ca.pem:/pki/ca.pem:ro
- ./pki/etcd:/pki/etcd:ro
- ./pki/health:/pki/health:ro
networks: [control]
healthcheck:
test: ["CMD", "/usr/local/bin/etcdctl", "--endpoints=https://etcd3:2379", "--cacert=/pki/ca.pem", "--cert=/pki/health/client.pem", "--key=/pki/health/client-key.pem", "endpoint", "health"]
interval: 10s
timeout: 5s
retries: 12
etcd-bootstrap:
image: news-harvester-manager:${MANAGER_VERSION}
command: ["node", "/app/bin/bootstrap-etcd.js"]
restart: "no"
environment:
ETCD_ENDPOINTS: https://etcd1:2379,https://etcd2:2379,https://etcd3:2379
ETCD_CA_FILE: /pki/ca.pem
ETCD_CERT_FILE: /pki/manager/client.pem
ETCD_KEY_FILE: /pki/manager/client-key.pem
volumes:
- ./pki/ca.pem:/pki/ca.pem:ro
- ./pki/manager:/pki/manager:ro
depends_on:
etcd1: {condition: service_healthy}
etcd2: {condition: service_healthy}
etcd3: {condition: service_healthy}
networks: [control]
manager:
image: news-harvester-manager:${MANAGER_VERSION}
restart: unless-stopped
environment:
ETCD_ENDPOINTS: https://etcd1:2379,https://etcd2:2379,https://etcd3:2379
ETCD_CA_FILE: /pki/ca.pem
ETCD_CERT_FILE: /pki/manager/client.pem
ETCD_KEY_FILE: /pki/manager/client-key.pem
ETCD_CLIENT_CA_KEY_FILE: /run/secrets/etcd_client_ca_key
SESSION_KEY_FILE: /run/secrets/session_key
MANAGER_AGE_IDENTITY_FILE: /run/secrets/manager_age_identity
ADMIN_PASSWORD_HASH_FILE: /run/secrets/admin_password_hash
ports:
- "${MANAGER_BIND_IP:-0.0.0.0}:3000:3000"
volumes:
- ./pki/ca.pem:/pki/ca.pem:ro
- ./pki/manager:/pki/manager:ro
secrets:
- session_key
- manager_age_identity
- etcd_client_ca_key
- admin_password_hash
depends_on:
etcd-bootstrap: {condition: service_completed_successfully}
networks: [control]
networks:
control:
volumes:
etcd1-data:
etcd2-data:
etcd3-data:
secrets:
session_key:
file: ./secrets/session-key
manager_age_identity:
file: ./secrets/manager-age-identity
etcd_client_ca_key:
file: ./secrets/etcd-client-ca-key
admin_password_hash:
file: ./secrets/admin-password-hash
```
实际提交的 compose 中 etcd1/2/3 公共配置应使用 YAML anchor 减少重复;这里展开是为了清楚展示每个 member 的关键差异。
### 11.4 bootstrap-etcd
`bootstrap-etcd` 必须幂等:
1. 检查三个 endpoint health。
2. 检查 member 数量为 3。
3. 首次启动时创建 `root`、`manager`、`health` 用户及角色,再启用 etcd auth;用户名称分别匹配 client 证书 CN。
4. 已启用时使用 Manager 证书 CN 继续校验权限。
5. 写 `/news-harvester/v1/system/schema-version`。
6. 不覆盖已有 desired/status。
Manager/health 证书 CN 与 etcd user 名必须一致。Manager 拥有控制平面管理权限;health 只用于容器健康检查并使用经验证可满足 `endpoint health` 的最小权限。Agent enrollment 同样创建 CN=`agent:{nodeId}` 的证书与用户。
用于签发 Agent 客户端证书的 CA 私钥只作为 Manager Docker secret 挂载,不放入 `pki/` 公共挂载目录,也不进入镜像。`bootstrap-etcd` 不需要读取该私钥。
## 12. etcd 维护
单机集群至少实现:
- 每日从 leader 执行 snapshot save,保存到宿主机独立目录。
- snapshot 保留 7 个日备份和 4 个周备份。
- 每周检查 `endpoint status`、数据库大小和 alarm。
- 按维护窗口执行 compact 后 defrag,不能三个 member 同时 defrag。
- 定期演练从 snapshot 恢复到隔离目录。
- 证书到期前 30 天告警。
Compose volume 不是备份。
## 13. 健康与启动
- `/api/health`:只证明 Node 进程事件循环可用。
- `/api/readiness`:至少一个 endpoint 可访问、quorum 可提交、schema-version 兼容、Manager 加密密钥可读取。
- etcd 失联时 Manager UI 进入只读错误页,不接受写操作。
- Manager 重启不影响 Agent 或节点 timer;Agent 直接连接 etcd。
- etcd 集群不可用时,已有 timer 继续本地执行。
## 14. 测试
### 14.1 Contracts
- TypeBox 生成的 JSON Schema 固定快照。
- 与 Go Agent 使用同一组合法/非法 fixture 做契约测试。
- 时间、名称、generation、unknown fields、secret redaction。
### 14.2 Manager 集成
- 使用真实临时 etcd 集群验证 Txn 冲突和引用完整性。
- Server Action/Route Handler 必须独立验证认证与权限。
- YAML 重复 key、alias bomb、大文件和 prune 防误删。
- Provider secret 不出现在 RSC payload、HTML、JSON、audit 和日志。
- Enrollment token 一次性与并发使用。
### 14.3 Compose 验收
- 停止任意一个 member,Manager 和 Agent 仍可读写。
- 停止两个 member,readiness 失败且不接受写入。
- 重启 member 后自动加入原集群,数据不丢失。
- Agent 从三个外部 endpoint 任一连接成功。
- 未持有效客户端证书的连接被拒绝。
## 15. 发布步骤
1. 构建并签名 Manager 镜像。
2. 生成 PKI 与 Docker secrets。
3. 启动三成员 etcd 和 bootstrap。
4. 启动 Manager,创建 RuntimeRelease。
5. 创建一次性 enrollment token。
6. 在首台节点运行 Agent 安装脚本。
7. Manager 确认 Node 后创建一个 SourceSchedule。
8. 验证 generation、timer、last result 全链路。
9. 再批量安装其余节点。
## 16. 官方技术依据
- [Next.js self-hosting 与 Docker standalone](https://nextjs.org/docs/app/getting-started/deploying)
- [Next.js Route Handlers](https://nextjs.org/docs/app/getting-started/route-handlers)
- [Next.js Backend for Frontend](https://nextjs.org/docs/app/guides/backend-for-frontend)
- [Next.js authentication guidance](https://nextjs.org/docs/app/guides/authentication)
- [etcd container deployment](https://etcd.io/docs/v3.6/op-guide/container/)
- [etcd transport security](https://etcd.io/docs/v3.6/op-guide/security/)
# Agent Runtime 框架:现有 Demo 完善、用户隔离与权限
> 状态:实施基线(v1alpha1)
>
> 目标:保留现有 Bash + systemd + Claude + news-harvester Skill 的有效部分,补齐固定用户、路径、权限、锁、结构化结果和 Agent 协调接口。
## 1. Runtime 定位
Runtime 是 Agent 节点上真正执行采集的框架,由以下部分组成:
```text
systemd timer
→ news-harvester@source.service
→ run-source(统一入口、锁、runId、结果收集)
→ harvest.sh(Claude 调用与日志)
→ Claude news-harvester Skill
→ harvest.js / summaries / finalize.js / optional push.js
```
Runtime 与 Agent 的边界:
- Agent 负责安装、配置、协调和读取结果。
- Runtime 负责一次采集从开始到结束的执行可靠性。
- systemd 负责时间、进程生命周期、超时和资源限制。
- Skill 负责网页与归档业务流程。
## 2. 从现有 Demo 保留什么
保留:
- `news-harvester@.service` + 每 source timer 的模式。
- `harvest.sh <source>` 作为 Claude 运行实现。
- default conf + source override 的配置层级。
- Claude `--output-format json` 元数据。
- Provider 引用和 fallback 逻辑。
- 本地原始日志、人类可读摘要和 journal。
- `continue.sh` 的 session resume 能力,但降级为受策略约束的 recovery adapter。
- `status.sh` 作为本机只读诊断工具。
替换或收敛:
- 删除 `/home/yichu`、nvm 版本和用户名硬编码。
- 保留节点级 Runtime Web 看板能力,但删除其中的配置变更和执行入口;Manager 负责集群期望,Web 只展示本机实际产物。
- 现有 Flask 面板作为迁移期只读实现保留,最终收敛到 Agent 二进制的独立 `web` 子命令。
- `add-source.sh` / `modify-source.sh` 不再是生产配置入口;其核心渲染逻辑迁入 Agent/nh-root。
- `deploy.sh` 的远程 rsync 模式由 RuntimeRelease Git 协调取代。
- `migrate.sh` 演进为一次性节点 installer/migrator。
- 成功判定从“Claude exit 0”升级为阶段化结构化结果。
- 所有执行入口共享同 source 文件锁。
## 3. 固定身份
### 3.1 用户与组
```text
user: news-harvest
group: news-harvest
home: /var/lib/news-harvester/home
shell: /usr/bin/nologin
```
该用户拥有可写:
- 自己的 HOME 和 Claude 状态。
- work、runs、results、logs。
- Skills checkout 和浏览器 profile。
- 配置的 Obsidian vault。
该用户不能直接写:
- `/etc/news-harvester`。
- `/etc/systemd/system`。
- `/opt/news-harvester/runtime`。
- `/usr/bin`、`/usr/libexec`。
- 其他用户 HOME。
### 3.2 为什么必须有真实 HOME
即使 shell 是 nologin,Claude CLI 和 Skills 仍需要稳定 HOME 保存:
- Claude 配置与会话。
- Skills 发现信息。
- npm/Node 运行时必要缓存。
- Chromium user-data-dir、Cookies 和登录状态。
Runtime unit 显式设置:
```text
HOME=/var/lib/news-harvester/home
XDG_CONFIG_HOME=/var/lib/news-harvester/home/.config
XDG_CACHE_HOME=/var/lib/news-harvester/home/.cache
```
不加载 `.bashrc`、`.zshrc`、nvm 初始化脚本。
## 4. 目录布局
```text
/usr/bin/news-harvester-agent root:root 0755
/usr/libexec/news-harvester/nh-root root:root 0755
/opt/news-harvester/runtime/ root:root 0755
├── releases/{runtime-commit}/ root:root 0755, read-only
└── current -> releases/{runtime-commit} root:root
/etc/news-harvester/ root:news-harvest 0750
├── agent.yaml root:news-harvest 0640
├── runtime.env root:news-harvest 0640
├── defaults.env root:news-harvest 0640
├── sources/{source}.env root:news-harvest 0640
├── providers/{provider}.json news-harvest:news-harvest 0600
└── pki/
├── ca.pem root:news-harvest 0644
├── agent.pem root:news-harvest 0640
├── agent-key.pem root:news-harvest 0640
└── age-identity.txt root:news-harvest 0640
/var/lib/news-harvester/ news-harvest:news-harvest 0750
├── home/
├── agent/state.db
├── work/
├── runs/{source}/{runId}/
├── results/{source}.json
├── recovery/{source}.json
├── skills/news-hub-skills/
├── browser-profile/
└── vault/ 默认 Obsidian vault
/var/log/news-harvester/ news-harvest:news-harvest 0750
└── compatibility/ 保留现有文本日志时使用
/run/news-harvester/ news-harvest:news-harvest, systemd 创建
└── {source}.lock
```
Provider 不再使用 `0644`。任何包含认证信息的文件必须 `0600` 或最小可读权限。
## 5. 安装方式
### 5.1 目标体验
管理员只需一次 root 安装:
```text
sudo ./install-agent.sh \
--manager-url https://manager.example.com \
--enrollment-token TOKEN \
--node-id collector-shanghai-01 \
--vault-path /var/lib/news-harvester/vault
```
后续 source、timer、Provider、Runtime/Skills 版本均由 Manager + etcd 管理,不再逐台 SSH 编辑。
### 5.2 安装步骤
1. 校验 Linux、systemd、架构、磁盘、DNS 和时间同步。
2. 创建 `news-harvest` 系统用户和目录。
3. 安装 Agent 与 `nh-root`,校验二进制 SHA-256/签名。
4. 安装最小 sudoers 规则。
5. 检查或安装 Node.js、Claude CLI、Git、jq、flock 和 Chromium 依赖。
6. 在节点本地生成 TLS 私钥/CSR 和 age identity。
7. 调用 Manager enrollment,保存证书与配置。
8. 安装 Agent、Runtime service templates。
9. 启用并启动 `news-harvester-agent.service`。
10. 等待 Manager 看到节点在线,输出 nodeId 和诊断命令。
安装脚本必须可重复运行:已存在用户、证书和数据时不重置;需要替换身份必须显式 `--reenroll`。
### 5.3 Vault 权限
默认创建独立 vault 并由 `news-harvest` 拥有,零额外操作。
如果接入已有 vault:
- 不递归 `chown` 用户已有文件。
- 优先通过专用 group 或 POSIX ACL 授权指定子目录。
- 安装时做真实 create/write/rename/delete 探针。
- 探针失败即停止安装,不等到第一次定时任务才暴露。
## 6. Runtime 环境文件
### 6.1 runtime.env
只包含安装时固定路径,不包含动态 source 期望:
```ini
NH_RUNTIME_ROOT=/opt/news-harvester/runtime/current
NH_WORK_DIR=/var/lib/news-harvester/work
NH_RUNS_DIR=/var/lib/news-harvester/runs
NH_RESULTS_DIR=/var/lib/news-harvester/results
NH_SKILLS_ROOT=/var/lib/news-harvester/skills/news-hub-skills
NH_VAULT_PATH=/var/lib/news-harvester/vault
CLAUDE_BIN=/usr/local/bin/claude
NODE_BIN=/usr/bin/node
```
### 6.2 defaults.env 与 source env
字段固定,Agent 只输出白名单:
```ini
COUNT=20
BUDGET_USD=15
TIMEOUT_SECONDS=1800
PROVIDER=primary
FALLBACK_PROVIDER=fallback
MAIL_ON_SUCCESS=0
```
source 文件覆盖 defaults:
```ini
COUNT=30
PROVIDER=primary
RECOVERY_POLICY_HASH=...
RESOURCE_GENERATION=7
```
不允许换行、NUL、shell substitution 或任意环境变量名。Agent 渲染值时使用 systemd EnvironmentFile 的转义规则,而不是把 desired 原文拼入 shell。
## 7. systemd 单元
### 7.1 采集 service
目标模板:
```ini
[Unit]
Description=News Harvester source %i
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=news-harvest
Group=news-harvest
WorkingDirectory=/var/lib/news-harvester/work
Environment=HOME=/var/lib/news-harvester/home
Environment=XDG_CONFIG_HOME=/var/lib/news-harvester/home/.config
Environment=XDG_CACHE_HOME=/var/lib/news-harvester/home/.cache
EnvironmentFile=/etc/news-harvester/runtime.env
EnvironmentFile=/etc/news-harvester/defaults.env
EnvironmentFile=-/etc/news-harvester/sources/%i.env
ExecStart=/opt/news-harvester/runtime/current/bin/run-source %i
TimeoutStartSec=1800
Nice=10
UMask=0077
RuntimeDirectory=news-harvester
RuntimeDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/var/lib/news-harvester /var/log/news-harvester
```
如果 vault 不在 `/var/lib/news-harvester`,installer 生成 root-owned drop-in 增加唯一必要的 `ReadWritePaths=`。不能开放整个 `/home`。
Claude 使用 `--permission-mode bypassPermissions` 仍然受 Linux 用户、文件权限和 systemd sandbox 约束,这是保留该参数的前提。
### 7.2 timer
Agent 为每个 SourceSchedule 生成:
```ini
# Managed by news-harvester-agent
# resource: SourceSchedule/nikkei-world
# generation: 7
# desired-sha256: ...
[Unit]
Description=Run News Harvester nikkei-world
[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true
AccuracySec=60s
RandomizedDelaySec=0
Unit=news-harvester@nikkei-world.service
[Install]
WantedBy=timers.target
```
使用 `/etc/systemd/system/news-harvester@{source}.timer`,不再通过 `systemctl edit --full` 产生不可见 drop-in。Agent发现非自己管理的同名 unit 时拒绝覆盖并报告冲突。
### 7.3 recovery service
```ini
[Unit]
Description=News Harvester recovery %i
After=network-online.target
[Service]
Type=oneshot
User=news-harvest
Group=news-harvest
Environment=HOME=/var/lib/news-harvester/home
EnvironmentFile=/etc/news-harvester/runtime.env
ExecStart=/opt/news-harvester/runtime/current/bin/recover-source %i
TimeoutStartSec=1800
UMask=0077
```
`recover-source` 读取 `/var/lib/news-harvester/recovery/{source}.json`,校验 runId、action、attempt 和过期时间,再进入与正常采集相同的 source 锁。
### 7.4 Agent service
Agent 以 `news-harvest` 运行,但需要执行受限 sudo helper,因此不能设置 `NoNewPrivileges=true`:
```ini
[Service]
User=news-harvest
Group=news-harvest
ExecStart=/usr/bin/news-harvester-agent run --config /etc/news-harvester/agent.yaml
Restart=always
RestartSec=5s
UMask=0077
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
RestrictSUIDSGID=true
ReadWritePaths=/var/lib/news-harvester/agent /var/lib/news-harvester/results /var/lib/news-harvester/recovery /var/lib/news-harvester/skills
```
权限最终边界由 sudoers 的单一 root helper 和 helper 内部 allowlist 保证。
## 8. 同 source 锁
所有正常采集、自愈和本机手动执行都通过 `run-source`/`recover-source`:
```text
open /run/news-harvester/{source}.lock
→ flock(LOCK_EX | LOCK_NB)
→ 成功:持锁覆盖整个 Claude + Skill + push 流程
→ 失败:生成 SKIPPED / SOURCE_LOCKED 结果并退出特定码
```
锁粒度是 `nodeId + sourceId`。不设置全局 Claude 锁。
节点总并发由额外计数 semaphore 控制:
- source slot 限制同时运行的 Runtime 数。
- Claude slot 限制 Claude 进程数。
- Skills 自身限制 Chromium tabs。
即使资源槽已满,systemd timer 也不应无限阻塞;Runtime 返回 `SKIPPED/CAPACITY_EXHAUSTED`,由下一次 timer 或 recoveryPolicy 决定后续行为。
## 9. run-source 执行协议
### 9.1 启动
1. 校验 sourceId。
2. 获取 source 锁和节点容量槽。
3. 生成 UUIDv7 runId。
4. 创建 `/var/lib/news-harvester/runs/{source}/{runId}`,权限 0700。
5. 写 `run-context.json`,记录 generation、配置 hash、起始时间和触发类型。
6. 检查 Provider、Claude、Node、Skill、vault 可用性。
7. 调用 `harvest.sh`,传入明确路径和 runId。
### 9.2 Claude 命令
现有命令保留核心参数,但移除硬编码 PATH/NVM:
```text
${CLAUDE_BIN} -p
--settings <validated provider settings>
--permission-mode bypassPermissions
--allowed-tools Bash Write
--output-format json
--max-budget-usd ${BUDGET_USD}
<prompt>
```
改进:
- `harvest.sh` 使用 `set -u -o pipefail`,显式捕获 Claude 退出码。
- 用 systemd timeout 和进程组终止控制超时,不只依赖 Claude。
- stdout 写 `claude-result.json`,stderr 写 `claude-stderr.log`。
- 文件只在 runDir 中创建,完成后生成最终 result。
- Provider 不存在/无效时直接失败,不再静默回退 default。
### 9.3 Prompt 合约
Prompt 除现有同步执行要求外,增加:
- 必须按 `news-harvester` Skill 的三阶段协议执行。
- 不允许重跑已存在 manifest 的采集,先检查 summary status。
- exit 70 时停止,不进行同会话重试。
- 最终返回结构化摘要:dayDir、new/skipped/failed、summary missing、push status、human intervention。
Claude 最终文本是辅助证据,不是唯一事实来源。
## 10. 结构化结果收集
### 10.1 当前 Demo 的问题
现有 `harvest.sh` 只用:
```text
Claude process exit code == 0 && is_error != true
```
判断 SUCCESS。这无法区分:
- 摘要缺失但 Claude 正常返回。
- 本地 finalize 成功但 News Hub push 失败。
- CAPTCHA 被 Skill 检测后 Claude只是说明情况并正常退出。
- Claude 报告成功但 Skill 状态文件不完整。
### 10.2 结果证据优先级
`collect-result` 按以下顺序取证:
1. Skill 的确定性状态文件:`.manifest.json`、`.summaries.json`、registry sentinel、`.syncstate.json`。
2. run 前后 registry 快照差异和 `processedAt`。
3. Claude JSON 的 process 元数据。
4. Claude 的结构化最终摘要。
5. stderr 中稳定错误标记和退出码。
如果证据冲突,选择更保守状态并设置 `RESULT_UNCERTAIN`,不能把未知当成功。
### 10.3 阶段判定
| 证据 | 判定 |
|---|---|
| Skill exit 70 / human block marker | `NEEDS_HUMAN` |
| `.manifest.json` 或有效待补摘要存在 | `SUMMARY_INCOMPLETE` |
| registry 新文章已全部 finalize | archive/summary success |
| `.syncstate.json.lastSyncStatus=partial/failed` | push partial/failed,overall degraded |
| push disabled | push disabled,不影响本地 success |
| 请求 20、只得到 8 且 exhausted | success,不能自动重跑 |
| Claude rc 非 0 且无更明确业务状态 | failed |
| Claude rc 0 但无法定位 Skill 结果 | `RESULT_UNCERTAIN` |
### 10.4 result 文件
先写 runDir:
```text
/var/lib/news-harvester/runs/{source}/{runId}/result.json
```
通过同目录临时文件 + fsync + rename 更新:
```text
/var/lib/news-harvester/results/{source}.json
```
格式严格使用 [01-concepts-and-control-plane.md](./01-concepts-and-control-plane.md) 中的 `SourceLastResult`。
Agent 只监听 results 目录,不解析人类可读 `.last.log`。
## 11. Recovery Runtime
### 11.1 不再使用宽泛 repair prompt
现有 `continue.sh --mode repair` 会要求 Claude 删除错误数据并 force 覆盖,范围过大,不适合作为无人值守自愈。
新 Runtime 将恢复拆为确定动作:
- `resume-session`
- `resume-summary`
- `retry-push`
- `rerun-harvest`
删除数据的 repair 只允许本机人工执行,并要求明确 dayDir 和备份,不由 Agent recoveryPolicy 自动触发。
### 11.2 resume-session
- 从 SourceLastResult 获取 sessionId。
- 验证 session 属于同 source 的最新未完成 run。
- 使用 fallbackProviderRef(若配置)。
- prompt 只允许检查并完成已有流程,不允许先删除。
- 产生新的 recovery runId,但 `parentRunId` 指向原 run。
### 11.3 resume-summary
- 根据 manifest/registry 确认 missing URL。
- 调用 Claude 生成缺失摘要。
- 使用 `summary-add.js` 分批合并。
- 只调用一次 finalize。
- 完成后根据配置执行 push。
### 11.4 retry-push
- 只允许 dayDir 已完整 finalize。
- 调用 Skill `push.js dayDir`。
- 依赖 `.syncstate.json` 续推失败项。
- 不使用 `--force`,除非人工明确操作。
## 12. Skills 与 source-manager 接口
Agent 使用固定命令:
```text
node scripts/source-manage.js list
node scripts/source-manage.js install bundles/{source}.nhsource.json
```
生产实现不要解析 `list` 的人类表格。优先补充或使用机器接口:
```text
node scripts/source-manage.js list --json
node scripts/source-manage.js inspect {source} --json
```
在该接口尚未完成时,Agent 可以安全读取 Skill `config.json`,但只能通过明确 JSON 字段判断 source 是否存在。
source 已存在时不重复 `--force` 安装。RuntimeRelease/Skills commit 变化后,如 bundle 内容变化,Agent可在协调阶段带 `--force` 升级,同时保留 source-manager 中用户调整的 concurrency/paginate 语义。
## 13. 受限 sudoer 与 nh-root
### 13.1 sudoers
`/etc/sudoers.d/news-harvester-agent`:
```sudoers
Defaults:/usr/libexec/news-harvester/nh-root !requiretty
news-harvest ALL=(root) NOPASSWD: /usr/libexec/news-harvester/nh-root
```
不要授权:
```text
systemctl *
cp *
install *
tee *
sh *
bash *
```
### 13.2 nh-root 可操作范围
只允许:
- `/etc/news-harvester/{runtime.env,defaults.env,sources,providers}`。
- `/etc/systemd/system/news-harvester@*.timer`。
- 固定的 Agent/Runtime service templates。
- `/opt/news-harvester/runtime/releases` 和 `current` symlink。
- `news-harvester@*.timer`、`news-harvester-recovery@*.service` 和 daemon-reload。
拒绝:
- 其他 systemd unit。
- caller 提供任意绝对目标路径。
- 名称含 `/`、`.`、空白、shell 元字符。
- 覆盖没有 Agent managed header 的同名文件。
- 通过符号链接逃逸目标目录。
### 13.3 应用事务
Agent 将完整变更集通过 stdin 发送给 helper。helper:
1. 校验 protocol version、nodeId、action 和所有内容。
2. 将文件写入目标目录内临时文件。
3. 设置 owner/mode,flush/fsync。
4. 原子 rename。
5. 对被删除文件先 disable/stop 对应 timer。
6. 所有文件准备成功后只 daemon-reload 一次。
7. enable/disable timer。
8. 返回每步结构化结果。
如果文件准备失败,不执行 systemd 状态变化。
## 14. Provider Runtime
`provider.sh` 的职责保留,但行为收紧:
- Provider 只能来自 `/etc/news-harvester/providers/{name}.json`。
- name 使用严格 source-name 规则。
- 文件 owner/mode 不符合要求时拒绝。
- JSON 必须是 object,value 必须是 string。
- key 使用白名单。
- 缺失、空或无效 Provider 直接返回稳定错误码,不静默 fallback。
- fallback 只有 recovery 明确选择时使用。
- 向日志只记录 Provider 名,不打印 settings JSON。
Claude Provider 与 Skill News Hub push credentials 是两类不同配置,不能放在同一个 Provider 文件中。
## 15. 本机人工操作
保留少量诊断入口:
```text
sudo -u news-harvest news-harvester-runtime status {source}
sudo -u news-harvest news-harvester-runtime inspect-result {source}
sudo -u news-harvest news-harvester-runtime run-local {source}
sudo -u news-harvest news-harvester-runtime recover-local {source} --action resume-summary
```
`run-local` 和 `recover-local` 仍使用同 source 锁,并在结果中标记 `trigger=local-manual`。人工执行产生的最新结果同样由 Agent 上报。
不鼓励直接调用内部 `harvest.sh`、`continue.sh` 或 Skill 脚本;它们作为实现细节保留。
## 16. 与旧安装迁移
### 16.1 发现
installer 扫描:
- `/home/yichu/Documents/News-Harvester`。
- `/etc/news-harvester/*.conf`、providers。
- `news-harvester@*.timer`。
- 旧 Claude/Skills/Chromium profile。
### 16.2 迁移原则
- 先备份,后停止旧 timer。
- 不直接删除旧日志、vault 或 Claude session。
- timer/source conf 转换为 SourceSchedule YAML,展示后导入 Manager。
- Provider 明文在本机读入,经 Manager 安全接口转换为加密 Provider;迁移日志不得输出值。
- Skills config 和 browser profile 复制给新用户时保持权限和 Cookies。
- 验证新用户可运行 preview/doctor 后才启用新 timer。
- 回滚只需 disable 新 timer、恢复旧 timer;采集数据不回滚。
### 16.3 旧 Web 和 digest
- Flask panel 在迁移期保留为节点只读看板,页面数据来自本机 conf、systemd、results、runs、Runtime/Skills 版本和权限检查。
- 删除 add/modify/delete source、enable/disable、run/retry/recovery、Provider 管理等全部写接口;旧写路由返回 `405`。
- Manager dashboard 展示集群期望和汇总状态;节点 Web 展示 Agent 协调后的实际产物,二者不是替代关系。
- Agent Go 实现完成后,由独立的 `news-harvester-agent web` 子命令接管节点看板;默认仅监听 `127.0.0.1`,通过 SSH tunnel 排障。
- Go Web 达到只读诊断能力等价后再停止 Flask service,并移除 Python/Flask 运行依赖。
- 旧 daily digest 可作为节点兼容功能暂时保留,默认 disabled。
- fleet 级摘要/告警后续在 Manager 读取 SourceLastResult 实现,不依赖扫描各节点日志。
## 17. 已知 Skill 边界与 Runtime 防护
Skills 当前不是本轮重构重点,但 Runtime 必须识别以下边界:
- `count` 是目标新文章数,不是强制成功数量。
- `.manifest.json/.summaries.json` 是摘要阶段状态,不能忽略。
- push 失败不等于本地 archive 失败。
- exit 70 是人工介入,不能自动重试。
- 同 source/day 的 registry/manifest 不适合并发写,因此 source 锁覆盖完整流程。
- finalize 的摘要字段和中文标题文件名需要进一步加强校验;在修复前 Runtime 对空摘要或异常路径报告失败,不宣布完整成功。
## 18. 测试与验收
### 18.1 静态检查
现有 Bash 继续执行:
```text
bash -n bin/*.sh
```
新 Runtime 增加 ShellCheck;Agent/nh-root 使用 `go test ./...`、`go vet ./...` 和静态安全扫描。
### 18.2 无 root 集成测试
通过路径环境变量和 fake systemctl 验证:
- 配置渲染。
- source/provider 名称注入防护。
- 同 source lock。
- Claude success/error/timeout JSON。
- manifest incomplete、push partial、exit70 映射。
- result 原子写入和 Agent 扫描。
### 18.3 root/VM 验收
在 disposable systemd VM 中验证:
- installer 和重复安装。
- `news-harvest` 无法写任意 `/etc` 和启动任意 unit。
- nh-root 只接受 allowlist。
- systemd sandbox 下 Claude、Node、Chromium、vault 正常。
- timer 更新只 daemon-reload 一次且下次触发时间正确。
- Agent/etcd 断线时已有 timer 继续运行。
- Manager 修改后全节点无需人工 SSH 即可追平。
## 19. Runtime-first 实施策略
先完成 Runtime,再开发 Agent。原因不是单纯按组件顺序,而是 Agent 最终只应该协调一个稳定、可验证的本机接口。如果 Runtime 的退出码、结果文件、锁和目录仍在变化,Agent 会被迫同时承担业务推断和兼容逻辑。
Runtime 改造遵循以下顺序:
```text
冻结现有行为
→ 路径和配置可移植
→ 统一执行入口与锁
→ 结构化结果
→ 确定性恢复入口
→ 固定用户迁移
→ systemd/提权边界
→ 发布与升级模型
→ Agent 开发
```
前四步先在当前用户和当前服务器上稳定行为;有了结构化结果后再切换固定用户,迁移失败时才有足够证据定位问题。
### 19.1 拆分原则
- 每个阶段只改变一个主要边界。
- 每个阶段完成后,旧的定时采集仍然可以独立运行。
- 数据格式先双读,再切写,最后删除兼容代码。
- 路径迁移先复制/验证,后切换,不原地搬走唯一副本。
- systemd 安全选项逐项启用并做真实 Chromium/Claude smoke test。
- 自动修复先做无破坏动作;删除、force push 和清库不进入无人值守路径。
- 每个阶段都形成干净 Git commit、验收记录和 annotated tag。
## 20. 存档标记规则
### 20.1 Git 标记命名
开发中间存档使用:
```text
archive/runtime-r0-demo-baseline
archive/runtime-r1-portable-config
archive/runtime-r2-execution-envelope
archive/runtime-r3-result-contract
archive/runtime-r4-recovery-contract
archive/runtime-r5-service-user
archive/runtime-r6-privilege-boundary
archive/runtime-r7-release-candidate
```
正式完成后使用语义化版本:
```text
runtime-v1.0.0
```
`archive/*` 是恢复点,不代表发布;`runtime-v*` 才是可以被 RuntimeRelease 引用的发布标签。
### 20.2 创建标记的前置条件
只有同时满足以下条件才创建 annotated tag:
1. 工作树干净。
2. 该阶段范围内没有混入其他功能修改。
3. 所有规定的静态和本机测试通过。
4. 需要 systemd/Chromium 的阶段已经在测试节点 smoke test。
5. 不含 Provider、SMTP、日志、Cookie、真实路径快照等敏感文件。
6. 已提交该阶段的 checkpoint 记录。
建议命令形式:
```text
git tag -a archive/runtime-r3-result-contract -m "Runtime R3: structured result contract verified"
```
本文只定义存档点;在阶段尚未实施、工作树未清理时不提前创建空标签。
### 20.3 Checkpoint 记录
每个存档点提交一份:
```text
docs/checkpoints/runtime-r{N}.md
```
固定内容:
```markdown
# Runtime R{N} checkpoint
- Commit:
- Tag:
- Date:
- Scope:
- Schema/version changes:
- Files changed:
- Verification commands:
- Verification results:
- Server smoke-test evidence:
- Known limitations:
- Rollback steps:
```
记录命令和结论,不复制真实 Provider、完整日志或文章内容。
### 20.4 当前基线处理
当前仓库已经存在脚本移动到 `bin/`、`status.sh`、Flask 页面及架构文档等未归档变更。R0 前应先按实际归属拆成独立提交:
1. 脚本目录整理。
2. 已完成的功能缺陷修复。
3. 架构文档。
确认这些提交组成当前可工作的 Demo 后,再创建 R0 标签。不要在混合 dirty worktree 上直接打标签。
## 21. 分阶段改造计划
### R0:冻结可运行 Demo
目标:获得可以随时回到的真实起点,不修改 Runtime 行为。
范围:
- 收口当前脚本目录迁移和已有功能修复。
- 更新 README 中的实际路径,保证与新的 `bin/` 布局一致。
- 确认 live provider、日志、缓存和生成内容均未进入 Git。
- 保存脱敏的当前配置结构、unit 内容和手工 smoke test 结论。
- 记录当前 Claude、Node、Skills commit 和 systemd 版本。
验收:
```text
bash -n bin/*.sh
python3 -m py_compile web/app.py
sudo systemctl start news-harvester@nikkei-world.service
journalctl -u news-harvester@nikkei-world.service --since today
```
必须确认:至少一个 source 能完成当前“采集 → 摘要 → finalize”流程,现有 status 能读取结果。
存档点:
```text
archive/runtime-r0-demo-baseline
```
回滚:直接回到 R0 commit;服务器恢复本阶段记录的原 unit 和脚本目录。
### R1:路径、命令和配置可移植化
目标:去掉固定用户名、HOME、nvm 和 Node 版本,不改变采集结果格式。
代码范围:
- 新增 `bin/runtime-common.sh`,集中完成 source 校验、路径解析、命令检查和时间工具。
- `harvest.sh`、`continue.sh`、`daily-digest.sh`、`status.sh` 统一使用 `NH_*` 路径变量。
- Claude、Node、jq、msmtp 使用明确可配置路径或受控 PATH 查找。
- 新增 `config/runtime.env.example`。
- Provider 缺失/无效暂时保留兼容行为,但输出稳定 warning code,R3 再纳入结果。
- 部署脚本仍支持当前 `/home/yichu/Documents/News-Harvester`,作为兼容默认,不立即切用户。
必须保持:
- 原 systemd unit 不修改也能运行。
- 原 `/etc/news-harvester/*.conf` 继续生效。
- 原日志目录和文件名继续可读。
测试:
- 使用临时目录覆盖 WORK/LOG/CONF/HOME。
- 使用 fake `claude` 验证参数数组和退出码。
- 没有 `.zshrc`、nvm 时仍能找到配置的 Claude/Node。
- source、Provider 名称注入用例被拒绝。
建议提交:
```text
refactor: make runtime paths and commands configurable
test: add portable runtime smoke harness
```
存档点:
```text
archive/runtime-r1-portable-config
```
回滚:恢复 R0 脚本;配置文件只是新增项,不需要迁移数据。
### R2:统一执行入口与同 source 锁
目标:让 timer、本机手动执行和后续自愈都经过同一个执行边界。
代码范围:
- 新增 `bin/run-source.sh`,成为 `news-harvester@.service` 唯一 ExecStart。
- 严格校验 sourceId、count、budget、timeout 和 Provider 引用。
- 创建 runId 和最小 `run-context.json`。
- 用 `flock` 持有 `/run/news-harvester/{source}.lock`,覆盖整个 Claude + Skill 流程。
- `harvest.sh` 收敛为被 wrapper 调用的实现,不再自己承担调度入口职责。
- `continue.sh` 也先获取相同 source 锁。
- 明确 Runtime 退出码,至少覆盖 success、invalid config、locked、timeout、Claude failure、result uncertain。
- 修改 systemd service ExecStart,但暂不切换运行用户。
退出码建议:
| 退出码 | 含义 |
|---:|---|
| 0 | Runtime 确认完成 |
| 10 | 配置无效 |
| 20 | source 锁已占用 |
| 30 | Claude/Runtime 超时 |
| 40 | Claude 启动或执行失败 |
| 50 | 结果无法确认 |
| 70 | Skill 要求人工介入 |
测试:
- 同 source 两次并发只有一次进入 Claude。
- 不同 source 可以并行。
- SIGTERM/timeout 能终止整个 Claude 子进程组并释放锁。
- wrapper 在所有退出路径都写最小结束状态。
- `systemd-analyze verify` 通过,真实 timer smoke test 通过。
建议提交:
```text
feat: add unified runtime execution envelope
feat: serialize runs per source
```
存档点:
```text
archive/runtime-r2-execution-envelope
```
回滚:将 service ExecStart 恢复为直接调用 `harvest.sh`;runDir 是新增数据,可保留。
### R3:结构化 SourceLastResult
目标:建立 Agent 可以长期依赖的 Runtime 输出协议,这是 Runtime 与 Agent 的第一条稳定接口。
代码范围:
- 提交 `schemas/source-last-result.v1alpha1.schema.json`。
- 新增结果收集模块,从 Skill 状态文件、Claude JSON、stderr 和 registry 差异交叉判定。
- 写 `/var/lib/.../runs/{source}/{runId}/result.json`。
- 原子更新 `/var/lib/.../results/{source}.json`。
- 分离 archive、summary、push 和 Claude 状态。
- 正确表达 exhausted、HUMAN_REQUIRED、PUSH_PARTIAL、SUMMARY_INCOMPLETE、SOURCE_LOCKED。
- `status.sh` 优先读取 v1 result,找不到时回退旧 `.last.log`。
- `daily-digest.sh` 同样双读,不立即删除旧日志格式。
- Flask 看板同步改为只读:优先读取 v1 result、本机 systemd 与实际 conf,并删除 source/provider/timer/执行类写路由。
- Flask 看板不得查询 etcd desired;Manager/etcd 不可达时仍能展示本机最后已知事实。
- Claude 最终说明仅作辅助证据,不能单独宣布成功。
测试夹具至少覆盖:
- 完整成功。
- 少于 count 但数据源耗尽。
- 单篇失败但整体完成。
- 摘要未补齐。
- 本地归档完成、push 部分失败。
- exit 70。
- Claude exit 0 但 Skill 状态不完整。
- Claude 非零退出。
- Web 所有旧写接口返回 `405`,敏感字段已脱敏,路径参数不能越出受管目录。
- etcd 不可达时 Web 仍可读取 timer、Runtime 版本和最后结果。
兼容窗口:至少保留两个 Runtime 版本的旧日志读取能力。
建议提交:
```text
feat: emit structured source last results
refactor: read runtime results in status and digest
```
存档点:
```text
archive/runtime-r3-result-contract
```
回滚:恢复 R2 writer;保留已经生成的 result JSON。旧 status/digest 不依赖新文件,因此可继续工作。
### R4:确定性 Recovery 接口
目标:把现有宽泛的 `continue/repair` 收敛为 Agent 可以安全调用的有限动作。
代码范围:
- 新增 `bin/recover-source.sh`。
- 定义 `recovery-request.v1alpha1.schema.json`。
- 支持 `resume-session`、`resume-summary`、`retry-push`、`rerun-harvest`。
- `continue.sh` 保留为人工兼容入口,内部映射到 recover-source。
- `resume-summary` 先读 manifest/summary status,不重新运行 harvest。
- `retry-push` 依赖 `.syncstate.json`,默认不带 force。
- 删除型 repair 不进入自动接口,只保留人工、明确 dayDir、先备份的专用流程。
- 所有 recovery 复用 R2 source 锁并产生新的 SourceLastResult,带 parentRunId。
测试:
- 每种 action 只能访问允许的阶段文件和命令。
- HUMAN_REQUIRED 和无 sessionId 不会自动降级为 rerun。
- 达到 maxAttempts 后停止。
- recovery 与 timer 同时触发时由同 source 锁裁决。
- push retry 不重复抓取或重写文章。
建议提交:
```text
feat: add deterministic runtime recovery actions
refactor: make continue a compatibility wrapper
```
存档点:
```text
archive/runtime-r4-recovery-contract
```
回滚:关闭 recovery 调用并恢复旧 continue;正常 timer 和 R3 result 不受影响。
### R5:固定 `news-harvest` 用户和标准目录
目标:从个人用户运行切换为可批量安装的服务身份。
为什么放在 R5:前面已经有锁、runId 和结构化结果,用户/权限迁移失败时能准确定位在启动、Provider、Claude、Skills、Chromium 还是 vault 阶段。
代码范围:
- 新增幂等 installer/migrator。
- 创建 `news-harvest` 用户、HOME、work、runs、results、logs、Skills 和 browser profile。
- 安装 Runtime 到标准目录,先复制后切换。
- Provider 权限调整为 0600。
- 迁移 Claude 配置、Skills checkout 和 Chromium profile;不修改唯一原件。
- 默认创建专用 vault;已有 vault 通过 group/ACL 授权,不递归 chown。
- systemd service 切换 User/Group/HOME/WorkingDirectory。
- `status.sh` 增加新旧路径只读兼容。
迁移顺序:
```text
停止 timer
→ 备份 unit/conf
→ 创建用户和新目录
→ 复制 Runtime/Skills/profile
→ 权限与读写探针
→ 以 news-harvest 做 preview
→ 以 news-harvest 做单 source 完整 smoke test
→ 切换 unit
→ daemon-reload + 启用 timer
```
验收:
- `news-harvest` 不依赖任何个人 HOME 文件。
- 重启后 Claude、Skills 和 Chromium profile 仍可用。
- vault create/write/rename/delete 探针通过。
- Provider 不对其他用户可读。
- 至少一台 canary 节点连续完成两个调度周期。
建议提交:
```text
feat: install runtime under dedicated service user
feat: add idempotent runtime migration
```
存档点:
```text
archive/runtime-r5-service-user
```
回滚:停止新 timer,恢复备份 unit 和旧 User/路径;新目录只停止使用,不立即删除,vault 内容不回滚。
### R6:systemd 隔离与 Agent 提权边界
目标:固定 Runtime 的权限面和未来 Agent 的唯一管理接口。
代码范围:
- 增加 `nh-root` 协议和允许动作。
- sudoers 只允许无参数执行 root-owned `nh-root`。
- 文件 apply 使用固定目录、managed header、fd 校验和原子 rename。
- 禁止 Agent 用户直接执行通配 `systemctl *`、`cp`、`install`、`tee` 或 shell。
- 增加 `news-harvester-recovery@.service`。
- 逐项启用 systemd sandbox:UMask、PrivateTmp、ProtectSystem、ProtectHome、ReadWritePaths 等。
- 对 Chromium profile、vault、Claude HOME 做最小 ReadWritePaths。
- 提交本机 management protocol schema,冻结 Agent 接口。
不要在同一次提交中一次性打开全部 hardening 选项。建议顺序:
1. UMask、PrivateTmp、NoNewPrivileges。
2. kernel/control-group 防护。
3. ProtectHome 与明确路径。
4. ProtectSystem=strict 与 ReadWritePaths。
5. address family 和其他限制。
每一步都跑 browser source 和 direct source;某项影响 Chromium 时记录原因后做最窄例外,不整体关闭 sandbox。
验收:
- `news-harvest` 无法通过 sudo 启动无关 unit 或写无关 `/etc` 文件。
- `nh-root` 拒绝路径穿越、符号链接替换、未知 action 和非受管 unit。
- Runtime 正常采集、自愈和写 vault。
- root helper 失败不会留下半份 timer/conf。
建议提交:
```text
feat: add restricted runtime privilege helper
security: harden runtime systemd units
```
存档点:
```text
archive/runtime-r6-privilege-boundary
```
回滚:恢复 R5 unit 和手工 root 安装流程;不要放宽为 `NOPASSWD: systemctl *`。
### R7:发布、升级和 Runtime v1 候选
目标:把已经稳定的 Runtime 变成 Agent 可以按 Git ref 安装和切换的发布物。
代码范围:
- 使用 `/opt/news-harvester/runtime/releases/{commit}` + `current` symlink。
- 生成 release manifest:版本、commit、文件 SHA-256、最低 Agent 版本、Schema 版本。
- Git checkout 禁用 hooks,生产只接受 tag/commit。
- 安装新 release 后先运行 `doctor` 和 dry-run,再切 current。
- 已运行的采集不被中断,新进程使用新 release。
- 保留最近两个已验证 release,提供明确 rollback 命令。
- 保留迁移期 Flask 只读面板的兼容部署,禁止携带任何写接口;Agent Web 等价后再退出 Flask。
- daily digest 保持兼容但默认可关闭。
- 完成全量回归和一台 canary 的升级/回滚演练。
验收:
- 从 R6 升级到候选 release 不丢配置和结果。
- current 切换失败保持旧 release。
- 一条命令可回退上一个 release。
- release tag 可直接填入 RuntimeRelease Kind。
建议提交:
```text
feat: package versioned runtime releases
chore: prepare runtime v1 release candidate
```
存档点:
```text
archive/runtime-r7-release-candidate
```
R7 在 canary 稳定运行后创建正式标签:
```text
runtime-v1.0.0
```
## 22. 阶段总表
| 阶段 | 主要变化 | 可独立上线 | 数据迁移 | 风险 | 存档点 |
|---|---|---:|---:|---|---|
| R0 | 冻结 Demo | 是 | 无 | 低 | `archive/runtime-r0-demo-baseline` |
| R1 | 路径/命令可配置 | 是 | 无 | 低 | `archive/runtime-r1-portable-config` |
| R2 | wrapper + source lock | 是 | 无 | 中 | `archive/runtime-r2-execution-envelope` |
| R3 | 结构化结果协议 | 是 | 只新增 | 中 | `archive/runtime-r3-result-contract` |
| R4 | 确定性 recovery | 是,可关闭 | 只新增 | 中 | `archive/runtime-r4-recovery-contract` |
| R5 | 固定用户与目录 | canary 后 | 有 | 高 | `archive/runtime-r5-service-user` |
| R6 | 提权边界/systemd sandbox | canary 后 | 无 | 高 | `archive/runtime-r6-privilege-boundary` |
| R7 | release/升级/回滚 | 是 | release 布局 | 中 | `archive/runtime-r7-release-candidate` |
## 23. Agent 开发的启动条件
不是所有 Runtime 工作都完成后才允许写 Agent,但至少满足以下条件再接入正式协调:
- R3 SourceLastResult Schema 已冻结。
- R4 recovery action 和 request Schema 已冻结。
- R5 节点目录、固定用户和文件 owner 已冻结。
- R6 `nh-root` 管理协议已冻结并完成安全测试。
- R7 release manifest 可以解析。
- R3 的 SourceLastResult 和 R5/R6 的本机只读检查接口足以支撑 Agent Web,不需要解析自然语言日志。
届时 Agent 只需要实现:
```text
etcd desired
→ 准备 release/source/provider/config
→ 调 nh-root apply
→ 读 ScheduleStatus/So~urceLastResult
→ 按 recoveryPolicy 写 recovery request
→ 按需启动只读 Web 展示本机实际状态
```
不会再反向解析 `.last.log`、Claude 自然语言或任意 systemctl 输出来猜测业务状态。
...@@ -112,6 +112,8 @@ class ResultTests(ArchiveFixtureMixin, unittest.TestCase): ...@@ -112,6 +112,8 @@ class ResultTests(ArchiveFixtureMixin, unittest.TestCase):
self.assertFalse(output['is_error']) self.assertFalse(output['is_error'])
self.assertEqual(output['session_id'], 'fixture-session') self.assertEqual(output['session_id'], 'fixture-session')
self.assertEqual(output['result_text'], '原始文字结果') self.assertEqual(output['result_text'], '原始文字结果')
self.assertEqual(output['provider'], 'default')
self.assertEqual(output['max_budget_usd'], 15)
result_module.validate(envelope([item])['structured_output'], result_module.validate(envelope([item])['structured_output'],
json.loads(result_module.SCHEMA_PATH.read_text())) json.loads(result_module.SCHEMA_PATH.read_text()))
...@@ -337,6 +339,9 @@ class ScriptTests(TemporaryEnvironment): ...@@ -337,6 +339,9 @@ class ScriptTests(TemporaryEnvironment):
self.assertEqual(process.returncode, 0, process.stderr) self.assertEqual(process.returncode, 0, process.stderr)
args = json.loads((self.base / 'calls').read_text()) args = json.loads((self.base / 'calls').read_text())
self.assertEqual(args[args.index('--max-budget-usd') + 1], '9.25') self.assertEqual(args[args.index('--max-budget-usd') + 1], '9.25')
result = json.loads(next(self.logs.glob('*.json')).read_text())
self.assertEqual(result['provider'], 'default')
self.assertEqual(result['max_budget_usd'], 9.25)
self.assertIn('成功', (self.logs / 'sample.last.log').read_text()) self.assertIn('成功', (self.logs / 'sample.last.log').read_text())
self.assertIn('测试新闻 | 测试新闻中文 | 归档 ✓ | 推送 updated', self.assertIn('测试新闻 | 测试新闻中文 | 归档 ✓ | 推送 updated',
(self.logs / 'sample.last.log').read_text()) (self.logs / 'sample.last.log').read_text())
...@@ -385,20 +390,45 @@ class ScriptTests(TemporaryEnvironment): ...@@ -385,20 +390,45 @@ class ScriptTests(TemporaryEnvironment):
def test_digest_retains_failures_without_sending_full_bodies_to_model(self): def test_digest_retains_failures_without_sending_full_bodies_to_model(self):
today = time.strftime('%Y%m%d') today = time.strftime('%Y%m%d')
output = result_module.normalize(envelope([self.make_article('短文' * 100)]), run_envelope = envelope([self.make_article('短文' * 100)])
'sample', 1, 0, self.base, self.base) run_envelope.update(total_cost_usd=1.98, duration_ms=125000)
output = result_module.normalize(run_envelope, 'sample', 1, 0, self.base, self.base,
'expensive-provider', 2)
(self.logs / f'sample.continue.{today}.143000.json').write_text(json.dumps(output)) (self.logs / f'sample.continue.{today}.143000.json').write_text(json.dumps(output))
(self.logs / f'sample.{today}.090000.json').write_text(json.dumps( (self.logs / f'sample.{today}.090000.json').write_text(json.dumps(
envelope([self.make_article()]) | {'structured_output': None})) envelope([self.make_article()]) | {'structured_output': None}))
self.save_fixture({'structured_output': {'alert': False, 'alert_reasons': [], 'anomalies': [], 'recommendations': [], 'failed_sources': []}}) self.env.update(MAIL_TO='test@example.invalid', DIGEST_MAIL_ALWAYS='1')
self.save_fixture({'structured_output': {
'alert': True,
'alert_reasons': ['sample 接近预算上限并中断'],
'issues': [{
'source': 'sample', 'category': 'BUDGET_EXCEEDED',
'summary': '采集在接近预算上限时中断',
'evidence': '成本 $1.98,预算 $2.00',
'recommendation': '提高该 provider 的独立预算或减少单批文章数。',
}],
'orchestration_recommendations': ['错开 expensive-provider 的长任务。'],
'failed_sources': ['sample'],
}})
process = self.run_script('daily-digest.sh') process = self.run_script('daily-digest.sh')
self.assertEqual(process.returncode, 0, process.stderr) self.assertEqual(process.returncode, 0, process.stderr)
summary = json.loads((self.logs / f'.digest-summary.{today}.json').read_text()) summary = json.loads((self.logs / f'.digest-summary.{today}.json').read_text())
failed = next(row for row in summary if row['time'] == '143000') failed = next(row for row in summary if row['time'] == '143000')
self.assertEqual(failed['status'], 'FAILED') self.assertEqual(failed['status'], 'FAILED')
self.assertEqual(failed['provider'], 'expensive-provider')
self.assertEqual(failed['attempted_count'], 1)
self.assertNotIn('body', failed['result']['articles'][0]) self.assertNotIn('body', failed['result']['articles'][0])
self.assertNotIn('body', failed['result']['articles'][0]['archive']) self.assertNotIn('body', failed['result']['articles'][0]['archive'])
self.assertIn('采集结果校验失败', (self.logs / f'digest.{today}.md').read_text()) digest = (self.logs / f'digest.{today}.md').read_text()
self.assertIn('Provider 成本', digest)
self.assertIn('文章 1/0/1', digest)
self.assertIn('2.1 分钟', digest)
mail = (self.base / 'calls.mail').read_text()
self.assertIn('expensive-provider', mail)
self.assertIn('异常分析与排查建议', mail)
self.assertIn('BUDGET_EXCEEDED', mail)
self.assertNotIn('Turns', mail)
self.assertNotIn('完整存档', mail)
class ScheduleTests(TemporaryEnvironment): class ScheduleTests(TemporaryEnvironment):
...@@ -502,6 +532,32 @@ class WebTests(TemporaryEnvironment): ...@@ -502,6 +532,32 @@ class WebTests(TemporaryEnvironment):
self.assertIn('新的采集记录', html) self.assertIn('新的采集记录', html)
self.assertIn('未经过新版正文质量核验', html) self.assertIn('未经过新版正文质量核验', html)
def test_history_lists_runs_and_shows_one_run_detail(self):
first = result_module.normalize(envelope([self.make_article()]), 'sample', 1, 0,
self.base, self.base)
first.update(provider='primary', duration_ms=65000)
second = result_module.normalize(envelope([self.make_article()]), 'sample', 1, 0,
self.base, self.base)
second.update(provider='fallback', duration_ms=2000)
(self.logs / 'sample.20260907.090000.json').write_text(json.dumps(first))
latest_name = 'sample.continue.20260908.101500.json'
(self.logs / latest_name).write_text(json.dumps(second))
html = self.client.get('/source/sample').get_data(as_text=True)
self.assertIn('历史执行记录', html)
self.assertIn('2026-09-08 10:15:00', html)
self.assertIn('续采 / 修复', html)
self.assertIn('1m05s', html)
self.assertIn(f'/source/sample/history/{latest_name}', html)
response = self.client.get(f'/source/sample/history/{latest_name}')
self.assertEqual(response.status_code, 200)
history_html = response.get_data(as_text=True)
self.assertIn('历史执行详情', history_html)
self.assertIn('fallback', history_html)
self.assertIn('成功 / 失败 / 目标', history_html)
self.assertEqual(self.client.get('/source/sample/history/not-a-run.json').status_code, 404)
def test_forms_accept_many_times_reject_invalid(self): def test_forms_accept_many_times_reject_invalid(self):
with patch.object(self.web, 'run_script', return_value=(0, '', '')) as run: with patch.object(self.web, 'run_script', return_value=(0, '', '')) as run:
self.assertEqual(self.client.post('/add', data={'source': 'sample', 'time': '09:00,20:00'}).status_code, 302) self.assertEqual(self.client.post('/add', data={'source': 'sample', 'time': '09:00,20:00'}).status_code, 302)
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
路由: 路由:
GET / List 所有 source(timer 状态 + 最近运行摘要) GET / List 所有 source(timer 状态 + 最近运行摘要)
GET /source/<name> Source 详情(timer 信息 + 日志 + JSON 元数据) GET /source/<name> Source 详情(timer 信息 + 最新结果 + 历史执行记录)
GET /source/<name>/history/<run_file> 某次历史执行详情
GET /add 添加 source 表单 GET /add 添加 source 表单
POST /add 执行 add-source.sh POST /add 执行 add-source.sh
GET /edit/<name> 编辑 source 表单(预填当前值) GET /edit/<name> 编辑 source 表单(预填当前值)
...@@ -14,7 +15,6 @@ ...@@ -14,7 +15,6 @@
""" """
import functools import functools
import glob
import json import json
import os import os
import re import re
...@@ -252,18 +252,130 @@ def parse_last_log(source): ...@@ -252,18 +252,130 @@ def parse_last_log(source):
def get_latest_json(source): def get_latest_json(source):
"""读取最新的 Claude JSON 输出文件""" """读取最新的 Claude JSON 输出文件"""
pattern = str(LOG_DIR / f"{source}.*.json") runs = get_history_runs(source, include_data=True)
files = sorted(glob.glob(pattern), key=lambda path: Path(path).stat().st_mtime_ns, reverse=True) if not runs:
if not files:
return None return None
latest = runs[0]
return {"path": str(latest["path"]), "data": latest["data"]}
def _history_filename_pattern(source):
"""返回某个 source 的历史结果文件名正则,避免路径穿越。"""
return re.compile(
rf"^{re.escape(source)}(?P<continue>\.continue)?\."
rf"(?P<date>\d{{8}})\.(?P<time>\d{{6}})\.json$"
)
def format_duration_ms(value):
"""将 Claude 返回的毫秒耗时格式化为简明文本。"""
if not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0:
return "-"
total_seconds = round(value / 1000)
minutes, seconds = divmod(total_seconds, 60)
if minutes:
return f"{minutes}m{seconds:02d}s"
return f"{seconds}s"
def _history_summary(source, path, data, match):
"""从一份运行 JSON 构造历史列表的安全、紧凑摘要。"""
try: try:
with open(files[0], encoding="utf-8") as f: run_at = datetime.strptime(
data = json.load(f) match.group("date") + match.group("time"), "%Y%m%d%H%M%S"
).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
run_at = datetime.fromtimestamp(path.stat().st_mtime).strftime(
"%Y-%m-%d %H:%M:%S"
)
result = data.get("result")
if isinstance(result, dict) and result.get("schema_version") == 1:
status = result.get("status", "UNKNOWN")
succeeded_count = result.get("succeeded_count", 0)
failed_count = result.get("failed_count", 0)
requested_count = result.get("requested_count", 0)
else:
status = "FAILED" if data.get("is_error") is True else "LEGACY"
succeeded_count = failed_count = requested_count = None
return {
"filename": path.name,
"path": path,
"data": data,
"run_at": run_at,
"mode": "续采 / 修复" if match.group("continue") else "常规采集",
"status": status,
"succeeded_count": succeeded_count,
"failed_count": failed_count,
"requested_count": requested_count,
"provider": data.get("provider", "-") or "-",
"duration": format_duration_ms(data.get("duration_ms")),
}
def get_history_runs(source, include_data=False):
"""读取一个 source 的全部有效执行记录,按运行时间倒序返回。"""
pattern = _history_filename_pattern(source)
runs = []
for path in LOG_DIR.glob(f"{source}.*.json"):
if not path.is_file():
continue
match = pattern.match(path.name)
if not match:
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(data, dict): if not isinstance(data, dict):
return None continue
return {"path": files[0], "data": data} run = _history_summary(source, path, data, match)
except Exception: if not include_data:
run.pop("data")
run.pop("path")
runs.append(run)
return sorted(runs, key=lambda run: run["run_at"], reverse=True)
def get_history_run(source, filename):
"""按严格校验过的文件名读取一条历史运行记录。"""
if not _history_filename_pattern(source).match(filename):
return None
path = LOG_DIR / filename
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None return None
if not isinstance(data, dict):
return None
return _history_summary(source, path, data, _history_filename_pattern(source).match(filename))
def build_json_meta(data):
"""提取详情页展示所需的 Claude 运行元数据。"""
return {
"cost": data.get("total_cost_usd", "-"),
"turns": data.get("num_turns", "-"),
"duration_api": data.get("duration_api_ms", "-"),
"session_id": data.get("session_id", "-"),
"is_error": data.get("is_error", False),
"terminal": data.get("terminal_reason", "-"),
"usage": data.get("usage", {}),
"permission_denials": data.get("permission_denials", []),
}
def get_business_result(data):
"""区分新版结构化结果和旧版文本结果。"""
value = data.get("result")
if isinstance(value, dict) and value.get("schema_version") == 1:
return value, None
if isinstance(value, str):
return None, value
return None, None
def read_conf(source): def read_conf(source):
...@@ -462,35 +574,32 @@ def source_detail(name): ...@@ -462,35 +574,32 @@ def source_detail(name):
log_info = parse_last_log(name) log_info = parse_last_log(name)
json_info = get_latest_json(name) json_info = get_latest_json(name)
# 解析 JSON 中的元数据 json_meta = build_json_meta(json_info["data"]) if json_info else None
json_meta = None business_result, legacy_result = get_business_result(json_info["data"]) if json_info else (None, None)
if json_info and json_info["data"]: history_runs = get_history_runs(name)
d = json_info["data"]
json_meta = {
"cost": d.get("total_cost_usd", "-"),
"turns": d.get("num_turns", "-"),
"duration_api": d.get("duration_api_ms", "-"),
"session_id": d.get("session_id", "-"),
"is_error": d.get("is_error", False),
"terminal": d.get("terminal_reason", "-"),
"usage": d.get("usage", {}),
"permission_denials": d.get("permission_denials", []),
}
business_result = None
legacy_result = None
if json_info:
value = json_info["data"].get("result")
if isinstance(value, dict) and value.get("schema_version") == 1:
business_result = value
elif isinstance(value, str):
legacy_result = value
return render_template("detail.html", name=name, run_time=run_time, return render_template("detail.html", name=name, run_time=run_time,
props=props, log_info=log_info, json_info=json_info, props=props, log_info=log_info, json_info=json_info,
json_meta=json_meta, conf=conf, business_result=business_result, legacy_result=legacy_result, json_meta=json_meta, conf=conf, business_result=business_result, legacy_result=legacy_result,
continue_status=continue_status, auto_refresh=auto_refresh, continue_status=continue_status, auto_refresh=auto_refresh,
providers=get_providers()) providers=get_providers(), history_runs=history_runs)
@app.route("/source/<name>/history/<run_file>")
@require_auth
def source_history_detail(name, run_file):
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404)
run = get_history_run(name, run_file)
if not run:
abort(404)
business_result, legacy_result = get_business_result(run["data"])
return render_template(
"history_detail.html", name=name, run=run,
json_meta=build_json_meta(run["data"]),
business_result=business_result, legacy_result=legacy_result,
)
@app.route("/add", methods=["GET", "POST"]) @app.route("/add", methods=["GET", "POST"])
......
...@@ -94,12 +94,25 @@ ...@@ -94,12 +94,25 @@
color: #888; color: #888;
border-bottom: 1px solid #2a2d37; border-bottom: 1px solid #2a2d37;
letter-spacing: 0.5px; letter-spacing: 0.5px;
white-space: nowrap;
} }
td { td {
padding: 10px 12px; padding: 10px 12px;
border-bottom: 1px solid #1e2128; border-bottom: 1px solid #1e2128;
font-size: 14px; font-size: 14px;
vertical-align: top;
overflow-wrap: break-word;
}
/* 超宽内容在容器内横向滚动,不撑破页面 */
.table-wrap { overflow-x: auto; }
/* 长标识符(如 Session ID)在表格中截断显示,悬停看全文 */
.truncate {
display: inline-block;
max-width: 18ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
vertical-align: bottom;
} }
tr:hover { background: #161922; } tr:hover { background: #161922; }
tr.ok td:first-child { border-left: 3px solid #4ec9b0; } tr.ok td:first-child { border-left: 3px solid #4ec9b0; }
...@@ -193,7 +206,7 @@ ...@@ -193,7 +206,7 @@
.card .value.red { color: #f44747; } .card .value.red { color: #f44747; }
.actions { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; } .actions { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
.session-id { font-family: monospace; font-size: 12px; color: #569cd6; } .session-id { font-family: monospace; font-size: 12px; color: #569cd6; word-break: break-all; }
a.source-link { color: #569cd6; text-decoration: none; } a.source-link { color: #569cd6; text-decoration: none; }
a.source-link:hover { text-decoration: underline; } a.source-link:hover { text-decoration: underline; }
.footer { text-align: center; color: #555; font-size: 12px; margin-top: 30px; padding: 16px; } .footer { text-align: center; color: #555; font-size: 12px; margin-top: 30px; padding: 16px; }
......
...@@ -109,6 +109,7 @@ ...@@ -109,6 +109,7 @@
{% if conf %} {% if conf %}
<h2>栏目配置 ({{ name }}.conf)</h2> <h2>栏目配置 ({{ name }}.conf)</h2>
<div class="table-wrap">
<table> <table>
<tbody> <tbody>
{% for k, v in conf.items() %} {% for k, v in conf.items() %}
...@@ -116,6 +117,7 @@ ...@@ -116,6 +117,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<h2>栏目配置</h2> <h2>栏目配置</h2>
<p style="color:#666">无独立配置,继承 <code>default.conf</code></p> <p style="color:#666">无独立配置,继承 <code>default.conf</code></p>
...@@ -132,6 +134,7 @@ ...@@ -132,6 +134,7 @@
{% endfor %} {% endfor %}
{% for warning in business_result.warnings %}<p>提示:{{ warning }}</p>{% endfor %} {% for warning in business_result.warnings %}<p>提示:{{ warning }}</p>{% endfor %}
{% if business_result.source_exhausted %}<p>已无更多新文章:{{ business_result.exhaustion_reason }}</p>{% endif %} {% if business_result.source_exhausted %}<p>已无更多新文章:{{ business_result.exhaustion_reason }}</p>{% endif %}
<div class="table-wrap">
<table> <table>
<thead><tr><th>文章</th><th>状态</th><th>正文长度</th><th>分类 / 标签</th><th>失败原因</th></tr></thead> <thead><tr><th>文章</th><th>状态</th><th>正文长度</th><th>分类 / 标签</th><th>失败原因</th></tr></thead>
<tbody> <tbody>
...@@ -156,6 +159,7 @@ ...@@ -156,6 +159,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<details><summary>查看统一 JSON</summary><pre>{{ business_result | tojson(indent=2) }}</pre></details> <details><summary>查看统一 JSON</summary><pre>{{ business_result | tojson(indent=2) }}</pre></details>
{% elif legacy_result is not none %} {% elif legacy_result is not none %}
<h2>历史采集结果</h2> <h2>历史采集结果</h2>
...@@ -250,6 +254,7 @@ ...@@ -250,6 +254,7 @@
{% if json_meta.usage %} {% if json_meta.usage %}
<h3>Token 用量</h3> <h3>Token 用量</h3>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>类型</th><th>Token 数</th></tr> <tr><th>类型</th><th>Token 数</th></tr>
...@@ -260,6 +265,7 @@ ...@@ -260,6 +265,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endif %} {% endif %}
{% if json_meta.permission_denials %} {% if json_meta.permission_denials %}
...@@ -270,4 +276,32 @@ ...@@ -270,4 +276,32 @@
<p style="color:#666;font-size:12px;margin-top:8px">来源: {{ json_info.path }}</p> <p style="color:#666;font-size:12px;margin-top:8px">来源: {{ json_info.path }}</p>
{% endif %} {% endif %}
<h2>历史执行记录</h2>
{% if history_runs %}
<div class="table-wrap">
<table>
<thead>
<tr><th>运行时间</th><th>类型</th><th>状态</th><th>文章(成功 / 失败 / 目标)</th><th>Provider</th><th>耗时</th><th></th></tr>
</thead>
<tbody>
{% for run in history_runs %}
<tr>
<td><code>{{ run.run_at }}</code></td>
<td>{{ run.mode }}</td>
<td><span class="badge {{ 'ok' if run.status == 'SUCCESS' else 'fail' if run.status == 'FAILED' else 'unknown' }}">
{{ '成功' if run.status == 'SUCCESS' else '失败' if run.status == 'FAILED' else '旧版记录' if run.status == 'LEGACY' else run.status }}
</span></td>
<td>{% if run.requested_count is not none %}{{ run.succeeded_count }} / {{ run.failed_count }} / {{ run.requested_count }}{% else %}—{% endif %}</td>
<td>{{ run.provider }}</td>
<td>{{ run.duration }}</td>
<td><a class="btn btn-sm" href="{{ url_for('source_history_detail', name=name, run_file=run.filename) }}">查看</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color:#666">暂无可读取的历史执行记录</p>
{% endif %}
{% endblock %} {% endblock %}
{% extends "base.html" %}
{% block title %}{{ name }} 历史执行 — News Harvester{% endblock %}
{% block content %}
<h1>{{ name }} · 历史执行详情</h1>
<div class="actions">
<a class="btn" href="{{ url_for('source_detail', name=name) }}">返回数据源详情</a>
</div>
<div class="cards">
<div class="card"><div class="label">运行时间</div><div class="value" style="font-size:14px">{{ run.run_at }}</div></div>
<div class="card"><div class="label">执行类型</div><div class="value" style="font-size:14px">{{ run.mode }}</div></div>
<div class="card"><div class="label">结果状态</div><div class="value {{ 'green' if run.status == 'SUCCESS' else 'red' if run.status == 'FAILED' else '' }}">{{ '成功' if run.status == 'SUCCESS' else '失败' if run.status == 'FAILED' else '旧版记录' if run.status == 'LEGACY' else run.status }}</div></div>
<div class="card"><div class="label">Provider</div><div class="value" style="font-size:14px">{{ run.provider }}</div></div>
<div class="card"><div class="label">耗时</div><div class="value">{{ run.duration }}</div></div>
</div>
{% if business_result %}
<h2>采集结果</h2>
<div class="cards">
<div class="card"><div class="label">成功 / 失败 / 目标</div><div class="value">{{ business_result.succeeded_count }} / {{ business_result.failed_count }} / {{ business_result.requested_count }}</div></div>
<div class="card"><div class="label">实际尝试</div><div class="value">{{ business_result.attempted_count }}</div></div>
</div>
{% for failure in business_result.failures %}
<p style="color:#f44747"><strong>{{ failure.type }}</strong> · {{ failure.message }}{% if failure.url %}({{ failure.url }}){% endif %}</p>
{% endfor %}
{% for warning in business_result.warnings %}<p>提示:{{ warning }}</p>{% endfor %}
{% if business_result.source_exhausted %}<p>已无更多新文章:{{ business_result.exhaustion_reason }}</p>{% endif %}
<div class="table-wrap">
<table>
<thead><tr><th>文章</th><th>状态</th><th>正文长度</th><th>失败原因</th></tr></thead>
<tbody>
{% for article in business_result.articles %}
{% set archive = article.archive if article.archive is defined else {} %}
<tr>
<td>{% if article.url.startswith('https://') or article.url.startswith('http://') %}<a class="source-link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">{{ article.title or article.url }}</a>{% else %}{{ article.title }}{% endif %}
<p style="color:#888;font-size:12px;margin:6px 0 0">归档:{{ article.archive_path or '未归档' }}</p></td>
<td><span class="badge {{ 'ok' if article.status == 'SUCCESS' else 'fail' }}">{{ '成功' if article.status == 'SUCCESS' else '失败' }}</span></td>
<td>{% if archive.get('body_length') is not none %}{{ archive.body_length }} 字符{% elif article.body_length is defined %}{{ article.body_length }} 字符{% else %}—{% endif %}</td>
<td>{{ article.failure_type or '—' }}{% if article.failure_reason %}<br>{{ article.failure_reason }}{% endif %}{% for evidence in article.quality_evidence %}{% if evidence != article.failure_reason %}<br>{{ evidence }}{% endif %}{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<details><summary>查看统一 JSON</summary><pre>{{ business_result | tojson(indent=2) }}</pre></details>
{% elif legacy_result is not none %}
<h2>采集结果</h2>
<p>此记录使用旧版文本格式,未经过新版正文质量核验。</p>
<pre>{{ legacy_result }}</pre>
{% else %}
<h2>采集结果</h2>
<p style="color:#666">该记录不包含可展示的业务结果。</p>
{% endif %}
<h2>运行元数据</h2>
<div class="cards">
<div class="card"><div class="label">总成本</div><div class="value">${{ json_meta.cost }}</div></div>
<div class="card"><div class="label">Turns</div><div class="value">{{ json_meta.turns }}</div></div>
<div class="card"><div class="label">API 耗时</div><div class="value" style="font-size:14px">{{ json_meta.duration_api }} ms</div></div>
<div class="card"><div class="label">Terminal</div><div class="value" style="font-size:14px">{{ json_meta.terminal }}</div></div>
</div>
{% if json_meta.permission_denials %}
<h3 style="color:#f44747">权限拒绝 ({{ json_meta.permission_denials|length }} 次)</h3>
<pre>{{ json_meta.permission_denials | tojson(indent=2) }}</pre>
{% endif %}
<p style="color:#666;font-size:12px">来源: {{ run.filename }}</p>
{% endblock %}
...@@ -26,6 +26,7 @@ ...@@ -26,6 +26,7 @@
</div> </div>
<h1>栏目列表 <span style="font-size:14px;color:#666">({{ sources|length }} 个)</span></h1> <h1>栏目列表 <span style="font-size:14px;color:#666">({{ sources|length }} 个)</span></h1>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -33,8 +34,7 @@ ...@@ -33,8 +34,7 @@
<th>状态</th> <th>状态</th>
<th>运行时间</th> <th>运行时间</th>
<th>Provider</th> <th>Provider</th>
<th>上次触发</th> <th>上次 / 下次触发</th>
<th>下次触发</th>
<th>最近运行</th> <th>最近运行</th>
<th>成本</th> <th>成本</th>
<th>Session</th> <th>Session</th>
...@@ -69,18 +69,14 @@ ...@@ -69,18 +69,14 @@
<span style="font-size:11px;color:#888" title="备用 provider(继承 default.conf 全局默认)">→ {{ default_fallback }}</span> <span style="font-size:11px;color:#888" title="备用 provider(继承 default.conf 全局默认)">→ {{ default_fallback }}</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ s.last }}</td> <td>
<td>{{ s.next }}</td> <span style="color:#666;font-size:12px">上次</span> {{ s.last }}<br>
<span style="color:#666;font-size:12px">下次</span> {{ s.next }}
</td>
<td> <td>
{% if s.log %} {% if s.log %}
{{ s.log.mtime }} · {% set status_text = s.log.get('status', '') %}
{% if '成功' in s.log.get('status', '') %} {{ s.log.mtime }}{% if status_text and '成功' not in status_text and '失败' not in status_text %} · {{ status_text }}{% endif %}
<span class="badge ok">成功</span>
{% elif '失败' in s.log.get('status', '') %}
<span class="badge fail">失败</span>
{% else %}
{{ s.log.get('status', '?') }}
{% endif %}
{% else %} {% else %}
<span style="color:#666">—</span> <span style="color:#666">—</span>
{% endif %} {% endif %}
...@@ -96,7 +92,7 @@ ...@@ -96,7 +92,7 @@
</td> </td>
<td> <td>
{% if s.log and s.log.get('session') %} {% if s.log and s.log.get('session') %}
<span class="session-id" title="{{ s.log.session }}">{{ s.log.session }}</span> <span class="session-id truncate" title="{{ s.log.session }}">{{ s.log.session }}</span>
{% else %} {% else %}
<span style="color:#666">—</span> <span style="color:#666">—</span>
{% endif %} {% endif %}
...@@ -109,8 +105,10 @@ ...@@ -109,8 +105,10 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<h2>每日汇总</h2> <h2>每日汇总</h2>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -146,5 +144,6 @@ ...@@ -146,5 +144,6 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
<div class="flash error">JSON 解析失败: {{ provider.error }}</div> <div class="flash error">JSON 解析失败: {{ provider.error }}</div>
{% else %} {% else %}
<h2>环境变量配置 <span style="font-size:12px;color:#666">(敏感值已脱敏)</span></h2> <h2>环境变量配置 <span style="font-size:12px;color:#666">(敏感值已脱敏)</span></h2>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -35,6 +36,7 @@ ...@@ -35,6 +36,7 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
</div>
<p style="color:#666;font-size:12px;margin-top:12px"> <p style="color:#666;font-size:12px;margin-top:12px">
此 provider 在 Claude 启动时通过 <code>--settings '{"env": {...}}'</code> 注入。 此 provider 在 Claude 启动时通过 <code>--settings '{"env": {...}}'</code> 注入。
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
<code>default</code> 或未设置 = 使用系统全局配置(不加 --settings)。 <code>default</code> 或未设置 = 使用系统全局配置(不加 --settings)。
</p> </p>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -52,5 +53,6 @@ ...@@ -52,5 +53,6 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment