opencode-metrics-plugin 0.3.5 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -0
- package/dist/metrics/analysis/codelines.d.ts +39 -0
- package/dist/metrics/analysis/codelines.js +225 -0
- package/dist/metrics/analysis/hvigor.d.ts +7 -0
- package/dist/metrics/analysis/hvigor.js +20 -0
- package/dist/metrics/engine/engine.js +73 -1
- package/dist/metrics/engine/handlers.js +45 -15
- package/dist/metrics/engine/state.d.ts +26 -0
- package/dist/metrics/engine/state.js +11 -1
- package/dist/metrics/runtime.d.ts +11 -0
- package/dist/metrics/runtime.js +6 -0
- package/dist/metrics/sessionFilter.d.ts +12 -0
- package/dist/metrics/sessionFilter.js +104 -0
- package/dist/metrics/snapshot/flush.d.ts +14 -8
- package/dist/metrics/snapshot/flush.js +105 -4
- package/dist/metrics/snapshot/merge.js +25 -5
- package/dist/metrics/types.d.ts +38 -1
- package/dist/metrics/upload/scenarios.js +3 -1
- package/dist/metrics/upload/uploadManager.d.ts +20 -2
- package/dist/metrics/upload/uploadManager.js +49 -19
- package/dist/plugin.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2(2026-09-16)
|
|
4
|
+
|
|
5
|
+
- **代码行多维统计**(工程全量扫描口径,替代 write 工具累计):`codeStats` 新增 `cLines` / `cppLines` / `testLines` / `businessCodeLines`,`etsLines` 改为鸿蒙工程全量扫描(排除测试)。根发现 = 会话工作目录 + bash workdir + write/edit 文件目录(向上提升到鸿蒙工程根 + 安卓工程的兄弟鸿蒙工程探测,mtime 时间窗防邻居混入);`.h` 计入 C、`.hpp` 等计入 C++(头文件统一归入语言维度);测试目录(ohosTest/test/mock 等)子树与 `.test.`/`.spec.` 命名文件计为测试维度(任意语言,不与语言维度重复计数);构建/配置文件(hvigorfile.ts、CMakeLists.txt、.json5)不计入。flush 时 30s 节流扫描,候选根集合变化立即重扫。配套 session-viewer Code Stats 卡片展示五维。
|
|
6
|
+
- **修复 droid2hmos 包装器构建漏记**:`droid2hmos build/test harmony` 内部执行 hvigorw 但命令行不含 hvigorw、输出仅状态摘要(hvigor 原始输出按迁移工作台规则重定向日志文件)——按命令形态识别为构建调用,摘要含 `failed` 标记判失败(曾致整个迁移会话 codeStats 构建统计为空)。
|
|
7
|
+
- **修复 git commit 场景漏触发**:内置 git-commit 正则支持 `git` 与 `commit` 之间出现选项(`-c user.name=x`、`-C <path>`、`--no-verify` 等,≤4 个),`git log` 类命令不误报(曾致 `git -c … commit` 形式的提交全部漏上传)。
|
|
8
|
+
- **修复启动清扫误删完整上传包**:清扫改两遍扫描——第一遍只处理 `.tar.gz`(三件套校验→移入 failed/ 待重传,并标记保护),第二遍清理孤儿 `.json`/组装残留。字母序下单遍边迭代边删 `.json` 会必然破坏三件套校验,崩溃遗留的完整包被当垃圾销毁(曾连续销毁终态包致云端缺 43 分钟数据)。
|
|
9
|
+
- **孤儿补传 .sent 时效判断**:已传标记只证明"标记时刻之前的快照已上云",快照 mtime 晚于标记时间(>60s 缓冲)仍纳入孤儿补传——中途终态触发(task 完成/session.deleted)上传后继续运行的会话,最终态不再永久漏传。
|
|
10
|
+
|
|
11
|
+
## 0.4.1(2026-09-16)
|
|
12
|
+
|
|
13
|
+
- 新增 **AI 活跃时长**指标(快照顶层 `aiActiveDuration` / `aiActiveRatio` / `waitBreakdown`,轮级 `activeDuration` / `questionMs` / `permissionMs` / `retryMs` / `retryCount`):从轮墙钟中扣减已知等待——question 工具等待用户回答、权限审批(`permission.asked`/`replied` 按 ID 配对,两事件键名不对称:`id` vs `requestID`)、apikey 限流挂起(`session.status` retry→busy 状态机);step 间死时间计入活跃。`waitBreakdown.otherIdleMs` 为轮间空闲,满足恒等式 `duration = aiActiveDuration + questionMs + permissionMs + retryMs + otherIdleMs`。
|
|
14
|
+
- 轮闭合时未决等待截断入轮(孤儿权限询问、挂起重试、未答 question),计时重锚防跨轮双计;子代理会话的权限/限流/question 等待同样扣减父轮。
|
|
15
|
+
- 合并兼容:旧版(≤0.4.0)快照无新字段,合并时旧轮回退按 `duration` 计(视为全活跃);会话级汇总从合并后轮数组重算,重启不丢不双计。配套 session-viewer 已做前向兼容(快照有值优先,旧快照回退 viewer 侧推导)。
|
|
16
|
+
|
|
17
|
+
## 0.4.0(2026-09-15)
|
|
18
|
+
|
|
19
|
+
- 新增会话 agent 白名单 `agents`(支持 `*` 通配,如 `["android-to-hmos-*", "build"]`):配置后只有根会话 agent 命中任一模式才记录——快照、事件日志、system prompt、上传全不产生;子会话(task/explore 等)随父会话判定正常并入;agent 未知的会话默认记录(fail-open)。缺省/空数组 = 不过滤全记(向后兼容)。
|
|
20
|
+
- 典型用法:全局 `~/.config/opencode/opencode.json` 注册 `["opencode-metrics-plugin", { "agents": ["android-to-hmos-00-orchestrator"] }]`,实现「插件处处加载、只统计迁移 agent 的会话」,不受 opencode 项目级配置发现的 git 边界限制。注意项目 `.opencode/opencode-metrics.json(c)` 存在时仍会完全接管(含 `agents`),接管文件需同步配置。
|
|
21
|
+
|
|
3
22
|
## 0.3.5(2026-09-15)
|
|
4
23
|
|
|
5
24
|
- 修复「重启 + resume 续跑」系列数据损坏:steps 重复乱序、planning 清零、tokens/工具统计丢段、agent/model 归因丢失;累加字段改差量并入,连发消息即切轮、退出兜底补轮。
|
package/README.md
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
| 选项 | 类型 | 默认 | 说明 |
|
|
91
91
|
|---|---|---|---|
|
|
92
92
|
| `enabled` | boolean | `true` | 总开关,`false` 时不记录任何会话 |
|
|
93
|
+
| `agents` | string[] | — | 会话 agent 白名单(`*` 通配,如 `["android-to-hmos-*", "build"]`):根会话 agent 命中才记录(快照/事件日志/上传全不产生),子会话随父判定;缺省全记 |
|
|
93
94
|
| `eventLogging` | boolean | `true` | 原始事件写盘;关闭后快照仍生成,但 `steps[].text/reasoning` 为空 |
|
|
94
95
|
| `checkpointFlushIntervalMs` | number | `2000` | 实时落盘冷却窗口(ms),`0` 关闭退回只在会话空闲时落盘 |
|
|
95
96
|
| `dirs` | object | env-paths | `metricsDir` / `eventsDir` / `logFile`(基准,实际写 `<所在目录>/log/plugin/YYYYMMDD.log`)/ `uploadStagingDir` |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codelines.ts — 会话关联鸿蒙工程的代码行多维统计。
|
|
3
|
+
*
|
|
4
|
+
* 口径(0.4.2 起,替代旧的 write 工具累计口径):
|
|
5
|
+
* - etsLines / cLines / cppLines:排除测试代码的各语言非空行数(.h 计入 C、
|
|
6
|
+
* .hpp 等计入 C++,头文件统一归入语言维度);
|
|
7
|
+
* - testLines:测试目录(ohosTest/test/tests/mock/mocks/__tests__)子树及
|
|
8
|
+
* *.test.* / *.spec.* 文件的代码行(任意语言,不与语言维度重复计数);
|
|
9
|
+
* - businessCodeLines:实际业务代码总量 = ets + C + C++ + 测试(构建/配置
|
|
10
|
+
* 文件 hvigorfile.ts、CMakeLists.txt、.json5 等不计入,故等于全量)。
|
|
11
|
+
*
|
|
12
|
+
* 根发现:候选路径(会话工作目录 + bash workdir + write/edit 文件目录)向上
|
|
13
|
+
* 提升到鸿蒙工程根(标记文件);非鸿蒙目录(如安卓源工程)在父目录一级探测
|
|
14
|
+
* 兄弟鸿蒙工程(mtime ≥ 会话起点,防邻居旧工程混入)。
|
|
15
|
+
*/
|
|
16
|
+
export interface CodeLineStats {
|
|
17
|
+
etsLines: number;
|
|
18
|
+
cLines: number;
|
|
19
|
+
cppLines: number;
|
|
20
|
+
testLines: number;
|
|
21
|
+
/** 实际业务代码总量 = ets + C + C++ + 测试(= 全量) */
|
|
22
|
+
businessCodeLines: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 从候选路径集合发现鸿蒙工程根:向上提升(≤8 层)+ 安卓工程的兄弟鸿蒙工程
|
|
26
|
+
* 探测(父目录一级,mtime 时间窗 [sessionStartedAt-60s, ∞))。
|
|
27
|
+
*
|
|
28
|
+
* @param candidates 候选路径(工作目录 / bash workdir / write|edit 文件目录)
|
|
29
|
+
* @param sessionStartedAt 会话起点(毫秒),兄弟工程 mtime 时间窗下界
|
|
30
|
+
* @returns 去重后的鸿蒙工程根绝对路径数组
|
|
31
|
+
*/
|
|
32
|
+
export declare function discoverHarmonyRoots(candidates: readonly string[], sessionStartedAt: number): string[];
|
|
33
|
+
/**
|
|
34
|
+
* 扫描鸿蒙工程根集合,输出多维代码行统计。语言判定基于扩展名;测试目录
|
|
35
|
+
* 子树与测试命名文件全部计入 testLines,不与语言维度重复计数。
|
|
36
|
+
*
|
|
37
|
+
* @param roots 鸿蒙工程根绝对路径数组(重叠根自动按文件去重)
|
|
38
|
+
*/
|
|
39
|
+
export declare function scanCodeLines(roots: readonly string[]): CodeLineStats;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codelines.ts — 会话关联鸿蒙工程的代码行多维统计。
|
|
3
|
+
*
|
|
4
|
+
* 口径(0.4.2 起,替代旧的 write 工具累计口径):
|
|
5
|
+
* - etsLines / cLines / cppLines:排除测试代码的各语言非空行数(.h 计入 C、
|
|
6
|
+
* .hpp 等计入 C++,头文件统一归入语言维度);
|
|
7
|
+
* - testLines:测试目录(ohosTest/test/tests/mock/mocks/__tests__)子树及
|
|
8
|
+
* *.test.* / *.spec.* 文件的代码行(任意语言,不与语言维度重复计数);
|
|
9
|
+
* - businessCodeLines:实际业务代码总量 = ets + C + C++ + 测试(构建/配置
|
|
10
|
+
* 文件 hvigorfile.ts、CMakeLists.txt、.json5 等不计入,故等于全量)。
|
|
11
|
+
*
|
|
12
|
+
* 根发现:候选路径(会话工作目录 + bash workdir + write/edit 文件目录)向上
|
|
13
|
+
* 提升到鸿蒙工程根(标记文件);非鸿蒙目录(如安卓源工程)在父目录一级探测
|
|
14
|
+
* 兄弟鸿蒙工程(mtime ≥ 会话起点,防邻居旧工程混入)。
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
/** 鸿蒙工程标记:命中其一即视为工程根 */
|
|
19
|
+
const HARMONY_MARKERS = ["oh-package.json5", "build-profile.json5", "hvigorfile.ts", "AppScope"];
|
|
20
|
+
/** 扫描时跳过的目录名(小写);ohosTest/test 等测试目录不跳过、整树计为测试 */
|
|
21
|
+
const EXCLUDED_DIRS = new Set([
|
|
22
|
+
"oh_modules", "node_modules", "build", ".hvigor", ".cxx", ".preview",
|
|
23
|
+
".git", ".clangd", ".idea", ".vscode", ".cache", "cxx-deps",
|
|
24
|
+
]);
|
|
25
|
+
/** 目录名命中(小写)即整棵子树视为测试代码 */
|
|
26
|
+
const TEST_DIRS = new Set(["ohostest", "test", "tests", "mock", "mocks", "__tests__"]);
|
|
27
|
+
/** 文件名命中即视为测试代码(任意目录下) */
|
|
28
|
+
const TEST_FILE_RE = /\.(test|spec)\.[^.]+$/;
|
|
29
|
+
const CPP_EXTS = new Set([".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"]);
|
|
30
|
+
const C_EXTS = new Set([".c", ".h"]);
|
|
31
|
+
/** Windows 大小写不敏感的路径归一化键 */
|
|
32
|
+
function normKey(p) {
|
|
33
|
+
const resolved = path.resolve(p);
|
|
34
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
35
|
+
}
|
|
36
|
+
function hasHarmonyMarkers(dir) {
|
|
37
|
+
try {
|
|
38
|
+
return HARMONY_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 从候选路径集合发现鸿蒙工程根:向上提升(≤8 层)+ 安卓工程的兄弟鸿蒙工程
|
|
46
|
+
* 探测(父目录一级,mtime 时间窗 [sessionStartedAt-60s, ∞))。
|
|
47
|
+
*
|
|
48
|
+
* @param candidates 候选路径(工作目录 / bash workdir / write|edit 文件目录)
|
|
49
|
+
* @param sessionStartedAt 会话起点(毫秒),兄弟工程 mtime 时间窗下界
|
|
50
|
+
* @returns 去重后的鸿蒙工程根绝对路径数组
|
|
51
|
+
*/
|
|
52
|
+
export function discoverHarmonyRoots(candidates, sessionStartedAt) {
|
|
53
|
+
const roots = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
const pushRoot = (dir) => {
|
|
56
|
+
const key = normKey(dir);
|
|
57
|
+
if (!seen.has(key)) {
|
|
58
|
+
seen.add(key);
|
|
59
|
+
roots.push(dir);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (!candidate || typeof candidate !== "string")
|
|
64
|
+
continue;
|
|
65
|
+
let dir;
|
|
66
|
+
try {
|
|
67
|
+
dir = path.resolve(candidate);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (seen.has(normKey(dir)))
|
|
73
|
+
continue;
|
|
74
|
+
// ① 向上提升到鸿蒙工程根
|
|
75
|
+
let found = null;
|
|
76
|
+
let cursor = dir;
|
|
77
|
+
for (let i = 0; i < 8; i++) {
|
|
78
|
+
if (hasHarmonyMarkers(cursor)) {
|
|
79
|
+
found = cursor;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
const parent = path.dirname(cursor);
|
|
83
|
+
if (parent === cursor)
|
|
84
|
+
break;
|
|
85
|
+
cursor = parent;
|
|
86
|
+
}
|
|
87
|
+
if (found) {
|
|
88
|
+
pushRoot(found);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// ② 非鸿蒙目录(如安卓源工程根/子目录):父目录一级探测兄弟鸿蒙工程
|
|
92
|
+
try {
|
|
93
|
+
const parent = path.dirname(dir);
|
|
94
|
+
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
|
|
95
|
+
if (!entry.isDirectory())
|
|
96
|
+
continue;
|
|
97
|
+
const sibling = path.join(parent, entry.name);
|
|
98
|
+
if (normKey(sibling) === normKey(dir))
|
|
99
|
+
continue;
|
|
100
|
+
if (seen.has(normKey(sibling)))
|
|
101
|
+
continue;
|
|
102
|
+
if (!hasHarmonyMarkers(sibling))
|
|
103
|
+
continue;
|
|
104
|
+
// mtime 时间窗:兄弟工程须在会话起点后仍被写过(留 60s 时钟余量)
|
|
105
|
+
try {
|
|
106
|
+
if (fs.statSync(sibling).mtimeMs < sessionStartedAt - 60_000)
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
pushRoot(sibling);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// 父目录不可读:跳过兄弟探测
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return roots;
|
|
120
|
+
}
|
|
121
|
+
/** 统计单个文件的非空行数(读取失败返回 0) */
|
|
122
|
+
function countNonEmptyLines(filePath) {
|
|
123
|
+
try {
|
|
124
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
125
|
+
let count = 0;
|
|
126
|
+
for (const line of content.split(/\r?\n/)) {
|
|
127
|
+
if (line.length > 0)
|
|
128
|
+
count++;
|
|
129
|
+
}
|
|
130
|
+
return count;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 扫描鸿蒙工程根集合,输出多维代码行统计。语言判定基于扩展名;测试目录
|
|
138
|
+
* 子树与测试命名文件全部计入 testLines,不与语言维度重复计数。
|
|
139
|
+
*
|
|
140
|
+
* @param roots 鸿蒙工程根绝对路径数组(重叠根自动按文件去重)
|
|
141
|
+
*/
|
|
142
|
+
export function scanCodeLines(roots) {
|
|
143
|
+
const stats = {
|
|
144
|
+
etsLines: 0, cLines: 0, cppLines: 0, testLines: 0, businessCodeLines: 0,
|
|
145
|
+
};
|
|
146
|
+
const seenFiles = new Set();
|
|
147
|
+
const classifyFile = (filePath, inTestDir) => {
|
|
148
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
149
|
+
let lang = null;
|
|
150
|
+
if (ext === ".ets")
|
|
151
|
+
lang = "ets";
|
|
152
|
+
else if (C_EXTS.has(ext))
|
|
153
|
+
lang = "c";
|
|
154
|
+
else if (CPP_EXTS.has(ext))
|
|
155
|
+
lang = "cpp";
|
|
156
|
+
if (!lang)
|
|
157
|
+
return;
|
|
158
|
+
const key = normKey(filePath);
|
|
159
|
+
if (seenFiles.has(key))
|
|
160
|
+
return;
|
|
161
|
+
seenFiles.add(key);
|
|
162
|
+
const lines = countNonEmptyLines(filePath);
|
|
163
|
+
if (inTestDir || TEST_FILE_RE.test(path.basename(filePath))) {
|
|
164
|
+
stats.testLines += lines;
|
|
165
|
+
}
|
|
166
|
+
else if (lang === "ets") {
|
|
167
|
+
stats.etsLines += lines;
|
|
168
|
+
}
|
|
169
|
+
else if (lang === "c") {
|
|
170
|
+
stats.cLines += lines;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
stats.cppLines += lines;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const countTestTree = (dir) => {
|
|
177
|
+
let entries;
|
|
178
|
+
try {
|
|
179
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
const full = path.join(dir, entry.name);
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
if (EXCLUDED_DIRS.has(entry.name.toLowerCase()))
|
|
188
|
+
continue;
|
|
189
|
+
countTestTree(full);
|
|
190
|
+
}
|
|
191
|
+
else if (entry.isFile()) {
|
|
192
|
+
classifyFile(full, true);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const walk = (dir) => {
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const full = path.join(dir, entry.name);
|
|
206
|
+
if (entry.isDirectory()) {
|
|
207
|
+
const nameLower = entry.name.toLowerCase();
|
|
208
|
+
if (EXCLUDED_DIRS.has(nameLower))
|
|
209
|
+
continue;
|
|
210
|
+
if (TEST_DIRS.has(nameLower)) {
|
|
211
|
+
countTestTree(full);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
walk(full);
|
|
215
|
+
}
|
|
216
|
+
else if (entry.isFile()) {
|
|
217
|
+
classifyFile(full, false);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
for (const root of roots)
|
|
222
|
+
walk(root);
|
|
223
|
+
stats.businessCodeLines = stats.etsLines + stats.cLines + stats.cppLines + stats.testLines;
|
|
224
|
+
return stats;
|
|
225
|
+
}
|
|
@@ -37,4 +37,11 @@ export declare function stripAnsi(raw: string): string;
|
|
|
37
37
|
export declare function extractErrorCodes(stripped: string): ErrorCodeEntry[];
|
|
38
38
|
export declare function extractWarnings(stripped: string): WarningEntry[];
|
|
39
39
|
export declare function extractModuleTimings(stripped: string): Map<string, ModuleTiming>;
|
|
40
|
+
/**
|
|
41
|
+
* 解析 droid2hmos 包装器(build/test harmony)的摘要输出:包装器内部执行
|
|
42
|
+
* hvigorw,但按迁移工作台规则 hvigor 原始输出重定向到日志文件,工具输出只有
|
|
43
|
+
* 状态摘要(status 表:passed/failed)。无 failed 标记视为成功(命令已完成);
|
|
44
|
+
* 错误码/告警/模块计时在摘要中不存在,返回空集合。
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseD2hWrapperOutput(raw: string): CompileResult;
|
|
40
47
|
export declare function parseHvigorwOutput(raw: string): CompileResult;
|
|
@@ -130,6 +130,26 @@ function addTiming(timings, moduleName, taskName, duration) {
|
|
|
130
130
|
});
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
// ─── parseD2hWrapperOutput ──────────────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* 解析 droid2hmos 包装器(build/test harmony)的摘要输出:包装器内部执行
|
|
136
|
+
* hvigorw,但按迁移工作台规则 hvigor 原始输出重定向到日志文件,工具输出只有
|
|
137
|
+
* 状态摘要(status 表:passed/failed)。无 failed 标记视为成功(命令已完成);
|
|
138
|
+
* 错误码/告警/模块计时在摘要中不存在,返回空集合。
|
|
139
|
+
*/
|
|
140
|
+
export function parseD2hWrapperOutput(raw) {
|
|
141
|
+
const stripped = stripAnsi(raw).toLowerCase();
|
|
142
|
+
const failed = /(^|[^a-z])failed([^a-z]|$)/m.test(stripped);
|
|
143
|
+
return {
|
|
144
|
+
success: !failed,
|
|
145
|
+
duration: 0,
|
|
146
|
+
errorCount: 0,
|
|
147
|
+
warnCount: 0,
|
|
148
|
+
errors: [],
|
|
149
|
+
warnings: [],
|
|
150
|
+
moduleTimings: new Map(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
133
153
|
// ─── parseHvigorwOutput ─────────────────────────────────────────────────────
|
|
134
154
|
export function parseHvigorwOutput(raw) {
|
|
135
155
|
const stripped = stripAnsi(raw);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TRACKED_EVENT_TYPES } from "../types.js";
|
|
2
2
|
import { createSessionMetrics } from "./state.js";
|
|
3
3
|
import { handlePartUpdated, handleSessionUpdated, handleMessageUpdated, handleSubAgentParts } from "./handlers.js";
|
|
4
|
-
import { handleSessionIdle, flushMetrics, buildRoundSnapshot } from "../snapshot/flush.js";
|
|
4
|
+
import { handleSessionIdle, flushMetrics, buildRoundSnapshot, finalizeRoundWaits } from "../snapshot/flush.js";
|
|
5
5
|
import { clearStepContentCache } from "../snapshot/steps.js";
|
|
6
6
|
import { mergeChildMetrics } from "../snapshot/merge.js";
|
|
7
7
|
import { resolveDirs } from "../dirs.js";
|
|
@@ -24,6 +24,70 @@ export function createMetricsEngine(opts = {}) {
|
|
|
24
24
|
function resolveSessionId(rawId) {
|
|
25
25
|
return childToParent.get(rawId) ?? rawId;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* 等待事件处理(0.4.1 活跃口径):
|
|
29
|
+
* - session.status 限流状态机:retry 记起点,busy/idle 结算本轮挂起时长并计数;
|
|
30
|
+
* - permission.asked/replied 配对:asked 的键是 `id`、replied 的键是 `requestID`
|
|
31
|
+
* (事件源字段名不对称),按 ID 配对差值入本轮;跨轮询问已在轮闭合时截断、条目不存。
|
|
32
|
+
* 父会话与子代理路由共用(子代理等待同样扣减父轮墙钟)。
|
|
33
|
+
*/
|
|
34
|
+
function handleSessionWaits(state, event) {
|
|
35
|
+
const props = event.properties;
|
|
36
|
+
if (!props)
|
|
37
|
+
return;
|
|
38
|
+
if (event.type === "session.status") {
|
|
39
|
+
const statusType = (props.status?.type);
|
|
40
|
+
if (statusType === "retry") {
|
|
41
|
+
// 每次 retry 事件 = 一次限流重试(attempt 递增),直接计数;窗口起点只记首个
|
|
42
|
+
if (state._retryStartedAt === null)
|
|
43
|
+
state._retryStartedAt = Date.now();
|
|
44
|
+
state._roundRetryCount++;
|
|
45
|
+
state._checkpointDirty = true;
|
|
46
|
+
}
|
|
47
|
+
else if ((statusType === "busy" || statusType === "idle") && state._retryStartedAt !== null) {
|
|
48
|
+
// busy/idle 恢复:结算本轮挂起时长
|
|
49
|
+
state._roundRetryMs += Math.max(0, Date.now() - state._retryStartedAt);
|
|
50
|
+
state._retryStartedAt = null;
|
|
51
|
+
state._checkpointDirty = true;
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (event.type === "permission.asked") {
|
|
56
|
+
const id = props.id || props.requestID || "";
|
|
57
|
+
if (id && !state._pendingPermissionAsks.has(id)) {
|
|
58
|
+
state._pendingPermissionAsks.set(id, { askedAt: Date.now(), roundStart: state._roundStartTime });
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (event.type === "permission.replied") {
|
|
63
|
+
const id = props.requestID || props.id || "";
|
|
64
|
+
const pending = id ? state._pendingPermissionAsks.get(id) : undefined;
|
|
65
|
+
if (pending) {
|
|
66
|
+
// 同轮内答复才结算(跨轮的询问已在轮闭合截断入上轮,此处不再双计)
|
|
67
|
+
if (pending.roundStart === state._roundStartTime) {
|
|
68
|
+
state._roundPermissionMs += Math.max(0, Date.now() - pending.askedAt);
|
|
69
|
+
}
|
|
70
|
+
state._pendingPermissionAsks.delete(id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** 子代理 question 工具等待入账(父路径由 handlePartUpdated 累加,此处补子代理路由) */
|
|
75
|
+
function handleSubAgentQuestionWait(state, props) {
|
|
76
|
+
const part = props?.part;
|
|
77
|
+
if (!part || part.type !== "tool" || part.tool !== "question")
|
|
78
|
+
return;
|
|
79
|
+
const toolState = part.state;
|
|
80
|
+
if (toolState?.status !== "completed")
|
|
81
|
+
return;
|
|
82
|
+
const time = toolState.time;
|
|
83
|
+
const callID = part.callID;
|
|
84
|
+
if (!time?.start || !time?.end || !callID)
|
|
85
|
+
return;
|
|
86
|
+
if (state._questionCountedCallIds.has(callID))
|
|
87
|
+
return;
|
|
88
|
+
state._questionCountedCallIds.add(callID);
|
|
89
|
+
state._roundQuestionMs += time.end - time.start;
|
|
90
|
+
}
|
|
27
91
|
function extractSessionId(event) {
|
|
28
92
|
const props = event.properties;
|
|
29
93
|
if (!props) {
|
|
@@ -92,6 +156,7 @@ export function createMetricsEngine(opts = {}) {
|
|
|
92
156
|
function finalizeAndFlush(sessionId, state, now) {
|
|
93
157
|
// 兜底补轮:退出/删除前最后一次交互往往等不到 session.idle(约 1 分钟静止判定),
|
|
94
158
|
// 此处把未闭合的非空轮收掉(空轮返回 null,已 idle 过的轮累计已重置、同样为 null,天然防重)
|
|
159
|
+
finalizeRoundWaits(state, now);
|
|
95
160
|
const pendingRound = buildRoundSnapshot(state, now);
|
|
96
161
|
if (pendingRound)
|
|
97
162
|
state.rounds.push(pendingRound);
|
|
@@ -189,6 +254,11 @@ export function createMetricsEngine(opts = {}) {
|
|
|
189
254
|
if (childSessionIds.has(originalSessionId)) {
|
|
190
255
|
const sub = state.subAgents.get(originalSessionId);
|
|
191
256
|
if (sub) {
|
|
257
|
+
// 子代理的限流/权限/question 等待同样扣减父轮墙钟(0.4.1 活跃口径)
|
|
258
|
+
handleSessionWaits(state, event);
|
|
259
|
+
if (event.type === "message.part.updated") {
|
|
260
|
+
handleSubAgentQuestionWait(state, props);
|
|
261
|
+
}
|
|
192
262
|
handleSubAgentParts(sub, event);
|
|
193
263
|
// 子代理的完整回复/单步结束:置脏父会话
|
|
194
264
|
if (event.type === "message.updated" ||
|
|
@@ -264,6 +334,8 @@ export function createMetricsEngine(opts = {}) {
|
|
|
264
334
|
state.anomaly.events.push(event.type);
|
|
265
335
|
}
|
|
266
336
|
}
|
|
337
|
+
// 限流挂起/权限审批等待配对(0.4.1 活跃口径)
|
|
338
|
+
handleSessionWaits(state, event);
|
|
267
339
|
// 统一的 checkpoint 判定点
|
|
268
340
|
evaluateCheckpoint(sessionId, state);
|
|
269
341
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createTokenUsage } from "../types.js";
|
|
2
|
-
import { parseHvigorwOutput } from "../analysis/hvigor.js";
|
|
3
|
-
import { buildRoundSnapshot, resetRoundState } from "../snapshot/flush.js";
|
|
2
|
+
import { parseHvigorwOutput, parseD2hWrapperOutput } from "../analysis/hvigor.js";
|
|
3
|
+
import { buildRoundSnapshot, resetRoundState, finalizeRoundWaits } from "../snapshot/flush.js";
|
|
4
|
+
import * as path from "node:path";
|
|
4
5
|
// ─── Input Simplification ────────────────────────────────────────────────────
|
|
5
6
|
function simplifyInput(raw) {
|
|
6
7
|
if (!raw)
|
|
@@ -12,6 +13,29 @@ function simplifyInput(raw) {
|
|
|
12
13
|
}
|
|
13
14
|
return result;
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* 收集代码行扫描的根候选:bash 工具的 workdir、write/edit 工具的文件目录。
|
|
18
|
+
* flush 时由 refreshCodeLineStats 汇总(连同会话工作目录)做工程根发现。
|
|
19
|
+
*/
|
|
20
|
+
function recordCodeRootCandidate(state, tool, input) {
|
|
21
|
+
try {
|
|
22
|
+
if (tool === "bash") {
|
|
23
|
+
const workdir = input?.workdir;
|
|
24
|
+
if (typeof workdir === "string" && workdir)
|
|
25
|
+
state.codeRootCandidates.add(workdir);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (tool === "write" || tool === "edit") {
|
|
29
|
+
const filePath = input?.filePath || input?.path;
|
|
30
|
+
if (typeof filePath === "string" && filePath.includes(path.sep)) {
|
|
31
|
+
state.codeRootCandidates.add(path.dirname(filePath));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// 候选收集失败不影响主流程
|
|
37
|
+
}
|
|
38
|
+
}
|
|
15
39
|
// ─── Invalid Tool Detection ──────────────────────────────────────────────────
|
|
16
40
|
function isInvalidTool(part) {
|
|
17
41
|
if (part.tool === "invalid")
|
|
@@ -109,6 +133,7 @@ function handlePartUpdated(state, props) {
|
|
|
109
133
|
endMs: 0,
|
|
110
134
|
parentMsgId,
|
|
111
135
|
});
|
|
136
|
+
recordCodeRootCandidate(state, part.tool || "", simplifyInput(rawInput));
|
|
112
137
|
}
|
|
113
138
|
if (status === "completed" || status === "error") {
|
|
114
139
|
const tc = existingEntry || {
|
|
@@ -130,7 +155,9 @@ function handlePartUpdated(state, props) {
|
|
|
130
155
|
tc.outputPreview = typeof output === "string" ? output.slice(0, 500) : "";
|
|
131
156
|
// Update input if available
|
|
132
157
|
if (toolState?.input && Object.keys(toolState.input).length > 0) {
|
|
133
|
-
|
|
158
|
+
const mergedInput = simplifyInput(toolState.input);
|
|
159
|
+
Object.assign(tc.input, mergedInput);
|
|
160
|
+
recordCodeRootCandidate(state, tc.tool, mergedInput);
|
|
134
161
|
}
|
|
135
162
|
state.toolCallMap.set(callID, tc);
|
|
136
163
|
}
|
|
@@ -150,6 +177,10 @@ function handlePartUpdated(state, props) {
|
|
|
150
177
|
const duration = time && time.start && time.end
|
|
151
178
|
? (time.end - time.start)
|
|
152
179
|
: 0;
|
|
180
|
+
// question 工具等待用户回答:time 窗口即等待时长(0.4.1 活跃口径扣减项)
|
|
181
|
+
if (toolName === "question" && time && time.start && time.end) {
|
|
182
|
+
state._roundQuestionMs += time.end - time.start;
|
|
183
|
+
}
|
|
153
184
|
// Update distribution
|
|
154
185
|
const stats = state.tools.distribution.get(toolName) ?? { calls: 0, errors: 0, totalDuration: 0, maxDuration: 0 };
|
|
155
186
|
stats.calls++;
|
|
@@ -173,11 +204,17 @@ function handlePartUpdated(state, props) {
|
|
|
173
204
|
// Compile stats extraction (with dedup)
|
|
174
205
|
if (part.tool === "bash") {
|
|
175
206
|
const command = state.toolCallMap.get(callID)?.input?.command || "";
|
|
176
|
-
|
|
207
|
+
// droid2hmos 包装器(build/test harmony)内部执行 hvigorw:命令行不含
|
|
208
|
+
// "hvigorw"、输出只有状态摘要(hvigor 原始输出被重定向到日志文件),
|
|
209
|
+
// 按命令形态识别为 HarmonyOS 构建调用
|
|
210
|
+
const isD2hHarmonyBuild = /\bdroid2hmos(?:\.(?:cmd|exe|bat|ps1))?\s+(?:build|test)\s+harmony\b/.test(command);
|
|
211
|
+
if (command.includes("hvigorw") || isD2hHarmonyBuild) {
|
|
177
212
|
if (status === "completed" && !alreadyCompleted) {
|
|
178
213
|
state.compileStats.hvigorwCalls++;
|
|
179
214
|
const output = toolState?.output || "";
|
|
180
|
-
const result =
|
|
215
|
+
const result = command.includes("hvigorw")
|
|
216
|
+
? parseHvigorwOutput(output)
|
|
217
|
+
: parseD2hWrapperOutput(output);
|
|
181
218
|
state.compileStats.lastBuildSuccess = result.success;
|
|
182
219
|
// First build per round
|
|
183
220
|
if (!state._roundFirstBuildTracked) {
|
|
@@ -252,16 +289,8 @@ function handlePartUpdated(state, props) {
|
|
|
252
289
|
}
|
|
253
290
|
}
|
|
254
291
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const filePath = entry?.input?.filePath || entry?.input?.path || "";
|
|
258
|
-
if (filePath.endsWith(".ets") && status === "completed") {
|
|
259
|
-
const content = entry?.input?.content;
|
|
260
|
-
if (content) {
|
|
261
|
-
state.compileStats.etsLines += content.split("\n").filter(l => l.length > 0).length;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
292
|
+
// (0.4.2 起 etsLines 等代码行维度改由 flush 时工程全量扫描产出,
|
|
293
|
+
// 不再对 write 工具内容做累计——见 analysis/codelines.ts 口径说明)
|
|
265
294
|
// Fix cycle: count code changes
|
|
266
295
|
if ((part.tool === "write" || part.tool === "edit") && status === "completed") {
|
|
267
296
|
if (state.compileStats._pendingFix) {
|
|
@@ -477,6 +506,7 @@ function handleMessageUpdated(state, props) {
|
|
|
477
506
|
// 新用户消息 = 天然轮边界:先收掉当前非空轮(不再等 idle 静止判定),
|
|
478
507
|
// 快速连发多条不再被合并进同一轮;resume 重放期间轮累计为空,
|
|
479
508
|
// buildRoundSnapshot 返回 null 无副作用,重放结束后首条真实消息正常开新轮
|
|
509
|
+
finalizeRoundWaits(state, Date.now());
|
|
480
510
|
const round = buildRoundSnapshot(state, Date.now());
|
|
481
511
|
if (round) {
|
|
482
512
|
state.rounds.push(round);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MessageEntry, PlanningCall, RoundSnapshot, SkillCallEntry, SkillSearchEntry, TokenUsage, ToolCallEntry, SessionMeta, CompileStats, StageInfo, ToolStats } from "../types.js";
|
|
2
|
+
import type { CodeLineStats } from "../analysis/codelines.js";
|
|
2
3
|
export interface SubAgentState {
|
|
3
4
|
sessionId: string;
|
|
4
5
|
agentName: string;
|
|
@@ -113,6 +114,23 @@ export interface SessionMetricsState {
|
|
|
113
114
|
_roundTokens: TokenUsage;
|
|
114
115
|
_roundToolCalls: number;
|
|
115
116
|
_roundErrors: number;
|
|
117
|
+
/** 本轮 question 工具等待用户累计(ms,0.4.1 活跃口径) */
|
|
118
|
+
_roundQuestionMs: number;
|
|
119
|
+
/** 本轮权限审批等待累计(ms) */
|
|
120
|
+
_roundPermissionMs: number;
|
|
121
|
+
/** 本轮限流重试挂起累计(ms) */
|
|
122
|
+
_roundRetryMs: number;
|
|
123
|
+
/** 本轮限流重试次数 */
|
|
124
|
+
_roundRetryCount: number;
|
|
125
|
+
/** 已问未答的权限请求:requestID → 询问时刻与所属轮起点(轮闭合时截断结算后移除) */
|
|
126
|
+
_pendingPermissionAsks: Map<string, {
|
|
127
|
+
askedAt: number;
|
|
128
|
+
roundStart: number;
|
|
129
|
+
}>;
|
|
130
|
+
/** 限流挂起起点(session.status retry 起始时刻);null = 未挂起 */
|
|
131
|
+
_retryStartedAt: number | null;
|
|
132
|
+
/** 已入账 question 等待的 callID(子代理路径去重用;父路径由 toolCallMap 去重) */
|
|
133
|
+
_questionCountedCallIds: Set<string>;
|
|
116
134
|
_roundFirstBuildTracked: boolean;
|
|
117
135
|
_roundIdleProcessed: boolean;
|
|
118
136
|
_idleFlushed: boolean;
|
|
@@ -120,6 +138,14 @@ export interface SessionMetricsState {
|
|
|
120
138
|
_checkpointDirty: boolean;
|
|
121
139
|
/** 上次 flush 的累加字段基线;null = 尚未 flush 过 */
|
|
122
140
|
_flushBaseline: FlushBaseline | null;
|
|
141
|
+
/** 最近一次扫描结果(flush 时按 30s 节流刷新;见 flush.ts refreshCodeLineStats) */
|
|
142
|
+
codeLines: CodeLineStats;
|
|
143
|
+
/** 根候选:bash workdir 与 write/edit 文件目录(工作目录取 sessionMeta) */
|
|
144
|
+
codeRootCandidates: Set<string>;
|
|
145
|
+
/** 最近一次代码行扫描时刻(ms) */
|
|
146
|
+
_lastCodeScanAt: number;
|
|
147
|
+
/** 最近一次扫描时的候选集合签名(变化即重扫) */
|
|
148
|
+
_codeScanRootsKey: string;
|
|
123
149
|
messageMap: Map<string, MessageEntry>;
|
|
124
150
|
toolCallMap: Map<string, ToolCallEntry>;
|
|
125
151
|
textLengthMap: Map<string, number>;
|
|
@@ -50,7 +50,6 @@ export function createSessionMetrics(sessionId) {
|
|
|
50
50
|
hvigorwCalls: 0,
|
|
51
51
|
hvigorwErrors: 0,
|
|
52
52
|
lastBuildSuccess: false,
|
|
53
|
-
etsLines: 0,
|
|
54
53
|
firstBuildPerRound: [],
|
|
55
54
|
errorCodes: new Map(),
|
|
56
55
|
fixCycles: [],
|
|
@@ -58,6 +57,10 @@ export function createSessionMetrics(sessionId) {
|
|
|
58
57
|
warnings: new Map(),
|
|
59
58
|
moduleTimings: new Map(),
|
|
60
59
|
},
|
|
60
|
+
codeLines: { etsLines: 0, cLines: 0, cppLines: 0, testLines: 0, businessCodeLines: 0 },
|
|
61
|
+
codeRootCandidates: new Set(),
|
|
62
|
+
_lastCodeScanAt: 0,
|
|
63
|
+
_codeScanRootsKey: "",
|
|
61
64
|
orderCounter: 0,
|
|
62
65
|
skillCallMap: new Map(),
|
|
63
66
|
htmlPreviewMsgId: null,
|
|
@@ -75,6 +78,13 @@ export function createSessionMetrics(sessionId) {
|
|
|
75
78
|
_roundTokens: createTokenUsage(),
|
|
76
79
|
_roundToolCalls: 0,
|
|
77
80
|
_roundErrors: 0,
|
|
81
|
+
_roundQuestionMs: 0,
|
|
82
|
+
_roundPermissionMs: 0,
|
|
83
|
+
_roundRetryMs: 0,
|
|
84
|
+
_roundRetryCount: 0,
|
|
85
|
+
_pendingPermissionAsks: new Map(),
|
|
86
|
+
_retryStartedAt: null,
|
|
87
|
+
_questionCountedCallIds: new Set(),
|
|
78
88
|
_roundFirstBuildTracked: false,
|
|
79
89
|
_roundIdleProcessed: false,
|
|
80
90
|
_idleFlushed: false,
|