## 背景:复盘 §四 那条判断仍成立 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 system.status 只能看当下快照; event.watch 解决「有事件时通知」,没解决「**持续记录并回看**」。 > 顺带纠正路线图一处过时判断:"fs.watch 未做" 不成立 —— event.watch 的 kind=file > 就是 fs.watch 实现(递归 + glob + 推送/拉取双出口)。本轮不重复造。 ## ① log.query(新增能力,低危只读) 在被控端侧过滤,只回匹配行 —— 日志常几万行,整份拉回既慢又占带宽。 - 流式逐行读(readline),扫到 limit 即停,不全量载入 - pattern(正则)/ level(含 WARNING→WARN 归一)/ since(按行首时间戳)/ offset / limit / tail - 单行 >4KB 截断后再匹配;单次扫描上限 200 万行防失控 - 路径复用 fs.guardPath —— **不新开安全面**(fs_roots 非空即受限) ## ② monitor.*(四项新能力,低危) start / report / stop / list / delete。四类采样源: - port(TCP 连通性 + 延迟)/ process(存活 + PID)/ command(退出码 + 首行)/ metric(CPU·内存) - 样本落 <数据目录>/monitors/<id>.jsonl(一行一针),**摘要直接给结论**: 「断了几次 + 最长连续中断样本数」「CPU/内存 min·avg·max」「成功失败数与退出码集合」 - 并发上限 8;单文件 10 万行封顶轮转;timer.unref() 不阻止 agent 退出 ## 真机验证(本地,15 例全过) port 采样造一次真实断线(关端口 1.6s 再开)→ report 给出 up=9/down=4/最长中断 4 针/延迟 avg 1.31ms; metric 给 CPU 16.9% 内存 50.24%;落盘 13 样本可回看;stop 后 report 仍可查; log.query 按级别/pattern+tail 过滤准确;fs_roots 白名单外的 /etc/hosts 被拒; 非法 source/target/id 字符/重复 id/不存在 id 均被拒。 ## 修复一个真 bug(测试抓到) parseLeadingTs 的正则把 ISO 的 时区标记吃掉了 —— UTC 时间被当本地时间解析, **整体偏 8 小时**。跨时区日志判断"何时断的"会错。已保留 Z/±hh:mm 后缀。 ## CLI / MCP - nodeagent monitor start|report|stop|list|delete ...;nodeagent log --path ... --level ERROR - MCP:na_monitor(action 分发)+ na_log ## 数据 能力数 43 → **49**;单测 231 → **247** 全绿;e2e 30 全绿;7 包构建通过。
74 lines
3.5 KiB
JavaScript
74 lines
3.5 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { findCapability, validate, CapabilityNames } from '../../packages/protocol/dist/index.js';
|
||
|
||
/**
|
||
* monitor.* 的契约层测试(v1.8 方向二第二块)。
|
||
* 运行时行为(真采样/落盘/摘要)在本地真机探针里验证:port 探测 + metric 采样 +
|
||
* report 摘要 + stop 落盘。
|
||
*/
|
||
|
||
function check(capName, args) {
|
||
return validate(args, findCapability(capName).params_schema);
|
||
}
|
||
|
||
test('monitor 五项能力均已登记且为低危只读/本地', () => {
|
||
for (const n of [CapabilityNames.MonitorStart, CapabilityNames.MonitorReport, CapabilityNames.MonitorStop, CapabilityNames.MonitorList, CapabilityNames.MonitorDelete, CapabilityNames.LogQuery]) {
|
||
const cap = findCapability(n);
|
||
assert.ok(cap, `缺少 ${n}`);
|
||
assert.equal(cap.risk, 'low', `${n} 应为 low risk`);
|
||
}
|
||
});
|
||
|
||
test('monitor.start:四类 source 通过,非法 source 被拒', () => {
|
||
for (const s of ['port', 'process', 'command', 'metric']) {
|
||
assert.equal(check('monitor.start', { source: s, target: 'x:1' }).length, 0, `${s} 应通过`);
|
||
}
|
||
assert.ok(check('monitor.start', { source: 'bogus' }).length > 0, '非法 source 应拒');
|
||
assert.ok(check('monitor.start', {}).length > 0, '缺 source 应拒');
|
||
});
|
||
|
||
test('monitor.start:interval_ms 有上下限(500~600000)', () => {
|
||
assert.equal(check('monitor.start', { source: 'metric', interval_ms: 500 }).length, 0);
|
||
assert.equal(check('monitor.start', { source: 'metric', interval_ms: 600000 }).length, 0);
|
||
assert.ok(check('monitor.start', { source: 'metric', interval_ms: 10 }).length > 0, '过小应拒');
|
||
assert.ok(check('monitor.start', { source: 'metric', interval_ms: 10 ** 7 }).length > 0, '过大应拒');
|
||
});
|
||
|
||
test('monitor.report/stop/delete:id 必填', () => {
|
||
for (const n of ['monitor.report', 'monitor.stop', 'monitor.delete']) {
|
||
assert.ok(check(n, {}).length > 0, `${n} 缺 id 应拒`);
|
||
assert.equal(check(n, { id: 'm1' }).length, 0, `${n} 带 id 应通过`);
|
||
}
|
||
assert.equal(check('monitor.list', {}).length, 0, 'monitor.list 无参数');
|
||
});
|
||
|
||
test('log.query:path 必填,limit 上限 5000', () => {
|
||
assert.ok(check('log.query', {}).length > 0, '缺 path 应拒');
|
||
assert.equal(check('log.query', { path: '/var/log/x.log' }).length, 0);
|
||
assert.equal(check('log.query', { path: 'x', limit: 5000 }).length, 0);
|
||
assert.ok(check('log.query', { path: 'x', limit: 99999 }).length > 0, '超上限应拒');
|
||
assert.ok(check('log.query', { path: 'x', level: 'FATAL' }).length > 0, '非法 level 应拒');
|
||
});
|
||
|
||
test('摘要算法:port 断线次数与最长中断(与 monitor.ts 的 summarize 同步)', () => {
|
||
// 复刻 summarize 的核心逻辑,锁住"最长连续中断"的语义(最容易写错的一处)
|
||
const samples = [
|
||
{ up: true }, { up: false }, { up: false }, { up: false },
|
||
{ up: true }, { up: false }, { up: true },
|
||
];
|
||
let run = 0, worst = 0, downs = 0;
|
||
for (const s of samples) {
|
||
if (s.up === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); downs += 1; }
|
||
}
|
||
assert.equal(downs, 4, 'down 计数');
|
||
assert.equal(worst, 3, '最长连续中断应为 3 个样本(中间那段)');
|
||
assert.equal(samples.filter((s) => s.up).length, 3, 'up 计数');
|
||
});
|
||
|
||
test('摘要算法:command 的成功/失败与出现过的退出码', () => {
|
||
const codes = [0, 0, 1, 0, 127];
|
||
assert.equal(codes.filter((c) => c === 0).length, 3);
|
||
assert.deepEqual([...new Set(codes)], [0, 1, 127]);
|
||
});
|