opencode-metrics-plugin 0.3.1 → 0.3.3
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 +17 -0
- package/README.md +14 -9
- package/dist/metrics/configLoader.d.ts +16 -7
- package/dist/metrics/configLoader.js +59 -20
- package/dist/metrics/runtime.js +5 -1
- package/dist/metrics/upload/agcUploader.d.ts +6 -0
- package/dist/metrics/upload/agcUploader.js +4 -3
- package/dist/metrics/upload/packageArchiver.d.ts +15 -0
- package/dist/metrics/upload/packageArchiver.js +90 -8
- package/dist/metrics/upload/scenarios.d.ts +16 -3
- package/dist/metrics/upload/scenarios.js +25 -3
- package/dist/metrics/upload/types.d.ts +22 -0
- package/dist/metrics/upload/types.js +1 -1
- package/dist/metrics/upload/uploadManager.d.ts +29 -2
- package/dist/metrics/upload/uploadManager.js +382 -22
- package/dist/metrics/upload/workspaceResolver.d.ts +2 -0
- package/dist/metrics/upload/workspaceResolver.js +100 -2
- package/dist/shared/log.d.ts +6 -1
- package/dist/shared/log.js +47 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.3(2026-09-12)
|
|
4
|
+
|
|
5
|
+
- session-end 三层保障,修复宿主进程突然死亡导致会话终态包永不产出的问题:
|
|
6
|
+
- **task 工具终态触发**:根会话的 task 工具 `completed` / `error` 即刻打包上传 session-end(子代理嵌套调用不触发,callID 去重);
|
|
7
|
+
- **打包前快照终稿化**:打包前先冲刷事件日志与引擎快照,包内快照必含触发事件本身(原 2s 冷却可能落后);
|
|
8
|
+
- **孤儿快照补传**:启动时扫描静默超 24h 且未上云终态的快照(`metrics/.sent/` 已传标记防重),按快照证据补打 session-end 包,超 7 天放弃(`upload.orphanRescue` 可调)。
|
|
9
|
+
- 场景系统支持 `tools` 工具名匹配器(内置 session-end 即 `tools: ["task"]`);`session.created` 即种子工作区证据,纯 task 会话也能完成根推断与包名推断。
|
|
10
|
+
|
|
11
|
+
## 0.3.2(2026-09-12)
|
|
12
|
+
|
|
13
|
+
- 产物根推断增强:迁移工作区标记修正为 `.migration`;安卓工程根容器化降级(向内提升鸿蒙子工程与 `.migration`/`.harmonyos` 工作区);会话时间窗内新建的相邻鸿蒙工程纳入,邻居老工程不混入;纯安卓工程不打包。
|
|
14
|
+
- 新增 hap 产物通道:build 目录内的 `.hap` 借道组装目录随包上传,不受 build 黑名单影响。
|
|
15
|
+
- 配置三级优先:项目 `.opencode/` → 用户目录 `~/.config/opencode/` → 内联;各级 json 优先于 jsonc,内容非法自动落下一级。
|
|
16
|
+
- 上传失败保留与启动补偿重传:失败包移入 `failed/` 与云端同构目录(可手动上传),下次启动自动重传,成功即清理;超期(默认 7 天)/超量(默认 1GB)自动清理(`upload.retryRescue`)。
|
|
17
|
+
- 运行日志按日期归档:写入 `<日志目录>/log/plugin/YYYYMMDD.log`(进程首写定死当天文件,跨午夜不切换),启动时自动清理超 30 天的日期日志;旧 `plugin.log` 保留不动。
|
|
18
|
+
- 云端路径日期目录改用包创建时间(createdAt),消除跨午夜漂移与重传漂移。
|
|
19
|
+
|
|
3
20
|
## 0.3.1(2026-09-10)
|
|
4
21
|
|
|
5
22
|
- 上传总开关 `upload.enabled` 缺省改为 true:插件装上即上传(包内 AGC 凭证),`false` 显式关闭。
|
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
- **开箱即用**:宿主只需在 `opencode.json` 加一行,无需任何配置
|
|
6
6
|
- **实时落盘**:会话进行中每 2s 增量写盘,中途退出不丢数据
|
|
7
7
|
- **开箱即传**:上传默认开启(包内 AGC 凭证),`git commit` / 会话结束自动打包上传,安卓源码工程自动剔除,`upload.enabled: false` 可关
|
|
8
|
-
- **独立配置文件**:`.opencode/opencode-metrics.json(.jsonc)`
|
|
8
|
+
- **独立配置文件**:`.opencode/opencode-metrics.json(.jsonc)` 支持注释,存在即完全接管内联配置;用户目录 `~/.config/opencode/` 可放全局兜底配置
|
|
9
|
+
- **失败不丢**:上传失败的包保留在 `failed/`(与云端路径同构,可手动上传),下次启动自动补偿重传
|
|
9
10
|
- **零打扰**:不修改工作区、不执行 clean
|
|
10
11
|
|
|
11
12
|
## 快速开始
|
|
@@ -56,11 +57,13 @@
|
|
|
56
57
|
|
|
57
58
|
默认开启(`upload.enabled: false` 可关):
|
|
58
59
|
|
|
59
|
-
- **触发场景**:bash 执行 `git commit`(label=`git
|
|
60
|
-
- **打包内容**:时点快照 JSON + 事件日志(恒含)+ 会话动过的工作区产物根(鸿蒙工程标记探测,≤3
|
|
60
|
+
- **触发场景**:bash 执行 `git commit`(label=`git`);会话结束/插件退出(label=`session-end`);**根会话 task 工具完成或失败即刻触发 session-end**(子代理嵌套调用不触发)。可自定义场景(命令正则、事件名或工具名)
|
|
61
|
+
- **打包内容**:时点快照 JSON + 事件日志(恒含)+ 会话动过的工作区产物根(鸿蒙工程标记探测,≤3 个)+ build 目录内的 `.hap` 产物包(借道组装目录,不受 build 黑名单影响)
|
|
62
|
+
- **根推断增强**:安卓工程根(如 gradle 外壳)不整根保留也不丢弃——向内扫描提升鸿蒙子工程(`ohos/` 等)与迁移工作区(`.migration` / `.harmonyos`);会话期间新建的相邻鸿蒙工程(目录 mtime 在会话开始之后)也会纳入,邻居老工程不混入;纯安卓工程(无任何鸿蒙子根)不打包
|
|
61
63
|
- **自动剔除**:黑名单目录(`oh_modules` / `build` / `node_modules` 等 8 项);安卓工程(根级 + 子树级,gradle 信号 ≥2 判定,可用 `excludeAndroidProjects: false` 关闭)
|
|
62
|
-
- **云端路径**:`YYYYMMDD/<安卓包名或项目名>-<sessionId>/<agent缩写>-<触发>-v<n>/<agent缩写>-<触发>-v<n>.tar.gz` 与同名 `.json
|
|
63
|
-
- **可靠性**:串行队列 + 指数退避重试(默认 5 次,4xx
|
|
64
|
+
- **云端路径**:`YYYYMMDD/<安卓包名或项目名>-<sessionId>/<agent缩写>-<触发>-v<n>/<agent缩写>-<触发>-v<n>.tar.gz` 与同名 `.json`(一个版本一个目录,双文件归拢;日期取包创建日)
|
|
65
|
+
- **可靠性**:串行队列 + 指数退避重试(默认 5 次,4xx 不重试);失败包移入暂存目录 `failed/` 下**与云端同构的目录**(含 meta 元数据,可整目录手动上传),下次启动自动补偿重传,重传成功即清理;保留超期(默认 7 天)或超量(默认 1GB,从最旧删)自动清理
|
|
66
|
+
- **进程死亡兜底**:宿主突然退出(session-end 从未触发)时,下次启动对静默超 24h 且未传终态的快照自动补打 session-end 包(`metrics/.sent/` 已传标记防重传,超 7 天放弃)
|
|
64
67
|
|
|
65
68
|
## 输出目录
|
|
66
69
|
|
|
@@ -68,7 +71,7 @@
|
|
|
68
71
|
|
|
69
72
|
| 平台 | 路径 |
|
|
70
73
|
|---|---|
|
|
71
|
-
| Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events
|
|
74
|
+
| Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events}`、`...\Log\log\plugin\YYYYMMDD.log`(运行日志按日期归档,保留 30 天)、`...\Data\upload-staging` |
|
|
72
75
|
| Linux | `~/.local/state/opencode-metrics-plugin/{metrics,events}` |
|
|
73
76
|
| macOS | `~/Library/Logs/opencode-metrics-plugin/{metrics,events}` |
|
|
74
77
|
|
|
@@ -83,7 +86,7 @@
|
|
|
83
86
|
| `enabled` | boolean | `true` | 总开关,`false` 时不记录任何会话 |
|
|
84
87
|
| `eventLogging` | boolean | `true` | 原始事件写盘;关闭后快照仍生成,但 `steps[].text/reasoning` 为空 |
|
|
85
88
|
| `checkpointFlushIntervalMs` | number | `2000` | 实时落盘冷却窗口(ms),`0` 关闭退回只在会话空闲时落盘 |
|
|
86
|
-
| `dirs` | object | env-paths | `metricsDir` / `eventsDir` / `logFile
|
|
89
|
+
| `dirs` | object | env-paths | `metricsDir` / `eventsDir` / `logFile`(基准,实际写 `<所在目录>/log/plugin/YYYYMMDD.log`)/ `uploadStagingDir` |
|
|
87
90
|
|
|
88
91
|
### upload 块
|
|
89
92
|
|
|
@@ -91,12 +94,14 @@
|
|
|
91
94
|
|---|---|---|---|
|
|
92
95
|
| `upload.enabled` | boolean | `true` | 上传总开关;`false` 显式关闭 |
|
|
93
96
|
| `upload.uploader.agcConfigPath` | string | 包内凭证 | `agc-apiclient.json` 绝对路径(client_id/secret/project_id/region/bucket) |
|
|
94
|
-
| `upload.scenarios` | object | 内置两场景 | 按 key 合并:`enabled:false` 禁用内置;自定义场景提供 `commands
|
|
97
|
+
| `upload.scenarios` | object | 内置两场景 | 按 key 合并:`enabled:false` 禁用内置;自定义场景提供 `commands`(正则)/ `events`(事件名)/ `tools`(工具名,终态触发)与 `label`(进文件名) |
|
|
95
98
|
| `upload.appendArtifacts` | array | — | 追加产物规则:`path`(绝对或相对会话工作目录)+ `matchBy`(`session-activity` 会话动过才纳入 / `existence` 存在即纳入)+ `dirMarkers`/`fileMarkers` |
|
|
96
99
|
| `upload.blacklist` | string[] | 默认 8 项 | 打包排除的目录/文件名(追加,不替换) |
|
|
97
100
|
| `upload.excludeAndroidProjects` | boolean | `true` | 剔除安卓工程(根级 + 根内子树) |
|
|
98
101
|
| `upload.maxArtifactBytes` | number | `524288000` | 单包体积上限(默认 500MB),超限该次标失败 |
|
|
99
102
|
| `upload.retry` | object | `{5, 1000}` | `maxAttempts` 最大尝试次数;`backoffMs` 退避基数(1s/4s/16s/64s/256s) |
|
|
103
|
+
| `upload.retryRescue` | object | `{true, 7, 1GB}` | 失败保留与补偿重传:`enabled` 开关;`retentionDays` 保留天数;`maxFailedBytes` failed/ 总量上限(超限从最旧删) |
|
|
104
|
+
| `upload.orphanRescue` | object | `{true, 24, 7}` | 孤儿快照补传:`enabled` 开关;`idleHours` 静默下限(小时);`maxAgeDays` 最大快照年龄(天),超龄放弃 |
|
|
100
105
|
|
|
101
106
|
## 可编程 API
|
|
102
107
|
|
|
@@ -122,7 +127,7 @@ rt.engine.dispose()
|
|
|
122
127
|
|
|
123
128
|
```bash
|
|
124
129
|
npm run typecheck # tsc --noEmit
|
|
125
|
-
npm test # 冒烟测试(example/smoke.mts,
|
|
130
|
+
npm test # 冒烟测试(example/smoke.mts,90 断言)
|
|
126
131
|
npm run build # 清空 dist 后 tsc
|
|
127
132
|
```
|
|
128
133
|
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import type { MetricsRuntimeOptions } from "./runtime.js";
|
|
2
2
|
/** 插件独立配置文件(项目 .opencode 目录下;.json 优先于 .jsonc) */
|
|
3
3
|
export declare const CONFIG_FILE_NAMES: readonly string[];
|
|
4
|
+
/** 插件全局配置文件(用户 home 下 .config/opencode;.json 优先于 .jsonc),项目级缺失时兜底 */
|
|
5
|
+
export declare const GLOBAL_CONFIG_FILE_NAMES: readonly string[];
|
|
6
|
+
/** 计算全局配置目录(用户 home 下 .config/opencode) */
|
|
7
|
+
export declare function globalConfigDirectory(): string;
|
|
4
8
|
export interface ResolvedPluginConfig {
|
|
5
9
|
/** 生效配置(独立文件内容或内联第二参) */
|
|
6
10
|
config: MetricsRuntimeOptions;
|
|
7
|
-
/** 配置来源:file =
|
|
8
|
-
source: "file" | "inline";
|
|
11
|
+
/** 配置来源:file = 项目独立文件,global = 全局兜底文件,inline = opencode.json 内联 */
|
|
12
|
+
source: "file" | "global" | "inline";
|
|
13
|
+
}
|
|
14
|
+
export interface ResolvePluginConfigOptions {
|
|
15
|
+
/** 全局配置目录(默认用户 home 下 .config/opencode,测试可注入) */
|
|
16
|
+
globalConfigDir?: string;
|
|
9
17
|
}
|
|
10
18
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
19
|
+
* 解析插件生效配置:优先级 项目 .opencode 独立文件 → 用户 home 全局文件 → 内联。
|
|
20
|
+
* 各级均按 .json → .jsonc 顺序查找,内容容忍 JSONC 注释与尾逗号;
|
|
21
|
+
* 命中且为合法对象即完全接管(opencode.json 内联第二参全部忽略)。
|
|
22
|
+
* 文件缺失静默落到下一级;内容非法记日志落下一级,不抛异常。
|
|
15
23
|
*
|
|
16
24
|
* @param projectDirectory opencode 启动的项目根(PluginInput.directory)
|
|
17
25
|
* @param inlineOptions opencode.json 插件内联第二参
|
|
26
|
+
* @param options 解析选项(globalConfigDir 可注入测试目录)
|
|
18
27
|
* @returns 生效配置与来源
|
|
19
28
|
*/
|
|
20
|
-
export declare function resolvePluginConfig(projectDirectory: string, inlineOptions?: MetricsRuntimeOptions): ResolvedPluginConfig;
|
|
29
|
+
export declare function resolvePluginConfig(projectDirectory: string, inlineOptions?: MetricsRuntimeOptions, options?: ResolvePluginConfigOptions): ResolvedPluginConfig;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { log } from "../shared/log.js";
|
|
4
5
|
/** 插件独立配置文件(项目 .opencode 目录下;.json 优先于 .jsonc) */
|
|
@@ -6,48 +7,86 @@ export const CONFIG_FILE_NAMES = [
|
|
|
6
7
|
".opencode/opencode-metrics.json",
|
|
7
8
|
".opencode/opencode-metrics.jsonc",
|
|
8
9
|
];
|
|
10
|
+
/** 插件全局配置文件(用户 home 下 .config/opencode;.json 优先于 .jsonc),项目级缺失时兜底 */
|
|
11
|
+
export const GLOBAL_CONFIG_FILE_NAMES = [
|
|
12
|
+
"opencode-metrics.json",
|
|
13
|
+
"opencode-metrics.jsonc",
|
|
14
|
+
];
|
|
15
|
+
/** 计算全局配置目录(用户 home 下 .config/opencode) */
|
|
16
|
+
export function globalConfigDirectory() {
|
|
17
|
+
return path.join(os.homedir(), ".config", "opencode");
|
|
18
|
+
}
|
|
9
19
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
20
|
+
* 解析插件生效配置:优先级 项目 .opencode 独立文件 → 用户 home 全局文件 → 内联。
|
|
21
|
+
* 各级均按 .json → .jsonc 顺序查找,内容容忍 JSONC 注释与尾逗号;
|
|
22
|
+
* 命中且为合法对象即完全接管(opencode.json 内联第二参全部忽略)。
|
|
23
|
+
* 文件缺失静默落到下一级;内容非法记日志落下一级,不抛异常。
|
|
14
24
|
*
|
|
15
25
|
* @param projectDirectory opencode 启动的项目根(PluginInput.directory)
|
|
16
26
|
* @param inlineOptions opencode.json 插件内联第二参
|
|
27
|
+
* @param options 解析选项(globalConfigDir 可注入测试目录)
|
|
17
28
|
* @returns 生效配置与来源
|
|
18
29
|
*/
|
|
19
|
-
export function resolvePluginConfig(projectDirectory, inlineOptions = {}) {
|
|
30
|
+
export function resolvePluginConfig(projectDirectory, inlineOptions = {}, options = {}) {
|
|
20
31
|
if (!projectDirectory)
|
|
21
32
|
return { config: inlineOptions, source: "inline" };
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
const globalDir = options.globalConfigDir ?? globalConfigDirectory();
|
|
34
|
+
const levels = [
|
|
35
|
+
{ label: "file", base: projectDirectory, names: CONFIG_FILE_NAMES },
|
|
36
|
+
{ label: "global", base: globalDir, names: GLOBAL_CONFIG_FILE_NAMES },
|
|
37
|
+
];
|
|
38
|
+
for (const level of levels) {
|
|
39
|
+
const found = readFirstExisting(level.base, level.names);
|
|
40
|
+
if (!found)
|
|
41
|
+
continue;
|
|
42
|
+
const parsed = parseConfigFile(found.configPath, found.raw);
|
|
43
|
+
if (!parsed)
|
|
44
|
+
continue;
|
|
45
|
+
log.info("[Metrics] 使用独立配置文件", { configPath: found.configPath, level: level.label });
|
|
46
|
+
return { config: parsed, source: level.label };
|
|
47
|
+
}
|
|
48
|
+
return { config: inlineOptions, source: "inline" };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 按顺序查找首个存在的配置文件。
|
|
52
|
+
*
|
|
53
|
+
* @param baseDirectory 基准目录
|
|
54
|
+
* @param names 相对文件名列表(json 优先于 jsonc)
|
|
55
|
+
* @returns 首个命中的文件路径与原始内容;全部缺失返回 undefined
|
|
56
|
+
*/
|
|
57
|
+
function readFirstExisting(baseDirectory, names) {
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const candidate = path.join(baseDirectory, name);
|
|
26
60
|
try {
|
|
27
|
-
raw
|
|
28
|
-
configPath = candidate;
|
|
29
|
-
break;
|
|
61
|
+
return { configPath: candidate, raw: fs.readFileSync(candidate, "utf-8") };
|
|
30
62
|
}
|
|
31
63
|
catch {
|
|
32
64
|
// 该名字的文件不存在,尝试下一个
|
|
33
65
|
}
|
|
34
66
|
}
|
|
35
|
-
|
|
36
|
-
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 解析并校验配置文件内容,非法时记日志返回 null。
|
|
71
|
+
*
|
|
72
|
+
* @param configPath 文件绝对路径(日志定位用)
|
|
73
|
+
* @param raw 原始文本
|
|
74
|
+
* @returns 合法配置对象;非法返回 null
|
|
75
|
+
*/
|
|
76
|
+
function parseConfigFile(configPath, raw) {
|
|
37
77
|
let parsed;
|
|
38
78
|
try {
|
|
39
79
|
parsed = JSON.parse(stripJsoncSyntax(raw));
|
|
40
80
|
}
|
|
41
81
|
catch (err) {
|
|
42
|
-
log.error("[Metrics]
|
|
43
|
-
return
|
|
82
|
+
log.error("[Metrics] 配置文件内容非法,回退下一级配置", { configPath, error: String(err) });
|
|
83
|
+
return null;
|
|
44
84
|
}
|
|
45
85
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
46
|
-
log.error("[Metrics]
|
|
47
|
-
return
|
|
86
|
+
log.error("[Metrics] 配置文件顶层必须是对象,回退下一级配置", { configPath });
|
|
87
|
+
return null;
|
|
48
88
|
}
|
|
49
|
-
|
|
50
|
-
return { config: parsed, source: "file" };
|
|
89
|
+
return parsed;
|
|
51
90
|
}
|
|
52
91
|
/**
|
|
53
92
|
* 去除 JSONC 语法(行注释 / 块注释 / 尾逗号),字符串内的 // 与 /* 原样保留。
|
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,
|
|
@@ -61,5 +61,11 @@ export declare function createAgcUploader(credentials: AgcCredentials): AgcUploa
|
|
|
61
61
|
* @returns 缩写后的安全路径段
|
|
62
62
|
*/
|
|
63
63
|
export declare function abbreviateAgentName(agentName: string): string;
|
|
64
|
+
/**
|
|
65
|
+
* 组装云端对象路径前缀(含版本目录,双文件归拢其内):
|
|
66
|
+
* YYYYMMDD/<projectName>-<sessionId>/<agent缩写>-<trigger>-v<n>/<agent缩写>-<trigger>-v<n>
|
|
67
|
+
* 日期目录取包创建日(createdAt):重传不漂移到重传当天,失败保留目录与之同构。
|
|
68
|
+
*/
|
|
69
|
+
export declare function composeRemoteBase(uploadPackage: UploadPackage): string;
|
|
64
70
|
/** 归档包云端对象完整路径(上传成功日志用) */
|
|
65
71
|
export declare function remoteArchiveKey(uploadPackage: UploadPackage): string;
|
|
@@ -213,11 +213,12 @@ export function abbreviateAgentName(agentName) {
|
|
|
213
213
|
/**
|
|
214
214
|
* 组装云端对象路径前缀(含版本目录,双文件归拢其内):
|
|
215
215
|
* YYYYMMDD/<projectName>-<sessionId>/<agent缩写>-<trigger>-v<n>/<agent缩写>-<trigger>-v<n>
|
|
216
|
+
* 日期目录取包创建日(createdAt):重传不漂移到重传当天,失败保留目录与之同构。
|
|
216
217
|
*/
|
|
217
|
-
function composeRemoteBase(uploadPackage) {
|
|
218
|
-
const
|
|
218
|
+
export function composeRemoteBase(uploadPackage) {
|
|
219
|
+
const created = new Date(uploadPackage.createdAt);
|
|
219
220
|
const pad = (value) => String(value).padStart(2, "0");
|
|
220
|
-
const dateDirectory = `${
|
|
221
|
+
const dateDirectory = `${created.getFullYear()}${pad(created.getMonth() + 1)}${pad(created.getDate())}`;
|
|
221
222
|
const fileBase = `${abbreviateAgentName(uploadPackage.agentName)}-${uploadPackage.trigger}-v${uploadPackage.sequence}`;
|
|
222
223
|
return `${dateDirectory}/${uploadPackage.projectName}-${uploadPackage.sessionId}/${fileBase}/${fileBase}`;
|
|
223
224
|
}
|
|
@@ -32,3 +32,18 @@ export interface ArchiveSessionOptions {
|
|
|
32
32
|
* @returns 成功返回产物包信息;失败返回原因
|
|
33
33
|
*/
|
|
34
34
|
export declare function archiveSessionPackage(options: ArchiveSessionOptions): Promise<ArchiveResult>;
|
|
35
|
+
/** 单个 hap 文件的收集描述:归属根与根内相对路径(分隔符统一 /) */
|
|
36
|
+
export interface HapFileRef {
|
|
37
|
+
root: string;
|
|
38
|
+
relativePath: string;
|
|
39
|
+
filePath: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 收集各产物根下 build 目录内的 .hap 产物包:普通区域跳过黑名单目录递归
|
|
43
|
+
* 下探,进入 build 目录后不再跳过(产物在 build 深层),仅收集 .hap 文件。
|
|
44
|
+
*
|
|
45
|
+
* @param roots 产物根绝对路径数组
|
|
46
|
+
* @param blacklist 额外黑名单(与默认合并)
|
|
47
|
+
* @returns hap 文件描述数组(root 归属 + 根内相对路径)
|
|
48
|
+
*/
|
|
49
|
+
export declare function collectHapFiles(roots: string[], blacklist?: string[]): HapFileRef[];
|
|
@@ -47,7 +47,22 @@ export async function archiveSessionPackage(options) {
|
|
|
47
47
|
for (const logFile of collectEventLogs(sessionId, eventsDir)) {
|
|
48
48
|
fs.copyFileSync(logFile, path.join(assemblyDir, "events", path.basename(logFile)));
|
|
49
49
|
}
|
|
50
|
-
const
|
|
50
|
+
const rootEntries = assignRootEntryNames(roots);
|
|
51
|
+
// hap 产物包独立收集:build 目录整体在 tar 黑名单中,hap 借道组装目录随包上传;
|
|
52
|
+
// 目标路径剥去 build 段(否则被 --exclude */build 连坐排除)
|
|
53
|
+
const hapFiles = collectHapFiles(roots, options.blacklist ?? []);
|
|
54
|
+
let hapBytes = 0;
|
|
55
|
+
for (const hap of hapFiles) {
|
|
56
|
+
const entry = rootEntries.find((candidate) => normalizeRootKey(candidate.root) === normalizeRootKey(hap.root));
|
|
57
|
+
if (!entry)
|
|
58
|
+
continue;
|
|
59
|
+
const targetRelative = hap.relativePath.replace(/(^|\/)build\//, "$1");
|
|
60
|
+
const target = path.join(assemblyDir, "hap", entry.entryName, ...targetRelative.split("/"));
|
|
61
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
62
|
+
fs.copyFileSync(hap.filePath, target);
|
|
63
|
+
hapBytes += fs.statSync(hap.filePath).size;
|
|
64
|
+
}
|
|
65
|
+
const { args, excludedAndroid } = buildTarArgs(tmpPath, assemblyDir, rootEntries, options.blacklist ?? [], options.excludeAndroidProjects !== false);
|
|
51
66
|
const tarError = await runTar(args);
|
|
52
67
|
if (tarError) {
|
|
53
68
|
return fail("tar-failed", tarError);
|
|
@@ -62,7 +77,7 @@ export async function archiveSessionPackage(options) {
|
|
|
62
77
|
// 快照单独一份(与包内时点一致)
|
|
63
78
|
fs.copyFileSync(path.join(assemblyDir, "snapshot", `${sessionId}.json`), snapshotPath);
|
|
64
79
|
const sha256 = await computeSha256(archivePath);
|
|
65
|
-
log.info("[Metrics] archived", { sessionId, trigger: triggerLabel, baseName, sizeBytes, excludedAndroid });
|
|
80
|
+
log.info("[Metrics] archived", { sessionId, trigger: triggerLabel, baseName, sizeBytes, excludedAndroid, hapCount: hapFiles.length, hapBytes });
|
|
66
81
|
const uploadPackage = {
|
|
67
82
|
sessionId,
|
|
68
83
|
trigger: triggerLabel,
|
|
@@ -79,6 +94,8 @@ export async function archiveSessionPackage(options) {
|
|
|
79
94
|
roots: [...roots],
|
|
80
95
|
createdAt: Date.now(),
|
|
81
96
|
};
|
|
97
|
+
// 元数据 sidecar:失败保留/启动补偿重建上传任务与云端路径的依据,终态时随双文件删除
|
|
98
|
+
fs.writeFileSync(path.join(stagingDir, `${baseName}.meta.json`), JSON.stringify(uploadPackage, null, 2));
|
|
82
99
|
return { ok: true, package: uploadPackage };
|
|
83
100
|
}
|
|
84
101
|
catch (err) {
|
|
@@ -93,13 +110,10 @@ export async function archiveSessionPackage(options) {
|
|
|
93
110
|
fs.rmSync(tmpPath, { force: true });
|
|
94
111
|
}
|
|
95
112
|
}
|
|
96
|
-
/**
|
|
97
|
-
function
|
|
98
|
-
const blacklist = [...DEFAULT_BLACKLIST, ...extraBlacklist];
|
|
99
|
-
const excludedAndroid = [];
|
|
100
|
-
// 根条目名先就位(重名加序号),安卓剔除以条目名锚定
|
|
113
|
+
/** 分配根的包内条目名:默认取根 basename,重名追加序号后缀(大小写不敏感去重) */
|
|
114
|
+
function assignRootEntryNames(roots) {
|
|
101
115
|
const usedNames = new Set();
|
|
102
|
-
|
|
116
|
+
return roots.map((root) => {
|
|
103
117
|
let entryName = path.basename(path.resolve(root)) || "workspace";
|
|
104
118
|
if (usedNames.has(entryName.toLowerCase())) {
|
|
105
119
|
entryName = `${entryName}-${usedNames.size + 1}`;
|
|
@@ -107,6 +121,74 @@ function buildTarArgs(output, assemblyDir, roots, extraBlacklist, excludeAndroid
|
|
|
107
121
|
usedNames.add(entryName.toLowerCase());
|
|
108
122
|
return { root, entryName };
|
|
109
123
|
});
|
|
124
|
+
}
|
|
125
|
+
/** 根路径归一化比较键(仅 Windows 大小写不敏感) */
|
|
126
|
+
function normalizeRootKey(target) {
|
|
127
|
+
const resolved = path.resolve(target);
|
|
128
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
129
|
+
}
|
|
130
|
+
/** hap 收集递归深度上限(build 目录内部层级防护) */
|
|
131
|
+
const HAP_SCAN_DEPTH = 10;
|
|
132
|
+
/**
|
|
133
|
+
* 收集各产物根下 build 目录内的 .hap 产物包:普通区域跳过黑名单目录递归
|
|
134
|
+
* 下探,进入 build 目录后不再跳过(产物在 build 深层),仅收集 .hap 文件。
|
|
135
|
+
*
|
|
136
|
+
* @param roots 产物根绝对路径数组
|
|
137
|
+
* @param blacklist 额外黑名单(与默认合并)
|
|
138
|
+
* @returns hap 文件描述数组(root 归属 + 根内相对路径)
|
|
139
|
+
*/
|
|
140
|
+
export function collectHapFiles(roots, blacklist = []) {
|
|
141
|
+
const skipped = new Set([...DEFAULT_BLACKLIST, ...blacklist].map((name) => name.toLowerCase()));
|
|
142
|
+
const results = [];
|
|
143
|
+
for (const root of roots) {
|
|
144
|
+
walkForHap(path.resolve(root), path.resolve(root), 0, skipped, false, results);
|
|
145
|
+
}
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 递归扫描 hap:普通区跳过黑名单目录,build 子树内全量下探收集 .hap 文件。
|
|
150
|
+
*
|
|
151
|
+
* @param current 当前扫描目录
|
|
152
|
+
* @param root 所属产物根(相对路径基准)
|
|
153
|
+
* @param depth 当前深度(防护上限)
|
|
154
|
+
* @param skipped 黑名单目录名集合(小写)
|
|
155
|
+
* @param insideBuild 当前是否已处于 build 子树内
|
|
156
|
+
* @param results 收集结果累加数组
|
|
157
|
+
*/
|
|
158
|
+
function walkForHap(current, root, depth, skipped, insideBuild, results) {
|
|
159
|
+
if (depth > HAP_SCAN_DEPTH)
|
|
160
|
+
return;
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
const child = path.join(current, entry.name);
|
|
170
|
+
if (entry.isDirectory()) {
|
|
171
|
+
const isBuild = entry.name.toLowerCase() === "build";
|
|
172
|
+
if (insideBuild || isBuild) {
|
|
173
|
+
walkForHap(child, root, depth + 1, skipped, true, results);
|
|
174
|
+
}
|
|
175
|
+
else if (!skipped.has(entry.name.toLowerCase())) {
|
|
176
|
+
walkForHap(child, root, depth + 1, skipped, false, results);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else if (insideBuild && entry.name.toLowerCase().endsWith(".hap")) {
|
|
180
|
+
results.push({
|
|
181
|
+
root,
|
|
182
|
+
relativePath: path.relative(root, child).split(path.sep).join("/"),
|
|
183
|
+
filePath: child,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** 组装 tar 参数:黑名单排除 + 嵌套安卓工程剔除 + 恒含项 + 各 workspace 根(按 basename 入包,重名加序号后缀);全部 --exclude 须在任何操作数之前(bsdtar 不解析操作数之后的选项) */
|
|
189
|
+
function buildTarArgs(output, assemblyDir, rootEntries, extraBlacklist, excludeAndroidProjects) {
|
|
190
|
+
const blacklist = [...DEFAULT_BLACKLIST, ...extraBlacklist];
|
|
191
|
+
const excludedAndroid = [];
|
|
110
192
|
const args = ["-czf", output];
|
|
111
193
|
for (const name of blacklist) {
|
|
112
194
|
// 裸名匹配末段(GNU tar),*/名匹配任意深度(bsdtar)
|
|
@@ -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 {
|
|
@@ -37,6 +39,24 @@ export interface UploadRetryConfig {
|
|
|
37
39
|
maxAttempts?: number;
|
|
38
40
|
backoffMs?: number;
|
|
39
41
|
}
|
|
42
|
+
/** 失败保留与启动补偿重传:失败包移入 failed/(与云端同构目录),启动时重传 */
|
|
43
|
+
export interface RetryRescueConfig {
|
|
44
|
+
/** 缺省 true:失败保留 + 启动补偿;false 时失败即删(旧行为) */
|
|
45
|
+
enabled?: boolean;
|
|
46
|
+
/** failed/ 内包保留天数,过期删除(缺省 7) */
|
|
47
|
+
retentionDays?: number;
|
|
48
|
+
/** failed/ 总量字节上限,超限从最旧删除(缺省 1GB) */
|
|
49
|
+
maxFailedBytes?: number;
|
|
50
|
+
}
|
|
51
|
+
/** 孤儿快照补传:宿主死亡导致终态触发从未发生时,启动后按快照年龄补打 session-end 包 */
|
|
52
|
+
export interface OrphanRescueConfig {
|
|
53
|
+
/** 缺省 true:启用孤儿快照补传 */
|
|
54
|
+
enabled?: boolean;
|
|
55
|
+
/** 快照静默多久视为孤儿(小时,缺省 24;活跃会话快照始终排除) */
|
|
56
|
+
idleHours?: number;
|
|
57
|
+
/** 快照最大补传年龄(天,缺省 7,超过视为陈旧放弃) */
|
|
58
|
+
maxAgeDays?: number;
|
|
59
|
+
}
|
|
40
60
|
/** opencode.json 的 upload 配置整块(§13.8) */
|
|
41
61
|
export interface UploadConfig {
|
|
42
62
|
/** 上传总开关,缺省 true(装上即上传);false 显式关闭 */
|
|
@@ -51,6 +71,8 @@ export interface UploadConfig {
|
|
|
51
71
|
/** 单包体积上限,超限该次标 failed */
|
|
52
72
|
maxArtifactBytes?: number;
|
|
53
73
|
retry?: UploadRetryConfig;
|
|
74
|
+
retryRescue?: RetryRescueConfig;
|
|
75
|
+
orphanRescue?: OrphanRescueConfig;
|
|
54
76
|
}
|
|
55
77
|
/** 暂存目录内的单个上传文件(归档包或快照) */
|
|
56
78
|
export interface UploadPackageFile {
|
|
@@ -16,7 +16,7 @@ export function sanitizeNameSegment(name, fallback = "project") {
|
|
|
16
16
|
}
|
|
17
17
|
// ─── 默认值(§13.4 / §13.5) ─────────────────────────────────────────────────
|
|
18
18
|
/** 目录内存在这些子目录 → 该目录是产物根 */
|
|
19
|
-
export const DEFAULT_DIR_MARKERS = [".harmonyos", ".
|
|
19
|
+
export const DEFAULT_DIR_MARKERS = [".harmonyos", ".migration"];
|
|
20
20
|
/** 目录内存在这些文件/目录 → 该目录是产物根 */
|
|
21
21
|
export const DEFAULT_FILE_MARKERS = [
|
|
22
22
|
"oh-package.json5",
|
|
@@ -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[]>;
|