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,9 @@ import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema";
import { validateCommandConfig } from "./validate";
const STDOUT_PREVIEW_MAX = 1024;
const STDERR_PREVIEW_MAX = 1024;
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
readonly configKey = "cmd";
@@ -20,6 +23,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
readonly type = "cmd";
buildDetail(observation: Record<string, unknown>): null | string {
const exitCode = observation["exitCode"];
return typeof exitCode === "number" ? `exitCode=${exitCode}` : null;
}
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -37,10 +45,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -70,10 +79,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
} catch {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("exitCode", "execution", "输出读取失败"),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -83,24 +93,33 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const durationMs = Math.round(performance.now() - start);
const exitCode = proc.exitCode ?? 1;
const stdoutPreview = truncatePreview(outputResult.stdout, STDOUT_PREVIEW_MAX);
const stderrPreview = truncatePreview(outputResult.stderr, STDERR_PREVIEW_MAX);
const observation: Record<string, unknown> = { error: null, exitCode, stderrPreview, stdoutPreview };
if (outputResult.exceeded) {
const message = `输出超过限制 ${t.cmd.maxOutputBytes} 字节`;
observation["error"] = message;
return {
detail: null,
durationMs,
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
failure: errorFailure("exitCode", "output", message),
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
}
if (ctx.signal.aborted) {
const message = `命令执行超时 (${t.timeoutMs}ms)`;
observation["error"] = message;
return {
detail: null,
durationMs,
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
failure: errorFailure("exitCode", "timeout", message),
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
@@ -109,10 +128,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
if (!exitCodeResult.matched) {
return {
detail: null,
durationMs,
failure: exitCodeResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -125,10 +145,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -138,10 +159,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" });
if (!stdoutResult.matched) {
return {
detail: null,
durationMs,
failure: stdoutResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -152,10 +174,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" });
if (!stderrResult.matched) {
return {
detail: null,
durationMs,
failure: stderrResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -163,10 +186,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -269,3 +293,8 @@ async function readOutput(
return { exceeded, stderr: err, stdout: out };
}
function truncatePreview(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen);
}