opencode-metrics-plugin 0.3.2 → 0.3.4
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 +14 -0
- package/README.md +13 -5
- package/dist/metrics/configLoader.js +1 -1
- package/dist/metrics/runtime.js +5 -1
- package/dist/metrics/upload/agcUploader.js +1 -1
- package/dist/metrics/upload/scenarios.d.ts +16 -3
- package/dist/metrics/upload/scenarios.js +25 -3
- package/dist/metrics/upload/types.d.ts +12 -0
- package/dist/metrics/upload/uploadManager.d.ts +29 -2
- package/dist/metrics/upload/uploadManager.js +154 -11
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.4(2026-09-14)
|
|
4
|
+
|
|
5
|
+
- 修复 0.3.3 发布事故:package.json 误带 UTF-8 BOM,npm 可安装但 opencode 解析插件清单失败并**静默跳过加载**——所有 0.3.3 用户完全不采集、不上传、日志无任何痕迹。升级本版后必须删除插件缓存并重启 opencode(Windows `%USERPROFILE%\.cache\opencode\packages\opencode-metrics-plugin*`,macOS `~/Library/Caches/opencode/packages/` 同名目录);事故期间未采集的会话无法补录,装好后孤儿补传会自动补传 7 天内未上终态的历史快照。
|
|
6
|
+
- 用户可编辑文件读取容忍 BOM:`opencode-metrics.json(.jsonc)` 与 `agc-apiclient.json` 用 Windows PowerShell 或旧版编辑器保存不再静默失效。
|
|
7
|
+
- 发布防复发:`prepublishOnly` 增加 package.json BOM 守卫,命中即发布失败。
|
|
8
|
+
|
|
9
|
+
## 0.3.3(2026-09-12)
|
|
10
|
+
|
|
11
|
+
- session-end 三层保障,修复宿主进程突然死亡导致会话终态包永不产出的问题:
|
|
12
|
+
- **task 工具终态触发**:根会话的 task 工具 `completed` / `error` 即刻打包上传 session-end(子代理嵌套调用不触发,callID 去重);
|
|
13
|
+
- **打包前快照终稿化**:打包前先冲刷事件日志与引擎快照,包内快照必含触发事件本身(原 2s 冷却可能落后);
|
|
14
|
+
- **孤儿快照补传**:启动时扫描静默超 24h 且未上云终态的快照(`metrics/.sent/` 已传标记防重),按快照证据补打 session-end 包,超 7 天放弃(`upload.orphanRescue` 可调)。
|
|
15
|
+
- 场景系统支持 `tools` 工具名匹配器(内置 session-end 即 `tools: ["task"]`);`session.created` 即种子工作区证据,纯 task 会话也能完成根推断与包名推断。
|
|
16
|
+
|
|
3
17
|
## 0.3.2(2026-09-12)
|
|
4
18
|
|
|
5
19
|
- 产物根推断增强:迁移工作区标记修正为 `.migration`;安卓工程根容器化降级(向内提升鸿蒙子工程与 `.migration`/`.harmonyos` 工作区);会话时间窗内新建的相邻鸿蒙工程纳入,邻居老工程不混入;纯安卓工程不打包。
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- **开箱即用**:宿主只需在 `opencode.json` 加一行,无需任何配置
|
|
6
6
|
- **实时落盘**:会话进行中每 2s 增量写盘,中途退出不丢数据
|
|
7
|
-
- **开箱即传**:上传默认开启(包内 AGC 凭证),`git commit` / 会话结束自动打包上传,安卓源码工程自动剔除,`upload.enabled: false` 可关
|
|
7
|
+
- **开箱即传**:上传默认开启(包内 AGC 凭证),`git commit` / 根会话 task 完成 / 会话结束自动打包上传,安卓源码工程自动剔除,`upload.enabled: false` 可关
|
|
8
8
|
- **独立配置文件**:`.opencode/opencode-metrics.json(.jsonc)` 支持注释,存在即完全接管内联配置;用户目录 `~/.config/opencode/` 可放全局兜底配置
|
|
9
9
|
- **失败不丢**:上传失败的包保留在 `failed/`(与云端路径同构,可手动上传),下次启动自动补偿重传
|
|
10
10
|
- **零打扰**:不修改工作区、不执行 clean
|
|
@@ -57,12 +57,13 @@
|
|
|
57
57
|
|
|
58
58
|
默认开启(`upload.enabled: false` 可关):
|
|
59
59
|
|
|
60
|
-
- **触发场景**:bash 执行 `git commit`(label=`git
|
|
60
|
+
- **触发场景**:bash 执行 `git commit`(label=`git`);会话结束/插件退出(label=`session-end`);**根会话 task 工具完成或失败即刻触发 session-end**(子代理嵌套调用不触发)。可自定义场景(命令正则、事件名或工具名)
|
|
61
61
|
- **打包内容**:时点快照 JSON + 事件日志(恒含)+ 会话动过的工作区产物根(鸿蒙工程标记探测,≤3 个)+ build 目录内的 `.hap` 产物包(借道组装目录,不受 build 黑名单影响)
|
|
62
62
|
- **根推断增强**:安卓工程根(如 gradle 外壳)不整根保留也不丢弃——向内扫描提升鸿蒙子工程(`ohos/` 等)与迁移工作区(`.migration` / `.harmonyos`);会话期间新建的相邻鸿蒙工程(目录 mtime 在会话开始之后)也会纳入,邻居老工程不混入;纯安卓工程(无任何鸿蒙子根)不打包
|
|
63
63
|
- **自动剔除**:黑名单目录(`oh_modules` / `build` / `node_modules` 等 8 项);安卓工程(根级 + 子树级,gradle 信号 ≥2 判定,可用 `excludeAndroidProjects: false` 关闭)
|
|
64
64
|
- **云端路径**:`YYYYMMDD/<安卓包名或项目名>-<sessionId>/<agent缩写>-<触发>-v<n>/<agent缩写>-<触发>-v<n>.tar.gz` 与同名 `.json`(一个版本一个目录,双文件归拢;日期取包创建日)
|
|
65
65
|
- **可靠性**:串行队列 + 指数退避重试(默认 5 次,4xx 不重试);失败包移入暂存目录 `failed/` 下**与云端同构的目录**(含 meta 元数据,可整目录手动上传),下次启动自动补偿重传,重传成功即清理;保留超期(默认 7 天)或超量(默认 1GB,从最旧删)自动清理
|
|
66
|
+
- **进程死亡兜底**:宿主突然退出(session-end 从未触发)时,下次启动对静默超 24h 且未传终态的快照自动补打 session-end 包(`metrics/.sent/` 已传标记防重传,超 7 天放弃)
|
|
66
67
|
|
|
67
68
|
## 输出目录
|
|
68
69
|
|
|
@@ -70,10 +71,16 @@
|
|
|
70
71
|
|
|
71
72
|
| 平台 | 路径 |
|
|
72
73
|
|---|---|
|
|
73
|
-
| Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events}
|
|
74
|
+
| Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events}`(metrics 下另有 `.sent/` 已传终态标记)、`...\Log\log\plugin\YYYYMMDD.log`(运行日志按日期归档,保留 30 天)、`...\Data\upload-staging` |
|
|
74
75
|
| Linux | `~/.local/state/opencode-metrics-plugin/{metrics,events}` |
|
|
75
76
|
| macOS | `~/Library/Logs/opencode-metrics-plugin/{metrics,events}` |
|
|
76
77
|
|
|
78
|
+
## 升级与排障
|
|
79
|
+
|
|
80
|
+
- **升级不生效**:opencode 将插件缓存在本机且不自动更新,升级后须删缓存再重启——Windows `%USERPROFILE%\.cache\opencode\packages\` 下的 `opencode-metrics-plugin*` 目录,macOS `~/Library/Caches/opencode/packages/` 下同名目录。
|
|
81
|
+
- **装上完全没反应(日志无 `[MetricsPlugin] started`)**:若装的是 0.3.3,系发布事故——该版 package.json 误带 UTF-8 BOM,opencode 静默跳过插件(不采集、不上传、无报错)。升级 0.3.4+ 并删缓存即恢复;期间丢的会话无法补录,装好后 7 天内未上终态的老快照会被孤儿补传自动救回。
|
|
82
|
+
- **验证插件已加载**:插件日志(`log/plugin/YYYYMMDD.log`)首屏出现 `[MetricsPlugin] started` 行。
|
|
83
|
+
|
|
77
84
|
## 配置项速查
|
|
78
85
|
|
|
79
86
|
全部可选;独立文件与内联第二参字段相同。
|
|
@@ -93,13 +100,14 @@
|
|
|
93
100
|
|---|---|---|---|
|
|
94
101
|
| `upload.enabled` | boolean | `true` | 上传总开关;`false` 显式关闭 |
|
|
95
102
|
| `upload.uploader.agcConfigPath` | string | 包内凭证 | `agc-apiclient.json` 绝对路径(client_id/secret/project_id/region/bucket) |
|
|
96
|
-
| `upload.scenarios` | object | 内置两场景 | 按 key 合并:`enabled:false` 禁用内置;自定义场景提供 `commands
|
|
103
|
+
| `upload.scenarios` | object | 内置两场景 | 按 key 合并:`enabled:false` 禁用内置;自定义场景提供 `commands`(正则)/ `events`(事件名)/ `tools`(工具名,终态触发)与 `label`(进文件名) |
|
|
97
104
|
| `upload.appendArtifacts` | array | — | 追加产物规则:`path`(绝对或相对会话工作目录)+ `matchBy`(`session-activity` 会话动过才纳入 / `existence` 存在即纳入)+ `dirMarkers`/`fileMarkers` |
|
|
98
105
|
| `upload.blacklist` | string[] | 默认 8 项 | 打包排除的目录/文件名(追加,不替换) |
|
|
99
106
|
| `upload.excludeAndroidProjects` | boolean | `true` | 剔除安卓工程(根级 + 根内子树) |
|
|
100
107
|
| `upload.maxArtifactBytes` | number | `524288000` | 单包体积上限(默认 500MB),超限该次标失败 |
|
|
101
108
|
| `upload.retry` | object | `{5, 1000}` | `maxAttempts` 最大尝试次数;`backoffMs` 退避基数(1s/4s/16s/64s/256s) |
|
|
102
109
|
| `upload.retryRescue` | object | `{true, 7, 1GB}` | 失败保留与补偿重传:`enabled` 开关;`retentionDays` 保留天数;`maxFailedBytes` failed/ 总量上限(超限从最旧删) |
|
|
110
|
+
| `upload.orphanRescue` | object | `{true, 24, 7}` | 孤儿快照补传:`enabled` 开关;`idleHours` 静默下限(小时);`maxAgeDays` 最大快照年龄(天),超龄放弃 |
|
|
103
111
|
|
|
104
112
|
## 可编程 API
|
|
105
113
|
|
|
@@ -125,7 +133,7 @@ rt.engine.dispose()
|
|
|
125
133
|
|
|
126
134
|
```bash
|
|
127
135
|
npm run typecheck # tsc --noEmit
|
|
128
|
-
npm test # 冒烟测试(example/smoke.mts,
|
|
136
|
+
npm test # 冒烟测试(example/smoke.mts,90 断言)
|
|
129
137
|
npm run build # 清空 dist 后 tsc
|
|
130
138
|
```
|
|
131
139
|
|
|
@@ -58,7 +58,7 @@ function readFirstExisting(baseDirectory, names) {
|
|
|
58
58
|
for (const name of names) {
|
|
59
59
|
const candidate = path.join(baseDirectory, name);
|
|
60
60
|
try {
|
|
61
|
-
return { configPath: candidate, raw: fs.readFileSync(candidate, "utf-8") };
|
|
61
|
+
return { configPath: candidate, raw: fs.readFileSync(candidate, "utf-8").replace(/^\uFEFF/, "") };
|
|
62
62
|
}
|
|
63
63
|
catch {
|
|
64
64
|
// 该名字的文件不存在,尝试下一个
|
package/dist/metrics/runtime.js
CHANGED
|
@@ -21,7 +21,11 @@ export function createMetricsRuntime(opts = {}) {
|
|
|
21
21
|
const uploadManager = createUploadManager({
|
|
22
22
|
config: opts.upload,
|
|
23
23
|
dirs,
|
|
24
|
-
onBeforeArchive: () =>
|
|
24
|
+
onBeforeArchive: (sessionId) => {
|
|
25
|
+
eventLogger?.flush();
|
|
26
|
+
// 引擎快照即时落盘:包内快照为触发时刻终稿(而非落后 checkpoint 间隔的旧稿)
|
|
27
|
+
engine.flush(sessionId);
|
|
28
|
+
},
|
|
25
29
|
});
|
|
26
30
|
return {
|
|
27
31
|
dirs,
|
|
@@ -31,7 +31,7 @@ export function defaultAgcConfigPath() {
|
|
|
31
31
|
export function loadAgcCredentials(configPath, tokenCacheDir) {
|
|
32
32
|
let raw;
|
|
33
33
|
try {
|
|
34
|
-
raw = fs.readFileSync(configPath, "utf-8");
|
|
34
|
+
raw = fs.readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
35
35
|
}
|
|
36
36
|
catch (err) {
|
|
37
37
|
return { ok: false, reason: `读取配置失败 ${configPath}: ${String(err)}` };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ScenarioConfig } from "./types.js";
|
|
2
|
-
/** 解析后的触发场景:label 用于文件命名,commands/events
|
|
2
|
+
/** 解析后的触发场景:label 用于文件命名,commands/events/tools 为三类匹配源 */
|
|
3
3
|
export interface ResolvedScenario {
|
|
4
4
|
key: string;
|
|
5
5
|
/** 命名标签(文件名 <sessionId>-<agentName>-<label>-v<n> 的一段) */
|
|
@@ -9,12 +9,25 @@ export interface ResolvedScenario {
|
|
|
9
9
|
commandRegexes: RegExp[];
|
|
10
10
|
/** 匹配的事件名(含特殊值 "dispose",由管理器结束时触发) */
|
|
11
11
|
events: string[];
|
|
12
|
+
/** 匹配的工具名(根会话直接调用的工具 completed/error 时触发) */
|
|
13
|
+
toolNames: string[];
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
14
|
-
* 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events
|
|
15
|
-
* enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
|
|
16
|
+
* 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events/tools
|
|
17
|
+
* 可覆盖、enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
|
|
16
18
|
*
|
|
17
19
|
* @param configs opencode.json upload.scenarios
|
|
18
20
|
* @returns 全部场景(含禁用的,调用方按 enabled 过滤)
|
|
19
21
|
*/
|
|
20
22
|
export declare function resolveScenarios(configs?: Record<string, ScenarioConfig>): ResolvedScenario[];
|
|
23
|
+
/**
|
|
24
|
+
* 判定工具终态事件命中的场景:根会话直接调用的工具进入终态
|
|
25
|
+
* (completed/error)时命中其 toolNames;子代理嵌套调用不命中。
|
|
26
|
+
*
|
|
27
|
+
* @param scenarios 已解析场景(含禁用项,函数内过滤 enabled)
|
|
28
|
+
* @param tool 工具名
|
|
29
|
+
* @param status 工具状态(仅 completed/error 视为终态)
|
|
30
|
+
* @param isRootSession 事件原始会话是否根会话(未被子代理映射)
|
|
31
|
+
* @returns 命中的启用场景列表
|
|
32
|
+
*/
|
|
33
|
+
export declare function matchToolScenarios(scenarios: ResolvedScenario[], tool: string, status: string, isRootSession: boolean): ResolvedScenario[];
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { log } from "../../shared/log.js";
|
|
2
2
|
/** 内置默认场景(opencode.json scenarios 按 key 合并覆盖) */
|
|
3
3
|
const BUILTIN_SCENARIOS = {
|
|
4
|
-
"session-end": {
|
|
4
|
+
"session-end": {
|
|
5
|
+
label: "session-end",
|
|
6
|
+
events: ["dispose", "session.deleted"],
|
|
7
|
+
tools: ["task"],
|
|
8
|
+
},
|
|
5
9
|
"git-commit": { label: "git", commands: ["\\bgit\\s+commit\\b"] },
|
|
6
10
|
};
|
|
7
11
|
/**
|
|
8
|
-
* 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events
|
|
9
|
-
* enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
|
|
12
|
+
* 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events/tools
|
|
13
|
+
* 可覆盖、enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
|
|
10
14
|
*
|
|
11
15
|
* @param configs opencode.json upload.scenarios
|
|
12
16
|
* @returns 全部场景(含禁用的,调用方按 enabled 过滤)
|
|
@@ -37,7 +41,25 @@ export function resolveScenarios(configs) {
|
|
|
37
41
|
enabled: config.enabled !== false,
|
|
38
42
|
commandRegexes,
|
|
39
43
|
events: config.events ?? [],
|
|
44
|
+
toolNames: config.tools ?? [],
|
|
40
45
|
});
|
|
41
46
|
}
|
|
42
47
|
return result;
|
|
43
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* 判定工具终态事件命中的场景:根会话直接调用的工具进入终态
|
|
51
|
+
* (completed/error)时命中其 toolNames;子代理嵌套调用不命中。
|
|
52
|
+
*
|
|
53
|
+
* @param scenarios 已解析场景(含禁用项,函数内过滤 enabled)
|
|
54
|
+
* @param tool 工具名
|
|
55
|
+
* @param status 工具状态(仅 completed/error 视为终态)
|
|
56
|
+
* @param isRootSession 事件原始会话是否根会话(未被子代理映射)
|
|
57
|
+
* @returns 命中的启用场景列表
|
|
58
|
+
*/
|
|
59
|
+
export function matchToolScenarios(scenarios, tool, status, isRootSession) {
|
|
60
|
+
if (!isRootSession || !tool)
|
|
61
|
+
return [];
|
|
62
|
+
if (status !== "completed" && status !== "error")
|
|
63
|
+
return [];
|
|
64
|
+
return scenarios.filter((scenario) => scenario.enabled && scenario.toolNames.includes(tool));
|
|
65
|
+
}
|
|
@@ -25,6 +25,8 @@ export interface ScenarioConfig {
|
|
|
25
25
|
commands?: string[];
|
|
26
26
|
/** TRACKED_EVENT_TYPES 内事件名 */
|
|
27
27
|
events?: string[];
|
|
28
|
+
/** 工具名匹配器:根会话直接调用的工具进入终态(completed/error)时触发 */
|
|
29
|
+
tools?: string[];
|
|
28
30
|
}
|
|
29
31
|
/** 上传通道配置(当前仅 agc) */
|
|
30
32
|
export interface UploaderConfig {
|
|
@@ -46,6 +48,15 @@ export interface RetryRescueConfig {
|
|
|
46
48
|
/** failed/ 总量字节上限,超限从最旧删除(缺省 1GB) */
|
|
47
49
|
maxFailedBytes?: number;
|
|
48
50
|
}
|
|
51
|
+
/** 孤儿快照补传:宿主死亡导致终态触发从未发生时,启动后按快照年龄补打 session-end 包 */
|
|
52
|
+
export interface OrphanRescueConfig {
|
|
53
|
+
/** 缺省 true:启用孤儿快照补传 */
|
|
54
|
+
enabled?: boolean;
|
|
55
|
+
/** 快照静默多久视为孤儿(小时,缺省 24;活跃会话快照始终排除) */
|
|
56
|
+
idleHours?: number;
|
|
57
|
+
/** 快照最大补传年龄(天,缺省 7,超过视为陈旧放弃) */
|
|
58
|
+
maxAgeDays?: number;
|
|
59
|
+
}
|
|
49
60
|
/** opencode.json 的 upload 配置整块(§13.8) */
|
|
50
61
|
export interface UploadConfig {
|
|
51
62
|
/** 上传总开关,缺省 true(装上即上传);false 显式关闭 */
|
|
@@ -61,6 +72,7 @@ export interface UploadConfig {
|
|
|
61
72
|
maxArtifactBytes?: number;
|
|
62
73
|
retry?: UploadRetryConfig;
|
|
63
74
|
retryRescue?: RetryRescueConfig;
|
|
75
|
+
orphanRescue?: OrphanRescueConfig;
|
|
64
76
|
}
|
|
65
77
|
/** 暂存目录内的单个上传文件(归档包或快照) */
|
|
66
78
|
export interface UploadPackageFile {
|
|
@@ -4,8 +4,8 @@ export interface UploadManagerOptions {
|
|
|
4
4
|
config?: UploadConfig;
|
|
5
5
|
/** 实例目录(metricsDir/eventsDir/uploadStagingDir) */
|
|
6
6
|
dirs: MetricsDirs;
|
|
7
|
-
/**
|
|
8
|
-
onBeforeArchive?: () => void;
|
|
7
|
+
/** 归档前回调(事件日志冲刷 + 引擎快照落盘,消除缓冲延迟导致的旧快照/空 events 竞态) */
|
|
8
|
+
onBeforeArchive?: (sessionId: string) => void;
|
|
9
9
|
}
|
|
10
10
|
export interface UploadManager {
|
|
11
11
|
/** 旁路观察事件:收集证据 + 场景命中判定(须在 engine.ingest 之后调用,保证快照先落盘) */
|
|
@@ -23,3 +23,30 @@ export interface UploadManager {
|
|
|
23
23
|
* @param options 配置与目录
|
|
24
24
|
*/
|
|
25
25
|
export declare function createUploadManager(options: UploadManagerOptions): UploadManager | null;
|
|
26
|
+
/**
|
|
27
|
+
* 记录会话终态包已成功上云。
|
|
28
|
+
* 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
|
|
29
|
+
* "已传标记"才是孤儿扫描防重复上传的依据。
|
|
30
|
+
*/
|
|
31
|
+
export declare function markSessionUploaded(metricsDir: string, sessionId: string): void;
|
|
32
|
+
/** 会话终态包是否已成功上云 */
|
|
33
|
+
export declare function isSessionUploaded(metricsDir: string, sessionId: string): boolean;
|
|
34
|
+
/** 孤儿快照候选:从磁盘快照恢复的会话证据 */
|
|
35
|
+
export interface OrphanSnapshot {
|
|
36
|
+
sessionId: string;
|
|
37
|
+
filePath: string;
|
|
38
|
+
workingDirectory: string;
|
|
39
|
+
agentName: string;
|
|
40
|
+
startedAt: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
|
|
44
|
+
* 上传成功后快照即被删除,"文件仍在"天然等价于"终态未传出"。
|
|
45
|
+
*
|
|
46
|
+
* @param metricsDir 快照目录
|
|
47
|
+
* @param idleMs 静默下限(毫秒)
|
|
48
|
+
* @param maxAgeMs 年龄上限(毫秒)
|
|
49
|
+
* @param isActive 活跃会话判定(IO 前后各查一次,防竞态)
|
|
50
|
+
* @returns 候选列表(无法解析的快照跳过)
|
|
51
|
+
*/
|
|
52
|
+
export declare function selectOrphanSnapshots(metricsDir: string, idleMs: number, maxAgeMs: number, isActive: (sessionId: string) => boolean): Promise<OrphanSnapshot[]>;
|
|
@@ -5,7 +5,7 @@ import { EvidenceCollector, isPathInside, resolveAndroidProjectName, resolveWork
|
|
|
5
5
|
import { archiveSessionPackage } from "./packageArchiver.js";
|
|
6
6
|
import { createAgcUploader, composeRemoteBase, defaultAgcConfigPath, loadAgcCredentials, remoteArchiveKey } from "./agcUploader.js";
|
|
7
7
|
import { createUploadQueue } from "./uploadQueue.js";
|
|
8
|
-
import { resolveScenarios } from "./scenarios.js";
|
|
8
|
+
import { resolveScenarios, matchToolScenarios } from "./scenarios.js";
|
|
9
9
|
import { MAX_ROOTS } from "./types.js";
|
|
10
10
|
/**
|
|
11
11
|
* 创建上传管理器:串联证据 → 根推断 → 打包 → 队列 → AGC 上传,
|
|
@@ -25,6 +25,10 @@ export function createUploadManager(options) {
|
|
|
25
25
|
const uploader = createAgcUploader(credentialsResult.credentials);
|
|
26
26
|
const scenarios = resolveScenarios(config.scenarios);
|
|
27
27
|
const directories = options.dirs;
|
|
28
|
+
// 终态场景标签集合:这些标签的包上传成功即写已传标记(孤儿扫描防重依据)
|
|
29
|
+
const terminalLabels = new Set(scenarios
|
|
30
|
+
.filter((s) => s.enabled && (s.events.includes("session.deleted") || s.toolNames.length > 0))
|
|
31
|
+
.map((s) => s.label));
|
|
28
32
|
// 失败保留与启动补偿配置(默认开启)
|
|
29
33
|
const rescueEnabled = config.retryRescue?.enabled !== false;
|
|
30
34
|
const retentionMs = (config.retryRescue?.retentionDays ?? 7) * 24 * 60 * 60 * 1000;
|
|
@@ -41,6 +45,10 @@ export function createUploadManager(options) {
|
|
|
41
45
|
removeQuietly(uploadPackage.archive.filePath);
|
|
42
46
|
removeQuietly(uploadPackage.snapshot.filePath);
|
|
43
47
|
removeQuietly(metaPath);
|
|
48
|
+
// 终态包上云成功:写已传标记,孤儿扫描据此跳过该会话
|
|
49
|
+
if (terminalLabels.has(uploadPackage.trigger)) {
|
|
50
|
+
markSessionUploaded(directories.metricsDir, uploadPackage.sessionId);
|
|
51
|
+
}
|
|
44
52
|
// 来自 failed/ 的补偿重传:清掉可能残留的空目录
|
|
45
53
|
removeEmptyDirsUp(path.dirname(uploadPackage.archive.filePath), path.join(directories.uploadStagingDir, "failed"));
|
|
46
54
|
log.info("[Metrics] 上传完成", {
|
|
@@ -81,9 +89,16 @@ export function createUploadManager(options) {
|
|
|
81
89
|
for (const pkg of rescued)
|
|
82
90
|
queue.enqueue(pkg);
|
|
83
91
|
}
|
|
92
|
+
// 孤儿快照补传:宿主死亡导致终态触发从未发生时,按快照年龄补打 session-end 包
|
|
93
|
+
if (config.orphanRescue?.enabled !== false) {
|
|
94
|
+
const idleMs = (config.orphanRescue?.idleHours ?? 24) * 3_600_000;
|
|
95
|
+
const maxAgeMs = (config.orphanRescue?.maxAgeDays ?? 7) * 86_400_000;
|
|
96
|
+
void rescueOrphanSnapshots(idleMs, maxAgeMs);
|
|
97
|
+
}
|
|
84
98
|
const sessions = new Map();
|
|
85
99
|
const childToParent = new Map();
|
|
86
100
|
const completedBashCalls = new Set();
|
|
101
|
+
const toolTriggeredCalls = new Set();
|
|
87
102
|
function resolveSessionId(rawId) {
|
|
88
103
|
return childToParent.get(rawId) ?? rawId;
|
|
89
104
|
}
|
|
@@ -98,11 +113,13 @@ export function createUploadManager(options) {
|
|
|
98
113
|
/**
|
|
99
114
|
* 触发一次上传:根推断 + 包名推断 + 打包入队。
|
|
100
115
|
*
|
|
101
|
-
* @param sessionId
|
|
102
|
-
* @param triggerLabel
|
|
116
|
+
* @param sessionId 会话 id
|
|
117
|
+
* @param triggerLabel 场景命名标签
|
|
118
|
+
* @param evidenceOverride 证据覆盖(孤儿补传时由快照构造,代替内存证据)
|
|
103
119
|
*/
|
|
104
|
-
async function triggerUpload(sessionId, triggerLabel) {
|
|
105
|
-
const evidence =
|
|
120
|
+
async function triggerUpload(sessionId, triggerLabel, evidenceOverride) {
|
|
121
|
+
const evidence = evidenceOverride ??
|
|
122
|
+
sessions.get(sessionId) ??
|
|
106
123
|
{ collector: new EvidenceCollector(), agentName: "", workingDirectory: "", startedAt: Date.now() };
|
|
107
124
|
const seeds = evidence.collector.seeds();
|
|
108
125
|
const { directRoots, extraMarkers } = collectArtifactRules(seeds, evidence.workingDirectory, config.appendArtifacts ?? []);
|
|
@@ -116,7 +133,7 @@ export function createUploadManager(options) {
|
|
|
116
133
|
const projectName = resolveAndroidProjectName(seeds, {
|
|
117
134
|
fallbackDirectory: evidence.workingDirectory || roots[0] || "",
|
|
118
135
|
}) ?? "project";
|
|
119
|
-
options.onBeforeArchive?.();
|
|
136
|
+
options.onBeforeArchive?.(sessionId);
|
|
120
137
|
const archiveResult = await archiveSessionPackage({
|
|
121
138
|
sessionId,
|
|
122
139
|
trigger: triggerLabel,
|
|
@@ -151,8 +168,12 @@ export function createUploadManager(options) {
|
|
|
151
168
|
const evidence = getOrCreateEvidence(resolveSessionId(id));
|
|
152
169
|
if (event.type === "session.created") {
|
|
153
170
|
const directory = info.directory;
|
|
154
|
-
if (directory)
|
|
171
|
+
if (directory) {
|
|
155
172
|
evidence.workingDirectory = directory;
|
|
173
|
+
// 工作目录作为证据种子:即使全程无 write/bash 证据(如纯 task 转换会话),
|
|
174
|
+
// 触发上传时也能扫描工作区完成根推断与包名推断
|
|
175
|
+
evidence.collector.recordBash(directory, "");
|
|
176
|
+
}
|
|
156
177
|
}
|
|
157
178
|
else {
|
|
158
179
|
const agent = info.agent;
|
|
@@ -171,18 +192,19 @@ export function createUploadManager(options) {
|
|
|
171
192
|
if (!part || part.type !== "tool")
|
|
172
193
|
return;
|
|
173
194
|
const toolState = part.state;
|
|
174
|
-
|
|
195
|
+
const status = toolState?.status;
|
|
196
|
+
if (status !== "completed" && status !== "error")
|
|
175
197
|
return;
|
|
176
198
|
const tool = part.tool;
|
|
177
|
-
const input = toolState
|
|
199
|
+
const input = toolState?.input;
|
|
178
200
|
const callID = part.callID;
|
|
179
|
-
if (tool === "write" || tool === "edit") {
|
|
201
|
+
if (status === "completed" && (tool === "write" || tool === "edit")) {
|
|
180
202
|
const filePath = input?.filePath || input?.path;
|
|
181
203
|
if (filePath)
|
|
182
204
|
evidence.collector.recordFileEdit(filePath);
|
|
183
205
|
return;
|
|
184
206
|
}
|
|
185
|
-
if (tool === "bash") {
|
|
207
|
+
if (status === "completed" && tool === "bash") {
|
|
186
208
|
const command = input?.command || "";
|
|
187
209
|
const workdir = input?.workdir;
|
|
188
210
|
evidence.collector.recordBash(workdir, command);
|
|
@@ -198,6 +220,18 @@ export function createUploadManager(options) {
|
|
|
198
220
|
void triggerUpload(sessionId, scenario.label);
|
|
199
221
|
}
|
|
200
222
|
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// 工具名场景命中:根会话直接调用的工具进入终态(completed/error)即触发
|
|
226
|
+
// (task 委派返回即业务终态,失败也存终态;子代理内部的嵌套调用不触发)
|
|
227
|
+
const matched = matchToolScenarios(scenarios, tool ?? "", status ?? "", !childToParent.has(rawSessionId));
|
|
228
|
+
if (matched.length > 0 && (!callID || !toolTriggeredCalls.has(callID))) {
|
|
229
|
+
if (callID)
|
|
230
|
+
toolTriggeredCalls.add(callID);
|
|
231
|
+
for (const scenario of matched) {
|
|
232
|
+
log.info("[Metrics] 工具终态触发", { sessionId, tool, status, scenario: scenario.label });
|
|
233
|
+
void triggerUpload(sessionId, scenario.label);
|
|
234
|
+
}
|
|
201
235
|
}
|
|
202
236
|
return;
|
|
203
237
|
}
|
|
@@ -219,6 +253,40 @@ export function createUploadManager(options) {
|
|
|
219
253
|
}
|
|
220
254
|
await queue.drain(10_000);
|
|
221
255
|
}
|
|
256
|
+
/** 终态场景标签:事件含 session.deleted 的启用场景,缺省回退 session-end */
|
|
257
|
+
function terminalScenarioLabel() {
|
|
258
|
+
return scenarios.find((s) => s.enabled && s.events.includes("session.deleted"))?.label ?? "session-end";
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* 扫描 metrics 目录孤儿快照并补打 session-end 包:mtime 静默 [idleMs, maxAgeMs]、
|
|
262
|
+
* 无已传标记(metrics/.sent/<sessionId>)且不属于本进程活跃会话的快照视为孤儿。
|
|
263
|
+
* 快照原件永久保留为本地记录,已传标记才是防重复补传的依据。
|
|
264
|
+
*
|
|
265
|
+
* @param idleMs 静默下限(毫秒)
|
|
266
|
+
* @param maxAgeMs 年龄上限(毫秒)
|
|
267
|
+
*/
|
|
268
|
+
async function rescueOrphanSnapshots(idleMs, maxAgeMs) {
|
|
269
|
+
const triggerLabel = terminalScenarioLabel();
|
|
270
|
+
const orphans = await selectOrphanSnapshots(directories.metricsDir, idleMs, maxAgeMs, (sessionId) => sessions.has(sessionId));
|
|
271
|
+
for (const orphan of orphans) {
|
|
272
|
+
try {
|
|
273
|
+
// 工作目录作为证据种子:推断产物根与项目名(内存证据已随进程丢失)
|
|
274
|
+
const collector = new EvidenceCollector();
|
|
275
|
+
if (orphan.workingDirectory)
|
|
276
|
+
collector.recordBash(orphan.workingDirectory, "");
|
|
277
|
+
await triggerUpload(orphan.sessionId, triggerLabel, {
|
|
278
|
+
collector,
|
|
279
|
+
agentName: orphan.agentName,
|
|
280
|
+
workingDirectory: orphan.workingDirectory,
|
|
281
|
+
startedAt: orphan.startedAt,
|
|
282
|
+
});
|
|
283
|
+
log.info("[Metrics] 孤儿快照补传", { sessionId: orphan.sessionId, trigger: triggerLabel });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
log.warn("[Metrics] 孤儿快照补传失败", { sessionId: orphan.sessionId, error: String(err) });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
222
290
|
return { observe, dispose };
|
|
223
291
|
}
|
|
224
292
|
/** 应用 appendArtifacts 规则:marker 进探测扩展,显式路径按 matchBy 命中后作为直接根 */
|
|
@@ -467,3 +535,78 @@ function removeQuietly(filePath) {
|
|
|
467
535
|
// 删除失败只记日志层面忽略
|
|
468
536
|
}
|
|
469
537
|
}
|
|
538
|
+
// ─── 孤儿快照补传(§13 失败补偿链路) ─────────────────────────────────────────
|
|
539
|
+
/** 已传终态标记目录名(metrics/.sent/<sessionId>) */
|
|
540
|
+
const SENT_MARKER_DIR = ".sent";
|
|
541
|
+
/**
|
|
542
|
+
* 记录会话终态包已成功上云。
|
|
543
|
+
* 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
|
|
544
|
+
* "已传标记"才是孤儿扫描防重复上传的依据。
|
|
545
|
+
*/
|
|
546
|
+
export function markSessionUploaded(metricsDir, sessionId) {
|
|
547
|
+
try {
|
|
548
|
+
fs.mkdirSync(path.join(metricsDir, SENT_MARKER_DIR), { recursive: true });
|
|
549
|
+
fs.writeFileSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId), String(Date.now()));
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// 标记失败仅导致下次启动多一次补传判断,不阻塞上传链路
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** 会话终态包是否已成功上云 */
|
|
556
|
+
export function isSessionUploaded(metricsDir, sessionId) {
|
|
557
|
+
return fs.existsSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId));
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
|
|
561
|
+
* 上传成功后快照即被删除,"文件仍在"天然等价于"终态未传出"。
|
|
562
|
+
*
|
|
563
|
+
* @param metricsDir 快照目录
|
|
564
|
+
* @param idleMs 静默下限(毫秒)
|
|
565
|
+
* @param maxAgeMs 年龄上限(毫秒)
|
|
566
|
+
* @param isActive 活跃会话判定(IO 前后各查一次,防竞态)
|
|
567
|
+
* @returns 候选列表(无法解析的快照跳过)
|
|
568
|
+
*/
|
|
569
|
+
export async function selectOrphanSnapshots(metricsDir, idleMs, maxAgeMs, isActive) {
|
|
570
|
+
const result = [];
|
|
571
|
+
let entries;
|
|
572
|
+
try {
|
|
573
|
+
entries = await fs.promises.readdir(metricsDir);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
const now = Date.now();
|
|
579
|
+
for (const entry of entries) {
|
|
580
|
+
if (!entry.endsWith(".json"))
|
|
581
|
+
continue;
|
|
582
|
+
const sessionId = entry.slice(0, -".json".length);
|
|
583
|
+
if (isSessionUploaded(metricsDir, sessionId))
|
|
584
|
+
continue;
|
|
585
|
+
const filePath = path.join(metricsDir, entry);
|
|
586
|
+
try {
|
|
587
|
+
const stats = await fs.promises.stat(filePath);
|
|
588
|
+
const age = now - stats.mtimeMs;
|
|
589
|
+
if (age < idleMs || age > maxAgeMs)
|
|
590
|
+
continue;
|
|
591
|
+
if (isActive(sessionId))
|
|
592
|
+
continue;
|
|
593
|
+
const snapshot = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
|
|
594
|
+
if (isActive(sessionId))
|
|
595
|
+
continue;
|
|
596
|
+
const header = (snapshot.header ?? {});
|
|
597
|
+
result.push({
|
|
598
|
+
sessionId,
|
|
599
|
+
filePath,
|
|
600
|
+
workingDirectory: header.workingDirectory || "",
|
|
601
|
+
agentName: header.agent || "",
|
|
602
|
+
startedAt: (typeof snapshot.startTime === "number" && snapshot.startTime > 0 && snapshot.startTime) ||
|
|
603
|
+
Date.parse(header.startTime || "") ||
|
|
604
|
+
stats.mtimeMs,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
// 无法解析的快照跳过,不阻塞其余候选
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return result;
|
|
612
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-metrics-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc",
|
|
34
34
|
"typecheck": "tsc --noEmit",
|
|
35
35
|
"test": "tsx example/smoke.mts",
|
|
36
|
-
"prepublishOnly": "npm run build && npm test"
|
|
36
|
+
"prepublishOnly": "node -e \"if(require('fs').readFileSync('package.json','utf8').charCodeAt(0)===0xFEFF){console.error('package.json 含 BOM,禁止发布');process.exit(1)}\" && npm run build && npm test"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
39
|
"node": ">=18"
|