opencode-metrics-plugin 0.4.0 → 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 +15 -1
- 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/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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.4.0(2026-09-15)
|
|
4
18
|
|
|
5
19
|
- 新增会话 agent 白名单 `agents`(支持 `*` 通配,如 `["android-to-hmos-*", "build"]`):配置后只有根会话 agent 命中任一模式才记录——快照、事件日志、system prompt、上传全不产生;子会话(task/explore 等)随父会话判定正常并入;agent 未知的会话默认记录(fail-open)。缺省/空数组 = 不过滤全记(向后兼容)。
|
|
6
|
-
- 典型用法:全局 `~/.config/opencode/opencode.json` 注册 `["opencode-metrics-plugin", { "agents": ["android-to-hmos
|
|
20
|
+
- 典型用法:全局 `~/.config/opencode/opencode.json` 注册 `["opencode-metrics-plugin", { "agents": ["android-to-hmos-00-orchestrator"] }]`,实现「插件处处加载、只统计迁移 agent 的会话」,不受 opencode 项目级配置发现的 git 边界限制。注意项目 `.opencode/opencode-metrics.json(c)` 存在时仍会完全接管(含 `agents`),接管文件需同步配置。
|
|
7
21
|
|
|
8
22
|
## 0.3.5(2026-09-15)
|
|
9
23
|
|
|
@@ -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,
|
|
@@ -2,15 +2,12 @@ import type { MetricsDirs } from "../dirs.js";
|
|
|
2
2
|
import type { SessionMetricsState } from "../engine/state.js";
|
|
3
3
|
import type { RoundSnapshot, MetricsOutput } from "../types.js";
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* 刷新会话的工程代码行多维统计(30s 节流;候选根集合变化立即重扫)。
|
|
6
|
+
* 扫描口径见 analysis/codelines.ts;找不到鸿蒙工程根时全维度保持 0。
|
|
7
7
|
*
|
|
8
|
-
* @param
|
|
9
|
-
* @param state 会话状态,写盘成功后原地刷新差量基线
|
|
10
|
-
* @param now 本次快照的结束时间(ms)
|
|
11
|
-
* @param dirs 输出目录;缺省用进程级默认目录
|
|
12
|
-
* @returns 本次由内存态构建的快照(未与旧文件合并的那份)
|
|
8
|
+
* @param state 会话状态(结果原地写入 state.codeLines)
|
|
13
9
|
*/
|
|
10
|
+
export declare function refreshCodeLineStats(state: SessionMetricsState): void;
|
|
14
11
|
declare function flushMetrics(sessionId: string, state: SessionMetricsState, now: number, dirs?: Partial<MetricsDirs>): MetricsOutput;
|
|
15
12
|
/**
|
|
16
13
|
* 处理一轮会话结束:保存本轮 round 快照并落盘。
|
|
@@ -19,6 +16,15 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
|
|
|
19
16
|
* @param sessionId 会话 id
|
|
20
17
|
* @param dirs 输出目录;缺省用进程级默认目录
|
|
21
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* 轮闭合前的等待结算(0.4.1 活跃口径):把仍挂起的 question/permission/retry
|
|
21
|
+
* 截断入本轮累加器,并把挂起计时重锚到闭合时刻——等待若延续到下一轮,
|
|
22
|
+
* 只在新轮计尾段,跨轮拆分总量不丢、不双计。
|
|
23
|
+
*
|
|
24
|
+
* @param state 会话状态(就地结算累加器与挂起计时)
|
|
25
|
+
* @param now 轮闭合时刻(ms)
|
|
26
|
+
*/
|
|
27
|
+
export declare function finalizeRoundWaits(state: SessionMetricsState, now: number): void;
|
|
22
28
|
/**
|
|
23
29
|
* 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
|
|
24
30
|
*
|
|
@@ -26,7 +32,7 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
|
|
|
26
32
|
* @param now 结算时刻(ms)
|
|
27
33
|
*/
|
|
28
34
|
export declare function buildRoundSnapshot(state: SessionMetricsState, now: number): RoundSnapshot | null;
|
|
29
|
-
/** 重置轮累计态,开始新轮(不改动 stage
|
|
35
|
+
/** 重置轮累计态,开始新轮(不改动 stage 相关状态;等待挂起计时由 finalizeRoundWaits 结算)。 */
|
|
30
36
|
export declare function resetRoundState(state: SessionMetricsState, now: number): void;
|
|
31
37
|
declare function handleSessionIdle(state: SessionMetricsState, sessionId: string, dirs?: Partial<MetricsDirs>): MetricsOutput | undefined;
|
|
32
38
|
export { flushMetrics, handleSessionIdle };
|
|
@@ -5,6 +5,7 @@ import { createTokenUsage } from "../types.js";
|
|
|
5
5
|
import { getMetricsDir } from "../dirs.js";
|
|
6
6
|
import { buildSteps, extractStepContent } from "./steps.js";
|
|
7
7
|
import { mergeMetricsOutput } from "./merge.js";
|
|
8
|
+
import { discoverHarmonyRoots, scanCodeLines } from "../analysis/codelines.js";
|
|
8
9
|
function filterByKeys(source, keys) {
|
|
9
10
|
const result = new Map();
|
|
10
11
|
for (const key of keys) {
|
|
@@ -24,7 +25,40 @@ function filterByKeys(source, keys) {
|
|
|
24
25
|
* @param dirs 输出目录;缺省用进程级默认目录
|
|
25
26
|
* @returns 本次由内存态构建的快照(未与旧文件合并的那份)
|
|
26
27
|
*/
|
|
28
|
+
/** 代码行扫描节流间隔:活跃会话 checkpoint 频率远高于此,避免重复扫盘 */
|
|
29
|
+
const CODE_SCAN_THROTTLE_MS = 30_000;
|
|
30
|
+
/**
|
|
31
|
+
* 刷新会话的工程代码行多维统计(30s 节流;候选根集合变化立即重扫)。
|
|
32
|
+
* 扫描口径见 analysis/codelines.ts;找不到鸿蒙工程根时全维度保持 0。
|
|
33
|
+
*
|
|
34
|
+
* @param state 会话状态(结果原地写入 state.codeLines)
|
|
35
|
+
*/
|
|
36
|
+
export function refreshCodeLineStats(state) {
|
|
37
|
+
try {
|
|
38
|
+
const candidates = [state.sessionMeta.workingDirectory, ...state.codeRootCandidates]
|
|
39
|
+
.filter((dir) => typeof dir === "string" && dir.length > 0);
|
|
40
|
+
const rootsKey = [...candidates]
|
|
41
|
+
.map((dir) => (process.platform === "win32" ? dir.toLowerCase() : dir))
|
|
42
|
+
.sort()
|
|
43
|
+
.join("|");
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
if (state._lastCodeScanAt > 0 &&
|
|
46
|
+
now - state._lastCodeScanAt < CODE_SCAN_THROTTLE_MS &&
|
|
47
|
+
state._codeScanRootsKey === rootsKey) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const roots = discoverHarmonyRoots(candidates, state.startTime);
|
|
51
|
+
state.codeLines = scanCodeLines(roots);
|
|
52
|
+
state._lastCodeScanAt = now;
|
|
53
|
+
state._codeScanRootsKey = rootsKey;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// 扫描失败保持上次结果(或全 0),不影响快照其余字段
|
|
57
|
+
}
|
|
58
|
+
}
|
|
27
59
|
function flushMetrics(sessionId, state, now, dirs) {
|
|
60
|
+
// 代码行多维统计(节流扫描,见 refreshCodeLineStats)
|
|
61
|
+
refreshCodeLineStats(state);
|
|
28
62
|
// Finalize agent usage for current agent
|
|
29
63
|
if (state.agent.current) {
|
|
30
64
|
const elapsed = now - state.agent.lastSwitchTime;
|
|
@@ -36,6 +70,16 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
36
70
|
}
|
|
37
71
|
const endTime = now;
|
|
38
72
|
const duration = endTime - state.startTime;
|
|
73
|
+
// AI 活跃统计(0.4.1 起):Σ 已闭合轮 activeDuration + 等待明细(进行中的开轮不计,闭合后下次 flush 补齐)
|
|
74
|
+
const aiActiveDuration = state.rounds.reduce((sum, r) => sum + (r.activeDuration ?? r.duration ?? 0), 0);
|
|
75
|
+
const roundsDuration = state.rounds.reduce((sum, r) => sum + (r.duration || 0), 0);
|
|
76
|
+
const waitBreakdown = {
|
|
77
|
+
questionMs: state.rounds.reduce((sum, r) => sum + (r.questionMs ?? 0), 0),
|
|
78
|
+
permissionMs: state.rounds.reduce((sum, r) => sum + (r.permissionMs ?? 0), 0),
|
|
79
|
+
retryMs: state.rounds.reduce((sum, r) => sum + (r.retryMs ?? 0), 0),
|
|
80
|
+
retryCount: state.rounds.reduce((sum, r) => sum + (r.retryCount ?? 0), 0),
|
|
81
|
+
otherIdleMs: Math.max(0, duration - roundsDuration),
|
|
82
|
+
};
|
|
39
83
|
// Calculate cache hit rate
|
|
40
84
|
const denom = state.tokens.input + state.tokens.cacheRead;
|
|
41
85
|
const cacheHitRate = denom > 0 ? state.tokens.cacheRead / denom : 0;
|
|
@@ -223,6 +267,9 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
223
267
|
startTime: state.startTime,
|
|
224
268
|
endTime,
|
|
225
269
|
duration,
|
|
270
|
+
aiActiveDuration,
|
|
271
|
+
aiActiveRatio: duration > 0 ? aiActiveDuration / duration : 0,
|
|
272
|
+
waitBreakdown,
|
|
226
273
|
systemPrompts,
|
|
227
274
|
rounds: state.rounds,
|
|
228
275
|
tokens: (() => {
|
|
@@ -260,7 +307,11 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
260
307
|
})),
|
|
261
308
|
header,
|
|
262
309
|
codeStats: {
|
|
263
|
-
etsLines: state.
|
|
310
|
+
etsLines: state.codeLines.etsLines,
|
|
311
|
+
cLines: state.codeLines.cLines,
|
|
312
|
+
cppLines: state.codeLines.cppLines,
|
|
313
|
+
testLines: state.codeLines.testLines,
|
|
314
|
+
businessCodeLines: state.codeLines.businessCodeLines,
|
|
264
315
|
buildSuccess: state.compileStats.lastBuildSuccess,
|
|
265
316
|
fixCompileCount: state.compileStats.hvigorwCalls,
|
|
266
317
|
totalCompileErrors: state.compileStats.hvigorwErrors,
|
|
@@ -394,6 +445,33 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
394
445
|
* @param sessionId 会话 id
|
|
395
446
|
* @param dirs 输出目录;缺省用进程级默认目录
|
|
396
447
|
*/
|
|
448
|
+
/**
|
|
449
|
+
* 轮闭合前的等待结算(0.4.1 活跃口径):把仍挂起的 question/permission/retry
|
|
450
|
+
* 截断入本轮累加器,并把挂起计时重锚到闭合时刻——等待若延续到下一轮,
|
|
451
|
+
* 只在新轮计尾段,跨轮拆分总量不丢、不双计。
|
|
452
|
+
*
|
|
453
|
+
* @param state 会话状态(就地结算累加器与挂起计时)
|
|
454
|
+
* @param now 轮闭合时刻(ms)
|
|
455
|
+
*/
|
|
456
|
+
export function finalizeRoundWaits(state, now) {
|
|
457
|
+
// 未答的权限询问:截断入本轮并移除(后续 replied 到达时无条目即忽略,防跨轮双计)
|
|
458
|
+
for (const [id, pending] of state._pendingPermissionAsks) {
|
|
459
|
+
state._roundPermissionMs += Math.max(0, now - pending.askedAt);
|
|
460
|
+
state._pendingPermissionAsks.delete(id);
|
|
461
|
+
}
|
|
462
|
+
// 挂起中的限流重试:截断入本轮,起点重锚(计数已在 retry 事件时入账,此处只补时长)
|
|
463
|
+
if (state._retryStartedAt !== null) {
|
|
464
|
+
state._roundRetryMs += Math.max(0, now - state._retryStartedAt);
|
|
465
|
+
state._retryStartedAt = now;
|
|
466
|
+
}
|
|
467
|
+
// 仍在等待回答的 question 工具:截断入本轮,startMs 重锚(completed 事件只补尾段)
|
|
468
|
+
for (const tc of state.toolCallMap.values()) {
|
|
469
|
+
if (tc.tool === "question" && tc.status === "running" && tc.startMs > 0 && tc.startMs < now) {
|
|
470
|
+
state._roundQuestionMs += now - tc.startMs;
|
|
471
|
+
tc.startMs = now;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
397
475
|
/**
|
|
398
476
|
* 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
|
|
399
477
|
*
|
|
@@ -401,16 +479,34 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
401
479
|
* @param now 结算时刻(ms)
|
|
402
480
|
*/
|
|
403
481
|
export function buildRoundSnapshot(state, now) {
|
|
404
|
-
|
|
482
|
+
// 空轮判定:无 token、无工具调用且无任何等待(限流/审批/提问)才视为空轮丢弃。
|
|
483
|
+
// 仅含等待的轮(如纯限流挂起)仍占用墙钟,须保留以维持 activeDuration 扣减恒等式。
|
|
484
|
+
const isEmptyRound = state._roundTokens.total === 0
|
|
485
|
+
&& state._roundToolCalls === 0
|
|
486
|
+
&& state._roundQuestionMs === 0
|
|
487
|
+
&& state._roundPermissionMs === 0
|
|
488
|
+
&& state._roundRetryMs === 0
|
|
489
|
+
&& state._roundRetryCount === 0;
|
|
405
490
|
if (isEmptyRound)
|
|
406
491
|
return null;
|
|
407
492
|
const firstTokenLatency = state._hasTextPart && state._firstEventTime > 0
|
|
408
493
|
? state._firstTextTime - state._firstEventTime
|
|
409
494
|
: 0;
|
|
495
|
+
const duration = now - state._roundStartTime;
|
|
496
|
+
// 活跃口径(0.4.1 起):轮墙钟减已知等待,各项 clamp 到 [0, duration],合计下限 0
|
|
497
|
+
const clampWait = (v) => Math.max(0, Math.min(v, duration));
|
|
498
|
+
const questionMs = clampWait(state._roundQuestionMs);
|
|
499
|
+
const permissionMs = clampWait(state._roundPermissionMs);
|
|
500
|
+
const retryMs = clampWait(state._roundRetryMs);
|
|
410
501
|
return {
|
|
411
502
|
startTime: state._roundStartTime,
|
|
412
503
|
roundIndex: state.rounds.length,
|
|
413
|
-
duration
|
|
504
|
+
duration,
|
|
505
|
+
activeDuration: Math.max(0, duration - questionMs - permissionMs - retryMs),
|
|
506
|
+
questionMs,
|
|
507
|
+
permissionMs,
|
|
508
|
+
retryMs,
|
|
509
|
+
retryCount: state._roundRetryCount,
|
|
414
510
|
firstTokenLatency,
|
|
415
511
|
tokens: { ...state._roundTokens },
|
|
416
512
|
toolCalls: state._roundToolCalls,
|
|
@@ -418,7 +514,7 @@ export function buildRoundSnapshot(state, now) {
|
|
|
418
514
|
userMessage: state._pendingUserMessage.length > 0 ? [...state._pendingUserMessage] : undefined,
|
|
419
515
|
};
|
|
420
516
|
}
|
|
421
|
-
/** 重置轮累计态,开始新轮(不改动 stage
|
|
517
|
+
/** 重置轮累计态,开始新轮(不改动 stage 相关状态;等待挂起计时由 finalizeRoundWaits 结算)。 */
|
|
422
518
|
export function resetRoundState(state, now) {
|
|
423
519
|
state._roundStartTime = now;
|
|
424
520
|
state._firstEventTime = 0;
|
|
@@ -428,6 +524,10 @@ export function resetRoundState(state, now) {
|
|
|
428
524
|
state._roundTokens = createTokenUsage();
|
|
429
525
|
state._roundToolCalls = 0;
|
|
430
526
|
state._roundErrors = 0;
|
|
527
|
+
state._roundQuestionMs = 0;
|
|
528
|
+
state._roundPermissionMs = 0;
|
|
529
|
+
state._roundRetryMs = 0;
|
|
530
|
+
state._roundRetryCount = 0;
|
|
431
531
|
state._pendingUserMessage = [];
|
|
432
532
|
state._roundFirstBuildTracked = false;
|
|
433
533
|
}
|
|
@@ -437,6 +537,7 @@ function handleSessionIdle(state, sessionId, dirs) {
|
|
|
437
537
|
if (state._roundIdleProcessed) {
|
|
438
538
|
return;
|
|
439
539
|
}
|
|
540
|
+
finalizeRoundWaits(state, now);
|
|
440
541
|
const round = buildRoundSnapshot(state, now);
|
|
441
542
|
if (round)
|
|
442
543
|
state.rounds.push(round);
|
|
@@ -57,7 +57,6 @@ function mergeChildMetrics(parent, child, meta) {
|
|
|
57
57
|
// Merge compileStats (additive)
|
|
58
58
|
parent.compileStats.hvigorwCalls += child.compileStats.hvigorwCalls;
|
|
59
59
|
parent.compileStats.hvigorwErrors += child.compileStats.hvigorwErrors;
|
|
60
|
-
parent.compileStats.etsLines += child.compileStats.etsLines;
|
|
61
60
|
// lastBuildSuccess: take child's value if child had any builds
|
|
62
61
|
if (child.compileStats.hvigorwCalls > 0 || child.compileStats.hvigorwErrors > 0) {
|
|
63
62
|
parent.compileStats.lastBuildSuccess = child.compileStats.lastBuildSuccess;
|
|
@@ -207,6 +206,18 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
207
206
|
...fresh.rounds.filter(r => !existingRoundIndices.has(r.roundIndex)),
|
|
208
207
|
].sort((a, b) => a.roundIndex - b.roundIndex);
|
|
209
208
|
})();
|
|
209
|
+
// AI 活跃统计(0.4.1 起):从合并后的轮数组重算——fresh 重启后可能只含新段,
|
|
210
|
+
// 不能直接展开 fresh 汇总;旧轮(0.4.0 快照)缺 activeDuration 回退按 duration 计(视为全活跃),
|
|
211
|
+
// 等待明细缺省按 0。与 planning 同思路:合并方从合并后明细重新推导。
|
|
212
|
+
const aiActiveDuration = rounds.reduce((sum, r) => sum + (r.activeDuration ?? r.duration ?? 0), 0);
|
|
213
|
+
const roundsDuration = rounds.reduce((sum, r) => sum + (r.duration || 0), 0);
|
|
214
|
+
const waitBreakdown = {
|
|
215
|
+
questionMs: rounds.reduce((sum, r) => sum + (r.questionMs ?? 0), 0),
|
|
216
|
+
permissionMs: rounds.reduce((sum, r) => sum + (r.permissionMs ?? 0), 0),
|
|
217
|
+
retryMs: rounds.reduce((sum, r) => sum + (r.retryMs ?? 0), 0),
|
|
218
|
+
retryCount: rounds.reduce((sum, r) => sum + (r.retryCount ?? 0), 0),
|
|
219
|
+
otherIdleMs: Math.max(0, duration - roundsDuration),
|
|
220
|
+
};
|
|
210
221
|
// Steps:进程重启后 orderCounter 从 0 重编,fresh 的 index 与磁盘 existing 撞车,
|
|
211
222
|
// 且时间上更晚的新步骤被排到列表头部;resume 重灌轮的时间戳全新,index 与时间戳都无法跨进程关联。
|
|
212
223
|
// 统一策略:按 startTime:endTime 去重(防同进程重放,fresh wins)后,
|
|
@@ -494,18 +505,24 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
494
505
|
};
|
|
495
506
|
})();
|
|
496
507
|
// codeStats 顶层分两类处理:累计型(fixCompileCount/totalCompileErrors)按差量并入;
|
|
497
|
-
//
|
|
498
|
-
//
|
|
508
|
+
// 快照型(代码行五维/buildSuccess/firstBuildPassRate/firstBuildPerRound)在 fresh
|
|
509
|
+
// 有值(>0)时取 fresh——工程全量扫描的时效值,fresh 无值保留 existing
|
|
510
|
+
// (重启进程未扫到工程是"不知道"而非"清零")。
|
|
499
511
|
const fixCompileCount = existing.codeStats.fixCompileCount
|
|
500
512
|
+ Math.max(0, fresh.codeStats.fixCompileCount - (baseline?.fixCompileCount ?? 0));
|
|
501
513
|
const totalCompileErrors = existing.codeStats.totalCompileErrors
|
|
502
514
|
+ Math.max(0, fresh.codeStats.totalCompileErrors - (baseline?.totalCompileErrors ?? 0));
|
|
503
|
-
const freshHasBuild = fresh.codeStats.
|
|
515
|
+
const freshHasBuild = fresh.codeStats.businessCodeLines > 0
|
|
504
516
|
|| fresh.codeStats.fixCompileCount > (baseline?.fixCompileCount ?? 0)
|
|
505
517
|
|| totalCompileErrors > existing.codeStats.totalCompileErrors
|
|
506
518
|
|| fresh.codeStats.firstBuildPerRound.length > 0;
|
|
519
|
+
const pickLineCount = (freshValue, existingValue) => (freshValue ?? 0) > 0 ? freshValue : (existingValue ?? 0);
|
|
507
520
|
const codeStats = {
|
|
508
|
-
etsLines: fresh.codeStats.etsLines
|
|
521
|
+
etsLines: pickLineCount(fresh.codeStats.etsLines, existing.codeStats.etsLines),
|
|
522
|
+
cLines: pickLineCount(fresh.codeStats.cLines, existing.codeStats.cLines),
|
|
523
|
+
cppLines: pickLineCount(fresh.codeStats.cppLines, existing.codeStats.cppLines),
|
|
524
|
+
testLines: pickLineCount(fresh.codeStats.testLines, existing.codeStats.testLines),
|
|
525
|
+
businessCodeLines: pickLineCount(fresh.codeStats.businessCodeLines, existing.codeStats.businessCodeLines),
|
|
509
526
|
buildSuccess: freshHasBuild ? fresh.codeStats.buildSuccess : existing.codeStats.buildSuccess,
|
|
510
527
|
fixCompileCount,
|
|
511
528
|
totalCompileErrors,
|
|
@@ -526,6 +543,9 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
526
543
|
startTime,
|
|
527
544
|
endTime: fresh.endTime,
|
|
528
545
|
duration,
|
|
546
|
+
aiActiveDuration,
|
|
547
|
+
aiActiveRatio: duration > 0 ? aiActiveDuration / duration : 0,
|
|
548
|
+
waitBreakdown,
|
|
529
549
|
systemPrompts,
|
|
530
550
|
rounds,
|
|
531
551
|
tokens,
|
package/dist/metrics/types.d.ts
CHANGED
|
@@ -85,7 +85,6 @@ export interface CompileStats {
|
|
|
85
85
|
hvigorwCalls: number;
|
|
86
86
|
hvigorwErrors: number;
|
|
87
87
|
lastBuildSuccess: boolean;
|
|
88
|
-
etsLines: number;
|
|
89
88
|
firstBuildPerRound: boolean[];
|
|
90
89
|
errorCodes: Map<string, {
|
|
91
90
|
count: number;
|
|
@@ -163,12 +162,35 @@ export interface RoundSnapshot {
|
|
|
163
162
|
startTime?: number;
|
|
164
163
|
roundIndex: number;
|
|
165
164
|
duration: number;
|
|
165
|
+
/** AI 活跃时长(0.4.1 起)= duration − questionMs − permissionMs − retryMs(下限 0);旧快照缺省,合并回退按 duration 计 */
|
|
166
|
+
activeDuration?: number;
|
|
167
|
+
/** question 工具等待用户回答累计(ms,0.4.1 起) */
|
|
168
|
+
questionMs?: number;
|
|
169
|
+
/** 权限审批等待累计(ms,0.4.1 起) */
|
|
170
|
+
permissionMs?: number;
|
|
171
|
+
/** 限流重试挂起累计(ms,0.4.1 起) */
|
|
172
|
+
retryMs?: number;
|
|
173
|
+
/** 限流重试次数(0.4.1 起) */
|
|
174
|
+
retryCount?: number;
|
|
166
175
|
firstTokenLatency: number;
|
|
167
176
|
tokens: TokenUsage;
|
|
168
177
|
toolCalls: number;
|
|
169
178
|
errors: number;
|
|
170
179
|
userMessage?: string[];
|
|
171
180
|
}
|
|
181
|
+
/** 会话级等待扣减明细(0.4.1 起):duration = aiActiveDuration + 各等待项之和 */
|
|
182
|
+
export interface WaitBreakdown {
|
|
183
|
+
/** question 工具等待用户回答累计(ms) */
|
|
184
|
+
questionMs: number;
|
|
185
|
+
/** 权限审批等待累计(ms) */
|
|
186
|
+
permissionMs: number;
|
|
187
|
+
/** 限流重试挂起累计(ms) */
|
|
188
|
+
retryMs: number;
|
|
189
|
+
/** 限流重试次数 */
|
|
190
|
+
retryCount: number;
|
|
191
|
+
/** 轮间空闲:墙钟未被任何已闭合轮覆盖的部分(ms) */
|
|
192
|
+
otherIdleMs: number;
|
|
193
|
+
}
|
|
172
194
|
export interface SubAgentOutput {
|
|
173
195
|
sessionId: string;
|
|
174
196
|
agent: string;
|
|
@@ -201,6 +223,12 @@ export interface MetricsOutput {
|
|
|
201
223
|
startTime: number;
|
|
202
224
|
endTime: number;
|
|
203
225
|
duration: number;
|
|
226
|
+
/** AI 活跃时长(0.4.1 起):Σ 轮 activeDuration(轮墙钟减去已知等待,step 间死时间计入活跃) */
|
|
227
|
+
aiActiveDuration?: number;
|
|
228
|
+
/** AI 活跃占比 = aiActiveDuration / duration(0-1,0.4.1 起) */
|
|
229
|
+
aiActiveRatio?: number;
|
|
230
|
+
/** 等待扣减明细(0.4.1 起) */
|
|
231
|
+
waitBreakdown?: WaitBreakdown;
|
|
204
232
|
systemPrompts: Record<string, string[]>;
|
|
205
233
|
rounds: RoundSnapshot[];
|
|
206
234
|
tokens: TokenUsage & {
|
|
@@ -235,7 +263,16 @@ export interface MetricsOutput {
|
|
|
235
263
|
}>;
|
|
236
264
|
header: SessionMeta;
|
|
237
265
|
codeStats: {
|
|
266
|
+
/** 排除测试的 .ets 行数(0.4.2 起为工程全量扫描口径,非 write 工具累计) */
|
|
238
267
|
etsLines: number;
|
|
268
|
+
/** 排除测试的 C(.c/.h)行数 */
|
|
269
|
+
cLines: number;
|
|
270
|
+
/** 排除测试的 C++(.cpp/.cc/.cxx/.hpp 等)行数 */
|
|
271
|
+
cppLines: number;
|
|
272
|
+
/** 测试代码行数(ohosTest、test 等目录子树与 .test.、.spec. 命名文件,任意语言) */
|
|
273
|
+
testLines: number;
|
|
274
|
+
/** 实际业务代码总量 = ets + C + C++ + 测试(= 全量;构建/配置文件不计入) */
|
|
275
|
+
businessCodeLines: number;
|
|
239
276
|
buildSuccess: boolean;
|
|
240
277
|
fixCompileCount: number;
|
|
241
278
|
totalCompileErrors: number;
|
|
@@ -6,7 +6,9 @@ const BUILTIN_SCENARIOS = {
|
|
|
6
6
|
events: ["dispose", "session.deleted"],
|
|
7
7
|
tools: ["task"],
|
|
8
8
|
},
|
|
9
|
-
|
|
9
|
+
// git 与 commit 之间允许 ≤4 个选项(如 -c user.name=x、-C <path>、--no-verify),
|
|
10
|
+
// 但选项必须以 - 开头——避免把 "git log --grep=commit" 一类误判为提交
|
|
11
|
+
"git-commit": { label: "git", commands: ["\\bgit\\s+(?:-{1,2}[\\w.-]+(?:[ =][^\\s]+)?\\s+){0,4}commit\\b"] },
|
|
10
12
|
};
|
|
11
13
|
/**
|
|
12
14
|
* 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events/tools
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MetricsDirs } from "../dirs.js";
|
|
2
|
-
import type { UploadConfig } from "./types.js";
|
|
2
|
+
import type { UploadConfig, UploadPackage } from "./types.js";
|
|
3
3
|
export interface UploadManagerOptions {
|
|
4
4
|
config?: UploadConfig;
|
|
5
5
|
/** 实例目录(metricsDir/eventsDir/uploadStagingDir) */
|
|
@@ -23,6 +23,21 @@ export interface UploadManager {
|
|
|
23
23
|
* @param options 配置与目录
|
|
24
24
|
*/
|
|
25
25
|
export declare function createUploadManager(options: UploadManagerOptions): UploadManager | null;
|
|
26
|
+
/**
|
|
27
|
+
* 启动清扫暂存目录顶层:崩溃遗留的完整三件套(tar.gz + json + meta.json)
|
|
28
|
+
* 移入 failed/ 同构目录并入队重传;孤立文件与组装目录直接删除。
|
|
29
|
+
*
|
|
30
|
+
* 两遍扫描:第一遍只处理 .tar.gz(三件套校验 → rescue 或删 tar.gz,并标记
|
|
31
|
+
* 三个文件名已处理)。字母序下 .json/.meta.json 先于 .tar.gz 迭代,若单遍
|
|
32
|
+
* 边迭代边删 .json,会在 tar.gz 校验前破坏三件套——完整崩溃遗留包必然被
|
|
33
|
+
* 误判"不完整"而销毁(曾连续销毁终态包)。第二遍才清理无 tar.gz 对应的
|
|
34
|
+
* 孤儿 .json/meta 与组装残留。
|
|
35
|
+
*
|
|
36
|
+
* @param stagingDir 暂存目录
|
|
37
|
+
* @param rescueEnabled 失败保留开关(false 时完整三件套也直接删除,旧行为)
|
|
38
|
+
* @returns 可补偿重传的包列表(顶层遗留部分)
|
|
39
|
+
*/
|
|
40
|
+
export declare function sweepStagingDirectory(stagingDir: string, rescueEnabled: boolean): UploadPackage[];
|
|
26
41
|
/**
|
|
27
42
|
* 记录会话终态包已成功上云。
|
|
28
43
|
* 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
|
|
@@ -41,7 +56,10 @@ export interface OrphanSnapshot {
|
|
|
41
56
|
}
|
|
42
57
|
/**
|
|
43
58
|
* 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
|
|
44
|
-
*
|
|
59
|
+
* 快照原件永久保留为本地记录,"已传标记"是防重复上传的依据。
|
|
60
|
+
* 时效判断:标记只证明"标记时刻之前的快照已上云"——会话在中途终态触发
|
|
61
|
+
* (task 完成/session.deleted)上传后仍可能继续运行并更新快照,因此快照
|
|
62
|
+
* mtime 晚于标记时间(超出 60s 缓冲)仍视为孤儿,补传最终态。
|
|
45
63
|
*
|
|
46
64
|
* @param metricsDir 快照目录
|
|
47
65
|
* @param idleMs 静默下限(毫秒)
|
|
@@ -353,11 +353,17 @@ function dedupePaths(paths) {
|
|
|
353
353
|
* 启动清扫暂存目录顶层:崩溃遗留的完整三件套(tar.gz + json + meta.json)
|
|
354
354
|
* 移入 failed/ 同构目录并入队重传;孤立文件与组装目录直接删除。
|
|
355
355
|
*
|
|
356
|
+
* 两遍扫描:第一遍只处理 .tar.gz(三件套校验 → rescue 或删 tar.gz,并标记
|
|
357
|
+
* 三个文件名已处理)。字母序下 .json/.meta.json 先于 .tar.gz 迭代,若单遍
|
|
358
|
+
* 边迭代边删 .json,会在 tar.gz 校验前破坏三件套——完整崩溃遗留包必然被
|
|
359
|
+
* 误判"不完整"而销毁(曾连续销毁终态包)。第二遍才清理无 tar.gz 对应的
|
|
360
|
+
* 孤儿 .json/meta 与组装残留。
|
|
361
|
+
*
|
|
356
362
|
* @param stagingDir 暂存目录
|
|
357
363
|
* @param rescueEnabled 失败保留开关(false 时完整三件套也直接删除,旧行为)
|
|
358
364
|
* @returns 可补偿重传的包列表(顶层遗留部分)
|
|
359
365
|
*/
|
|
360
|
-
function sweepStagingDirectory(stagingDir, rescueEnabled) {
|
|
366
|
+
export function sweepStagingDirectory(stagingDir, rescueEnabled) {
|
|
361
367
|
const rescued = [];
|
|
362
368
|
try {
|
|
363
369
|
const entries = fs.readdirSync(stagingDir);
|
|
@@ -365,26 +371,31 @@ function sweepStagingDirectory(stagingDir, rescueEnabled) {
|
|
|
365
371
|
const handled = new Set(["failed"]);
|
|
366
372
|
let removed = 0;
|
|
367
373
|
const removedNames = [];
|
|
374
|
+
// 第一遍:.tar.gz 三件套判定(必须先于 .json 清理,见函数注释)
|
|
368
375
|
for (const entry of entries) {
|
|
369
376
|
if (handled.has(entry))
|
|
370
377
|
continue;
|
|
371
|
-
if (entry.endsWith(".tar.gz"))
|
|
372
|
-
const base = entry.slice(0, -".tar.gz".length);
|
|
373
|
-
const pkg = rescueEnabled ? loadIntactPackage(stagingDir, base, names) : undefined;
|
|
374
|
-
if (pkg) {
|
|
375
|
-
movePackageIntoFailed(stagingDir, pkg, path.join(stagingDir, `${base}.meta.json`));
|
|
376
|
-
rescued.push(pkg);
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
fs.rmSync(path.join(stagingDir, entry), { force: true });
|
|
380
|
-
removed++;
|
|
381
|
-
removedNames.push(entry);
|
|
382
|
-
}
|
|
383
|
-
handled.add(entry);
|
|
384
|
-
handled.add(`${base}.json`);
|
|
385
|
-
handled.add(`${base}.meta.json`);
|
|
378
|
+
if (!entry.endsWith(".tar.gz"))
|
|
386
379
|
continue;
|
|
380
|
+
const base = entry.slice(0, -".tar.gz".length);
|
|
381
|
+
const pkg = rescueEnabled ? loadIntactPackage(stagingDir, base, names) : undefined;
|
|
382
|
+
if (pkg) {
|
|
383
|
+
movePackageIntoFailed(stagingDir, pkg, path.join(stagingDir, `${base}.meta.json`));
|
|
384
|
+
rescued.push(pkg);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
fs.rmSync(path.join(stagingDir, entry), { force: true });
|
|
388
|
+
removed++;
|
|
389
|
+
removedNames.push(entry);
|
|
387
390
|
}
|
|
391
|
+
handled.add(entry);
|
|
392
|
+
handled.add(`${base}.json`);
|
|
393
|
+
handled.add(`${base}.meta.json`);
|
|
394
|
+
}
|
|
395
|
+
// 第二遍:清理剩余孤儿(无对应 tar.gz 的快照副本/meta、组装残留目录)
|
|
396
|
+
for (const entry of entries) {
|
|
397
|
+
if (handled.has(entry))
|
|
398
|
+
continue;
|
|
388
399
|
if (entry.endsWith(".json") || entry.startsWith(".assembly-")) {
|
|
389
400
|
fs.rmSync(path.join(stagingDir, entry), { recursive: true, force: true });
|
|
390
401
|
removed++;
|
|
@@ -560,6 +571,8 @@ function removeQuietly(filePath) {
|
|
|
560
571
|
// ─── 孤儿快照补传(§13 失败补偿链路) ─────────────────────────────────────────
|
|
561
572
|
/** 已传终态标记目录名(metrics/.sent/<sessionId>) */
|
|
562
573
|
const SENT_MARKER_DIR = ".sent";
|
|
574
|
+
/** 已传标记时效缓冲:快照 mtime 超出标记时间该值仍视为"标记后更新过"需补传 */
|
|
575
|
+
const SENT_NEWER_GRACE_MS = 60_000;
|
|
563
576
|
/**
|
|
564
577
|
* 记录会话终态包已成功上云。
|
|
565
578
|
* 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
|
|
@@ -578,9 +591,23 @@ export function markSessionUploaded(metricsDir, sessionId) {
|
|
|
578
591
|
export function isSessionUploaded(metricsDir, sessionId) {
|
|
579
592
|
return fs.existsSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId));
|
|
580
593
|
}
|
|
594
|
+
/** 读取已传标记时间戳(内容为上传完成时刻的毫秒值);无标记/内容非法返回 null */
|
|
595
|
+
function sentTimestamp(metricsDir, sessionId) {
|
|
596
|
+
try {
|
|
597
|
+
const content = fs.readFileSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId), "utf-8").trim();
|
|
598
|
+
const time = Number(content);
|
|
599
|
+
return Number.isFinite(time) && time > 0 ? time : null;
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
581
605
|
/**
|
|
582
606
|
* 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
|
|
583
|
-
*
|
|
607
|
+
* 快照原件永久保留为本地记录,"已传标记"是防重复上传的依据。
|
|
608
|
+
* 时效判断:标记只证明"标记时刻之前的快照已上云"——会话在中途终态触发
|
|
609
|
+
* (task 完成/session.deleted)上传后仍可能继续运行并更新快照,因此快照
|
|
610
|
+
* mtime 晚于标记时间(超出 60s 缓冲)仍视为孤儿,补传最终态。
|
|
584
611
|
*
|
|
585
612
|
* @param metricsDir 快照目录
|
|
586
613
|
* @param idleMs 静默下限(毫秒)
|
|
@@ -602,11 +629,14 @@ export async function selectOrphanSnapshots(metricsDir, idleMs, maxAgeMs, isActi
|
|
|
602
629
|
if (!entry.endsWith(".json"))
|
|
603
630
|
continue;
|
|
604
631
|
const sessionId = entry.slice(0, -".json".length);
|
|
605
|
-
if (isSessionUploaded(metricsDir, sessionId))
|
|
606
|
-
continue;
|
|
607
632
|
const filePath = path.join(metricsDir, entry);
|
|
608
633
|
try {
|
|
609
634
|
const stats = await fs.promises.stat(filePath);
|
|
635
|
+
// 已传标记时效判断:标记只证明"标记时刻之前的快照已上云",快照 mtime
|
|
636
|
+
// 晚于标记时间(超出 60s 缓冲)说明会话其后仍更新过,最终态未上云,仍补传
|
|
637
|
+
const sentAt = sentTimestamp(metricsDir, sessionId);
|
|
638
|
+
if (sentAt !== null && stats.mtimeMs <= sentAt + SENT_NEWER_GRACE_MS)
|
|
639
|
+
continue;
|
|
610
640
|
const age = now - stats.mtimeMs;
|
|
611
641
|
if (age < idleMs || age > maxAgeMs)
|
|
612
642
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-metrics-plugin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "opencode plugin: auto-records every session's metrics (tokens/rounds/steps/sub-agents/hvigor builds) to snapshot JSON. Zero DB, zero config. Pair with session-viewer for visualization.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|