opencode-metrics-plugin 0.3.1 → 0.3.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 +9 -0
- package/README.md +10 -7
- package/dist/metrics/configLoader.d.ts +16 -7
- package/dist/metrics/configLoader.js +59 -20
- 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/types.d.ts +10 -0
- package/dist/metrics/upload/types.js +1 -1
- package/dist/metrics/upload/uploadManager.js +229 -12
- 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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2(2026-09-12)
|
|
4
|
+
|
|
5
|
+
- 产物根推断增强:迁移工作区标记修正为 `.migration`;安卓工程根容器化降级(向内提升鸿蒙子工程与 `.migration`/`.harmonyos` 工作区);会话时间窗内新建的相邻鸿蒙工程纳入,邻居老工程不混入;纯安卓工程不打包。
|
|
6
|
+
- 新增 hap 产物通道:build 目录内的 `.hap` 借道组装目录随包上传,不受 build 黑名单影响。
|
|
7
|
+
- 配置三级优先:项目 `.opencode/` → 用户目录 `~/.config/opencode/` → 内联;各级 json 优先于 jsonc,内容非法自动落下一级。
|
|
8
|
+
- 上传失败保留与启动补偿重传:失败包移入 `failed/` 与云端同构目录(可手动上传),下次启动自动重传,成功即清理;超期(默认 7 天)/超量(默认 1GB)自动清理(`upload.retryRescue`)。
|
|
9
|
+
- 运行日志按日期归档:写入 `<日志目录>/log/plugin/YYYYMMDD.log`(进程首写定死当天文件,跨午夜不切换),启动时自动清理超 30 天的日期日志;旧 `plugin.log` 保留不动。
|
|
10
|
+
- 云端路径日期目录改用包创建时间(createdAt),消除跨午夜漂移与重传漂移。
|
|
11
|
+
|
|
3
12
|
## 0.3.1(2026-09-10)
|
|
4
13
|
|
|
5
14
|
- 上传总开关 `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
|
## 快速开始
|
|
@@ -57,10 +58,11 @@
|
|
|
57
58
|
默认开启(`upload.enabled: false` 可关):
|
|
58
59
|
|
|
59
60
|
- **触发场景**:bash 执行 `git commit`(label=`git`)、会话结束/插件退出(label=`session-end`);可自定义场景(命令正则或事件名)
|
|
60
|
-
- **打包内容**:时点快照 JSON + 事件日志(恒含)+ 会话动过的工作区产物根(鸿蒙工程标记探测,≤3
|
|
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,从最旧删)自动清理
|
|
64
66
|
|
|
65
67
|
## 输出目录
|
|
66
68
|
|
|
@@ -68,7 +70,7 @@
|
|
|
68
70
|
|
|
69
71
|
| 平台 | 路径 |
|
|
70
72
|
|---|---|
|
|
71
|
-
| Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events
|
|
73
|
+
| Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events}`、`...\Log\log\plugin\YYYYMMDD.log`(运行日志按日期归档,保留 30 天)、`...\Data\upload-staging` |
|
|
72
74
|
| Linux | `~/.local/state/opencode-metrics-plugin/{metrics,events}` |
|
|
73
75
|
| macOS | `~/Library/Logs/opencode-metrics-plugin/{metrics,events}` |
|
|
74
76
|
|
|
@@ -83,7 +85,7 @@
|
|
|
83
85
|
| `enabled` | boolean | `true` | 总开关,`false` 时不记录任何会话 |
|
|
84
86
|
| `eventLogging` | boolean | `true` | 原始事件写盘;关闭后快照仍生成,但 `steps[].text/reasoning` 为空 |
|
|
85
87
|
| `checkpointFlushIntervalMs` | number | `2000` | 实时落盘冷却窗口(ms),`0` 关闭退回只在会话空闲时落盘 |
|
|
86
|
-
| `dirs` | object | env-paths | `metricsDir` / `eventsDir` / `logFile
|
|
88
|
+
| `dirs` | object | env-paths | `metricsDir` / `eventsDir` / `logFile`(基准,实际写 `<所在目录>/log/plugin/YYYYMMDD.log`)/ `uploadStagingDir` |
|
|
87
89
|
|
|
88
90
|
### upload 块
|
|
89
91
|
|
|
@@ -97,6 +99,7 @@
|
|
|
97
99
|
| `upload.excludeAndroidProjects` | boolean | `true` | 剔除安卓工程(根级 + 根内子树) |
|
|
98
100
|
| `upload.maxArtifactBytes` | number | `524288000` | 单包体积上限(默认 500MB),超限该次标失败 |
|
|
99
101
|
| `upload.retry` | object | `{5, 1000}` | `maxAttempts` 最大尝试次数;`backoffMs` 退避基数(1s/4s/16s/64s/256s) |
|
|
102
|
+
| `upload.retryRescue` | object | `{true, 7, 1GB}` | 失败保留与补偿重传:`enabled` 开关;`retentionDays` 保留天数;`maxFailedBytes` failed/ 总量上限(超限从最旧删) |
|
|
100
103
|
|
|
101
104
|
## 可编程 API
|
|
102
105
|
|
|
@@ -122,7 +125,7 @@ rt.engine.dispose()
|
|
|
122
125
|
|
|
123
126
|
```bash
|
|
124
127
|
npm run typecheck # tsc --noEmit
|
|
125
|
-
npm test # 冒烟测试(example/smoke.mts,
|
|
128
|
+
npm test # 冒烟测试(example/smoke.mts,71 断言)
|
|
126
129
|
npm run build # 清空 dist 后 tsc
|
|
127
130
|
```
|
|
128
131
|
|
|
@@ -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 语法(行注释 / 块注释 / 尾逗号),字符串内的 // 与 /* 原样保留。
|
|
@@ -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)
|
|
@@ -37,6 +37,15 @@ export interface UploadRetryConfig {
|
|
|
37
37
|
maxAttempts?: number;
|
|
38
38
|
backoffMs?: number;
|
|
39
39
|
}
|
|
40
|
+
/** 失败保留与启动补偿重传:失败包移入 failed/(与云端同构目录),启动时重传 */
|
|
41
|
+
export interface RetryRescueConfig {
|
|
42
|
+
/** 缺省 true:失败保留 + 启动补偿;false 时失败即删(旧行为) */
|
|
43
|
+
enabled?: boolean;
|
|
44
|
+
/** failed/ 内包保留天数,过期删除(缺省 7) */
|
|
45
|
+
retentionDays?: number;
|
|
46
|
+
/** failed/ 总量字节上限,超限从最旧删除(缺省 1GB) */
|
|
47
|
+
maxFailedBytes?: number;
|
|
48
|
+
}
|
|
40
49
|
/** opencode.json 的 upload 配置整块(§13.8) */
|
|
41
50
|
export interface UploadConfig {
|
|
42
51
|
/** 上传总开关,缺省 true(装上即上传);false 显式关闭 */
|
|
@@ -51,6 +60,7 @@ export interface UploadConfig {
|
|
|
51
60
|
/** 单包体积上限,超限该次标 failed */
|
|
52
61
|
maxArtifactBytes?: number;
|
|
53
62
|
retry?: UploadRetryConfig;
|
|
63
|
+
retryRescue?: RetryRescueConfig;
|
|
54
64
|
}
|
|
55
65
|
/** 暂存目录内的单个上传文件(归档包或快照) */
|
|
56
66
|
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",
|
|
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { log } from "../../shared/log.js";
|
|
4
4
|
import { EvidenceCollector, isPathInside, resolveAndroidProjectName, resolveWorkspaceRoots, } from "./workspaceResolver.js";
|
|
5
5
|
import { archiveSessionPackage } from "./packageArchiver.js";
|
|
6
|
-
import { createAgcUploader, defaultAgcConfigPath, loadAgcCredentials, remoteArchiveKey } from "./agcUploader.js";
|
|
6
|
+
import { createAgcUploader, composeRemoteBase, defaultAgcConfigPath, loadAgcCredentials, remoteArchiveKey } from "./agcUploader.js";
|
|
7
7
|
import { createUploadQueue } from "./uploadQueue.js";
|
|
8
8
|
import { resolveScenarios } from "./scenarios.js";
|
|
9
9
|
import { MAX_ROOTS } from "./types.js";
|
|
@@ -25,27 +25,62 @@ 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
|
-
|
|
28
|
+
// 失败保留与启动补偿配置(默认开启)
|
|
29
|
+
const rescueEnabled = config.retryRescue?.enabled !== false;
|
|
30
|
+
const retentionMs = (config.retryRescue?.retentionDays ?? 7) * 24 * 60 * 60 * 1000;
|
|
31
|
+
const maxFailedBytes = config.retryRescue?.maxFailedBytes ?? 1024 * 1024 * 1024;
|
|
32
|
+
// 启动扫残留:崩溃遗留的完整三件套移入 failed/(待重传),孤立文件删除;
|
|
33
|
+
// failed/ 存量先做过期/超量清理再重新入队补偿上传
|
|
30
34
|
const queue = createUploadQueue({
|
|
31
35
|
worker: (uploadPackage) => uploader.uploadPackage(uploadPackage),
|
|
32
36
|
maxAttempts: config.retry?.maxAttempts,
|
|
33
37
|
backoffMs: config.retry?.backoffMs,
|
|
34
38
|
onSettled: (uploadPackage, ok, reason) => {
|
|
35
|
-
|
|
36
|
-
removeQuietly(uploadPackage.snapshot.filePath);
|
|
39
|
+
const metaPath = uploadPackage.archive.filePath.replace(/\.tar\.gz$/, ".meta.json");
|
|
37
40
|
if (ok) {
|
|
41
|
+
removeQuietly(uploadPackage.archive.filePath);
|
|
42
|
+
removeQuietly(uploadPackage.snapshot.filePath);
|
|
43
|
+
removeQuietly(metaPath);
|
|
44
|
+
// 来自 failed/ 的补偿重传:清掉可能残留的空目录
|
|
45
|
+
removeEmptyDirsUp(path.dirname(uploadPackage.archive.filePath), path.join(directories.uploadStagingDir, "failed"));
|
|
38
46
|
log.info("[Metrics] 上传完成", {
|
|
39
47
|
sessionId: uploadPackage.sessionId,
|
|
40
48
|
fileName: uploadPackage.archive.fileName,
|
|
41
49
|
remoteKey: remoteArchiveKey(uploadPackage),
|
|
42
50
|
});
|
|
51
|
+
return;
|
|
43
52
|
}
|
|
44
|
-
|
|
53
|
+
if (!rescueEnabled) {
|
|
54
|
+
removeQuietly(uploadPackage.archive.filePath);
|
|
55
|
+
removeQuietly(uploadPackage.snapshot.filePath);
|
|
56
|
+
removeQuietly(metaPath);
|
|
45
57
|
log.error("[Metrics] 上传失败(暂存已清理)", { sessionId: uploadPackage.sessionId, reason: reason ?? "" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const atStagingTop = path.resolve(path.dirname(uploadPackage.archive.filePath)) === path.resolve(directories.uploadStagingDir);
|
|
61
|
+
if (atStagingTop) {
|
|
62
|
+
const failedDir = movePackageIntoFailed(directories.uploadStagingDir, uploadPackage, metaPath);
|
|
63
|
+
log.error("[Metrics] 上传失败(已保留至 failed 目录,下次启动重传)", {
|
|
64
|
+
sessionId: uploadPackage.sessionId,
|
|
65
|
+
reason: reason ?? "",
|
|
66
|
+
failedDir,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// 补偿重传仍失败:原地保留,下次启动再试
|
|
71
|
+
log.error("[Metrics] 补偿重传仍失败(继续保留待下次)", { sessionId: uploadPackage.sessionId, reason: reason ?? "" });
|
|
46
72
|
}
|
|
47
73
|
},
|
|
48
74
|
});
|
|
75
|
+
const rescued = [
|
|
76
|
+
...sweepStagingDirectory(directories.uploadStagingDir, rescueEnabled),
|
|
77
|
+
...collectFailedPackages(directories.uploadStagingDir, retentionMs, maxFailedBytes),
|
|
78
|
+
];
|
|
79
|
+
if (rescued.length > 0) {
|
|
80
|
+
log.info("[Metrics] 启动补偿:历史失败/遗留包重新入队", { count: rescued.length });
|
|
81
|
+
for (const pkg of rescued)
|
|
82
|
+
queue.enqueue(pkg);
|
|
83
|
+
}
|
|
49
84
|
const sessions = new Map();
|
|
50
85
|
const childToParent = new Map();
|
|
51
86
|
const completedBashCalls = new Set();
|
|
@@ -55,7 +90,7 @@ export function createUploadManager(options) {
|
|
|
55
90
|
function getOrCreateEvidence(sessionId) {
|
|
56
91
|
let entry = sessions.get(sessionId);
|
|
57
92
|
if (!entry) {
|
|
58
|
-
entry = { collector: new EvidenceCollector(), agentName: "", workingDirectory: "" };
|
|
93
|
+
entry = { collector: new EvidenceCollector(), agentName: "", workingDirectory: "", startedAt: Date.now() };
|
|
59
94
|
sessions.set(sessionId, entry);
|
|
60
95
|
}
|
|
61
96
|
return entry;
|
|
@@ -67,13 +102,15 @@ export function createUploadManager(options) {
|
|
|
67
102
|
* @param triggerLabel 场景命名标签
|
|
68
103
|
*/
|
|
69
104
|
async function triggerUpload(sessionId, triggerLabel) {
|
|
70
|
-
const evidence = sessions.get(sessionId) ??
|
|
105
|
+
const evidence = sessions.get(sessionId) ??
|
|
106
|
+
{ collector: new EvidenceCollector(), agentName: "", workingDirectory: "", startedAt: Date.now() };
|
|
71
107
|
const seeds = evidence.collector.seeds();
|
|
72
108
|
const { directRoots, extraMarkers } = collectArtifactRules(seeds, evidence.workingDirectory, config.appendArtifacts ?? []);
|
|
73
109
|
const detectedRoots = resolveWorkspaceRoots(seeds, {
|
|
74
110
|
extraDirMarkers: extraMarkers.extraDirMarkers,
|
|
75
111
|
extraFileMarkers: extraMarkers.extraFileMarkers,
|
|
76
112
|
excludeAndroidProjects: config.excludeAndroidProjects,
|
|
113
|
+
sessionStartedAt: evidence.startedAt,
|
|
77
114
|
});
|
|
78
115
|
const roots = dedupePaths([...detectedRoots, ...directRoots]).slice(0, MAX_ROOTS);
|
|
79
116
|
const projectName = resolveAndroidProjectName(seeds, {
|
|
@@ -225,12 +262,41 @@ function dedupePaths(paths) {
|
|
|
225
262
|
}
|
|
226
263
|
return result;
|
|
227
264
|
}
|
|
228
|
-
/**
|
|
229
|
-
|
|
265
|
+
/**
|
|
266
|
+
* 启动清扫暂存目录顶层:崩溃遗留的完整三件套(tar.gz + json + meta.json)
|
|
267
|
+
* 移入 failed/ 同构目录并入队重传;孤立文件与组装目录直接删除。
|
|
268
|
+
*
|
|
269
|
+
* @param stagingDir 暂存目录
|
|
270
|
+
* @param rescueEnabled 失败保留开关(false 时完整三件套也直接删除,旧行为)
|
|
271
|
+
* @returns 可补偿重传的包列表(顶层遗留部分)
|
|
272
|
+
*/
|
|
273
|
+
function sweepStagingDirectory(stagingDir, rescueEnabled) {
|
|
274
|
+
const rescued = [];
|
|
230
275
|
try {
|
|
276
|
+
const entries = fs.readdirSync(stagingDir);
|
|
277
|
+
const names = new Set(entries);
|
|
278
|
+
const handled = new Set(["failed"]);
|
|
231
279
|
let removed = 0;
|
|
232
|
-
for (const entry of
|
|
233
|
-
if (
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
if (handled.has(entry))
|
|
282
|
+
continue;
|
|
283
|
+
if (entry.endsWith(".tar.gz")) {
|
|
284
|
+
const base = entry.slice(0, -".tar.gz".length);
|
|
285
|
+
const pkg = rescueEnabled ? loadIntactPackage(stagingDir, base, names) : undefined;
|
|
286
|
+
if (pkg) {
|
|
287
|
+
movePackageIntoFailed(stagingDir, pkg, path.join(stagingDir, `${base}.meta.json`));
|
|
288
|
+
rescued.push(pkg);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
fs.rmSync(path.join(stagingDir, entry), { force: true });
|
|
292
|
+
removed++;
|
|
293
|
+
}
|
|
294
|
+
handled.add(entry);
|
|
295
|
+
handled.add(`${base}.json`);
|
|
296
|
+
handled.add(`${base}.meta.json`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (entry.endsWith(".json") || entry.startsWith(".assembly-")) {
|
|
234
300
|
fs.rmSync(path.join(stagingDir, entry), { recursive: true, force: true });
|
|
235
301
|
removed++;
|
|
236
302
|
}
|
|
@@ -241,6 +307,157 @@ function sweepStagingDirectory(stagingDir) {
|
|
|
241
307
|
catch {
|
|
242
308
|
// 目录不存在:无需处理
|
|
243
309
|
}
|
|
310
|
+
return rescued;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* 读取暂存顶层完整三件套并重建上传包(meta 反序列化 + 双文件在位校验)。
|
|
314
|
+
*
|
|
315
|
+
* @param stagingDir 暂存目录
|
|
316
|
+
* @param base 包基名(不含后缀)
|
|
317
|
+
* @param names 顶层文件名集合
|
|
318
|
+
* @returns 完整时返回包;任何缺失/损坏返回 undefined
|
|
319
|
+
*/
|
|
320
|
+
function loadIntactPackage(stagingDir, base, names) {
|
|
321
|
+
const jsonName = `${base}.json`;
|
|
322
|
+
const metaName = `${base}.meta.json`;
|
|
323
|
+
if (!names.has(jsonName) || !names.has(metaName))
|
|
324
|
+
return undefined;
|
|
325
|
+
try {
|
|
326
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(stagingDir, metaName), "utf-8"));
|
|
327
|
+
if (pkg?.archive?.fileName !== `${base}.tar.gz` || pkg?.snapshot?.fileName !== jsonName)
|
|
328
|
+
return undefined;
|
|
329
|
+
pkg.archive.filePath = path.join(stagingDir, pkg.archive.fileName);
|
|
330
|
+
pkg.snapshot.filePath = path.join(stagingDir, pkg.snapshot.fileName);
|
|
331
|
+
if (!fs.existsSync(pkg.archive.filePath) || !fs.existsSync(pkg.snapshot.filePath))
|
|
332
|
+
return undefined;
|
|
333
|
+
return pkg;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* 计算包在 failed/ 下的落位目录(与云端对象路径同构,便于查找与手动上传):
|
|
341
|
+
* failed/YYYYMMDD/<projectName>-<sessionId>/<agent缩写>-<trigger>-v<n>/
|
|
342
|
+
*/
|
|
343
|
+
function failedDirFor(stagingDir, pkg) {
|
|
344
|
+
const segments = composeRemoteBase(pkg).split("/");
|
|
345
|
+
segments.pop();
|
|
346
|
+
return path.join(stagingDir, "failed", ...segments);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* 把包的双文件与 meta 移入 failed/ 同构目录(同盘 rename)。
|
|
350
|
+
*
|
|
351
|
+
* @param stagingDir 暂存目录
|
|
352
|
+
* @param pkg 上传包
|
|
353
|
+
* @param metaPath meta 文件当前路径
|
|
354
|
+
* @returns 落位目录
|
|
355
|
+
*/
|
|
356
|
+
function movePackageIntoFailed(stagingDir, pkg, metaPath) {
|
|
357
|
+
const targetDir = failedDirFor(stagingDir, pkg);
|
|
358
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
359
|
+
pkg.archive.filePath = renameInto(pkg.archive.filePath, path.join(targetDir, pkg.archive.fileName));
|
|
360
|
+
pkg.snapshot.filePath = renameInto(pkg.snapshot.filePath, path.join(targetDir, pkg.snapshot.fileName));
|
|
361
|
+
renameInto(metaPath, path.join(targetDir, path.basename(metaPath)));
|
|
362
|
+
return targetDir;
|
|
363
|
+
}
|
|
364
|
+
/** rename 到目标(已存在则先删);返回目标路径 */
|
|
365
|
+
function renameInto(source, target) {
|
|
366
|
+
try {
|
|
367
|
+
fs.rmSync(target, { force: true });
|
|
368
|
+
fs.renameSync(source, target);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// 移动失败:文件留在原处,下次启动清扫兜底
|
|
372
|
+
}
|
|
373
|
+
return target;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* 收集 failed/ 存量包:先做过期(retentionMs)与总量(maxFailedBytes,从最旧删起)
|
|
377
|
+
* 清理,再把完整包重建(filePath 重指 failed/ 内)返回入队。
|
|
378
|
+
*
|
|
379
|
+
* @param stagingDir 暂存目录
|
|
380
|
+
* @param retentionMs 保留时长(毫秒)
|
|
381
|
+
* @param maxFailedBytes 总量字节上限
|
|
382
|
+
* @returns 可重传的包列表
|
|
383
|
+
*/
|
|
384
|
+
function collectFailedPackages(stagingDir, retentionMs, maxFailedBytes) {
|
|
385
|
+
const failedRoot = path.join(stagingDir, "failed");
|
|
386
|
+
if (!fs.existsSync(failedRoot))
|
|
387
|
+
return [];
|
|
388
|
+
const packages = [];
|
|
389
|
+
const walk = (dir, depth) => {
|
|
390
|
+
if (depth > 4)
|
|
391
|
+
return;
|
|
392
|
+
let entries;
|
|
393
|
+
try {
|
|
394
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
for (const entry of entries) {
|
|
400
|
+
const child = path.join(dir, entry.name);
|
|
401
|
+
if (entry.isDirectory()) {
|
|
402
|
+
walk(child, depth + 1);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!entry.name.endsWith(".meta.json"))
|
|
406
|
+
continue;
|
|
407
|
+
try {
|
|
408
|
+
const pkg = JSON.parse(fs.readFileSync(child, "utf-8"));
|
|
409
|
+
const pkgDir = path.dirname(child);
|
|
410
|
+
pkg.archive.filePath = path.join(pkgDir, pkg.archive.fileName);
|
|
411
|
+
pkg.snapshot.filePath = path.join(pkgDir, pkg.snapshot.fileName);
|
|
412
|
+
if (!fs.existsSync(pkg.archive.filePath) || !fs.existsSync(pkg.snapshot.filePath)) {
|
|
413
|
+
fs.rmSync(pkgDir, { recursive: true, force: true });
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
pkg.archive.sizeBytes = fs.statSync(pkg.archive.filePath).size;
|
|
417
|
+
pkg.snapshot.sizeBytes = fs.statSync(pkg.snapshot.filePath).size;
|
|
418
|
+
packages.push({ pkg, dir: pkgDir, size: pkg.archive.sizeBytes + pkg.snapshot.sizeBytes, createdAt: pkg.createdAt ?? 0 });
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// meta 损坏:整目录删除,防止永久残留
|
|
422
|
+
fs.rmSync(path.dirname(child), { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
walk(failedRoot, 0);
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
const survivors = [];
|
|
429
|
+
let totalBytes = 0;
|
|
430
|
+
let removed = 0;
|
|
431
|
+
for (const item of packages.sort((a, b) => a.createdAt - b.createdAt)) {
|
|
432
|
+
if (now - item.createdAt > retentionMs || totalBytes + item.size > maxFailedBytes) {
|
|
433
|
+
fs.rmSync(item.dir, { recursive: true, force: true });
|
|
434
|
+
removeEmptyDirsUp(path.dirname(item.dir), failedRoot);
|
|
435
|
+
removed++;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
totalBytes += item.size;
|
|
439
|
+
survivors.push(item);
|
|
440
|
+
}
|
|
441
|
+
if (removed > 0)
|
|
442
|
+
log.info("[Metrics] failed 目录清理(过期/超量)", { stagingDir, removed });
|
|
443
|
+
removeEmptyDirsUp(failedRoot, failedRoot);
|
|
444
|
+
return survivors.map((item) => item.pkg);
|
|
445
|
+
}
|
|
446
|
+
/** 自下而上删除空目录(直到 root,含 root 本身可删) */
|
|
447
|
+
function removeEmptyDirsUp(directory, root) {
|
|
448
|
+
let current = path.resolve(directory);
|
|
449
|
+
const top = path.resolve(root);
|
|
450
|
+
while (current.startsWith(top)) {
|
|
451
|
+
try {
|
|
452
|
+
fs.rmdirSync(current);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (current === top)
|
|
458
|
+
return;
|
|
459
|
+
current = path.dirname(current);
|
|
460
|
+
}
|
|
244
461
|
}
|
|
245
462
|
function removeQuietly(filePath) {
|
|
246
463
|
try {
|
|
@@ -30,6 +30,8 @@ export interface ResolveRootsOptions {
|
|
|
30
30
|
extraFileMarkers?: string[];
|
|
31
31
|
/** 剔除安卓工程根(缺省 true) */
|
|
32
32
|
excludeAndroidProjects?: boolean;
|
|
33
|
+
/** 会话开始时间戳(毫秒);提供时启用兄弟鸿蒙工程探测(mtime 时间窗防邻居混入) */
|
|
34
|
+
sessionStartedAt?: number;
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
35
37
|
* 判断目录是否为安卓工程:统计 gradle 构建信号(settings.gradle、gradlew、
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { DEFAULT_DIR_MARKERS, DEFAULT_FILE_MARKERS, MAX_PROBE_DEPTH, MAX_ROOTS, sanitizeNameSegment, } from "./types.js";
|
|
3
|
+
import { DEFAULT_BLACKLIST, DEFAULT_DIR_MARKERS, DEFAULT_FILE_MARKERS, MAX_PROBE_DEPTH, MAX_ROOTS, sanitizeNameSegment, } from "./types.js";
|
|
4
4
|
/**
|
|
5
5
|
* 会话证据收集器:旁路记录 write/edit 的文件路径与 bash 的工作目录、命令路径线索。
|
|
6
6
|
* 触发上传时用这些路径推断产物根。
|
|
@@ -87,6 +87,86 @@ export function looksLikeAndroidProject(directory) {
|
|
|
87
87
|
function hasHarmonyProjectMarkers(directory) {
|
|
88
88
|
return ["oh-package.json5", "build-profile.json5", "hvigorfile.ts", "AppScope"].some((marker) => fs.existsSync(path.join(directory, marker)));
|
|
89
89
|
}
|
|
90
|
+
/** 安卓根内可直接提升为产物根的工作区目录名(droid2hmos 的 .migration 与转换工具的 .harmonyos) */
|
|
91
|
+
const HARMONY_WORKSPACE_DIRS = [".migration", ".harmonyos"];
|
|
92
|
+
/** 容器化降级扫描深度:安卓根内向内找鸿蒙子根的最大层数 */
|
|
93
|
+
const NESTED_SCAN_DEPTH = 2;
|
|
94
|
+
/**
|
|
95
|
+
* 在安卓工程根内部收集鸿蒙产物根:BFS 扫描(最多 NESTED_SCAN_DEPTH 层,
|
|
96
|
+
* 跳过黑名单目录),子目录含鸿蒙工程标记、或为 .migration/.harmonyos
|
|
97
|
+
* 工作区目录时提升为产物根;命中后不再深入该子树。
|
|
98
|
+
*
|
|
99
|
+
* @param androidRoot 安卓工程根绝对路径
|
|
100
|
+
* @returns 提升的鸿蒙产物根数组(空数组 = 纯安卓工程,整体丢弃)
|
|
101
|
+
*/
|
|
102
|
+
function findNestedHarmonyRoots(androidRoot) {
|
|
103
|
+
const found = [];
|
|
104
|
+
const skipped = new Set(DEFAULT_BLACKLIST.map((name) => name.toLowerCase()));
|
|
105
|
+
const queue = [{ directory: androidRoot, depth: 0 }];
|
|
106
|
+
while (queue.length > 0) {
|
|
107
|
+
const { directory, depth } = queue.shift();
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isDirectory() || skipped.has(entry.name.toLowerCase()))
|
|
117
|
+
continue;
|
|
118
|
+
const child = path.join(directory, entry.name);
|
|
119
|
+
if (HARMONY_WORKSPACE_DIRS.includes(entry.name) || hasHarmonyProjectMarkers(child)) {
|
|
120
|
+
found.push(child);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (depth + 1 < NESTED_SCAN_DEPTH) {
|
|
124
|
+
queue.push({ directory: child, depth: depth + 1 });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 在安卓工程根的同级目录探测兄弟鸿蒙工程:扫描父目录一级,子目录含鸿蒙
|
|
132
|
+
* 工程标记、且目录 mtime 不早于会话开始时间(本会话内被写入,防邻居旧
|
|
133
|
+
* 工程混入)时提升为产物根。
|
|
134
|
+
*
|
|
135
|
+
* @param androidRoot 安卓工程根绝对路径
|
|
136
|
+
* @param sessionStartedAt 会话开始时间戳(毫秒)
|
|
137
|
+
* @returns 提升的兄弟鸿蒙产物根数组
|
|
138
|
+
*/
|
|
139
|
+
function findSiblingHarmonyRoots(androidRoot, sessionStartedAt) {
|
|
140
|
+
const parent = path.dirname(path.resolve(androidRoot));
|
|
141
|
+
const selfKey = normalizeKey(androidRoot);
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = fs.readdirSync(parent, { withFileTypes: true });
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const found = [];
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
// 隐藏目录(.git/.idea 等)不作为工程候选
|
|
152
|
+
if (!entry.isDirectory() || entry.name.startsWith("."))
|
|
153
|
+
continue;
|
|
154
|
+
const sibling = path.join(parent, entry.name);
|
|
155
|
+
if (normalizeKey(sibling) === selfKey)
|
|
156
|
+
continue;
|
|
157
|
+
if (!hasHarmonyProjectMarkers(sibling))
|
|
158
|
+
continue;
|
|
159
|
+
try {
|
|
160
|
+
if (fs.statSync(sibling).mtimeMs < sessionStartedAt)
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
found.push(sibling);
|
|
167
|
+
}
|
|
168
|
+
return found;
|
|
169
|
+
}
|
|
90
170
|
/**
|
|
91
171
|
* 从证据路径推断产物根:自每条证据所在目录向上逐级探测标记(最多 5 层),
|
|
92
172
|
* 目录内存在任一标记即记为根;安卓工程根(自身无鸿蒙工程标记)先剔除,
|
|
@@ -111,6 +191,11 @@ export function resolveWorkspaceRoots(seeds, options) {
|
|
|
111
191
|
if (markers.some((marker) => fs.existsSync(path.join(current, marker)))) {
|
|
112
192
|
found.push(current);
|
|
113
193
|
}
|
|
194
|
+
else if (looksLikeAndroidProject(current)) {
|
|
195
|
+
// 无标记的安卓根也纳入候选:由剔除分支做容器化降级
|
|
196
|
+
// (向内提升鸿蒙子根/工作区、按时间窗探测兄弟工程)
|
|
197
|
+
found.push(current);
|
|
198
|
+
}
|
|
114
199
|
const parent = path.dirname(current);
|
|
115
200
|
if (parent === current)
|
|
116
201
|
break;
|
|
@@ -119,7 +204,20 @@ export function resolveWorkspaceRoots(seeds, options) {
|
|
|
119
204
|
}
|
|
120
205
|
let candidates = dedupeRoots(found);
|
|
121
206
|
if (options?.excludeAndroidProjects !== false) {
|
|
122
|
-
|
|
207
|
+
const kept = [];
|
|
208
|
+
for (const root of candidates) {
|
|
209
|
+
if (!looksLikeAndroidProject(root) || hasHarmonyProjectMarkers(root)) {
|
|
210
|
+
kept.push(root);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// 安卓根容器化降级:不整体丢弃,向内收集鸿蒙子根/工作区,
|
|
214
|
+
// 并按会话时间窗向外探测兄弟鸿蒙工程(防邻居旧工程混入)
|
|
215
|
+
kept.push(...findNestedHarmonyRoots(root));
|
|
216
|
+
if (options?.sessionStartedAt !== undefined) {
|
|
217
|
+
kept.push(...findSiblingHarmonyRoots(root, options.sessionStartedAt));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
candidates = dedupeRoots(kept);
|
|
123
221
|
}
|
|
124
222
|
return limitRoots(mergeNestedRoots(candidates));
|
|
125
223
|
}
|
package/dist/shared/log.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
2
|
export declare function setLogLevel(level: LogLevel): void;
|
|
3
|
-
/**
|
|
3
|
+
/** 设置运行日志基准文件;实际写入 <所在目录>/log/plugin/YYYYMMDD.log;传 null 关闭文件输出(仅 console)。 */
|
|
4
4
|
export declare function setLogFile(file: string | null): void;
|
|
5
|
+
/**
|
|
6
|
+
* 清理过期运行日志:只删基准同目录 log/plugin/ 下严格 YYYYMMDD.log
|
|
7
|
+
* 且 mtime 超过保留天数的文件;其余任何文件一概不碰。幂等,可反复调用。
|
|
8
|
+
*/
|
|
9
|
+
export declare function cleanupOldLogFiles(): void;
|
|
5
10
|
export declare function flushLogs(): void;
|
|
6
11
|
export declare const log: {
|
|
7
12
|
debug(...parts: unknown[]): void;
|
package/dist/shared/log.js
CHANGED
|
@@ -8,10 +8,52 @@ export function setLogLevel(level) {
|
|
|
8
8
|
}
|
|
9
9
|
const DEFAULT_LOG_FILE = path.join(envPaths('opencode-metrics-plugin', { suffix: '' }).log, 'plugin.log');
|
|
10
10
|
let logFile = DEFAULT_LOG_FILE;
|
|
11
|
-
/**
|
|
11
|
+
/** 设置运行日志基准文件;实际写入 <所在目录>/log/plugin/YYYYMMDD.log;传 null 关闭文件输出(仅 console)。 */
|
|
12
12
|
export function setLogFile(file) {
|
|
13
13
|
logFile = file;
|
|
14
|
+
resolvedFile = null;
|
|
14
15
|
}
|
|
16
|
+
// ─── 按日期归档(0.3.2):进程首写时按当天定死目标文件,跨午夜不切换 ───
|
|
17
|
+
/** 运行日志保留天数,超期文件启动清理时删除 */
|
|
18
|
+
const MAX_AGE_DAYS = 30;
|
|
19
|
+
/** 缓存的目标文件(基准 + log/plugin/日期 推导);null 表示尚未解析或未启用 */
|
|
20
|
+
let resolvedFile = null;
|
|
21
|
+
/**
|
|
22
|
+
* 解析当天目标日志文件:<基准所在目录>/log/plugin/YYYYMMDD.log。
|
|
23
|
+
* 进程生命周期内锁定(跨午夜不切换),下次进程自然写新日期文件。
|
|
24
|
+
*/
|
|
25
|
+
function resolveTargetFile(base) {
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
28
|
+
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
29
|
+
return path.join(path.dirname(base), 'log', 'plugin', `${date}.log`);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 清理过期运行日志:只删基准同目录 log/plugin/ 下严格 YYYYMMDD.log
|
|
33
|
+
* 且 mtime 超过保留天数的文件;其余任何文件一概不碰。幂等,可反复调用。
|
|
34
|
+
*/
|
|
35
|
+
export function cleanupOldLogFiles() {
|
|
36
|
+
if (logFile == null)
|
|
37
|
+
return;
|
|
38
|
+
try {
|
|
39
|
+
const dir = path.join(path.dirname(logFile), 'log', 'plugin');
|
|
40
|
+
if (!fs.existsSync(dir))
|
|
41
|
+
return;
|
|
42
|
+
const maxAgeMs = MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
43
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
44
|
+
if (!/^\d{8}\.log$/.test(entry))
|
|
45
|
+
continue;
|
|
46
|
+
const full = path.join(dir, entry);
|
|
47
|
+
try {
|
|
48
|
+
if (Date.now() - fs.statSync(full).mtimeMs > maxAgeMs)
|
|
49
|
+
fs.unlinkSync(full);
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
setImmediate(cleanupOldLogFiles);
|
|
15
57
|
let buffer = [];
|
|
16
58
|
let flushTimer = null;
|
|
17
59
|
const FLUSH_INTERVAL_MS = 500;
|
|
@@ -20,8 +62,10 @@ function writeLine(line) {
|
|
|
20
62
|
if (logFile == null)
|
|
21
63
|
return;
|
|
22
64
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
65
|
+
if (resolvedFile == null)
|
|
66
|
+
resolvedFile = resolveTargetFile(logFile);
|
|
67
|
+
fs.mkdirSync(path.dirname(resolvedFile), { recursive: true });
|
|
68
|
+
fs.appendFileSync(resolvedFile, line + '\n');
|
|
25
69
|
}
|
|
26
70
|
catch {
|
|
27
71
|
// 文件不可写时回退 console,避免静默吞日志
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-metrics-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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",
|