diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index 320b398..7a9f920 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -152,9 +152,11 @@ scripts/ - `service/` 作用:核心业务逻辑 比如任务编排、调用兄弟技能、外部 API、可选浏览器自动化 + **负责业务关键节点日志**(`task_start` / 外部调用 / `task_failed` 等,见 [`LOGGING.md`](LOGGING.md)) - `util/` 作用:常量、日志、路径、时间工具、通用帮助函数 + `util/logging_config.py` 是 `jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger 公共能力(config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics)从共享 runtime 的 `jiangchang-platform-kit>=1.0.17` import,**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。 @@ -421,6 +423,7 @@ metadata: - `REQUIREMENTS.md` — 需求与验收 - `DEVELOPMENT.md` — 开发教程(本文档) - `TESTING.md` — 测试分层与隔离(详见 [`TESTING.md`](TESTING.md)) +- `LOGGING.md` — 日志分层、必打节点、敏感信息红线 - `ADAPTER.md`、`RPA.md`、`CONFIG.md`、`RUNTIME.md` — 技术规范 ## 8. `assets/` 应该放什么 @@ -478,6 +481,16 @@ metadata: - `entitlement_service.py` 放鉴权逻辑 +### 日志要求(`cmd_run` / 核心业务) + +`service` 层是**关键业务日志**的落点,须遵循 [`LOGGING.md`](LOGGING.md): + +- `cmd_run` 及主任务入口必须:`log.info("task_start ...")`、`log.info("task_log_saved ...")`;失败路径必须 `log.exception("task_failed ...")`。 +- 外部 API、兄弟技能、RPA 操作须记录**开始 / 结束 / 耗时 / 状态**(`external_call_start` / `external_call_done elapsed_ms=...`)。 +- 长任务 / RPA 须配合 `activity.emit` 或 `RpaVideoSession.add_step` 输出用户可见进度。 +- 使用 `from util.logging_config import get_skill_logger`,**不要**用 `print` 替代 logger 做排障日志。 +- **禁止**在日志中写入 password、token、cookie 等敏感明文(见 `LOGGING.md` §敏感信息红线)。 + ### 一个很重要的原则 不要把所有逻辑都堆进一个文件。 @@ -602,6 +615,14 @@ python scripts/main.py logs python scripts/main.py log-get 1 ``` +除 `health` / `version` / `logs` 外,执行一次 `run`(或你的主任务命令)后,应确认**统一日志文件**已生成并可检索: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log +``` + +其中应能看到 `cli_start`、`task_start` 等带 `trace_id` / `skill_slug` 的行。路径与分层说明见 [`LOGGING.md`](LOGGING.md)。 + ### 4. 最后再验证真实业务 比如: @@ -768,6 +789,8 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea - [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag) - [ ] `.github/workflows/release_skill.yaml` 存在 - [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`) +- [ ] 主任务入口(如 `cmd_run`)有关键日志:`task_start`、`task_log_saved`;失败路径使用 `logger.exception` +- [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md)) - [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测 - [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行) - [ ] 源码/文档/配置为 **UTF-8 without BOM**(Python 文件不得含 `U+FEFF`) diff --git a/development/LOGGING.md b/development/LOGGING.md new file mode 100644 index 0000000..8cbdd9c --- /dev/null +++ b/development/LOGGING.md @@ -0,0 +1,274 @@ +# 日志规范 + +本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文,并在 code review 时对照「必打日志节点清单」人工检查。 + +--- + +## 1. 为什么日志是必需的 + +OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览器、ERP、微信等外部系统。这类任务具备以下特征: + +- **耗时长**:单次 run 可能数分钟甚至更久,stdout 长时间无输出不代表卡死。 +- **失败点多**:网络抖动、页面改版、登录态失效、验证码、U 盾等都会导致中途失败。 +- **环境差异大**:客户 OS、浏览器版本、代理、杀毒软件、屏幕分辨率各不相同,无法复现开发机行为。 +- **必须离线排查**:技术支持往往无法远程登录客户电脑,只能依赖本地落盘的日志与 artifacts。 + +日志用于定位: + +| 维度 | 说明 | +|------|------| +| 失败阶段 | 任务停在鉴权、取号、打开页面、提交还是等待结果 | +| 输入范围 | `task_type`、`target_id`、`input_id`、`batch_id` | +| 外部系统状态 | HTTP 状态、RPA 步骤、兄弟技能返回码 | +| 耗时 | `elapsed_ms`、批量 `current/total` | +| 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 | +| 存证路径 | 截图、录屏、`video_log`、失败 artifacts | + +**没有关键节点日志 = 客户现场不可排障。** 不要把 `print` 当作日志;stdout 面向用户与 Agent,文件日志与 Run Journal 面向运维。 + +--- + +## 2. 日志分层 + +技能日志分四层,职责互不替代: + +### 2.1 统一文件日志(开发 / 运维排查) + +**来源**:`jiangchang_skill_core.unified_logging`(技能内通过 `util.logging_config` 薄封装 import)。 + +| API | 用途 | +|-----|------| +| `setup_skill_logging` | CLI 入口启动时初始化(`cli/app.py` 的 `main()`) | +| `get_skill_logger` | 获取带 `skill_slug` 的 logger | +| `subprocess_env_with_trace` | 子进程 / 兄弟技能调用时传递 `trace_id` | + +**路径**: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log +``` + +**特征**:每行带 `trace_id`、`skill_slug`;适合 grep、离线打包发给技术支持。 + +### 2.2 Activity / Run Journal(宿主 UI 与用户可见进度) + +**来源**:`jiangchang_skill_core.activity` + +| API | 用途 | +|-----|------| +| `emit` | 推送用户可读进度(不写 stdout) | +| `step` | 结构化步骤 | +| `finish` | 任务结束;**唯一**写单行 result JSON 到 stdout 的场景 | +| `rpa_step` | RPA 专用步骤文案 | + +**路径**: + +```text +{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl +``` + +**约定**: + +- `emit` **不写 stdout**;长任务应持续 emit,避免用户以为卡死。 +- `finish` 才输出单行 result JSON 供宿主解析。 +- RPA 类技能:`RpaVideoSession.add_step` 会自动同步 activity(无需重复手写每步 emit)。 + +### 2.3 `task_logs`(任务结果审计) + +**来源**:`db/task_logs_repository.save_task_log` + +- 适合 `python scripts/main.py logs` / `log-get ` 查询。 +- 记录任务最终状态、`result_summary`(可含 `video_path`、`video_log` 等)。 +- 面向「这次任务成功还是失败、摘要是什么」,不是逐步 trace。 +- **鉴权失败与未捕获异常也必须写入 `task_logs`**,保证 `logs` / `log-get` 排查链不断(见模板 `cmd_run` 示范)。 + +表结构与字段见 [`references/SCHEMA.md`](../references/SCHEMA.md)。 + +### 2.4 RPA video step / artifacts(用户可见动作与存证) + +**来源**:`RpaVideoSession`、`rpa-artifacts/` 目录 + +- 用户可见的中文步骤(「打开登录页」「点击提交」)。 +- 录屏 MP4、`video_log`(ffmpeg 诊断)。 +- 失败截图:`{数据目录}/rpa-artifacts/{batch_id}/...` + +详见 [`RPA.md`](RPA.md) §失败存证与录屏标准。 + +--- + +## 3. 必打日志节点清单 + +以下节点应在**生产代码**中有对应 `log.info` / `log.warning` / `log.exception`(或 Activity / RPA step)。复制模板后按业务增删,但**不可整体省略**。 + +| 阶段 | 建议日志 / emit | +|------|-----------------| +| CLI 入口启动 | `cli_start`(`cli/app.py`,已由模板提供) | +| 参数解析完成 | `run_args target_id=... input_id=...` | +| 鉴权开始 | `entitlement_check_start` | +| 鉴权成功 | `entitlement_ok` | +| 鉴权失败 | `entitlement_failed`(`warning`) | +| 任务开始 | `task_start task_type=... target_id=... input_id=... batch_id=...` + `emit("开始处理任务")` | +| adapter / test target 选择 | `adapter_selected adapter=... system=...` | +| 账号 / profile / lease 获取 | `account_acquired account_id=...` | +| 账号 / lease 释放 | `account_released account_id=...` | +| 外部 API 开始 | `external_call_start system=... operation=...` | +| 外部 API 结束 | `external_call_done system=... operation=... elapsed_ms=... status=...` | +| 兄弟技能调用开始 / 结束 | 同上,或 `sibling_call_start/done skill_slug=...` | +| RPA 操作开始 / 结束 | `rpa_start` / `rpa_done` + `video.add_step` 中文步骤 | +| 打开页面 | step:「打开 xxx 页面」 | +| 检查登录 | step + log | +| 定位输入框 / 点击提交 | step + log | +| 等待结果 | step + log(含 timeout 预期) | +| 批量循环 | `batch_progress current=... total=...` | +| 重试 | `retry_attempt n=... reason=...`(`warning`) | +| 跳过 / 部分失败 | `skipped` / `partial_failure`(`warning`) | +| 人工介入 | `human_intervention type=captcha|login|ukey|otp`(`warning`) | +| 失败截图 / 视频 | `artifact_saved artifact_path=...` / `video_path=...` | +| 任务成功 | `task_success ...` + `task_log_saved status=success` | +| 任务失败 | `task_failed ...`(`exception` 保留 traceback)+ `task_log_saved status=failed` | + +--- + +## 4. 字段规范 + +推荐在 `log.info` 等消息中使用 **key=value** 结构化字段,便于 grep: + +| 字段 | 含义 | +|------|------| +| `task_type` | 任务类型(与 `task_logs.task_type` 对齐) | +| `target_id` | 目标标识(平台、店铺、报表类型等) | +| `input_id` | 输入记录 ID | +| `batch_id` | 批次 / 录屏批次 | +| `stage` | 阶段:`auth` / `run` / `cleanup` | +| `system` | 外部系统名:`erp` / `wechat` / `1688` | +| `operation` | 操作名:`submit_order` / `fetch_report` | +| `adapter` | adapter 档位:`mock` / `simulator_api` / `real_api` | +| `elapsed_ms` | 耗时毫秒 | +| `status` | `ok` / `failed` / `timeout` / `skipped` | +| `error_code` | 业务错误码(非 Python 异常名) | +| `artifact_path` | 截图 / 附件路径 | +| `video_path` | 录屏成品路径 | + +示例: + +```text +external_call_done system=erp operation=download_report elapsed_ms=4523 status=ok +batch_progress current=7 total=20 target_id=store-001 +``` + +--- + +## 5. 日志级别规范 + +| 级别 | 使用场景 | +|------|----------| +| **info** | 关键阶段、成功路径、外部调用开始/结束、批量进度、`task_log_saved` | +| **warning** | 可恢复问题、重试、跳过、人工介入、部分失败、鉴权失败(业务可预期) | +| **exception** | 异常路径;**必须**用 `log.exception(...)` 保留 traceback | +| **debug** | 低频诊断;**默认不依赖 debug 才能排查问题** | + +原则:**生产排障应主要靠 info + warning + exception**,不要把 debug 当作唯一线索。 + +--- + +## 6. 敏感信息红线 + +### 禁止记录原始值 + +以下字段**不得**以明文出现在日志、Activity、task_logs 的 `error_msg` / `result_summary`、stdout: + +- `password`、`token`、`cookie`、`secret`、`api_key`、`authorization` +- 验证码、动态口令全文 +- 身份证、银行卡、手机号**完整值** + +### 建议记录的安全替代 + +| 替代字段 | 示例 | +|----------|------| +| `credential_ref` | vault / account-manager 引用 ID | +| `account_id` | 账号表主键 | +| `masked_user` | `138****8000` | +| `domain` | `erp.example.com` | +| `last4` | 卡号后四位 | +| `hash` / `short_id` | 内容摘要 | + +变量名可以叫 `token`,但日志里不能出现 `token=eyJhbG...` 或 f-string 拼接明文。 + +--- + +## 7. 推荐代码示例(service 层) + +```python +from util.logging_config import get_skill_logger +from jiangchang_skill_core.activity import emit + +from util.constants import SKILL_SLUG + +log = get_skill_logger() + +def cmd_run(target=None, input_id=None): + task_type = "your_task" + log.info( + "task_start task_type=%s target_id=%s input_id=%s", + task_type, target, input_id, + ) + emit("开始处理任务", skill=SKILL_SLUG, stage="run") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason) + return 1 + + t0 = time.monotonic() + # ... 外部调用 ... + elapsed_ms = int((time.monotonic() - t0) * 1000) + log.info( + "external_call_done system=%s operation=%s elapsed_ms=%d status=%s", + "erp", "submit", elapsed_ms, "ok", + ) + + tlr.save_task_log(..., status="success", ...) + log.info("task_log_saved task_type=%s status=success", task_type) + emit("任务完成", skill=SKILL_SLUG, stage="run") + return 0 + except Exception: + log.exception( + "task_failed task_type=%s target_id=%s input_id=%s", + task_type, target, input_id, + ) + emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run") + tlr.save_task_log(..., status="failed", error_msg="...", ...) + return 1 +``` + +模板示范见 [`scripts/service/task_service.py`](../scripts/service/task_service.py) 的 `cmd_run`。 + +--- + +## 8. 错误排查顺序 + +客户现场或测试机排障,建议按以下顺序(由快到慢、由用户可见到运维细节): + +1. **CLI stdout** — 是否有 `ERROR::`、`HINT:`、模板提示文案。 +2. **`python scripts/main.py logs`** — 最近任务是否写入 `task_logs`、status 与 `error_msg`。 +3. **`python scripts/main.py log-get `** — 单条 JSON,含 `result_summary`(video 路径等)。 +4. **统一日志文件** — `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(grep `trace_id`、`task_failed`)。 +5. **Run Journal** — `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`(宿主 UI 进度来源)。 +6. **RPA artifacts** — `rpa-artifacts/` 截图、录屏 MP4、`video_log`。 + +长时间无 stdout **不代表卡死**;先看 Run Journal / Activity,再看统一日志。 + +--- + +## 9. 与现有文档关系 + +| 文档 | 关系 | +|------|------| +| [`RUNTIME.md`](RUNTIME.md) | 统一日志能力来源、`util.logging_config` 薄封装、数据根约定 | +| [`TESTING.md`](TESTING.md) | 日志相关 hard policy 测试(`test_development_policy_guard.py`) | +| [`RPA.md`](RPA.md) | RPA step、`RpaVideoSession`、video / artifacts 标准 | +| [`SCHEMA.md`](../references/SCHEMA.md) | `task_logs` 表结构与查询命令 | +| [`CLI.md`](../references/CLI.md) | `logs` / `log-get` 命令与手工排查入口 | +| [`CONFIG.md`](CONFIG.md) | 敏感配置不进日志;`.env` 边界 | +| [`DEVELOPMENT.md`](DEVELOPMENT.md) | `service/` 层日志职责与发布前检查清单 | +| [`POLICY_MATRIX.md`](POLICY_MATRIX.md) | 可自动检测的 logging hard policy 索引 | diff --git a/development/POLICY_MATRIX.md b/development/POLICY_MATRIX.md index 3320c19..72a7f93 100644 --- a/development/POLICY_MATRIX.md +++ b/development/POLICY_MATRIX.md @@ -18,6 +18,11 @@ | POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` | | POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` | | POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` | +| POLICY-LOGGING-001 | CLI 入口必须调用 `setup_skill_logging` | development/LOGGING.md;development/RUNTIME.md | hard | 扫描 `scripts/cli/app.py` 含 `setup_skill_logging` 与 logger info | `tests/test_development_policy_guard.py::TestPolicyLogging001` | +| POLICY-LOGGING-002 | service 主任务入口必须使用 `get_skill_logger` | development/LOGGING.md;development/DEVELOPMENT.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging002` | +| POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info`、`log.exception`/`logger.exception`,并写入 `save_task_log` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` | +| POLICY-LOGGING-004 | 长任务 / RPA 模板须使用 Activity 或 RPA video step 输出进度 | development/LOGGING.md;development/RPA.md | hard | 扫描 `scripts/service/task_service.py` 含 `emit(` / `activity.emit` / `video.add_step` | `tests/test_development_policy_guard.py::TestPolicyLogging004` | +| POLICY-LOGGING-005 | 交付代码不得在 logging 调用行以 `key=value` 形式记录明显敏感字段 | development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/**/*.py` 的 logging 调用行(不含注释) | `tests/test_development_policy_guard.py::TestPolicyLogging005` | --- diff --git a/development/README.md b/development/README.md index 862ce89..49b90fd 100644 --- a/development/README.md +++ b/development/README.md @@ -6,10 +6,11 @@ 2. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读) 3. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范 4. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关 -5. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时 -6. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时 -7. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 -8. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 +5. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**) +6. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时 +7. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时 +8. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 +9. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)(`scaffold_skill.ps1`)。 diff --git a/development/TESTING.md b/development/TESTING.md index f15de72..b3bac24 100644 --- a/development/TESTING.md +++ b/development/TESTING.md @@ -191,6 +191,17 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t - `POLICY-RPA-002`:只禁止 `subprocess` / `os.system` / `os.popen` **直接执行** ffmpeg,不误伤 `ffmpeg_path` 等诊断字段。 - 与已有测试分工:`test_release_packaging_constraints.py` 继续守护 PyArmor 行数与 UTF-8 BOM;`test_no_rpa_helpers_import.py` 与 policy guard 的 `POLICY-RPA-001` 语义一致,二者并存防回归。 +### 日志规范测试 + +[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) 中的 `POLICY-LOGGING-*` 规则会检查 logging **hard policy**(见 [`POLICY_MATRIX.md`](POLICY_MATRIX.md)): + +- CLI 是否调用 `setup_skill_logging`(`POLICY-LOGGING-001`) +- `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`、写入 `save_task_log`(`POLICY-LOGGING-002` / `003`) +- 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`) +- 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`) + +**局限**:自动检测只覆盖可静态判断的**底线**,**不**判断业务日志是否足够完整。复制新 skill 后,仍须人工对照 [`LOGGING.md`](LOGGING.md) §必打日志节点清单做 code review。 + 新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。 --- diff --git a/references/CLI.md b/references/CLI.md index 25a3102..e5a2215 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -16,7 +16,13 @@ python {baseDir}/scripts/main.py version - **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。 - **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。 - **`version`**:输出 JSON(`version`、`skill`)。 -- **`run`**:长时间无 stdout **不代表卡死**;应通过 `logs` / `log-get` 排查。 +- **`run`**:长时间无 stdout **不代表卡死**;RPA / 外部调用期间应通过 Activity 或 Run Journal 看进度。 +- **排查顺序**(由快到慢): + 1. `python {baseDir}/scripts/main.py logs` + 2. `python {baseDir}/scripts/main.py log-get ` + 3. 统一日志文件 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(含 `trace_id`、`task_failed` 等) + 4. Run Journal `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl` + 5. RPA artifacts / `video_log` / 失败截图(见 `development/RPA.md`) - **任务完成后**若有 video artifact,CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`)。 ## config-path(配置路径) diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py index ce12b9c..433ed21 100644 --- a/scripts/service/task_service.py +++ b/scripts/service/task_service.py @@ -13,6 +13,7 @@ import uuid from typing import Any, Dict, Optional from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines +from jiangchang_skill_core.activity import emit from jiangchang_skill_core.rpa.video_session import RpaVideoSession from db import task_logs_repository as tlr @@ -22,11 +23,20 @@ from service.task_run_support import ( build_video_info, merge_video_into_result_summary, ) -from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION +from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION +from util.logging_config import get_skill_logger, setup_skill_logging from util.runtime_paths import get_skill_data_dir, get_skill_root from util.timeutil import unix_to_iso +def _get_task_logger(): + try: + return get_skill_logger() + except RuntimeError: + setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) + return get_skill_logger() + + async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]: """最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。""" batch_id = uuid.uuid4().hex[:12] @@ -53,35 +63,105 @@ async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int: """通用任务执行入口模板。复制后请实现真实业务逻辑。""" - ok, reason = check_entitlement(SKILL_SLUG) - if not ok: - print(f"❌ {reason}") + log = _get_task_logger() + task_type = "demo" + log.info( + "task_start task_type=%s target_id=%s input_id=%s", + task_type, + target, + input_id, + ) + emit("开始处理任务", skill=SKILL_SLUG, stage="run") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason) + emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth") + tlr.save_task_log( + task_type=task_type, + target_id=target, + input_id=input_id, + input_title="模板示例任务", + status="failed", + error_msg=reason, + result_summary=json.dumps({"stage": "auth", "error": reason}, ensure_ascii=False), + ) + log.info( + "task_log_saved task_type=%s target_id=%s input_id=%s stage=auth status=failed", + task_type, + target, + input_id, + ) + print(f"❌ {reason}") + return 1 + + log.info("task_run_demo_start target_id=%s input_id=%s", target, input_id) + rc, video_info = asyncio.run(_run_template_demo(target, input_id)) + log.info("task_run_demo_done target_id=%s input_id=%s status=failed", target, input_id) + _print_video_summary(video_info) + + summary_payload = merge_video_into_result_summary( + { + "template_demo": True, + "target": target, + "input_id": input_id, + }, + video_info, + ) + tlr.save_task_log( + task_type=task_type, + target_id=target, + input_id=input_id, + input_title="模板示例任务", + status="failed", + error_msg="模板仓库未实现真实业务", + result_summary=json.dumps(summary_payload, ensure_ascii=False), + ) + log.info( + "task_log_saved task_type=%s target_id=%s input_id=%s status=failed", + task_type, + target, + input_id, + ) + + print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。") + return rc + except Exception: + log.exception( + "task_failed task_type=%s target_id=%s input_id=%s", + task_type, + target, + input_id, + ) + emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run") + try: + tlr.save_task_log( + task_type=task_type, + target_id=target, + input_id=input_id, + input_title="模板示例任务", + status="failed", + error_msg="任务执行异常,详见统一日志", + result_summary=json.dumps( + {"stage": "run", "error": "unexpected_exception"}, + ensure_ascii=False, + ), + ) + log.info( + "task_log_saved task_type=%s target_id=%s input_id=%s stage=run status=failed", + task_type, + target, + input_id, + ) + except Exception: + log.exception( + "task_log_save_failed task_type=%s target_id=%s input_id=%s", + task_type, + target, + input_id, + ) return 1 - rc, video_info = asyncio.run(_run_template_demo(target, input_id)) - _print_video_summary(video_info) - - summary_payload = merge_video_into_result_summary( - { - "template_demo": True, - "target": target, - "input_id": input_id, - }, - video_info, - ) - tlr.save_task_log( - task_type="demo", - target_id=target, - input_id=input_id, - input_title="模板示例任务", - status="failed", - error_msg="模板仓库未实现真实业务", - result_summary=json.dumps(summary_payload, ensure_ascii=False), - ) - - print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。") - return rc - def cmd_logs( limit: int = 10, diff --git a/tests/test_development_policy_guard.py b/tests/test_development_policy_guard.py index e08529e..07825b5 100644 --- a/tests/test_development_policy_guard.py +++ b/tests/test_development_policy_guard.py @@ -35,6 +35,11 @@ POLICY_IDS = ( "POLICY-PACKAGING-001", "POLICY-PACKAGING-002", "POLICY-DOCS-001", + "POLICY-LOGGING-001", + "POLICY-LOGGING-002", + "POLICY-LOGGING-003", + "POLICY-LOGGING-004", + "POLICY-LOGGING-005", ) STRUCTURE_PATHS = ( @@ -104,6 +109,25 @@ RPA_002_FFMPEG_PATTERNS = ( RPA_002_EXEMPT_REL = "scripts/service/task_run_support.py" +LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md" +LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md" +LOGGING_003_SOURCE = "development/LOGGING.md" +LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md" +LOGGING_005_SOURCE = "development/LOGGING.md; development/CONFIG.md" + +LOGGING_CALL_PATTERN = re.compile( + r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(", + re.IGNORECASE, +) +LOGGING_CALL_GET_LOGGER_PATTERN = re.compile( + r"get_skill_logger\s*\(\s*\)\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(", + re.IGNORECASE, +) +SENSITIVE_LOG_ASSIGNMENT_PATTERN = re.compile( + r"(password|token|cookie|secret|api_key|authorization)\s*=", + re.IGNORECASE, +) + def _policy_msg(policy_id: str, source: str, detail: str) -> str: return f"{policy_id} [{source}]: {detail}" @@ -446,6 +470,126 @@ class TestPolicyRpa002(unittest.TestCase): ) +def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]: + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + for lineno, line in enumerate(_read_text(path).splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not LOGGING_CALL_PATTERN.search(line): + continue + if SENSITIVE_LOG_ASSIGNMENT_PATTERN.search(line): + offenders.append(f"{rel}:{lineno}: {stripped}") + return offenders + + +class TestPolicyLogging001(unittest.TestCase): + def test_cli_app_sets_up_unified_logging(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "cli", "app.py") + text = _read_text(path) + rel = _rel(skill_root, path) + self.assertIn( + "setup_skill_logging", + text, + msg=_policy_msg( + "POLICY-LOGGING-001", + LOGGING_001_SOURCE, + f"{rel} missing setup_skill_logging", + ), + ) + has_logger_info = bool( + LOGGING_CALL_GET_LOGGER_PATTERN.search(text) + or re.search(r"\bget_skill_logger\s*\(\s*\)\s*\.\s*info\s*\(", text) + ) + self.assertTrue( + has_logger_info, + msg=_policy_msg( + "POLICY-LOGGING-001", + LOGGING_001_SOURCE, + f"{rel} missing get_skill_logger().info (or equivalent)", + ), + ) + + +class TestPolicyLogging002(unittest.TestCase): + def test_task_service_uses_get_skill_logger(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + self.assertRegex( + text, + r"get_skill_logger", + msg=_policy_msg( + "POLICY-LOGGING-002", + LOGGING_002_SOURCE, + f"{rel} must import/use get_skill_logger", + ), + ) + + +class TestPolicyLogging003(unittest.TestCase): + def test_task_service_logs_start_failure_and_task_logs(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + missing: list[str] = [] + if not re.search(r"\b(log|logger)\.info\s*\(", text): + missing.append("log.info or logger.info") + if not re.search(r"\b(log|logger)\.exception\s*\(", text): + missing.append("log.exception or logger.exception") + if "save_task_log" not in text: + missing.append("save_task_log") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-LOGGING-003", + LOGGING_003_SOURCE, + f"{rel} missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyLogging004(unittest.TestCase): + def test_task_service_emits_progress_for_long_tasks(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + has_progress = bool( + re.search(r"\bemit\s*\(", text) + or re.search(r"\bactivity\.emit\s*\(", text) + or re.search(r"\bvideo\.add_step\s*\(", text) + ) + self.assertTrue( + has_progress, + msg=_policy_msg( + "POLICY-LOGGING-004", + LOGGING_004_SOURCE, + f"{rel} must use emit( / activity.emit / video.add_step", + ), + ) + + +class TestPolicyLogging005(unittest.TestCase): + def test_scripts_do_not_log_sensitive_assignments(self) -> None: + offenders = _scan_sensitive_logging_assignments(get_skill_root()) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-LOGGING-005", + LOGGING_005_SOURCE, + "\n".join(offenders), + ), + ) + + class TestPolicyDocs001(unittest.TestCase): def test_policy_matrix_exists_and_lists_policy_ids(self) -> None: skill_root = get_skill_root() diff --git a/tests/test_video_service.py b/tests/test_video_service.py index d77372d..384af5e 100644 --- a/tests/test_video_service.py +++ b/tests/test_video_service.py @@ -157,5 +157,78 @@ class TestTemplateRunVideoSession(unittest.TestCase): self.assertIn("video_path", summary) +class TestCmdRunTaskLogCoverage(unittest.TestCase): + def test_cmd_run_entitlement_failure_writes_task_log(self) -> None: + with IsolatedDataRoot(user_id="_entitlement_fail"): + from service import task_service + + with patch.object(task_service, "check_entitlement", return_value=(False, "mock denied")): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = task_service.cmd_run(target="t1", input_id="99") + + self.assertEqual(rc, 1) + from db import task_logs_repository as tlr + + rows = tlr.list_task_logs(1) + self.assertTrue(rows) + _rid, _ttype, tid, iid, _ititle, status, err, rsum, _cat, _uat = rows[0] + self.assertEqual(status, "failed") + self.assertEqual(tid, "t1") + self.assertEqual(iid, "99") + self.assertIn("mock denied", err or "") + summary = json.loads(rsum or "{}") + self.assertEqual(summary.get("stage"), "auth") + + def test_cmd_run_exception_writes_task_log(self) -> None: + with IsolatedDataRoot(user_id="_exception_run"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + config.reset_cache() + + from service import task_service + + async def _boom(*_args, **_kwargs): + raise RuntimeError("boom") + + with patch.object(task_service, "check_entitlement", return_value=(True, "")): + with patch.object(task_service, "_run_template_demo", side_effect=_boom): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = task_service.cmd_run(target="t2", input_id="88") + + self.assertEqual(rc, 1) + from db import task_logs_repository as tlr + + rows = tlr.list_task_logs(1) + self.assertTrue(rows) + _rid, _ttype, tid, iid, _ititle, status, err, rsum, _cat, _uat = rows[0] + self.assertEqual(status, "failed") + self.assertEqual(tid, "t2") + self.assertEqual(iid, "88") + self.assertIn("任务执行异常", err or "") + summary = json.loads(rsum or "{}") + self.assertEqual(summary.get("stage"), "run") + self.assertEqual(summary.get("error"), "unexpected_exception") + + def test_get_task_logger_fallback_when_not_initialized(self) -> None: + from service import task_service + + mock_logger = MagicMock() + responses = [RuntimeError("logging not initialized"), mock_logger] + + def _fake_get_skill_logger(): + item = responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + with patch.object(task_service, "get_skill_logger", side_effect=_fake_get_skill_logger): + with patch.object(task_service, "setup_skill_logging") as mock_setup: + logger = task_service._get_task_logger() + + mock_setup.assert_called_once_with(task_service.SKILL_SLUG, task_service.LOG_LOGGER_NAME) + self.assertIs(logger, mock_logger) + + if __name__ == "__main__": unittest.main()