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
|
@@ -3,9 +3,9 @@ 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
|
-
import { resolveScenarios } from "./scenarios.js";
|
|
8
|
+
import { resolveScenarios, matchToolScenarios } from "./scenarios.js";
|
|
9
9
|
import { MAX_ROOTS } from "./types.js";
|
|
10
10
|
/**
|
|
11
11
|
* 创建上传管理器:串联证据 → 根推断 → 打包 → 队列 → AGC 上传,
|
|
@@ -25,37 +25,87 @@ 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 terminalLabels = new Set(scenarios
|
|
30
|
+
.filter((s) => s.enabled && (s.events.includes("session.deleted") || s.toolNames.length > 0))
|
|
31
|
+
.map((s) => s.label));
|
|
32
|
+
// 失败保留与启动补偿配置(默认开启)
|
|
33
|
+
const rescueEnabled = config.retryRescue?.enabled !== false;
|
|
34
|
+
const retentionMs = (config.retryRescue?.retentionDays ?? 7) * 24 * 60 * 60 * 1000;
|
|
35
|
+
const maxFailedBytes = config.retryRescue?.maxFailedBytes ?? 1024 * 1024 * 1024;
|
|
36
|
+
// 启动扫残留:崩溃遗留的完整三件套移入 failed/(待重传),孤立文件删除;
|
|
37
|
+
// failed/ 存量先做过期/超量清理再重新入队补偿上传
|
|
30
38
|
const queue = createUploadQueue({
|
|
31
39
|
worker: (uploadPackage) => uploader.uploadPackage(uploadPackage),
|
|
32
40
|
maxAttempts: config.retry?.maxAttempts,
|
|
33
41
|
backoffMs: config.retry?.backoffMs,
|
|
34
42
|
onSettled: (uploadPackage, ok, reason) => {
|
|
35
|
-
|
|
36
|
-
removeQuietly(uploadPackage.snapshot.filePath);
|
|
43
|
+
const metaPath = uploadPackage.archive.filePath.replace(/\.tar\.gz$/, ".meta.json");
|
|
37
44
|
if (ok) {
|
|
45
|
+
removeQuietly(uploadPackage.archive.filePath);
|
|
46
|
+
removeQuietly(uploadPackage.snapshot.filePath);
|
|
47
|
+
removeQuietly(metaPath);
|
|
48
|
+
// 终态包上云成功:写已传标记,孤儿扫描据此跳过该会话
|
|
49
|
+
if (terminalLabels.has(uploadPackage.trigger)) {
|
|
50
|
+
markSessionUploaded(directories.metricsDir, uploadPackage.sessionId);
|
|
51
|
+
}
|
|
52
|
+
// 来自 failed/ 的补偿重传:清掉可能残留的空目录
|
|
53
|
+
removeEmptyDirsUp(path.dirname(uploadPackage.archive.filePath), path.join(directories.uploadStagingDir, "failed"));
|
|
38
54
|
log.info("[Metrics] 上传完成", {
|
|
39
55
|
sessionId: uploadPackage.sessionId,
|
|
40
56
|
fileName: uploadPackage.archive.fileName,
|
|
41
57
|
remoteKey: remoteArchiveKey(uploadPackage),
|
|
42
58
|
});
|
|
59
|
+
return;
|
|
43
60
|
}
|
|
44
|
-
|
|
61
|
+
if (!rescueEnabled) {
|
|
62
|
+
removeQuietly(uploadPackage.archive.filePath);
|
|
63
|
+
removeQuietly(uploadPackage.snapshot.filePath);
|
|
64
|
+
removeQuietly(metaPath);
|
|
45
65
|
log.error("[Metrics] 上传失败(暂存已清理)", { sessionId: uploadPackage.sessionId, reason: reason ?? "" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const atStagingTop = path.resolve(path.dirname(uploadPackage.archive.filePath)) === path.resolve(directories.uploadStagingDir);
|
|
69
|
+
if (atStagingTop) {
|
|
70
|
+
const failedDir = movePackageIntoFailed(directories.uploadStagingDir, uploadPackage, metaPath);
|
|
71
|
+
log.error("[Metrics] 上传失败(已保留至 failed 目录,下次启动重传)", {
|
|
72
|
+
sessionId: uploadPackage.sessionId,
|
|
73
|
+
reason: reason ?? "",
|
|
74
|
+
failedDir,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// 补偿重传仍失败:原地保留,下次启动再试
|
|
79
|
+
log.error("[Metrics] 补偿重传仍失败(继续保留待下次)", { sessionId: uploadPackage.sessionId, reason: reason ?? "" });
|
|
46
80
|
}
|
|
47
81
|
},
|
|
48
82
|
});
|
|
83
|
+
const rescued = [
|
|
84
|
+
...sweepStagingDirectory(directories.uploadStagingDir, rescueEnabled),
|
|
85
|
+
...collectFailedPackages(directories.uploadStagingDir, retentionMs, maxFailedBytes),
|
|
86
|
+
];
|
|
87
|
+
if (rescued.length > 0) {
|
|
88
|
+
log.info("[Metrics] 启动补偿:历史失败/遗留包重新入队", { count: rescued.length });
|
|
89
|
+
for (const pkg of rescued)
|
|
90
|
+
queue.enqueue(pkg);
|
|
91
|
+
}
|
|
92
|
+
// 孤儿快照补传:宿主死亡导致终态触发从未发生时,按快照年龄补打 session-end 包
|
|
93
|
+
if (config.orphanRescue?.enabled !== false) {
|
|
94
|
+
const idleMs = (config.orphanRescue?.idleHours ?? 24) * 3_600_000;
|
|
95
|
+
const maxAgeMs = (config.orphanRescue?.maxAgeDays ?? 7) * 86_400_000;
|
|
96
|
+
void rescueOrphanSnapshots(idleMs, maxAgeMs);
|
|
97
|
+
}
|
|
49
98
|
const sessions = new Map();
|
|
50
99
|
const childToParent = new Map();
|
|
51
100
|
const completedBashCalls = new Set();
|
|
101
|
+
const toolTriggeredCalls = new Set();
|
|
52
102
|
function resolveSessionId(rawId) {
|
|
53
103
|
return childToParent.get(rawId) ?? rawId;
|
|
54
104
|
}
|
|
55
105
|
function getOrCreateEvidence(sessionId) {
|
|
56
106
|
let entry = sessions.get(sessionId);
|
|
57
107
|
if (!entry) {
|
|
58
|
-
entry = { collector: new EvidenceCollector(), agentName: "", workingDirectory: "" };
|
|
108
|
+
entry = { collector: new EvidenceCollector(), agentName: "", workingDirectory: "", startedAt: Date.now() };
|
|
59
109
|
sessions.set(sessionId, entry);
|
|
60
110
|
}
|
|
61
111
|
return entry;
|
|
@@ -63,23 +113,27 @@ export function createUploadManager(options) {
|
|
|
63
113
|
/**
|
|
64
114
|
* 触发一次上传:根推断 + 包名推断 + 打包入队。
|
|
65
115
|
*
|
|
66
|
-
* @param sessionId
|
|
67
|
-
* @param triggerLabel
|
|
116
|
+
* @param sessionId 会话 id
|
|
117
|
+
* @param triggerLabel 场景命名标签
|
|
118
|
+
* @param evidenceOverride 证据覆盖(孤儿补传时由快照构造,代替内存证据)
|
|
68
119
|
*/
|
|
69
|
-
async function triggerUpload(sessionId, triggerLabel) {
|
|
70
|
-
const evidence =
|
|
120
|
+
async function triggerUpload(sessionId, triggerLabel, evidenceOverride) {
|
|
121
|
+
const evidence = evidenceOverride ??
|
|
122
|
+
sessions.get(sessionId) ??
|
|
123
|
+
{ collector: new EvidenceCollector(), agentName: "", workingDirectory: "", startedAt: Date.now() };
|
|
71
124
|
const seeds = evidence.collector.seeds();
|
|
72
125
|
const { directRoots, extraMarkers } = collectArtifactRules(seeds, evidence.workingDirectory, config.appendArtifacts ?? []);
|
|
73
126
|
const detectedRoots = resolveWorkspaceRoots(seeds, {
|
|
74
127
|
extraDirMarkers: extraMarkers.extraDirMarkers,
|
|
75
128
|
extraFileMarkers: extraMarkers.extraFileMarkers,
|
|
76
129
|
excludeAndroidProjects: config.excludeAndroidProjects,
|
|
130
|
+
sessionStartedAt: evidence.startedAt,
|
|
77
131
|
});
|
|
78
132
|
const roots = dedupePaths([...detectedRoots, ...directRoots]).slice(0, MAX_ROOTS);
|
|
79
133
|
const projectName = resolveAndroidProjectName(seeds, {
|
|
80
134
|
fallbackDirectory: evidence.workingDirectory || roots[0] || "",
|
|
81
135
|
}) ?? "project";
|
|
82
|
-
options.onBeforeArchive?.();
|
|
136
|
+
options.onBeforeArchive?.(sessionId);
|
|
83
137
|
const archiveResult = await archiveSessionPackage({
|
|
84
138
|
sessionId,
|
|
85
139
|
trigger: triggerLabel,
|
|
@@ -114,8 +168,12 @@ export function createUploadManager(options) {
|
|
|
114
168
|
const evidence = getOrCreateEvidence(resolveSessionId(id));
|
|
115
169
|
if (event.type === "session.created") {
|
|
116
170
|
const directory = info.directory;
|
|
117
|
-
if (directory)
|
|
171
|
+
if (directory) {
|
|
118
172
|
evidence.workingDirectory = directory;
|
|
173
|
+
// 工作目录作为证据种子:即使全程无 write/bash 证据(如纯 task 转换会话),
|
|
174
|
+
// 触发上传时也能扫描工作区完成根推断与包名推断
|
|
175
|
+
evidence.collector.recordBash(directory, "");
|
|
176
|
+
}
|
|
119
177
|
}
|
|
120
178
|
else {
|
|
121
179
|
const agent = info.agent;
|
|
@@ -134,18 +192,19 @@ export function createUploadManager(options) {
|
|
|
134
192
|
if (!part || part.type !== "tool")
|
|
135
193
|
return;
|
|
136
194
|
const toolState = part.state;
|
|
137
|
-
|
|
195
|
+
const status = toolState?.status;
|
|
196
|
+
if (status !== "completed" && status !== "error")
|
|
138
197
|
return;
|
|
139
198
|
const tool = part.tool;
|
|
140
|
-
const input = toolState
|
|
199
|
+
const input = toolState?.input;
|
|
141
200
|
const callID = part.callID;
|
|
142
|
-
if (tool === "write" || tool === "edit") {
|
|
201
|
+
if (status === "completed" && (tool === "write" || tool === "edit")) {
|
|
143
202
|
const filePath = input?.filePath || input?.path;
|
|
144
203
|
if (filePath)
|
|
145
204
|
evidence.collector.recordFileEdit(filePath);
|
|
146
205
|
return;
|
|
147
206
|
}
|
|
148
|
-
if (tool === "bash") {
|
|
207
|
+
if (status === "completed" && tool === "bash") {
|
|
149
208
|
const command = input?.command || "";
|
|
150
209
|
const workdir = input?.workdir;
|
|
151
210
|
evidence.collector.recordBash(workdir, command);
|
|
@@ -161,6 +220,18 @@ export function createUploadManager(options) {
|
|
|
161
220
|
void triggerUpload(sessionId, scenario.label);
|
|
162
221
|
}
|
|
163
222
|
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// 工具名场景命中:根会话直接调用的工具进入终态(completed/error)即触发
|
|
226
|
+
// (task 委派返回即业务终态,失败也存终态;子代理内部的嵌套调用不触发)
|
|
227
|
+
const matched = matchToolScenarios(scenarios, tool ?? "", status ?? "", !childToParent.has(rawSessionId));
|
|
228
|
+
if (matched.length > 0 && (!callID || !toolTriggeredCalls.has(callID))) {
|
|
229
|
+
if (callID)
|
|
230
|
+
toolTriggeredCalls.add(callID);
|
|
231
|
+
for (const scenario of matched) {
|
|
232
|
+
log.info("[Metrics] 工具终态触发", { sessionId, tool, status, scenario: scenario.label });
|
|
233
|
+
void triggerUpload(sessionId, scenario.label);
|
|
234
|
+
}
|
|
164
235
|
}
|
|
165
236
|
return;
|
|
166
237
|
}
|
|
@@ -182,6 +253,40 @@ export function createUploadManager(options) {
|
|
|
182
253
|
}
|
|
183
254
|
await queue.drain(10_000);
|
|
184
255
|
}
|
|
256
|
+
/** 终态场景标签:事件含 session.deleted 的启用场景,缺省回退 session-end */
|
|
257
|
+
function terminalScenarioLabel() {
|
|
258
|
+
return scenarios.find((s) => s.enabled && s.events.includes("session.deleted"))?.label ?? "session-end";
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* 扫描 metrics 目录孤儿快照并补打 session-end 包:mtime 静默 [idleMs, maxAgeMs]
|
|
262
|
+
* 且不属于本进程活跃会话的快照视为孤儿。上传成功后快照即被删除,
|
|
263
|
+
* "文件仍在"天然等价于"终态未传出",无需额外标记。
|
|
264
|
+
*
|
|
265
|
+
* @param idleMs 静默下限(毫秒)
|
|
266
|
+
* @param maxAgeMs 年龄上限(毫秒)
|
|
267
|
+
*/
|
|
268
|
+
async function rescueOrphanSnapshots(idleMs, maxAgeMs) {
|
|
269
|
+
const triggerLabel = terminalScenarioLabel();
|
|
270
|
+
const orphans = await selectOrphanSnapshots(directories.metricsDir, idleMs, maxAgeMs, (sessionId) => sessions.has(sessionId));
|
|
271
|
+
for (const orphan of orphans) {
|
|
272
|
+
try {
|
|
273
|
+
// 工作目录作为证据种子:推断产物根与项目名(内存证据已随进程丢失)
|
|
274
|
+
const collector = new EvidenceCollector();
|
|
275
|
+
if (orphan.workingDirectory)
|
|
276
|
+
collector.recordBash(orphan.workingDirectory, "");
|
|
277
|
+
await triggerUpload(orphan.sessionId, triggerLabel, {
|
|
278
|
+
collector,
|
|
279
|
+
agentName: orphan.agentName,
|
|
280
|
+
workingDirectory: orphan.workingDirectory,
|
|
281
|
+
startedAt: orphan.startedAt,
|
|
282
|
+
});
|
|
283
|
+
log.info("[Metrics] 孤儿快照补传", { sessionId: orphan.sessionId, trigger: triggerLabel });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
log.warn("[Metrics] 孤儿快照补传失败", { sessionId: orphan.sessionId, error: String(err) });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
185
290
|
return { observe, dispose };
|
|
186
291
|
}
|
|
187
292
|
/** 应用 appendArtifacts 规则:marker 进探测扩展,显式路径按 matchBy 命中后作为直接根 */
|
|
@@ -225,12 +330,41 @@ function dedupePaths(paths) {
|
|
|
225
330
|
}
|
|
226
331
|
return result;
|
|
227
332
|
}
|
|
228
|
-
/**
|
|
229
|
-
|
|
333
|
+
/**
|
|
334
|
+
* 启动清扫暂存目录顶层:崩溃遗留的完整三件套(tar.gz + json + meta.json)
|
|
335
|
+
* 移入 failed/ 同构目录并入队重传;孤立文件与组装目录直接删除。
|
|
336
|
+
*
|
|
337
|
+
* @param stagingDir 暂存目录
|
|
338
|
+
* @param rescueEnabled 失败保留开关(false 时完整三件套也直接删除,旧行为)
|
|
339
|
+
* @returns 可补偿重传的包列表(顶层遗留部分)
|
|
340
|
+
*/
|
|
341
|
+
function sweepStagingDirectory(stagingDir, rescueEnabled) {
|
|
342
|
+
const rescued = [];
|
|
230
343
|
try {
|
|
344
|
+
const entries = fs.readdirSync(stagingDir);
|
|
345
|
+
const names = new Set(entries);
|
|
346
|
+
const handled = new Set(["failed"]);
|
|
231
347
|
let removed = 0;
|
|
232
|
-
for (const entry of
|
|
233
|
-
if (
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
if (handled.has(entry))
|
|
350
|
+
continue;
|
|
351
|
+
if (entry.endsWith(".tar.gz")) {
|
|
352
|
+
const base = entry.slice(0, -".tar.gz".length);
|
|
353
|
+
const pkg = rescueEnabled ? loadIntactPackage(stagingDir, base, names) : undefined;
|
|
354
|
+
if (pkg) {
|
|
355
|
+
movePackageIntoFailed(stagingDir, pkg, path.join(stagingDir, `${base}.meta.json`));
|
|
356
|
+
rescued.push(pkg);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
fs.rmSync(path.join(stagingDir, entry), { force: true });
|
|
360
|
+
removed++;
|
|
361
|
+
}
|
|
362
|
+
handled.add(entry);
|
|
363
|
+
handled.add(`${base}.json`);
|
|
364
|
+
handled.add(`${base}.meta.json`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (entry.endsWith(".json") || entry.startsWith(".assembly-")) {
|
|
234
368
|
fs.rmSync(path.join(stagingDir, entry), { recursive: true, force: true });
|
|
235
369
|
removed++;
|
|
236
370
|
}
|
|
@@ -241,6 +375,157 @@ function sweepStagingDirectory(stagingDir) {
|
|
|
241
375
|
catch {
|
|
242
376
|
// 目录不存在:无需处理
|
|
243
377
|
}
|
|
378
|
+
return rescued;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* 读取暂存顶层完整三件套并重建上传包(meta 反序列化 + 双文件在位校验)。
|
|
382
|
+
*
|
|
383
|
+
* @param stagingDir 暂存目录
|
|
384
|
+
* @param base 包基名(不含后缀)
|
|
385
|
+
* @param names 顶层文件名集合
|
|
386
|
+
* @returns 完整时返回包;任何缺失/损坏返回 undefined
|
|
387
|
+
*/
|
|
388
|
+
function loadIntactPackage(stagingDir, base, names) {
|
|
389
|
+
const jsonName = `${base}.json`;
|
|
390
|
+
const metaName = `${base}.meta.json`;
|
|
391
|
+
if (!names.has(jsonName) || !names.has(metaName))
|
|
392
|
+
return undefined;
|
|
393
|
+
try {
|
|
394
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(stagingDir, metaName), "utf-8"));
|
|
395
|
+
if (pkg?.archive?.fileName !== `${base}.tar.gz` || pkg?.snapshot?.fileName !== jsonName)
|
|
396
|
+
return undefined;
|
|
397
|
+
pkg.archive.filePath = path.join(stagingDir, pkg.archive.fileName);
|
|
398
|
+
pkg.snapshot.filePath = path.join(stagingDir, pkg.snapshot.fileName);
|
|
399
|
+
if (!fs.existsSync(pkg.archive.filePath) || !fs.existsSync(pkg.snapshot.filePath))
|
|
400
|
+
return undefined;
|
|
401
|
+
return pkg;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* 计算包在 failed/ 下的落位目录(与云端对象路径同构,便于查找与手动上传):
|
|
409
|
+
* failed/YYYYMMDD/<projectName>-<sessionId>/<agent缩写>-<trigger>-v<n>/
|
|
410
|
+
*/
|
|
411
|
+
function failedDirFor(stagingDir, pkg) {
|
|
412
|
+
const segments = composeRemoteBase(pkg).split("/");
|
|
413
|
+
segments.pop();
|
|
414
|
+
return path.join(stagingDir, "failed", ...segments);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* 把包的双文件与 meta 移入 failed/ 同构目录(同盘 rename)。
|
|
418
|
+
*
|
|
419
|
+
* @param stagingDir 暂存目录
|
|
420
|
+
* @param pkg 上传包
|
|
421
|
+
* @param metaPath meta 文件当前路径
|
|
422
|
+
* @returns 落位目录
|
|
423
|
+
*/
|
|
424
|
+
function movePackageIntoFailed(stagingDir, pkg, metaPath) {
|
|
425
|
+
const targetDir = failedDirFor(stagingDir, pkg);
|
|
426
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
427
|
+
pkg.archive.filePath = renameInto(pkg.archive.filePath, path.join(targetDir, pkg.archive.fileName));
|
|
428
|
+
pkg.snapshot.filePath = renameInto(pkg.snapshot.filePath, path.join(targetDir, pkg.snapshot.fileName));
|
|
429
|
+
renameInto(metaPath, path.join(targetDir, path.basename(metaPath)));
|
|
430
|
+
return targetDir;
|
|
431
|
+
}
|
|
432
|
+
/** rename 到目标(已存在则先删);返回目标路径 */
|
|
433
|
+
function renameInto(source, target) {
|
|
434
|
+
try {
|
|
435
|
+
fs.rmSync(target, { force: true });
|
|
436
|
+
fs.renameSync(source, target);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// 移动失败:文件留在原处,下次启动清扫兜底
|
|
440
|
+
}
|
|
441
|
+
return target;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* 收集 failed/ 存量包:先做过期(retentionMs)与总量(maxFailedBytes,从最旧删起)
|
|
445
|
+
* 清理,再把完整包重建(filePath 重指 failed/ 内)返回入队。
|
|
446
|
+
*
|
|
447
|
+
* @param stagingDir 暂存目录
|
|
448
|
+
* @param retentionMs 保留时长(毫秒)
|
|
449
|
+
* @param maxFailedBytes 总量字节上限
|
|
450
|
+
* @returns 可重传的包列表
|
|
451
|
+
*/
|
|
452
|
+
function collectFailedPackages(stagingDir, retentionMs, maxFailedBytes) {
|
|
453
|
+
const failedRoot = path.join(stagingDir, "failed");
|
|
454
|
+
if (!fs.existsSync(failedRoot))
|
|
455
|
+
return [];
|
|
456
|
+
const packages = [];
|
|
457
|
+
const walk = (dir, depth) => {
|
|
458
|
+
if (depth > 4)
|
|
459
|
+
return;
|
|
460
|
+
let entries;
|
|
461
|
+
try {
|
|
462
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
for (const entry of entries) {
|
|
468
|
+
const child = path.join(dir, entry.name);
|
|
469
|
+
if (entry.isDirectory()) {
|
|
470
|
+
walk(child, depth + 1);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (!entry.name.endsWith(".meta.json"))
|
|
474
|
+
continue;
|
|
475
|
+
try {
|
|
476
|
+
const pkg = JSON.parse(fs.readFileSync(child, "utf-8"));
|
|
477
|
+
const pkgDir = path.dirname(child);
|
|
478
|
+
pkg.archive.filePath = path.join(pkgDir, pkg.archive.fileName);
|
|
479
|
+
pkg.snapshot.filePath = path.join(pkgDir, pkg.snapshot.fileName);
|
|
480
|
+
if (!fs.existsSync(pkg.archive.filePath) || !fs.existsSync(pkg.snapshot.filePath)) {
|
|
481
|
+
fs.rmSync(pkgDir, { recursive: true, force: true });
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
pkg.archive.sizeBytes = fs.statSync(pkg.archive.filePath).size;
|
|
485
|
+
pkg.snapshot.sizeBytes = fs.statSync(pkg.snapshot.filePath).size;
|
|
486
|
+
packages.push({ pkg, dir: pkgDir, size: pkg.archive.sizeBytes + pkg.snapshot.sizeBytes, createdAt: pkg.createdAt ?? 0 });
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
// meta 损坏:整目录删除,防止永久残留
|
|
490
|
+
fs.rmSync(path.dirname(child), { recursive: true, force: true });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
walk(failedRoot, 0);
|
|
495
|
+
const now = Date.now();
|
|
496
|
+
const survivors = [];
|
|
497
|
+
let totalBytes = 0;
|
|
498
|
+
let removed = 0;
|
|
499
|
+
for (const item of packages.sort((a, b) => a.createdAt - b.createdAt)) {
|
|
500
|
+
if (now - item.createdAt > retentionMs || totalBytes + item.size > maxFailedBytes) {
|
|
501
|
+
fs.rmSync(item.dir, { recursive: true, force: true });
|
|
502
|
+
removeEmptyDirsUp(path.dirname(item.dir), failedRoot);
|
|
503
|
+
removed++;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
totalBytes += item.size;
|
|
507
|
+
survivors.push(item);
|
|
508
|
+
}
|
|
509
|
+
if (removed > 0)
|
|
510
|
+
log.info("[Metrics] failed 目录清理(过期/超量)", { stagingDir, removed });
|
|
511
|
+
removeEmptyDirsUp(failedRoot, failedRoot);
|
|
512
|
+
return survivors.map((item) => item.pkg);
|
|
513
|
+
}
|
|
514
|
+
/** 自下而上删除空目录(直到 root,含 root 本身可删) */
|
|
515
|
+
function removeEmptyDirsUp(directory, root) {
|
|
516
|
+
let current = path.resolve(directory);
|
|
517
|
+
const top = path.resolve(root);
|
|
518
|
+
while (current.startsWith(top)) {
|
|
519
|
+
try {
|
|
520
|
+
fs.rmdirSync(current);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (current === top)
|
|
526
|
+
return;
|
|
527
|
+
current = path.dirname(current);
|
|
528
|
+
}
|
|
244
529
|
}
|
|
245
530
|
function removeQuietly(filePath) {
|
|
246
531
|
try {
|
|
@@ -250,3 +535,78 @@ function removeQuietly(filePath) {
|
|
|
250
535
|
// 删除失败只记日志层面忽略
|
|
251
536
|
}
|
|
252
537
|
}
|
|
538
|
+
// ─── 孤儿快照补传(§13 失败补偿链路) ─────────────────────────────────────────
|
|
539
|
+
/** 已传终态标记目录名(metrics/.sent/<sessionId>) */
|
|
540
|
+
const SENT_MARKER_DIR = ".sent";
|
|
541
|
+
/**
|
|
542
|
+
* 记录会话终态包已成功上云。
|
|
543
|
+
* 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
|
|
544
|
+
* "已传标记"才是孤儿扫描防重复上传的依据。
|
|
545
|
+
*/
|
|
546
|
+
export function markSessionUploaded(metricsDir, sessionId) {
|
|
547
|
+
try {
|
|
548
|
+
fs.mkdirSync(path.join(metricsDir, SENT_MARKER_DIR), { recursive: true });
|
|
549
|
+
fs.writeFileSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId), String(Date.now()));
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// 标记失败仅导致下次启动多一次补传判断,不阻塞上传链路
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** 会话终态包是否已成功上云 */
|
|
556
|
+
export function isSessionUploaded(metricsDir, sessionId) {
|
|
557
|
+
return fs.existsSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId));
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
|
|
561
|
+
* 上传成功后快照即被删除,"文件仍在"天然等价于"终态未传出"。
|
|
562
|
+
*
|
|
563
|
+
* @param metricsDir 快照目录
|
|
564
|
+
* @param idleMs 静默下限(毫秒)
|
|
565
|
+
* @param maxAgeMs 年龄上限(毫秒)
|
|
566
|
+
* @param isActive 活跃会话判定(IO 前后各查一次,防竞态)
|
|
567
|
+
* @returns 候选列表(无法解析的快照跳过)
|
|
568
|
+
*/
|
|
569
|
+
export async function selectOrphanSnapshots(metricsDir, idleMs, maxAgeMs, isActive) {
|
|
570
|
+
const result = [];
|
|
571
|
+
let entries;
|
|
572
|
+
try {
|
|
573
|
+
entries = await fs.promises.readdir(metricsDir);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
const now = Date.now();
|
|
579
|
+
for (const entry of entries) {
|
|
580
|
+
if (!entry.endsWith(".json"))
|
|
581
|
+
continue;
|
|
582
|
+
const sessionId = entry.slice(0, -".json".length);
|
|
583
|
+
if (isSessionUploaded(metricsDir, sessionId))
|
|
584
|
+
continue;
|
|
585
|
+
const filePath = path.join(metricsDir, entry);
|
|
586
|
+
try {
|
|
587
|
+
const stats = await fs.promises.stat(filePath);
|
|
588
|
+
const age = now - stats.mtimeMs;
|
|
589
|
+
if (age < idleMs || age > maxAgeMs)
|
|
590
|
+
continue;
|
|
591
|
+
if (isActive(sessionId))
|
|
592
|
+
continue;
|
|
593
|
+
const snapshot = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
|
|
594
|
+
if (isActive(sessionId))
|
|
595
|
+
continue;
|
|
596
|
+
const header = (snapshot.header ?? {});
|
|
597
|
+
result.push({
|
|
598
|
+
sessionId,
|
|
599
|
+
filePath,
|
|
600
|
+
workingDirectory: header.workingDirectory || "",
|
|
601
|
+
agentName: header.agent || "",
|
|
602
|
+
startedAt: (typeof snapshot.startTime === "number" && snapshot.startTime > 0 && snapshot.startTime) ||
|
|
603
|
+
Date.parse(header.startTime || "") ||
|
|
604
|
+
stats.mtimeMs,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
// 无法解析的快照跳过,不阻塞其余候选
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
@@ -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;
|