1
0

feat: 结构化 observation 替代 statusDetail,API 层动态构造 detail

- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生)
- 存储: status_detail 列 -> observation TEXT (JSON)
- CheckerDefinition: 新增 buildDetail(observation) 方法
- 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail
- HTTP: bodyPreview 在 status/header 失败时也提前采集
- UDP: observation 包含 durationMs,未响应归为 error failure
- CMD: 超时/输出超限时保留已收集 observation
- TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待
- 新增 buildDetail 单测和 mapCheckResult 覆盖测试
- 同步 openspec 主规范,归档 checker-observation 变更
This commit is contained in:
2026-05-19 22:49:00 +08:00
parent 22c06820fa
commit 375dd3492b
64 changed files with 915 additions and 965 deletions

View File

@@ -13,6 +13,7 @@ import { dbCheckerSchemas } from "./schema";
import { validateDbConfig } from "./validate";
const PROBE_QUERY = "SELECT 1";
const ROWS_PREVIEW_MAX = 5;
export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
readonly configKey = "db";
@@ -21,16 +22,27 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
readonly type = "db";
buildDetail(observation: Record<string, unknown>): null | string {
const connected = observation["connected"];
if (connected !== true) {
const error = observation["error"];
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
}
const rowCount = observation["rowCount"];
if (typeof rowCount === "number") {
return `${rowCount} rows`;
}
return "connected";
}
async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
let db: SQL | undefined;
try {
// 创建连接SQLite 不需要 max 选项)
db = new SQL(t.db.url);
// 监听 abort signal
ctx.signal.addEventListener(
"abort",
() => {
@@ -41,24 +53,30 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
{ once: true },
);
// 连接测试Bun SQL 是 lazy 的,首次查询才真正连接)
try {
await db.unsafe(PROBE_QUERY);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const errorMsg = isError(error) ? error.message : String(error);
return {
detail: null,
durationMs,
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
failure: errorFailure("connect", "connect", errorMsg),
matched: false,
statusDetail: null,
observation: { connected: false, error: errorMsg, rowCount: null, rowsPreview: null },
targetId: t.id,
timestamp,
};
}
// 无 query 时仅测试连接
if (!t.db.query) {
const durationMs = Math.round(performance.now() - start);
const observation: Record<string, unknown> = {
connected: true,
error: null,
rowCount: null,
rowsPreview: null,
};
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
@@ -66,55 +84,60 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: "connected",
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: "connected",
observation,
targetId: t.id,
timestamp,
};
}
// 执行用户 SQL
let rows: unknown[];
try {
rows = await db.unsafe(t.db.query);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const errorMsg = isError(error) ? error.message : String(error);
return {
detail: null,
durationMs,
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
failure: errorFailure("query", "query", errorMsg),
matched: false,
statusDetail: null,
observation: { connected: true, error: errorMsg, rowCount: null, rowsPreview: null },
targetId: t.id,
timestamp,
};
}
const durationMs = Math.round(performance.now() - start);
const rowCount = Array.isArray(rows) ? rows.length : 0;
const rowsPreview = Array.isArray(rows) ? rows.slice(0, ROWS_PREVIEW_MAX) : null;
const observation: Record<string, unknown> = { connected: true, error: null, rowCount, rowsPreview };
// 检查是否超时
if (ctx.signal.aborted) {
return {
detail: null,
durationMs,
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
}
// duration 断言
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
@@ -122,39 +145,40 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
observation,
targetId: t.id,
timestamp,
};
}
// rowCount 断言
if (t.expect?.rowCount) {
const rowCountResult = checkRowCount(Array.isArray(rows) ? rows.length : 0, t.expect.rowCount);
const rowCountResult = checkRowCount(rowCount, t.expect.rowCount);
if (!rowCountResult.matched) {
return {
detail: null,
durationMs,
failure: rowCountResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
observation,
targetId: t.id,
timestamp,
};
}
}
// rows 断言
if (t.expect?.rows && t.expect.rows.length > 0) {
const rowsResult = checkRows(rows, t.expect.rows);
if (!rowsResult.matched) {
return {
detail: null,
durationMs,
failure: rowsResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
observation,
targetId: t.id,
timestamp,
};
@@ -162,14 +186,14 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
}
if (t.expect?.result && t.expect.result.length > 0) {
const rowCount = Array.isArray(rows) ? rows.length : 0;
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
if (!resultCheck.matched) {
return {
detail: null,
durationMs,
failure: resultCheck.failure,
matched: false,
statusDetail: `${rowCount} rows`,
observation,
targetId: t.id,
timestamp,
};
@@ -177,10 +201,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
observation,
targetId: t.id,
timestamp,
};