diff --git a/openspec/changes/checker-observation/.openspec.yaml b/openspec/changes/checker-observation/.openspec.yaml new file mode 100644 index 0000000..28882f7 --- /dev/null +++ b/openspec/changes/checker-observation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-19 diff --git a/openspec/changes/checker-observation/design.md b/openspec/changes/checker-observation/design.md new file mode 100644 index 0000000..02ff266 --- /dev/null +++ b/openspec/changes/checker-observation/design.md @@ -0,0 +1,159 @@ +## Context + +当前所有 checker(HTTP/TCP/UDP/ICMP/DB/CMD/LLM)在 execute 执行后,将丰富的结构化观测数据压缩为一个 `statusDetail: string | null` 字符串,然后丢弃原始数据。`CheckResult` 仅有 5 个字段(durationMs、failure、matched、statusDetail、timestamp),无法承载排障所需的上下文信息,也无法支持历史观测数据的结构化分析。 + +现有代码中,LLM checker 已有执行期 `LlmCheckObservation` 对象且 `expect.output` 依赖其中的完整 `outputText`,ICMP/Ping checker 已有 `PingStats` 结构,其余 checker 的观测数据散布在 execute 函数的局部变量中。 + +前端通过 `statusDetail` 字符串直接展示摘要,仅在 `history-table-columns.tsx` 和 `OverviewTab.tsx` 两处引用。 + +项目未上线,不需要考虑向前兼容和数据迁移。 + +现有 `openspec/specs/probe-data-store/spec.md` 中 `check_results.target_id` 仍写为 INTEGER,但当前代码中的 `targets.id` 和 `check_results.target_id` 均为 TEXT。本 change 的 `probe-data-store` delta 在替换 status_detail 时同步修正该过时 spec 描述,不引入额外代码类型变更。 + +## Goals / Non-Goals + +**Goals:** +- 在 CheckResult 中引入结构化 observation 字段,持久化完整观测数据 +- 用 `detail` 字段替代 `statusDetail`,从 observation 动态构造,不再持久化 +- 各 checker 定义各自的 observation schema 和 detail 构造逻辑 +- 对大文本/集合字段执行截断,控制存储膨胀 +- 前端展示行为不变(仍显示人可读摘要字符串) + +**Non-Goals:** +- 前端 observation 详情展开 UI(后续单独做) +- observation 的 JSON 深层查询(不需要 json_extract 等) +- 数据脱敏(仅截断,不脱敏) +- 引入新的序列化依赖(使用原生 JSON) + +## Decisions + +### Decision 1: observation 字段类型使用 `Record | null` + +**选择**: 使用 `Record` 作为 observation 的共享类型,各 checker 内部定义强类型 interface 用于构造,序列化后类型擦除。 + +**替代方案**: +- Discriminated union(`{ type: "http", ... } | { type: "tcp", ... }`):类型安全但导致共享层与所有 checker 类型耦合,每新增一个 checker 都需要修改共享类型 +- `unknown`:过于宽松,缺乏结构约束 + +**理由**: 折中方案。共享层不关心 observation 的内部结构(只需序列化/反序列化),类型安全由各 checker 内部保证。buildDetail 方法接收 `Record` 并在内部做类型断言。 + +### Decision 2: 存储格式使用 JSON TEXT + +**选择**: observation 列使用 SQLite TEXT 类型,JSON.stringify 写入,JSON.parse 读出。 + +**替代方案**: +- BLOB + MessagePack:更紧凑,但需要引入 `@msgpack/msgpack` 依赖 +- BLOB + JSON Buffer:不引入依赖,但 sqlite3 CLI 无法直接查看内容 + +**理由**: 不引入新依赖(项目规范);开发者可以用 sqlite3 CLI 直接查看和调试 observation 数据。空间换可读性,实际膨胀有限(截断策略控制了上界)。 + +### Decision 3: detail 在 API 序列化层动态构造 + +**选择**: detail 不持久化到数据库,在 API 路由的 `mapCheckResult` 中通过 `checkerRegistry.get(type).buildDetail(observation)` 动态生成。 + +**替代方案**: +- 在 execute 中同时生成 detail 并持久化:简单但存储冗余数据,且 detail 格式变更需要回填 +- 在前端从 observation 构造:前端需要了解所有 checker 类型的 detail 格式,违反关注点分离 + +**理由**: detail 是 observation 的派生数据,不应独立存储。API 层已经能获取 target type(dashboard 路由遍历 targets,history 路由已查出 target),调用 buildDetail 的成本极低。mapCheckResult 函数签名调整为接收 type 参数。 + +### Decision 4: statusDetail 重命名为 detail + +**选择**: API 合约中 `statusDetail` 字段重命名为 `detail`。 + +**理由**: 更简洁。项目未上线,无兼容性负担。 + +### Decision 5: CheckerDefinition 接口新增 buildDetail 方法 + +**选择**: 在 `CheckerDefinition` 接口中新增 `buildDetail(observation: Record): string | null`。 + +**替代方案**: +- 独立的 detailBuilder registry:过度设计,且打破 checker 内聚性 +- 在 observation 中嵌入 detail 字段:混淆数据和展示 + +**理由**: buildDetail 是 checker 领域知识的一部分,与 execute/serialize 同属 checker 职责。放在接口中保持 checker 内聚。 + +### Decision 6: 可收集的负向结果仍写入 observation + +**选择**: observation 为 null 仅表示无法形成有意义的领域观测,例如进程 spawn 失败、执行框架内部异常、请求在拿到响应前失败且没有可记录元数据。TCP 连接拒绝、UDP 未收到响应、Ping 不可达、DB 连接失败、CMD 非零退出、HTTP 非 2xx/expect 不匹配、LLM 返回错误状态等可收集上下文的负向结果 SHALL 返回结构化 observation,并通过 failure 表示失败原因。 + +**理由**: observation 的目标是排障和趋势分析。可预期的负向结果本身就是关键观测数据,如果统一写 null 会丢失连接错误、丢包率、stderr、HTTP status/body 等上下文。 + +### Decision 7: HTTP 成功拿到响应后始终采集 bodyPreview + +**选择**: HTTP checker 在 fetch 成功返回 Response 后,无论是否配置 body expect,都读取响应体前 1024 字符作为 `bodyPreview`。当配置 body expect 时,仍按现有 `maxBodyBytes` 读取完整可校验范围,并从已读取文本派生 `bodyPreview`。 + +**理由**: 主要排障诉求包含“HTTP 502 返回了什么 body”。如果仅在配置 body expect 时读取 body,默认 HTTP 探测无法提供失败响应正文。该行为会增加一次响应体读取成本,但 preview 上限较小,且 HTTP 响应体不会被后续其他逻辑复用。 + +### Decision 8: 截断策略 + +各 checker 的截断上限: + +| 字段 | 上限 | 说明 | +|------|------|------| +| HTTP bodyPreview | 1024 chars | 错误页面/API 错误体足够排障 | +| HTTP headers | 前 20 个 | 避免大量自定义 header 膨胀 | +| TCP banner | 256 chars | 与现有 truncateBanner 逻辑一致 | +| UDP responsePreview | 512 chars | 协议响应通常较短 | +| DB rowsPreview | 前 5 行 | 验证查询结果形态即可 | +| CMD stdoutPreview | 1024 chars | 错误日志/诊断输出的前段 | +| CMD stderrPreview | 1024 chars | 同上 | +| LLM outputPreview | 512 chars | 输出文本摘要即可 | +| LLM headers | 前 20 个 | 同 HTTP | + +### Decision 9: execute 返回值变更策略 + +各 checker 的 execute 方法改造方式: + +- **LLM**: 执行期继续使用包含完整 `outputText` 的 `LlmCheckObservation` 支撑 expect 校验;写入 CheckResult.observation 前派生持久化 observation,将 `outputText` 转为 `outputPreview` 和 `outputLength`,并截断 HTTP headers。不能直接把执行期 `outputText` 替换为 preview,否则会破坏 `expect.output`。 +- **ICMP**: 已有 `PingStats`,直接作为 observation 基础,补充 error 字段。 +- **HTTP/TCP/UDP/DB/CMD**: 在 execute 函数中将散布的局部变量聚合为 observation 对象。现有的 `buildStatusDetail` 辅助函数逻辑迁移到 `buildDetail` 方法中,输入改为 observation。 + +### Decision 10: 数据流架构 + +``` + Checker.execute() + │ + ├─ observation: { statusCode: 200, headers: {...}, ... } + ├─ detail: 不设置 + ├─ matched / failure / durationMs / timestamp + │ + ▼ + Engine.writeResult() + │ + ├─ observation → JSON.stringify → TEXT 列 + ├─ failure → JSON.stringify → TEXT 列 + ├─ matched / durationMs / timestamp → 原样写入 + │ + ▼ + API Route (dashboard / history) + │ + ├─ 已知 target.type + ├─ observation → JSON.parse + ├─ detail = checkerRegistry.get(type).buildDetail(obs) + │ + ▼ + API Response (CheckResult) + │ + ├─ observation: { ... } ← 结构化数据,前端可用于未来排障 UI + ├─ detail: "HTTP 200" ← 人可读摘要,前端直接展示 + ├─ matched / failure / durationMs / timestamp + │ + ▼ + Frontend + │ + ├─ 显示 detail(与原 statusDetail 行为一致) + └─ observation 暂不使用 +``` + +## Risks / Trade-offs + +**[存储膨胀]** → observation JSON 比原 statusDetail 字符串占用更多空间。通过截断策略控制上界:单条 observation 最大约 5-10KB(含 CMD stdout + stderr 各 1024 chars),远小于 SQLite 单行存储上限。配合已有的 data-retention prune 机制,整体可控。 + +**[buildDetail 性能]** → 每次 API 请求都需要 JSON.parse + buildDetail 调用。对于 dashboard(仅取 latest check per target)和 history(分页,默认 20 条/页),开销极小。如果未来需要批量处理大量记录,可以考虑缓存或批量优化。 + +**[类型安全断层]** → observation 在共享层是 `Record`,buildDetail 内部需要做类型断言。如果 observation 结构与 buildDetail 期望不一致,会产生运行时错误。通过 checker 内部 execute、持久化 observation 派生函数和 buildDetail 共享同一个 observation interface(仅 checker 内部可见)来降低风险。 + +**[前端字段重命名]** → statusDetail → detail 需要修改前端 2 处引用。变更量小,但需要确保前端编译通过。 + +**[HTTP body 读取成本]** → HTTP checker 将在拿到响应后读取 body preview,即使未配置 body expect。通过 1024 字符 preview 上限控制额外内存占用;如果 body 解码失败,仍应保留 status/headers/contentLength 等已收集 observation,并通过 failure 描述解码问题。 diff --git a/openspec/changes/checker-observation/proposal.md b/openspec/changes/checker-observation/proposal.md new file mode 100644 index 0000000..27ba368 --- /dev/null +++ b/openspec/changes/checker-observation/proposal.md @@ -0,0 +1,37 @@ +## Why + +各 checker 在执行过程中收集了丰富的结构化数据(HTTP 状态码/headers/body、ICMP 延迟/丢包率、LLM token 用量/首 token 延迟等),但 `CheckResult` 仅有一个 `statusDetail: string | null` 字段,所有观测数据被压缩为人可读字符串后丢弃。这导致:排障时无法获取失败上下文(HTTP 502 返回了什么 body?CMD 的 stderr 输出了什么?)、无法对历史观测数据做结构化查询和趋势分析(ICMP 丢包率变化、LLM token 消耗趋势)。 + +## What Changes + +- **BREAKING**: `CheckResult` 移除 `statusDetail` 字段,新增 `detail: string | null` 和 `observation: Record | null` +- **BREAKING**: 存储层 `check_results` 表移除 `status_detail` 列,新增 `observation TEXT` 列(JSON 格式) +- 每个 checker 在 execute 返回时组装结构化 observation 对象,包含该类型特有的观测数据(含截断策略);可收集的负向结果保留 observation,仅无法形成领域观测时返回 null +- `CheckerDefinition` 接口新增 `buildDetail(observation)` 方法,从 observation 动态构造人可读摘要 +- API 序列化层根据 target type 调用对应 checker 的 `buildDetail`,动态生成 `detail` 字段返回给前端 +- 前端展示层将 `statusDetail` 引用改为 `detail`,逻辑不变 + +## Capabilities + +### New Capabilities +- `checker-observation`: 定义 observation 数据模型、各 checker 的 observation schema、截断策略、序列化/反序列化规则 + +### Modified Capabilities +- `checker-runner-abstraction`: CheckerDefinition 接口新增 `buildDetail` 方法;CheckResult 类型变更(statusDetail → detail + observation) +- `probe-engine`: checker 执行结果写入字段从 status_detail 改为 observation,detail 不进入存储层 +- `probe-data-store`: check_results 表 schema 变更(status_detail → observation);insert/query 方法适配新字段;同步修正现有 spec 中已过时的 target_id 类型为当前代码实际使用的 TEXT +- `probe-api`: CheckResult API 合约变更(statusDetail → detail + observation);序列化层需根据 target type 动态构造 detail +- `cmd-checker`: CMD 执行结果改为返回 observation,detail 由 buildDetail 构造 +- `tcp-checker`: TCP 执行和 banner 摘要改为通过 observation/detail 表达 +- `udp-checker`: UDP 执行和响应摘要改为通过 observation/detail 表达 +- `icmp-checker`: Ping/ICMP 摘要改为通过 observation/detail 表达,API registry type 仍为 `ping` +- `llm-checker`: LLM 执行期 observation 与持久化 preview 分层,状态摘要改为 detail +- `target-detail-drawer`: 记录面板详情列从 statusDetail 改为 detail + +## Impact + +- **后端**: 7 个 checker 的 execute/buildDetail 需改造返回 observation;LLM 还涉及 types.ts、observation.ts、expect.ts 的执行期/持久化结构分层;engine.ts/store.ts/helpers.ts/routes 适配新字段 +- **前端**: 2 处源码 statusDetail 引用改为 detail(history-table-columns.tsx、OverviewTab.tsx),相关测试 fixture 同步更新 +- **存储**: SQLite DDL 变更,不做数据迁移(项目未上线);target_id 继续使用当前代码实际的 TEXT 类型 +- **依赖**: 无新增依赖,observation 使用 JSON.stringify/JSON.parse 序列化 +- **测试**: 所有涉及 CheckResult、StoredCheckResult、CheckerDefinition mock、API dashboard/history、各 checker execute/buildDetail、前端展示的测试需适配新字段 diff --git a/openspec/changes/checker-observation/specs/checker-observation/spec.md b/openspec/changes/checker-observation/specs/checker-observation/spec.md new file mode 100644 index 0000000..c6b1b8e --- /dev/null +++ b/openspec/changes/checker-observation/specs/checker-observation/spec.md @@ -0,0 +1,154 @@ +## ADDED Requirements + +### Requirement: Observation 数据模型 +CheckResult SHALL 包含 `observation: Record | null` 字段,用于承载 checker 执行过程中收集的结构化观测数据。observation 为 null 表示执行过程中无法形成有意义的领域观测数据(如进程 spawn 失败、内部异常、请求在拿到响应前失败且无可记录元数据等场景)。各 checker SHALL 自行定义 observation 的内部结构,不做跨 checker 类型的统一约束。 + +#### Scenario: 正常执行返回 observation +- **WHEN** checker 执行成功、expect 断言失败或产生可收集上下文的负向结果 +- **THEN** CheckResult.observation SHALL 包含该 checker 类型定义的完整观测数据 + +#### Scenario: 异常执行返回 null observation +- **WHEN** checker 执行过程中发生无法收集领域观测数据的异常 +- **THEN** CheckResult.observation SHALL 为 null + +### Requirement: HTTP Checker Observation +HTTP checker 的 observation SHALL 包含 statusCode(number)、headers(Record,截断)、bodyPreview(string | null,截断)、contentType(string | null)、contentLength(number | null)。 + +#### Scenario: 正常 HTTP 响应 +- **WHEN** HTTP 请求成功返回 +- **THEN** observation SHALL 包含响应状态码、截断后的响应 headers、响应体预览、Content-Type 和 Content-Length,即使未配置 body expect 也 SHALL 采集 bodyPreview + +#### Scenario: HTTP 请求失败 +- **WHEN** HTTP 请求因网络错误或超时失败 +- **THEN** observation SHALL 为 null + +### Requirement: TCP Checker Observation +TCP checker 的 observation SHALL 包含 connected(boolean)、connectTimeMs(number | null)、banner(string | null,截断)、error(string | null)。 + +#### Scenario: TCP 连接成功且读取 banner +- **WHEN** TCP 连接成功并读取到 banner +- **THEN** observation SHALL 包含 connected=true、连接耗时、截断后的 banner 内容 + +#### Scenario: TCP 连接失败 +- **WHEN** TCP 连接失败 +- **THEN** observation SHALL 包含 connected=false 和错误信息 + +### Requirement: UDP Checker Observation +UDP checker 的 observation SHALL 包含 responded(boolean)、responseSize(number | null)、responsePreview(string | null,截断)、sourceAddress(string | null)、sourcePort(number | null)、error(string | null)。 + +#### Scenario: UDP 收到响应 +- **WHEN** UDP 发送数据后收到响应 +- **THEN** observation SHALL 包含 responded=true、响应大小、截断后的响应预览、来源地址和端口 + +#### Scenario: UDP 未收到响应 +- **WHEN** UDP 发送数据后超时未收到响应 +- **THEN** observation SHALL 包含 responded=false + +### Requirement: Ping Checker Observation +Ping/ICMP checker 的 observation SHALL 包含 alive(boolean)、transmitted(number)、received(number)、packetLoss(number)、avgLatencyMs(number | null)、maxLatencyMs(number | null)、minLatencyMs(number | null)、error(string | null)。API registry type SHALL 仍为 `ping`。 + +#### Scenario: Ping 正常返回统计 +- **WHEN** ping 命令成功执行并解析出统计数据 +- **THEN** observation SHALL 包含完整的 PingStats 字段 + +#### Scenario: Ping 命令失败 +- **WHEN** ping 命令未找到或超时 +- **THEN** observation SHALL 为 null 或包含 error 字段 + +### Requirement: DB Checker Observation +DB checker 的 observation SHALL 包含 connected(boolean)、rowCount(number | null)、rowsPreview(unknown[] | null,截断前 N 行)、error(string | null)。 + +#### Scenario: 数据库查询成功 +- **WHEN** 数据库连接和查询成功 +- **THEN** observation SHALL 包含 connected=true、行数、截断后的行预览 + +#### Scenario: 仅探活无查询 +- **WHEN** 数据库配置仅探活连接(无 query) +- **THEN** observation SHALL 包含 connected=true,rowCount 和 rowsPreview 为 null + +### Requirement: CMD Checker Observation +CMD checker 的 observation SHALL 包含 exitCode(number | null)、stdoutPreview(string | null,截断)、stderrPreview(string | null,截断)、error(string | null)。 + +#### Scenario: 命令正常执行 +- **WHEN** 命令执行完成 +- **THEN** observation SHALL 包含退出码、截断后的 stdout 和 stderr 预览 + +#### Scenario: 命令 spawn 失败 +- **WHEN** 命令进程无法启动 +- **THEN** observation SHALL 为 null + +### Requirement: LLM Checker Observation +LLM checker SHALL 保留执行期 `LlmCheckObservation.outputText` 完整文本用于 expect 校验,并从执行期 observation 派生持久化 observation。持久化 observation SHALL 包含 provider(string)、mode(string)、model(string)、http({ status, statusText, headers } | null,headers 截断)、finishReason(string | null)、rawFinishReason(string | null)、outputPreview(string | null,截断)、outputLength(number | null)、usage({ inputTokens, outputTokens, totalTokens } | null)、stream({ completed, firstTokenMs } | null)、warnings(string[])。 + +#### Scenario: LLM HTTP 模式成功 +- **WHEN** LLM 以 HTTP 模式成功执行 +- **THEN** observation SHALL 包含 provider、mode、model、HTTP 元数据、finish 原因、截断后的输出预览、完整输出长度和 token 用量 + +#### Scenario: LLM Stream 模式成功 +- **WHEN** LLM 以 stream 模式成功执行 +- **THEN** observation SHALL 额外包含 stream 观测数据(completed、firstTokenMs) + +### Requirement: Observation 截断策略 +各 checker SHALL 对 observation 中的大文本和集合字段执行截断,防止存储膨胀。 + +#### Scenario: HTTP body 截断 +- **WHEN** HTTP 响应体超过 1024 字符 +- **THEN** bodyPreview SHALL 截断为前 1024 字符 + +#### Scenario: HTTP headers 截断 +- **WHEN** HTTP 响应 headers 超过 20 个 +- **THEN** headers SHALL 仅保留前 20 个 + +#### Scenario: TCP banner 截断 +- **WHEN** TCP banner 内容超过 256 字符 +- **THEN** banner SHALL 截断为前 256 字符 + +#### Scenario: UDP response 截断 +- **WHEN** UDP 响应预览超过 512 字符 +- **THEN** responsePreview SHALL 截断为前 512 字符 + +#### Scenario: DB rows 截断 +- **WHEN** 查询返回超过 5 行 +- **THEN** rowsPreview SHALL 仅保留前 5 行 + +#### Scenario: CMD stdout/stderr 截断 +- **WHEN** stdout 或 stderr 超过 1024 字符 +- **THEN** 对应 Preview 字段 SHALL 截断为前 1024 字符 + +#### Scenario: LLM output 截断 +- **WHEN** LLM 输出文本超过 512 字符 +- **THEN** outputPreview SHALL 截断为前 512 字符 + +#### Scenario: LLM headers 截断 +- **WHEN** LLM 响应 headers 超过 20 个 +- **THEN** headers SHALL 仅保留前 20 个 + +### Requirement: Observation 序列化规则 +observation SHALL 使用 JSON.stringify 序列化为 TEXT 格式存入 SQLite,使用 JSON.parse 反序列化读出。不引入额外的序列化依赖。 + +#### Scenario: 写入 observation +- **WHEN** 存储 CheckResult 到数据库 +- **THEN** observation 字段 SHALL 使用 JSON.stringify 序列化后存入 TEXT 列;observation 为 null 时 SHALL 存入 SQL NULL + +#### Scenario: 读取 observation +- **WHEN** 从数据库读取 CheckResult +- **THEN** observation 字段 SHALL 使用 JSON.parse 反序列化;SQL NULL 值 SHALL 映射为 null + +#### Scenario: 使用 SQLite CLI 直接查看 +- **WHEN** 开发者使用 sqlite3 CLI 工具查看 check_results 表 +- **THEN** observation 列 SHALL 为人可读的 JSON 文本 + +### Requirement: Detail 动态构造 +CheckResult SHALL 包含 `detail: string | null` 字段(替代原 statusDetail),该字段 SHALL 不持久化到数据库,而是在 API 序列化层根据 target type 从 observation 动态构造。每个 checker SHALL 提供 `buildDetail(observation)` 方法,定义该 checker 类型的人可读摘要格式。 + +#### Scenario: API 返回 detail 字段 +- **WHEN** API 序列化 CheckResult 返回给前端 +- **THEN** 系统 SHALL 根据 target type 调用对应 checker 的 buildDetail 方法,从 observation 动态生成 detail 字段 + +#### Scenario: observation 为 null 时 +- **WHEN** observation 为 null +- **THEN** detail SHALL 为 null + +#### Scenario: 各 checker 的 detail 格式 +- **WHEN** 各 checker 的 buildDetail 被调用 +- **THEN** HTTP SHALL 返回 `"HTTP {statusCode}"` 格式;TCP SHALL 返回连接状态和 banner 摘要;UDP SHALL 返回响应状态和大小摘要;Ping SHALL 返回存活状态、平均延迟和丢包率摘要;DB SHALL 返回连接状态或行数摘要;CMD SHALL 返回 `"exitCode={N}"` 格式;LLM SHALL 返回 provider、mode、状态码、finish 原因、输出长度和 token 用量摘要 diff --git a/openspec/changes/checker-observation/specs/checker-runner-abstraction/spec.md b/openspec/changes/checker-observation/specs/checker-runner-abstraction/spec.md new file mode 100644 index 0000000..712d585 --- /dev/null +++ b/openspec/changes/checker-observation/specs/checker-runner-abstraction/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Checker 接口定义 +系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize`、`buildDetail` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。 + +#### Scenario: Checker 接口包含必要方法 +- **WHEN** 开发者实现一个新的 Checker +- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)、`serialize(target)`(返回 target 展示文本和 config JSON)和 `buildDetail(observation)`(从 observation 构造人可读摘要) + +#### Scenario: CheckerContext 注入 signal +- **WHEN** 引擎调用 `checker.execute(target, ctx)` +- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort + +#### Scenario: resolve 不承担通用契约校验 +- **WHEN** config-loader 调用 checker.resolve() +- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换 + +#### Scenario: type 与 configKey 默认一致 +- **WHEN** checker 定义 `type: "tcp"` +- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组 + +#### Scenario: 接口方法使用泛型约束 +- **WHEN** 开发者查看 `CheckerDefinition` 接口签名 +- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved` + +#### Scenario: checker 实现无需手动断言 +- **WHEN** HttpChecker 实现 `CheckerDefinition` +- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言 + +#### Scenario: registry 使用默认泛型参数 +- **WHEN** CheckerRegistry 存储和返回 checker 实例 +- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition`),实现类型擦除 + +#### Scenario: buildDetail 方法签名 +- **WHEN** 开发者实现 buildDetail 方法 +- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record): string | null`,接收 observation 对象并返回人可读摘要字符串或 null + +#### Scenario: buildDetail 由 API 层调用 +- **WHEN** API 序列化 CheckResult +- **THEN** API 层 SHALL 通过 registry 获取对应 checker 并调用 buildDetail,而非由 execute 方法直接生成 detail diff --git a/openspec/changes/checker-observation/specs/cmd-checker/spec.md b/openspec/changes/checker-observation/specs/cmd-checker/spec.md new file mode 100644 index 0000000..fffc310 --- /dev/null +++ b/openspec/changes/checker-observation/specs/cmd-checker/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: cmd checker 执行 +系统 SHALL 按 cmd target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr observation,并在执行失败时产生结构化错误信息。 + +#### Scenario: 命令正常退出 +- **WHEN** cmd target 执行的进程正常退出且 exit code 为 0 +- **THEN** 系统 SHALL 记录 `durationMs` 和包含 exitCode、stdoutPreview、stderrPreview 的 observation,并进入 expect 校验;API detail SHALL 为 `exitCode=0` + +#### Scenario: 命令非零退出 +- **WHEN** cmd target 执行的进程正常退出但 exit code 为 1 +- **THEN** 系统 SHALL 记录包含 exitCode、stdoutPreview、stderrPreview 的 observation,并由 expect.exitCode 决定 matched 结果;API detail SHALL 为 `exitCode=1` + +#### Scenario: 命令启动失败 +- **WHEN** cmd target 的 exec 不存在或无法启动 +- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 为 null,并在 failure 中写入 kind=`error` 和可读错误信息 + +#### Scenario: 命令超时 +- **WHEN** cmd target 在 timeout 时间内未结束 +- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息;如已收集输出片段,observation SHALL 包含 stdoutPreview、stderrPreview 和 error + +#### Scenario: 命令输出超限 +- **WHEN** cmd target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes` +- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息;observation SHALL 包含已截断输出预览和 error diff --git a/openspec/changes/checker-observation/specs/icmp-checker/spec.md b/openspec/changes/checker-observation/specs/icmp-checker/spec.md new file mode 100644 index 0000000..9340dcd --- /dev/null +++ b/openspec/changes/checker-observation/specs/icmp-checker/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: ping detail 摘要 +系统 SHALL 在 ping API 序列化时从 observation 动态生成结构化 detail 摘要,展示关键指标。API registry type SHALL 仍为 `ping`。 + +#### Scenario: 目标可达无丢包 +- **WHEN** ping observation 为 alive=true, avgLatencyMs=12, packetLoss=0%, transmitted=3, received=3 +- **THEN** detail SHALL 为 `alive, avg 12ms, loss 0% (3/3)` + +#### Scenario: 目标可达有丢包 +- **WHEN** ping observation 为 alive=true, avgLatencyMs=156, maxLatencyMs=340, packetLoss=33%, transmitted=3, received=2 +- **THEN** detail SHALL 包含 avg、max 和 loss 信息 + +#### Scenario: 目标不可达 +- **WHEN** ping observation 为 alive=false, transmitted=3, received=0 +- **THEN** detail SHALL 为 `unreachable (0/3 received)` diff --git a/openspec/changes/checker-observation/specs/llm-checker/spec.md b/openspec/changes/checker-observation/specs/llm-checker/spec.md new file mode 100644 index 0000000..8aba918 --- /dev/null +++ b/openspec/changes/checker-observation/specs/llm-checker/spec.md @@ -0,0 +1,39 @@ +## MODIFIED Requirements + +### Requirement: LLM Failure Phase 与状态摘要 +LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`、`finishReason`、`rawFinishReason`、`usage`、`duration` 作为第一版 failure phase。成功结果的 API detail SHALL 从持久化 observation 动态构造,简短描述 provider、mode、HTTP status、finish reason、raw finish reason、first token、输出长度和 token usage 中可用的信息。observation 和 detail MUST NOT 写入完整 prompt、完整输出或 key。 + +#### Scenario: request failure +- **WHEN** 模型请求因网络错误、认证调用异常、AbortSignal 或无 HTTP metadata 的 SDK 错误失败 +- **THEN** LLM checker SHALL 返回 `phase: "request"` 的 error failure + +#### Scenario: output mismatch failure +- **WHEN** 模型输出不满足 `expect.output` +- **THEN** LLM checker SHALL 返回 `phase: "output"` 的 mismatch failure + +#### Scenario: 非流式成功摘要 +- **WHEN** `provider: openai` 的非流式检查成功 +- **THEN** detail SHALL 使用类似 `LLM openai http 200 finish=stop, output=2 chars, usage=12/2 tokens` 的简短格式 + +#### Scenario: 流式成功摘要 +- **WHEN** `provider: anthropic` 的流式检查成功且存在 raw finish reason +- **THEN** detail SHALL 使用类似 `LLM anthropic stream 200 finish=stop raw=end_turn, firstToken=624ms, output=2 chars` 的简短格式 + +#### Scenario: serialize 展示文本 +- **WHEN** store 同步 LLM target +- **THEN** LLM checker `serialize()` SHALL 返回类似 `openai:gpt-4o-mini @ https://api.openai.com/v1` 的 target 展示文本和 resolved config JSON + +### Requirement: LLM Checker 测试策略 +LLM checker 的自动化测试 MUST 不访问真实外部模型服务。测试 SHALL 使用本地 mock HTTP/SSE 服务模拟 OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages 的成功、错误和流式响应。测试 SHALL 覆盖 schema、语义校验、defaults 合并、变量替换、provider factory、observation、expect、execute、registry 注册、配置加载和 JSON Schema 导出。 + +#### Scenario: 本地 mock provider 测试成功路径 +- **WHEN** 测试运行 LLM checker 的 OpenAI、OpenAI Responses 和 Anthropic 成功路径 +- **THEN** 测试 SHALL 使用本地 mock 服务返回 provider 响应,不依赖外部网络或真实 API key + +#### Scenario: 本地 mock provider 测试错误路径 +- **WHEN** 测试运行 401、429、500、超时、stream error、stream abort、缺 usage 或无文本输出路径 +- **THEN** 测试 SHALL 断言 LLM checker 返回符合 spec 的 matched、failure phase、actual、detail 和 observation + +#### Scenario: 质量检查覆盖 LLM checker +- **WHEN** 实现完成后执行质量检查 +- **THEN** `bun run schema:check`、`bun run check` SHALL 通过 diff --git a/openspec/changes/checker-observation/specs/probe-api/spec.md b/openspec/changes/checker-observation/specs/probe-api/spec.md new file mode 100644 index 0000000..c27f24d --- /dev/null +++ b/openspec/changes/checker-observation/specs/probe-api/spec.md @@ -0,0 +1,48 @@ +## MODIFIED Requirements + +### Requirement: 新增共享类型 +系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。CheckResult SHALL 包含 durationMs(null | number)、failure(CheckFailure | null)、matched(boolean)、detail(null | string)、observation(Record | null)、timestamp(string)。其中 detail 替代原 statusDetail 字段名。 + +#### Scenario: DashboardResponse 类型 +- **WHEN** 前后端共享 `DashboardResponse` 类型 +- **THEN** 该类型 SHALL 包含 summary 和 targets 字段 + +#### Scenario: TargetStatus 类型 +- **WHEN** 前后端共享 `TargetStatus` 类型 +- **THEN** 该类型 SHALL 包含目标基本信息字段(id、name、description、group、type、target、interval)、stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段,其中 name 和 description 类型均为 null 或字符串 + +#### Scenario: TargetMetricsResponse 类型 +- **WHEN** 前后端共享 `TargetMetricsResponse` 类型 +- **THEN** 该类型 SHALL 包含 targetId、window、stats 和 trend 字段 + +#### Scenario: TrendPoint 类型 +- **WHEN** 前后端共享 `TrendPoint` 类型 +- **THEN** 该类型 SHALL 包含 bucketStart、avgDurationMs、minDurationMs、maxDurationMs、availability、totalChecks、upChecks、downChecks 字段 + +#### Scenario: CheckResult 类型变更 +- **WHEN** 前端或后端引用 CheckResult 类型 +- **THEN** 该类型 SHALL 包含 `timestamp: string`、`matched: boolean`、`durationMs: number | null`、`detail: string | null`、`observation: Record | null`、`failure` 字段,不包含 statusDetail 字段,不包含 success 字段 + +#### Scenario: RecentSample 类型 +- **WHEN** 前后端共享 `RecentSample` 类型 +- **THEN** 该类型 SHALL 包含 `timestamp: string`、`durationMs: number | null`、`up: boolean` 字段,其中 up 为 boolean 且等于 matched + +#### Scenario: HistoryResponse 类型 +- **WHEN** 前后端共享 `HistoryResponse` 类型 +- **THEN** 该类型 SHALL 包含 `items: CheckResult[]`、`total: number`、`page: number`、`pageSize: number` 字段 + +#### Scenario: API 序列化构造 detail +- **WHEN** API 路由序列化 StoredCheckResult 为 API 响应 +- **THEN** 系统 SHALL 从 StoredCheckResult 中反序列化 observation,根据 target type 通过 checkerRegistry 获取对应 checker 并调用 buildDetail(observation) 动态生成 detail 字段 + +#### Scenario: mapCheckResult 接收 type 参数 +- **WHEN** 序列化辅助函数 mapCheckResult 被调用 +- **THEN** 函数 SHALL 接收 target type 参数,用于从 registry 获取对应 checker 调用 buildDetail + +#### Scenario: Dashboard API 传递 type +- **WHEN** Dashboard 路由序列化 latestCheck +- **THEN** 路由 SHALL 将 target.type 传递给 mapCheckResult + +#### Scenario: History API 传递 type +- **WHEN** History 路由序列化历史记录列表 +- **THEN** 路由 SHALL 将已查询的 target.type 传递给 mapCheckResult diff --git a/openspec/changes/checker-observation/specs/probe-data-store/spec.md b/openspec/changes/checker-observation/specs/probe-data-store/spec.md new file mode 100644 index 0000000..3dac28c --- /dev/null +++ b/openspec/changes/checker-observation/specs/probe-data-store/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: SQLite 数据库初始化 +系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息,且 targets 表的 `name` 和 `description` 列 MUST 允许 NULL。 + +#### Scenario: 首次启动创建数据库 +- **WHEN** 指定的数据目录下不存在数据库文件 +- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表,check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(TEXT NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、observation(TEXT)、failure(TEXT),不包含 status_detail 列,不包含 success 列 + +#### Scenario: targets name 列允许 NULL +- **WHEN** 系统首次创建 targets 表 +- **THEN** targets.name 列 SHALL 允许存储 NULL + +#### Scenario: 数据目录不存在 +- **WHEN** 配置的数据目录路径不存在 +- **THEN** 系统 SHALL 自动创建该目录 + +#### Scenario: 数据库已存在时启动 +- **WHEN** 数据库文件已存在 +- **THEN** 系统 SHALL 直接打开数据库,不重新建表 + +#### Scenario: 外键约束 +- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON` + +#### Scenario: 级联删除 +- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录 + +### Requirement: check_results 表追加写入 +系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 + +#### Scenario: 写入检查结果 +- **WHEN** 一次 checker 执行完成 +- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、observation、failure 的记录,其中 observation 使用 JSON.stringify 序列化为 TEXT + +#### Scenario: 查询检查结果 +- **WHEN** 系统查询 latest check 或历史 check_results +- **THEN** 存储层 SHALL 返回 observation 字段而非 status_detail 字段,供 API 序列化层反序列化并构造 detail + +#### Scenario: 写入结构化失败信息 +- **WHEN** checker 执行失败或 expect 不匹配 +- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段 diff --git a/openspec/changes/checker-observation/specs/probe-engine/spec.md b/openspec/changes/checker-observation/specs/probe-engine/spec.md new file mode 100644 index 0000000..a7492c7 --- /dev/null +++ b/openspec/changes/checker-observation/specs/probe-engine/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: 拨测结果记录 +系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、observation、failure 字段。detail SHALL 为 API 层派生字段,不写入存储层;系统 SHALL NOT 写入 status_detail 字段。 + +#### Scenario: 成功检查结果记录 +- **WHEN** checker 成功执行且 expect 全部匹配 +- **THEN** 系统 SHALL 记录 matched=true、duration_ms、observation,failure 为 null + +#### Scenario: 执行失败结果记录 +- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等) +- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息,并在可收集领域观测数据时记录 observation + +#### Scenario: expect 不匹配结果记录 +- **WHEN** checker 执行成功但 expect 不匹配 +- **THEN** 系统 SHALL 记录 matched=false、observation、failure.kind="mismatch" 和具体不匹配信息 diff --git a/openspec/changes/checker-observation/specs/target-detail-drawer/spec.md b/openspec/changes/checker-observation/specs/target-detail-drawer/spec.md new file mode 100644 index 0000000..5cd40fd --- /dev/null +++ b/openspec/changes/checker-observation/specs/target-detail-drawer/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: 记录面板 +记录 Tab SHALL 展示分页检查结果列表,使用 TDesign PrimaryTable。 + +#### Scenario: 检查结果表格 +- **WHEN** 记录面板渲染且数据可用 +- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,列包含:状态(StatusDot 圆点)、时间(YYYY-MM-DD HH:mm:ss 格式)、耗时(标题含 ms 单位,单元格仅显示数值,居中对齐)、详情(detail 和 failure.message 用冒号拼接) + +#### Scenario: 服务端分页 +- **WHEN** 检查结果总数超过一页 +- **THEN** 表格 SHALL 使用内建 pagination(disableDataPage=true),分页器显示在表格底部 + +#### Scenario: 翻页触发请求 +- **WHEN** 用户切换分页页码 +- **THEN** 系统 SHALL 请求对应页码的服务端数据,表格更新 diff --git a/openspec/changes/checker-observation/specs/tcp-checker/spec.md b/openspec/changes/checker-observation/specs/tcp-checker/spec.md new file mode 100644 index 0000000..2f87be9 --- /dev/null +++ b/openspec/changes/checker-observation/specs/tcp-checker/spec.md @@ -0,0 +1,51 @@ +## MODIFIED Requirements + +### Requirement: tcp checker 执行 +系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时和 TCP observation,并在连接失败、超时或资源超限时产生结构化失败信息。 + +#### Scenario: TCP 连接成功 +- **WHEN** tcp target 指向可连接的 TCP 服务,且未配置 expect 或 `expect.connected` 为 `true` +- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含 connected、connectTimeMs、banner 的 observation,并关闭 socket + +#### Scenario: TCP 连接失败 +- **WHEN** tcp target 指向不可连接的 host/port,且未配置 expect 或 `expect.connected` 为 `true` +- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=false 和错误信息,failure 的 kind 为 `error`,phase 为 `connect`,message 包含可读连接失败原因 + +#### Scenario: 期望端口不可达且连接失败 +- **WHEN** tcp target 配置 `expect.connected: false`,且 TCP 连接失败 +- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 connected=false 和实际连接失败原因,API detail SHALL 展示实际连接失败原因摘要 + +#### Scenario: 期望端口不可达但连接成功 +- **WHEN** tcp target 配置 `expect.connected: false`,但 TCP 连接成功 +- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=true,failure 的 kind 为 `mismatch`,phase 为 `connected` + +#### Scenario: TCP 执行超时 +- **WHEN** 引擎注入的 `ctx.signal` 在 TCP 连接或 banner 读取过程中 abort +- **THEN** 系统 SHALL best-effort 关闭 socket,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect` 或 `banner`,message 包含超时信息,并在可收集时记录 observation + +#### Scenario: duration 包含 banner 读取 +- **WHEN** tcp target 开启 `readBanner` 且服务端延迟发送 banner +- **THEN** 结果中的 `durationMs` SHALL 覆盖连接建立、banner 等待、banner 读取和 expect 校验的完整耗时 + +### Requirement: tcp banner 读取 +系统 SHALL 仅在 `tcp.readBanner: true` 时读取服务端主动发送的 banner 数据,并同时受 `bannerReadTimeout` 和 `maxBannerBytes` 限制。 + +#### Scenario: 默认不读取 banner +- **WHEN** tcp target 未配置 `readBanner` 或配置为 `false` +- **THEN** 系统 SHALL 在连接建立后立即进入 connected 和 duration 校验,不等待服务端数据 + +#### Scenario: 读取服务端 banner +- **WHEN** tcp target 配置 `readBanner: true`,且服务端连接后发送 `220 smtp.example.com ESMTP` +- **THEN** 系统 SHALL 收集 banner 文本,并允许后续 `expect.banner` 对该文本执行 operator 断言 + +#### Scenario: banner 等待超时无数据 +- **WHEN** tcp target 配置 `readBanner: true`,但服务端在 `bannerReadTimeout` 内未发送任何数据 +- **THEN** 系统 SHALL 将 banner 视为空字符串并继续执行 expect 校验,不将无 banner 本身作为连接错误 + +#### Scenario: banner 读取超过最大字节数 +- **WHEN** 服务端发送的 banner 数据超过 `maxBannerBytes` +- **THEN** 系统 SHALL 停止读取并记录 `matched=false`、failure.kind=`error`、failure.phase=`banner` 的结构化错误 + +#### Scenario: banner detail 截断展示 +- **WHEN** tcp target 成功读取到较长 banner +- **THEN** observation.banner SHALL 保存截断后的 banner 摘要,API detail SHALL 展示截断后的 banner 摘要,避免 UI 展示过长文本 diff --git a/openspec/changes/checker-observation/specs/udp-checker/spec.md b/openspec/changes/checker-observation/specs/udp-checker/spec.md new file mode 100644 index 0000000..6cf712a --- /dev/null +++ b/openspec/changes/checker-observation/specs/udp-checker/spec.md @@ -0,0 +1,55 @@ +## MODIFIED Requirements + +### Requirement: udp checker 执行 +系统 SHALL 使用 Bun connected UDP socket 向目标发送单个 datagram,等待第一个 UDP 响应 datagram,记录完整执行耗时和 UDP observation,并在发送失败、超时、资源超限或底层 socket 错误时产生结构化失败信息。 + +#### Scenario: UDP 请求响应成功 +- **WHEN** udp target 指向会返回 `PONG` 的 UDP 服务,且未配置 expect 或 `expect.responded` 为 `true` +- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含响应大小的 observation,并关闭 socket + +#### Scenario: 使用 hostname 执行 UDP 探测 +- **WHEN** udp target 的 `udp.host` 为可解析域名或 `localhost` +- **THEN** 系统 SHALL 使用 Bun connected UDP socket 完成发送和接收,不要求配置 IP 地址 + +#### Scenario: 只处理第一个响应 datagram +- **WHEN** UDP 服务对一次请求返回多个 datagram +- **THEN** 系统 SHALL 仅使用第一个收到的 UDP datagram 执行 expect 校验,并关闭 socket + +#### Scenario: UDP 无响应且默认期望响应 +- **WHEN** udp target 指向在 timeout 内不返回 UDP datagram 的服务,且未配置 expect 或 `expect.responded` 为 `true` +- **THEN** 系统 SHALL 在 `ctx.signal` abort 后记录 `matched=false`,observation SHALL 包含 responded=false 和 error,failure 的 kind 为 `error`,phase 为 `response`,message 包含超时或未响应信息 + +#### Scenario: 期望无响应且实际无响应 +- **WHEN** udp target 配置 `expect.responded: false`,且 timeout 内未收到 UDP datagram +- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 responded=false,API detail SHALL 表示未收到响应 + +#### Scenario: 期望无响应但实际收到响应 +- **WHEN** udp target 配置 `expect.responded: false`,但收到了 UDP datagram +- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 responded=true 和响应摘要,failure 的 kind 为 `mismatch`,phase 为 `responded` + +#### Scenario: UDP socket 底层错误 +- **WHEN** Bun UDP socket 在发送或接收过程中触发 error 事件 +- **THEN** 系统 SHALL best-effort 关闭 socket,并记录 `matched=false`、failure.kind=`error` 和可读错误信息,并在可收集时记录 observation + +#### Scenario: ICMP unreachable 不作为 UDP 响应 +- **WHEN** 底层系统因目标端口不可达产生 ICMP unreachable +- **THEN** 系统 SHALL NOT 将其视为 `responded=true` 的 UDP datagram 响应 + +#### Scenario: UDP 执行超时关闭 socket +- **WHEN** 引擎注入的 `ctx.signal` 在 UDP 发送或等待响应过程中 abort +- **THEN** 系统 SHALL best-effort 关闭 UDP socket,并记录结构化超时或未响应结果 + +### Requirement: udp detail 摘要 +系统 SHALL 在 udp API 序列化时从 observation 动态生成简短 detail 摘要,展示关键结果并避免返回过长响应内容。 + +#### Scenario: 收到响应的摘要 +- **WHEN** udp target 收到 4 字节响应且完整执行耗时为 12ms +- **THEN** detail SHALL 包含 `responded in 12ms` 和 `4 bytes` + +#### Scenario: 未收到响应的摘要 +- **WHEN** udp target 配置 `expect.responded: false` 且 timeout 内未收到 UDP datagram +- **THEN** detail SHALL 包含 `no response` 和执行耗时 + +#### Scenario: 响应内容摘要截断 +- **WHEN** udp target 收到较长响应内容 +- **THEN** detail SHALL 只展示按 `responseEncoding` 转换并截断后的响应摘要 diff --git a/openspec/changes/checker-observation/tasks.md b/openspec/changes/checker-observation/tasks.md new file mode 100644 index 0000000..1b1debe --- /dev/null +++ b/openspec/changes/checker-observation/tasks.md @@ -0,0 +1,56 @@ +## 1. 共享类型与接口变更 + +- [ ] 1.1 修改 `src/shared/api.ts` 中 CheckResult 类型:移除 statusDetail,新增 detail 和 observation 字段 +- [ ] 1.2 修改 `src/server/checker/types.ts` 中 StoredCheckResult 类型:status_detail 替换为 observation +- [ ] 1.3 修改 `src/server/checker/runner/types.ts` 中 CheckerDefinition 接口:新增 buildDetail 方法 + +## 2. 存储层适配 + +- [ ] 2.1 修改 `src/server/checker/store.ts` 中 check_results 表 DDL:status_detail 列替换为 observation TEXT 列 +- [ ] 2.2 修改 `src/server/checker/store.ts` 中 insertCheckResult 方法:写入 observation(JSON.stringify)替代 statusDetail +- [ ] 2.3 修改 `src/server/checker/store.ts` 中 getHistory、getLatestCheck、getLatestChecksMap 读取类型:返回 observation 字段替代 status_detail +- [ ] 2.4 修改 `src/server/checker/engine.ts` 中 writeResult 方法:传递 observation 替代 statusDetail,异常兜底结果 observation 为 null + +## 3. Checker execute 改造(返回 observation 替代 statusDetail) + +- [ ] 3.1 改造 HTTP checker execute.ts:组装 observation 对象(statusCode/headers/bodyPreview/contentType/contentLength),拿到响应后始终采集 bodyPreview,移除 statusDetail 赋值 +- [ ] 3.2 改造 TCP checker execute.ts:组装 observation 对象(connected/connectTimeMs/banner/error),移除 statusDetail 赋值和 buildStatusDetail 函数 +- [ ] 3.3 改造 UDP checker execute.ts:组装 observation 对象(responded/responseSize/responsePreview/sourceAddress/sourcePort/error),移除 statusDetail 赋值和 build*Detail 函数 +- [ ] 3.4 改造 ICMP checker execute.ts:组装 observation 对象(复用 PingStats 字段 + error),移除 statusDetail 赋值和 buildStatusDetail 函数 +- [ ] 3.5 改造 DB checker execute.ts:组装 observation 对象(connected/rowCount/rowsPreview/error),移除 statusDetail 赋值 +- [ ] 3.6 改造 CMD checker execute.ts:组装 observation 对象(exitCode/stdoutPreview/stderrPreview/error),移除 statusDetail 赋值 +- [ ] 3.7 改造 LLM checker types.ts 和 observation.ts:保留执行期完整 outputText,新增持久化 observation 派生结构(outputPreview/outputLength/截断 headers) +- [ ] 3.8 改造 LLM checker execute.ts:返回持久化 observation,继续用执行期 LlmCheckObservation 执行 expect,移除 buildStatusDetail 函数 + +## 4. Checker buildDetail 实现 + +- [ ] 4.1 为 HTTP checker 实现 buildDetail 方法:返回 `"HTTP {statusCode}"` 格式 +- [ ] 4.2 为 TCP checker 实现 buildDetail 方法:返回连接状态和 banner 摘要 +- [ ] 4.3 为 UDP checker 实现 buildDetail 方法:返回响应状态和大小摘要 +- [ ] 4.4 为 ICMP checker 实现 buildDetail 方法:返回存活状态、平均延迟和丢包率摘要 +- [ ] 4.5 为 DB checker 实现 buildDetail 方法:返回连接状态或行数摘要 +- [ ] 4.6 为 CMD checker 实现 buildDetail 方法:返回 `"exitCode={N}"` 格式 +- [ ] 4.7 为 LLM checker 实现 buildDetail 方法:返回 provider/mode/status/finish/output/usage 摘要 + +## 5. API 序列化层适配 + +- [ ] 5.1 修改 `src/server/helpers.ts` 中 mapCheckResult:接收 type 参数,反序列化 observation,observation 为 null 时 detail 为 null,否则调用 buildDetail 动态构造 detail +- [ ] 5.2 修改 `src/server/routes/dashboard.ts`:传递 target.type 给 mapCheckResult +- [ ] 5.3 修改 `src/server/routes/history.ts`:传递 target.type 给 mapCheckResult + +## 6. 前端适配 + +- [ ] 6.1 修改 `src/web/constants/history-table-columns.tsx`:statusDetail 引用改为 detail +- [ ] 6.2 修改 `src/web/components/OverviewTab.tsx`:statusDetail 引用改为 detail + +## 7. 测试与质量保障 + +- [ ] 7.1 更新所有涉及 CheckResult 的现有测试,适配 statusDetail → detail + observation 字段变更 +- [ ] 7.2 为各 checker 的 buildDetail 方法编写单元测试 +- [ ] 7.3 更新 CheckerDefinition mock、store、engine、dashboard/history API、前端组件与 constants 的测试 fixture +- [ ] 7.4 为 mapCheckResult 编写 observation JSON parse、null observation、unknown type 或 malformed observation 的覆盖测试 +- [ ] 7.5 执行完整测试套件、代码检查和格式检查,确保无回归 + +## 8. 文档更新 + +- [ ] 8.1 更新 README.md 和/或 DEVELOPMENT.md,反映 CheckResult 类型变更和 observation 机制