opencode-metrics-plugin 0.3.0 → 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 CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## 0.3.0(未发布)
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
+
12
+ ## 0.3.1(2026-09-10)
13
+
14
+ - 上传总开关 `upload.enabled` 缺省改为 true:插件装上即上传(包内 AGC 凭证),`false` 显式关闭。
15
+
16
+ ## 0.3.0(2026-09-10)
4
17
 
5
18
  - 新增会话产物上传:`git commit` / 会话结束自动打包(快照 + 事件日志 + 工作区产物)上传 AGC 云存储,安卓工程自动剔除,失败指数退避重试。
6
19
  - 新增 checkpoint 实时落盘(默认 2s 冷却),会话中途退出不再丢指标。
package/README.md CHANGED
@@ -4,9 +4,10 @@
4
4
 
5
5
  - **开箱即用**:宿主只需在 `opencode.json` 加一行,无需任何配置
6
6
  - **实时落盘**:会话进行中每 2s 增量写盘,中途退出不丢数据
7
- - **产物上传**(可选):`git commit` 或会话结束时自动打包上传,安卓源码工程自动剔除
8
- - **独立配置文件**:`.opencode/opencode-metrics.json(.jsonc)` 支持注释,存在即完全接管内联配置
9
- - **零打扰**:不修改工作区、不执行 clean;上传默认关闭,不配置零开销
7
+ - **开箱即传**:上传默认开启(包内 AGC 凭证),`git commit` / 会话结束自动打包上传,安卓源码工程自动剔除,`upload.enabled: false` 可关
8
+ - **独立配置文件**:`.opencode/opencode-metrics.json(.jsonc)` 支持注释,存在即完全接管内联配置;用户目录 `~/.config/opencode/` 可放全局兜底配置
9
+ - **失败不丢**:上传失败的包保留在 `failed/`(与云端路径同构,可手动上传),下次启动自动补偿重传
10
+ - **零打扰**:不修改工作区、不执行 clean
10
11
 
11
12
  ## 快速开始
12
13
 
@@ -18,23 +19,19 @@
18
19
  { "plugin": ["opencode-metrics-plugin"] }
19
20
  ```
20
21
 
21
- 仅此一步即完成指标采集,无需其他配置。
22
+ 仅此一步即完成指标采集与产物上传——上传默认开启,使用包内 AGC 凭证,无需其他配置。
22
23
 
23
24
  ### 第 2 步(可选):独立配置文件
24
25
 
25
- 在项目 `.opencode/` 目录下创建 `opencode-metrics.json` 或 `opencode-metrics.jsonc`(支持注释与尾逗号)。文件存在时**完全接管**内联第二参,推荐用这种方式配置:
26
+ 需要自定义上传行为或关闭上传时,在项目 `.opencode/` 目录下创建 `opencode-metrics.json` 或 `opencode-metrics.jsonc`(支持注释与尾逗号)。文件存在时**完全接管**内联第二参:
26
27
 
27
28
  ```jsonc
28
- // .opencode/opencode-metrics.jsonc —— 最小上传配置
29
+ // .opencode/opencode-metrics.jsonc —— 关闭上传示例
29
30
  {
30
- "upload": {
31
- "enabled": true
32
- }
31
+ "upload": { "enabled": false }
33
32
  }
34
33
  ```
35
34
 
36
- 上传开启后默认使用包内 AGC 凭证,无需额外配置。
37
-
38
35
  ### 第 3 步(可选):验证
39
36
 
40
37
  - 指标:会话进行几轮后,查看输出目录(见下)出现 `<sessionId>.json` 与 `events/<sessionId>.log`
@@ -58,13 +55,14 @@
58
55
 
59
56
  ## 产物上传
60
57
 
61
- 开启 `upload.enabled` 后:
58
+ 默认开启(`upload.enabled: false` 可关):
62
59
 
63
60
  - **触发场景**:bash 执行 `git commit`(label=`git`)、会话结束/插件退出(label=`session-end`);可自定义场景(命令正则或事件名)
64
- - **打包内容**:时点快照 JSON + 事件日志(恒含)+ 会话动过的工作区产物根(鸿蒙工程标记探测,≤3 个)
61
+ - **打包内容**:时点快照 JSON + 事件日志(恒含)+ 会话动过的工作区产物根(鸿蒙工程标记探测,≤3 个)+ build 目录内的 `.hap` 产物包(借道组装目录,不受 build 黑名单影响)
62
+ - **根推断增强**:安卓工程根(如 gradle 外壳)不整根保留也不丢弃——向内扫描提升鸿蒙子工程(`ohos/` 等)与迁移工作区(`.migration` / `.harmonyos`);会话期间新建的相邻鸿蒙工程(目录 mtime 在会话开始之后)也会纳入,邻居老工程不混入;纯安卓工程(无任何鸿蒙子根)不打包
65
63
  - **自动剔除**:黑名单目录(`oh_modules` / `build` / `node_modules` 等 8 项);安卓工程(根级 + 子树级,gradle 信号 ≥2 判定,可用 `excludeAndroidProjects: false` 关闭)
66
- - **云端路径**:`YYYYMMDD/<安卓包名或项目名>-<sessionId>/<agent缩写>-<触发>-v<n>/<agent缩写>-<触发>-v<n>.tar.gz` 与同名 `.json`(一个版本一个目录,双文件归拢)
67
- - **可靠性**:串行队列 + 指数退避重试(默认 5 次,4xx 不重试);上传成功/终态失败后自动清理本地暂存
64
+ - **云端路径**:`YYYYMMDD/<安卓包名或项目名>-<sessionId>/<agent缩写>-<触发>-v<n>/<agent缩写>-<触发>-v<n>.tar.gz` 与同名 `.json`(一个版本一个目录,双文件归拢;日期取包创建日)
65
+ - **可靠性**:串行队列 + 指数退避重试(默认 5 次,4xx 不重试);失败包移入暂存目录 `failed/` 下**与云端同构的目录**(含 meta 元数据,可整目录手动上传),下次启动自动补偿重传,重传成功即清理;保留超期(默认 7 天)或超量(默认 1GB,从最旧删)自动清理
68
66
 
69
67
  ## 输出目录
70
68
 
@@ -72,7 +70,7 @@
72
70
 
73
71
  | 平台 | 路径 |
74
72
  |---|---|
75
- | Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events,plugin.log}`、`...\Data\upload-staging` |
73
+ | Windows | `%LOCALAPPDATA%\opencode-metrics-plugin\Log\{metrics,events}`、`...\Log\log\plugin\YYYYMMDD.log`(运行日志按日期归档,保留 30 天)、`...\Data\upload-staging` |
76
74
  | Linux | `~/.local/state/opencode-metrics-plugin/{metrics,events}` |
77
75
  | macOS | `~/Library/Logs/opencode-metrics-plugin/{metrics,events}` |
78
76
 
@@ -87,13 +85,13 @@
87
85
  | `enabled` | boolean | `true` | 总开关,`false` 时不记录任何会话 |
88
86
  | `eventLogging` | boolean | `true` | 原始事件写盘;关闭后快照仍生成,但 `steps[].text/reasoning` 为空 |
89
87
  | `checkpointFlushIntervalMs` | number | `2000` | 实时落盘冷却窗口(ms),`0` 关闭退回只在会话空闲时落盘 |
90
- | `dirs` | object | env-paths | `metricsDir` / `eventsDir` / `logFile` / `uploadStagingDir` |
88
+ | `dirs` | object | env-paths | `metricsDir` / `eventsDir` / `logFile`(基准,实际写 `<所在目录>/log/plugin/YYYYMMDD.log`)/ `uploadStagingDir` |
91
89
 
92
90
  ### upload 块
93
91
 
94
92
  | 选项 | 类型 | 默认 | 说明 |
95
93
  |---|---|---|---|
96
- | `upload.enabled` | boolean | `false` | 上传总开关;不配置本块零开销 |
94
+ | `upload.enabled` | boolean | `true` | 上传总开关;`false` 显式关闭 |
97
95
  | `upload.uploader.agcConfigPath` | string | 包内凭证 | `agc-apiclient.json` 绝对路径(client_id/secret/project_id/region/bucket) |
98
96
  | `upload.scenarios` | object | 内置两场景 | 按 key 合并:`enabled:false` 禁用内置;自定义场景提供 `commands`(正则)或 `events`(事件名)与 `label`(进文件名) |
99
97
  | `upload.appendArtifacts` | array | — | 追加产物规则:`path`(绝对或相对会话工作目录)+ `matchBy`(`session-activity` 会话动过才纳入 / `existence` 存在即纳入)+ `dirMarkers`/`fileMarkers` |
@@ -101,6 +99,7 @@
101
99
  | `upload.excludeAndroidProjects` | boolean | `true` | 剔除安卓工程(根级 + 根内子树) |
102
100
  | `upload.maxArtifactBytes` | number | `524288000` | 单包体积上限(默认 500MB),超限该次标失败 |
103
101
  | `upload.retry` | object | `{5, 1000}` | `maxAttempts` 最大尝试次数;`backoffMs` 退避基数(1s/4s/16s/64s/256s) |
102
+ | `upload.retryRescue` | object | `{true, 7, 1GB}` | 失败保留与补偿重传:`enabled` 开关;`retentionDays` 保留天数;`maxFailedBytes` failed/ 总量上限(超限从最旧删) |
104
103
 
105
104
  ## 可编程 API
106
105
 
@@ -126,7 +125,7 @@ rt.engine.dispose()
126
125
 
127
126
  ```bash
128
127
  npm run typecheck # tsc --noEmit
129
- npm test # 冒烟测试(example/smoke.mts,53 断言)
128
+ npm test # 冒烟测试(example/smoke.mts,71 断言)
130
129
  npm run build # 清空 dist 后 tsc
131
130
  ```
132
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 = 独立文件接管,inline = opencode.json 内联 */
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
- * 解析插件生效配置:项目 .opencode 目录下存在 opencode-metrics.json / .jsonc
12
- * (按此顺序查找,内容容忍 JSONC 注释与尾逗号)且为合法对象时完全接管
13
- * opencode.json 内联第二参全部忽略),否则回退内联。
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
- * 解析插件生效配置:项目 .opencode 目录下存在 opencode-metrics.json / .jsonc
11
- * (按此顺序查找,内容容忍 JSONC 注释与尾逗号)且为合法对象时完全接管
12
- * opencode.json 内联第二参全部忽略),否则回退内联。
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
- let configPath = "";
23
- let raw;
24
- for (const name of CONFIG_FILE_NAMES) {
25
- const candidate = path.join(projectDirectory, name);
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 = fs.readFileSync(candidate, "utf-8");
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
- if (raw === undefined)
36
- return { config: inlineOptions, source: "inline" };
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] 配置文件内容非法,回退内联配置", { configPath, error: String(err) });
43
- return { config: inlineOptions, source: "inline" };
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] 配置文件顶层必须是对象,回退内联配置", { configPath });
47
- return { config: inlineOptions, source: "inline" };
86
+ log.error("[Metrics] 配置文件顶层必须是对象,回退下一级配置", { configPath });
87
+ return null;
48
88
  }
49
- log.info("[Metrics] 使用独立配置文件", { configPath });
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 now = new Date();
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 = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
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 { args, excludedAndroid } = buildTarArgs(tmpPath, assemblyDir, roots, options.blacklist ?? [], options.excludeAndroidProjects !== false);
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
- /** 组装 tar 参数:黑名单排除 + 嵌套安卓工程剔除 + 恒含项 + 各 workspace 根(按 basename 入包,重名加序号后缀);全部 --exclude 须在任何操作数之前(bsdtar 不解析操作数之后的选项) */
97
- function buildTarArgs(output, assemblyDir, roots, extraBlacklist, excludeAndroidProjects) {
98
- const blacklist = [...DEFAULT_BLACKLIST, ...extraBlacklist];
99
- const excludedAndroid = [];
100
- // 根条目名先就位(重名加序号),安卓剔除以条目名锚定
113
+ /** 分配根的包内条目名:默认取根 basename,重名追加序号后缀(大小写不敏感去重) */
114
+ function assignRootEntryNames(roots) {
101
115
  const usedNames = new Set();
102
- const rootEntries = roots.map((root) => {
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,9 +37,18 @@ 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
- /** 上传总开关,缺省 false(行为与 0.2.x 零差异) */
51
+ /** 上传总开关,缺省 true(装上即上传);false 显式关闭 */
43
52
  enabled?: boolean;
44
53
  uploader?: UploaderConfig;
45
54
  scenarios?: Record<string, ScenarioConfig>;
@@ -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", ".mr"];
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";
@@ -15,7 +15,7 @@ import { MAX_ROOTS } from "./types.js";
15
15
  */
16
16
  export function createUploadManager(options) {
17
17
  const config = options.config ?? {};
18
- if (config.enabled !== true)
18
+ if (config.enabled === false)
19
19
  return null;
20
20
  const credentialsResult = loadAgcCredentials(config.uploader?.agcConfigPath ?? defaultAgcConfigPath());
21
21
  if (!credentialsResult.ok) {
@@ -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
- sweepStagingDirectory(directories.uploadStagingDir);
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
- removeQuietly(uploadPackage.archive.filePath);
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
- else {
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) ?? { collector: new EvidenceCollector(), agentName: "", workingDirectory: "" };
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
- function sweepStagingDirectory(stagingDir) {
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 fs.readdirSync(stagingDir)) {
233
- if (entry.endsWith(".tar.gz") || entry.endsWith(".json") || entry.startsWith(".assembly-")) {
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
- candidates = candidates.filter((root) => !looksLikeAndroidProject(root) || hasHarmonyProjectMarkers(root));
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
  }
@@ -1,7 +1,12 @@
1
1
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
2
  export declare function setLogLevel(level: LogLevel): void;
3
- /** 设置运行日志落盘文件;传 null 关闭文件输出(仅 console)。 */
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;
@@ -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
- /** 设置运行日志落盘文件;传 null 关闭文件输出(仅 console)。 */
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
- fs.mkdirSync(path.dirname(logFile), { recursive: true });
24
- fs.appendFileSync(logFile, line + '\n');
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.0",
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",