opencode-metrics-plugin 0.2.1 → 0.3.0
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 +16 -0
- package/README.md +80 -53
- package/assets/agc-apiclient.json +10 -0
- package/dist/metrics/configLoader.d.ts +20 -0
- package/dist/metrics/configLoader.js +99 -0
- package/dist/metrics/dirs.d.ts +2 -0
- package/dist/metrics/dirs.js +1 -0
- package/dist/metrics/engine/engine.d.ts +8 -4
- package/dist/metrics/engine/engine.js +105 -27
- package/dist/metrics/engine/state.d.ts +17 -0
- package/dist/metrics/engine/state.js +2 -0
- package/dist/metrics/index.d.ts +3 -0
- package/dist/metrics/index.js +1 -0
- package/dist/metrics/runtime.d.ts +4 -0
- package/dist/metrics/runtime.js +10 -0
- package/dist/metrics/snapshot/flush.d.ts +17 -0
- package/dist/metrics/snapshot/flush.js +34 -4
- package/dist/metrics/snapshot/merge.d.ts +10 -2
- package/dist/metrics/snapshot/merge.js +47 -14
- package/dist/metrics/snapshot/steps.d.ts +15 -1
- package/dist/metrics/snapshot/steps.js +112 -48
- package/dist/metrics/upload/agcUploader.d.ts +65 -0
- package/dist/metrics/upload/agcUploader.js +244 -0
- package/dist/metrics/upload/packageArchiver.d.ts +34 -0
- package/dist/metrics/upload/packageArchiver.js +213 -0
- package/dist/metrics/upload/scenarios.d.ts +20 -0
- package/dist/metrics/upload/scenarios.js +43 -0
- package/dist/metrics/upload/types.d.ts +111 -0
- package/dist/metrics/upload/types.js +43 -0
- package/dist/metrics/upload/uploadManager.d.ts +25 -0
- package/dist/metrics/upload/uploadManager.js +252 -0
- package/dist/metrics/upload/uploadQueue.d.ts +34 -0
- package/dist/metrics/upload/uploadQueue.js +77 -0
- package/dist/metrics/upload/workspaceResolver.d.ts +75 -0
- package/dist/metrics/upload/workspaceResolver.js +275 -0
- package/dist/plugin.d.ts +8 -5
- package/dist/plugin.js +12 -7
- package/dist/shared/log.d.ts +0 -1
- package/dist/shared/log.js +0 -3
- package/package.json +4 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// ─── 上传子系统共享类型与默认值(DESIGN.md §13) ───────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* 清洗名称为安全的路径段:非法字符(含空格)替换为 "-",保留中文与内部点号,
|
|
4
|
+
* 首尾修剪 "." "-",结果为空时返回 fallback。
|
|
5
|
+
*
|
|
6
|
+
* @param name 原始名称
|
|
7
|
+
* @param fallback 清洗后为空时的替代值
|
|
8
|
+
* @returns 可安全用于文件名/云端路径的段
|
|
9
|
+
*/
|
|
10
|
+
export function sanitizeNameSegment(name, fallback = "project") {
|
|
11
|
+
const cleaned = name
|
|
12
|
+
.replace(/[\\/:*?"<>|\s]+/g, "-")
|
|
13
|
+
.replace(/-+/g, "-")
|
|
14
|
+
.replace(/^[.\-]+|[.\-]+$/g, "");
|
|
15
|
+
return cleaned || fallback;
|
|
16
|
+
}
|
|
17
|
+
// ─── 默认值(§13.4 / §13.5) ─────────────────────────────────────────────────
|
|
18
|
+
/** 目录内存在这些子目录 → 该目录是产物根 */
|
|
19
|
+
export const DEFAULT_DIR_MARKERS = [".harmonyos", ".mr"];
|
|
20
|
+
/** 目录内存在这些文件/目录 → 该目录是产物根 */
|
|
21
|
+
export const DEFAULT_FILE_MARKERS = [
|
|
22
|
+
"oh-package.json5",
|
|
23
|
+
"build-profile.json5",
|
|
24
|
+
"hvigorfile.ts",
|
|
25
|
+
"AppScope",
|
|
26
|
+
];
|
|
27
|
+
/** 打包排除的目录/文件名(按路径段匹配) */
|
|
28
|
+
export const DEFAULT_BLACKLIST = [
|
|
29
|
+
"oh_modules",
|
|
30
|
+
"build",
|
|
31
|
+
".hvigor",
|
|
32
|
+
".cxx",
|
|
33
|
+
".git",
|
|
34
|
+
"node_modules",
|
|
35
|
+
".idea",
|
|
36
|
+
"local.properties",
|
|
37
|
+
];
|
|
38
|
+
/** 单包体积上限默认 500MB */
|
|
39
|
+
export const DEFAULT_MAX_ARTIFACT_BYTES = 500 * 1024 * 1024;
|
|
40
|
+
/** 自证据路径向上探测标记的最大层级 */
|
|
41
|
+
export const MAX_PROBE_DEPTH = 5;
|
|
42
|
+
/** 产物根数量上限 */
|
|
43
|
+
export const MAX_ROOTS = 3;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MetricsDirs } from "../dirs.js";
|
|
2
|
+
import type { UploadConfig } from "./types.js";
|
|
3
|
+
export interface UploadManagerOptions {
|
|
4
|
+
config?: UploadConfig;
|
|
5
|
+
/** 实例目录(metricsDir/eventsDir/uploadStagingDir) */
|
|
6
|
+
dirs: MetricsDirs;
|
|
7
|
+
/** 归档前回调(事件日志冲刷落盘,消除缓冲延迟导致的空 events 竞态) */
|
|
8
|
+
onBeforeArchive?: () => void;
|
|
9
|
+
}
|
|
10
|
+
export interface UploadManager {
|
|
11
|
+
/** 旁路观察事件:收集证据 + 场景命中判定(须在 engine.ingest 之后调用,保证快照先落盘) */
|
|
12
|
+
observe(event: {
|
|
13
|
+
type: string;
|
|
14
|
+
properties?: Record<string, unknown>;
|
|
15
|
+
}): void;
|
|
16
|
+
/** 结束:对所有活跃会话触发 dispose 场景并排空队列(best-effort 10s) */
|
|
17
|
+
dispose(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 创建上传管理器:串联证据 → 根推断 → 打包 → 队列 → AGC 上传,
|
|
21
|
+
* 终态(成功/重试耗尽)删除暂存双文件。upload 未启用或凭证不可用时返回 null。
|
|
22
|
+
*
|
|
23
|
+
* @param options 配置与目录
|
|
24
|
+
*/
|
|
25
|
+
export declare function createUploadManager(options: UploadManagerOptions): UploadManager | null;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { log } from "../../shared/log.js";
|
|
4
|
+
import { EvidenceCollector, isPathInside, resolveAndroidProjectName, resolveWorkspaceRoots, } from "./workspaceResolver.js";
|
|
5
|
+
import { archiveSessionPackage } from "./packageArchiver.js";
|
|
6
|
+
import { createAgcUploader, defaultAgcConfigPath, loadAgcCredentials, remoteArchiveKey } from "./agcUploader.js";
|
|
7
|
+
import { createUploadQueue } from "./uploadQueue.js";
|
|
8
|
+
import { resolveScenarios } from "./scenarios.js";
|
|
9
|
+
import { MAX_ROOTS } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* 创建上传管理器:串联证据 → 根推断 → 打包 → 队列 → AGC 上传,
|
|
12
|
+
* 终态(成功/重试耗尽)删除暂存双文件。upload 未启用或凭证不可用时返回 null。
|
|
13
|
+
*
|
|
14
|
+
* @param options 配置与目录
|
|
15
|
+
*/
|
|
16
|
+
export function createUploadManager(options) {
|
|
17
|
+
const config = options.config ?? {};
|
|
18
|
+
if (config.enabled !== true)
|
|
19
|
+
return null;
|
|
20
|
+
const credentialsResult = loadAgcCredentials(config.uploader?.agcConfigPath ?? defaultAgcConfigPath());
|
|
21
|
+
if (!credentialsResult.ok) {
|
|
22
|
+
log.error("[Metrics] 上传未启用:凭证不可用", { reason: credentialsResult.reason });
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const uploader = createAgcUploader(credentialsResult.credentials);
|
|
26
|
+
const scenarios = resolveScenarios(config.scenarios);
|
|
27
|
+
const directories = options.dirs;
|
|
28
|
+
// 启动扫残留:上次进程未清理的暂存产物直接删除
|
|
29
|
+
sweepStagingDirectory(directories.uploadStagingDir);
|
|
30
|
+
const queue = createUploadQueue({
|
|
31
|
+
worker: (uploadPackage) => uploader.uploadPackage(uploadPackage),
|
|
32
|
+
maxAttempts: config.retry?.maxAttempts,
|
|
33
|
+
backoffMs: config.retry?.backoffMs,
|
|
34
|
+
onSettled: (uploadPackage, ok, reason) => {
|
|
35
|
+
removeQuietly(uploadPackage.archive.filePath);
|
|
36
|
+
removeQuietly(uploadPackage.snapshot.filePath);
|
|
37
|
+
if (ok) {
|
|
38
|
+
log.info("[Metrics] 上传完成", {
|
|
39
|
+
sessionId: uploadPackage.sessionId,
|
|
40
|
+
fileName: uploadPackage.archive.fileName,
|
|
41
|
+
remoteKey: remoteArchiveKey(uploadPackage),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
log.error("[Metrics] 上传失败(暂存已清理)", { sessionId: uploadPackage.sessionId, reason: reason ?? "" });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const sessions = new Map();
|
|
50
|
+
const childToParent = new Map();
|
|
51
|
+
const completedBashCalls = new Set();
|
|
52
|
+
function resolveSessionId(rawId) {
|
|
53
|
+
return childToParent.get(rawId) ?? rawId;
|
|
54
|
+
}
|
|
55
|
+
function getOrCreateEvidence(sessionId) {
|
|
56
|
+
let entry = sessions.get(sessionId);
|
|
57
|
+
if (!entry) {
|
|
58
|
+
entry = { collector: new EvidenceCollector(), agentName: "", workingDirectory: "" };
|
|
59
|
+
sessions.set(sessionId, entry);
|
|
60
|
+
}
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 触发一次上传:根推断 + 包名推断 + 打包入队。
|
|
65
|
+
*
|
|
66
|
+
* @param sessionId 会话 id
|
|
67
|
+
* @param triggerLabel 场景命名标签
|
|
68
|
+
*/
|
|
69
|
+
async function triggerUpload(sessionId, triggerLabel) {
|
|
70
|
+
const evidence = sessions.get(sessionId) ?? { collector: new EvidenceCollector(), agentName: "", workingDirectory: "" };
|
|
71
|
+
const seeds = evidence.collector.seeds();
|
|
72
|
+
const { directRoots, extraMarkers } = collectArtifactRules(seeds, evidence.workingDirectory, config.appendArtifacts ?? []);
|
|
73
|
+
const detectedRoots = resolveWorkspaceRoots(seeds, {
|
|
74
|
+
extraDirMarkers: extraMarkers.extraDirMarkers,
|
|
75
|
+
extraFileMarkers: extraMarkers.extraFileMarkers,
|
|
76
|
+
excludeAndroidProjects: config.excludeAndroidProjects,
|
|
77
|
+
});
|
|
78
|
+
const roots = dedupePaths([...detectedRoots, ...directRoots]).slice(0, MAX_ROOTS);
|
|
79
|
+
const projectName = resolveAndroidProjectName(seeds, {
|
|
80
|
+
fallbackDirectory: evidence.workingDirectory || roots[0] || "",
|
|
81
|
+
}) ?? "project";
|
|
82
|
+
options.onBeforeArchive?.();
|
|
83
|
+
const archiveResult = await archiveSessionPackage({
|
|
84
|
+
sessionId,
|
|
85
|
+
trigger: triggerLabel,
|
|
86
|
+
agentName: evidence.agentName || "agent",
|
|
87
|
+
projectName,
|
|
88
|
+
roots,
|
|
89
|
+
metricsDir: directories.metricsDir,
|
|
90
|
+
eventsDir: directories.eventsDir,
|
|
91
|
+
stagingDir: directories.uploadStagingDir,
|
|
92
|
+
blacklist: config.blacklist,
|
|
93
|
+
excludeAndroidProjects: config.excludeAndroidProjects,
|
|
94
|
+
maxArtifactBytes: config.maxArtifactBytes,
|
|
95
|
+
});
|
|
96
|
+
if (archiveResult.ok) {
|
|
97
|
+
queue.enqueue(archiveResult.package);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function observe(event) {
|
|
101
|
+
const props = event.properties;
|
|
102
|
+
if (!props)
|
|
103
|
+
return;
|
|
104
|
+
if (event.type === "session.created" || event.type === "session.updated") {
|
|
105
|
+
const info = props.info;
|
|
106
|
+
if (!info)
|
|
107
|
+
return;
|
|
108
|
+
const id = info.id;
|
|
109
|
+
if (!id)
|
|
110
|
+
return;
|
|
111
|
+
const parentID = info.parentID;
|
|
112
|
+
if (parentID)
|
|
113
|
+
childToParent.set(id, parentID);
|
|
114
|
+
const evidence = getOrCreateEvidence(resolveSessionId(id));
|
|
115
|
+
if (event.type === "session.created") {
|
|
116
|
+
const directory = info.directory;
|
|
117
|
+
if (directory)
|
|
118
|
+
evidence.workingDirectory = directory;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const agent = info.agent;
|
|
122
|
+
if (agent)
|
|
123
|
+
evidence.agentName = agent;
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const rawSessionId = props.sessionID || props.info?.id;
|
|
128
|
+
if (!rawSessionId)
|
|
129
|
+
return;
|
|
130
|
+
const sessionId = resolveSessionId(rawSessionId);
|
|
131
|
+
const evidence = getOrCreateEvidence(sessionId);
|
|
132
|
+
if (event.type === "message.part.updated") {
|
|
133
|
+
const part = props.part;
|
|
134
|
+
if (!part || part.type !== "tool")
|
|
135
|
+
return;
|
|
136
|
+
const toolState = part.state;
|
|
137
|
+
if (toolState?.status !== "completed")
|
|
138
|
+
return;
|
|
139
|
+
const tool = part.tool;
|
|
140
|
+
const input = toolState.input;
|
|
141
|
+
const callID = part.callID;
|
|
142
|
+
if (tool === "write" || tool === "edit") {
|
|
143
|
+
const filePath = input?.filePath || input?.path;
|
|
144
|
+
if (filePath)
|
|
145
|
+
evidence.collector.recordFileEdit(filePath);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (tool === "bash") {
|
|
149
|
+
const command = input?.command || "";
|
|
150
|
+
const workdir = input?.workdir;
|
|
151
|
+
evidence.collector.recordBash(workdir, command);
|
|
152
|
+
if (callID) {
|
|
153
|
+
if (completedBashCalls.has(callID))
|
|
154
|
+
return;
|
|
155
|
+
completedBashCalls.add(callID);
|
|
156
|
+
}
|
|
157
|
+
for (const scenario of scenarios) {
|
|
158
|
+
if (!scenario.enabled || scenario.commandRegexes.length === 0)
|
|
159
|
+
continue;
|
|
160
|
+
if (scenario.commandRegexes.some((regex) => regex.test(command))) {
|
|
161
|
+
void triggerUpload(sessionId, scenario.label);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// 事件名场景命中(session.deleted 时 engine 已先落最终快照)
|
|
168
|
+
const matched = scenarios.some((scenario) => scenario.enabled && scenario.events.includes(event.type));
|
|
169
|
+
if (matched) {
|
|
170
|
+
void triggerUpload(sessionId, scenarios.find((scenario) => scenario.enabled && scenario.events.includes(event.type)).label);
|
|
171
|
+
if (event.type === "session.deleted")
|
|
172
|
+
sessions.delete(sessionId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function dispose() {
|
|
176
|
+
for (const scenario of scenarios) {
|
|
177
|
+
if (!scenario.enabled || !scenario.events.includes("dispose"))
|
|
178
|
+
continue;
|
|
179
|
+
for (const sessionId of [...sessions.keys()]) {
|
|
180
|
+
await triggerUpload(sessionId, scenario.label);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
await queue.drain(10_000);
|
|
184
|
+
}
|
|
185
|
+
return { observe, dispose };
|
|
186
|
+
}
|
|
187
|
+
/** 应用 appendArtifacts 规则:marker 进探测扩展,显式路径按 matchBy 命中后作为直接根 */
|
|
188
|
+
function collectArtifactRules(seeds, workingDirectory, rules) {
|
|
189
|
+
const extraDirMarkers = [];
|
|
190
|
+
const extraFileMarkers = [];
|
|
191
|
+
const directRoots = [];
|
|
192
|
+
for (const rule of rules) {
|
|
193
|
+
if (rule.dirMarkers)
|
|
194
|
+
extraDirMarkers.push(...rule.dirMarkers);
|
|
195
|
+
if (rule.fileMarkers)
|
|
196
|
+
extraFileMarkers.push(...rule.fileMarkers);
|
|
197
|
+
if (!rule.path)
|
|
198
|
+
continue;
|
|
199
|
+
const resolved = path.isAbsolute(rule.path)
|
|
200
|
+
? rule.path
|
|
201
|
+
: workingDirectory
|
|
202
|
+
? path.resolve(workingDirectory, rule.path)
|
|
203
|
+
: "";
|
|
204
|
+
if (!resolved)
|
|
205
|
+
continue;
|
|
206
|
+
const matchBy = rule.matchBy ?? "session-activity";
|
|
207
|
+
const hit = matchBy === "existence"
|
|
208
|
+
? fs.existsSync(resolved)
|
|
209
|
+
: seeds.some((seed) => isPathInside(seed, resolved));
|
|
210
|
+
if (hit && fs.existsSync(resolved))
|
|
211
|
+
directRoots.push(resolved);
|
|
212
|
+
}
|
|
213
|
+
return { directRoots, extraMarkers: { extraDirMarkers, extraFileMarkers } };
|
|
214
|
+
}
|
|
215
|
+
/** 去重路径(Windows 大小写不敏感),保持首次出现顺序 */
|
|
216
|
+
function dedupePaths(paths) {
|
|
217
|
+
const seen = new Set();
|
|
218
|
+
const result = [];
|
|
219
|
+
for (const target of paths) {
|
|
220
|
+
const key = process.platform === "win32" ? target.toLowerCase() : target;
|
|
221
|
+
if (seen.has(key))
|
|
222
|
+
continue;
|
|
223
|
+
seen.add(key);
|
|
224
|
+
result.push(target);
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
/** 启动清扫暂存目录:删除上次进程遗留的产物与组装目录 */
|
|
229
|
+
function sweepStagingDirectory(stagingDir) {
|
|
230
|
+
try {
|
|
231
|
+
let removed = 0;
|
|
232
|
+
for (const entry of fs.readdirSync(stagingDir)) {
|
|
233
|
+
if (entry.endsWith(".tar.gz") || entry.endsWith(".json") || entry.startsWith(".assembly-")) {
|
|
234
|
+
fs.rmSync(path.join(stagingDir, entry), { recursive: true, force: true });
|
|
235
|
+
removed++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (removed > 0)
|
|
239
|
+
log.info("[Metrics] 启动清扫暂存目录", { stagingDir, removed });
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// 目录不存在:无需处理
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function removeQuietly(filePath) {
|
|
246
|
+
try {
|
|
247
|
+
fs.rmSync(filePath, { force: true });
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// 删除失败只记日志层面忽略
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { UploadPackage } from "./types.js";
|
|
2
|
+
/** 单次任务执行结果:失败时给出是否可重试(不可重试的失败直接终态) */
|
|
3
|
+
export type UploadTaskOutcome = {
|
|
4
|
+
ok: true;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
retryable: boolean;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export interface UploadQueueOptions {
|
|
11
|
+
/** 执行一次上传任务(一个任务对应两个文件,两者都成功才算成功) */
|
|
12
|
+
worker: (uploadPackage: UploadPackage) => Promise<UploadTaskOutcome>;
|
|
13
|
+
/** 单任务最大尝试次数(含首次),默认 5 */
|
|
14
|
+
maxAttempts?: number;
|
|
15
|
+
/** 退避基数(毫秒),实际延迟为 backoffMs × 4^(n-1),默认 1000 */
|
|
16
|
+
backoffMs?: number;
|
|
17
|
+
/** 任务终态回调(成功或不可重试失败或重试耗尽);调用方在此清理暂存双文件 */
|
|
18
|
+
onSettled?: (uploadPackage: UploadPackage, ok: boolean, reason?: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface UploadQueue {
|
|
21
|
+
/** 入队一个上传任务,立即返回不阻塞 */
|
|
22
|
+
enqueue(uploadPackage: UploadPackage): void;
|
|
23
|
+
/** 等待队列排空(最多 timeoutMs),返回是否已排空 */
|
|
24
|
+
drain(timeoutMs: number): Promise<boolean>;
|
|
25
|
+
/** 当前待处理与进行中的任务数 */
|
|
26
|
+
pendingCount(): number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 创建串行上传队列:任务按入队顺序逐个执行,
|
|
30
|
+
* 可重试失败按指数退避重试,终态(成功/失败)回调 onSettled。
|
|
31
|
+
*
|
|
32
|
+
* @param options 队列行为配置
|
|
33
|
+
*/
|
|
34
|
+
export declare function createUploadQueue(options: UploadQueueOptions): UploadQueue;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { log } from "../../shared/log.js";
|
|
2
|
+
/**
|
|
3
|
+
* 创建串行上传队列:任务按入队顺序逐个执行,
|
|
4
|
+
* 可重试失败按指数退避重试,终态(成功/失败)回调 onSettled。
|
|
5
|
+
*
|
|
6
|
+
* @param options 队列行为配置
|
|
7
|
+
*/
|
|
8
|
+
export function createUploadQueue(options) {
|
|
9
|
+
const maxAttempts = options.maxAttempts ?? 5;
|
|
10
|
+
const backoffMs = options.backoffMs ?? 1000;
|
|
11
|
+
const pending = [];
|
|
12
|
+
let processing = false;
|
|
13
|
+
async function runWithRetry(uploadPackage) {
|
|
14
|
+
let lastReason = "";
|
|
15
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
16
|
+
let outcome;
|
|
17
|
+
try {
|
|
18
|
+
outcome = await options.worker(uploadPackage);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
outcome = { ok: false, retryable: true, reason: String(err) };
|
|
22
|
+
}
|
|
23
|
+
if (outcome.ok) {
|
|
24
|
+
options.onSettled?.(uploadPackage, true);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
lastReason = outcome.reason;
|
|
28
|
+
if (!outcome.retryable) {
|
|
29
|
+
options.onSettled?.(uploadPackage, false, outcome.reason);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (attempt < maxAttempts) {
|
|
33
|
+
const delayMs = backoffMs * 4 ** (attempt - 1);
|
|
34
|
+
log.info("[Metrics] 上传任务将重试", {
|
|
35
|
+
sessionId: uploadPackage.sessionId,
|
|
36
|
+
attempt,
|
|
37
|
+
delayMs,
|
|
38
|
+
reason: outcome.reason,
|
|
39
|
+
});
|
|
40
|
+
await sleep(delayMs);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
options.onSettled?.(uploadPackage, false, `重试耗尽(${maxAttempts}次): ${lastReason}`);
|
|
44
|
+
}
|
|
45
|
+
async function processPending() {
|
|
46
|
+
processing = true;
|
|
47
|
+
try {
|
|
48
|
+
while (pending.length > 0) {
|
|
49
|
+
const uploadPackage = pending.shift();
|
|
50
|
+
await runWithRetry(uploadPackage);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
processing = false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
enqueue(uploadPackage) {
|
|
59
|
+
pending.push(uploadPackage);
|
|
60
|
+
if (!processing)
|
|
61
|
+
void processPending();
|
|
62
|
+
},
|
|
63
|
+
async drain(timeoutMs) {
|
|
64
|
+
const deadline = Date.now() + timeoutMs;
|
|
65
|
+
while ((pending.length > 0 || processing) && Date.now() < deadline) {
|
|
66
|
+
await sleep(25);
|
|
67
|
+
}
|
|
68
|
+
return pending.length === 0 && !processing;
|
|
69
|
+
},
|
|
70
|
+
pendingCount() {
|
|
71
|
+
return pending.length + (processing ? 1 : 0);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function sleep(ms) {
|
|
76
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话证据收集器:旁路记录 write/edit 的文件路径与 bash 的工作目录、命令路径线索。
|
|
3
|
+
* 触发上传时用这些路径推断产物根。
|
|
4
|
+
*/
|
|
5
|
+
export declare class EvidenceCollector {
|
|
6
|
+
private readonly filePaths;
|
|
7
|
+
private readonly directories;
|
|
8
|
+
/**
|
|
9
|
+
* 记录一次文件写入/编辑。
|
|
10
|
+
*
|
|
11
|
+
* @param filePath 被写入文件的绝对路径
|
|
12
|
+
*/
|
|
13
|
+
recordFileEdit(filePath: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* 记录一次 bash 调用:登记工作目录,并从命令行提取路径线索
|
|
16
|
+
* (cd <path>、git -C <path>、出现 hvigorw 时登记工作目录本身)。
|
|
17
|
+
*
|
|
18
|
+
* @param workdir 命令执行目录(可能为空)
|
|
19
|
+
* @param command 完整命令行
|
|
20
|
+
*/
|
|
21
|
+
recordBash(workdir: string | undefined, command: string): void;
|
|
22
|
+
/** 全部证据路径(文件 + 目录),供根推断使用 */
|
|
23
|
+
seeds(): string[];
|
|
24
|
+
private resolveAgainst;
|
|
25
|
+
}
|
|
26
|
+
export interface ResolveRootsOptions {
|
|
27
|
+
/** 追加目录标记(与内置合并) */
|
|
28
|
+
extraDirMarkers?: string[];
|
|
29
|
+
/** 追加文件标记(与内置合并) */
|
|
30
|
+
extraFileMarkers?: string[];
|
|
31
|
+
/** 剔除安卓工程根(缺省 true) */
|
|
32
|
+
excludeAndroidProjects?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 判断目录是否为安卓工程:统计 gradle 构建信号(settings.gradle、gradlew、
|
|
36
|
+
* app/build.gradle、根 build.gradle+app/、AndroidManifest+src/),
|
|
37
|
+
* 命中 2 个及以上即判定,避免单文件误判。
|
|
38
|
+
*
|
|
39
|
+
* @param directory 待判定目录
|
|
40
|
+
* @returns 是安卓工程返回 true
|
|
41
|
+
*/
|
|
42
|
+
export declare function looksLikeAndroidProject(directory: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* 从证据路径推断产物根:自每条证据所在目录向上逐级探测标记(最多 5 层),
|
|
45
|
+
* 目录内存在任一标记即记为根;安卓工程根(自身无鸿蒙工程标记)先剔除,
|
|
46
|
+
* 嵌套根归并到最外层,去重后最多 3 个。
|
|
47
|
+
*
|
|
48
|
+
* @param seeds 证据路径(文件或目录的绝对路径)
|
|
49
|
+
* @param options 追加标记与安卓剔除开关(来自 appendArtifacts 配置)
|
|
50
|
+
* @returns 产物根绝对路径数组;无命中返回空数组
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveWorkspaceRoots(seeds: string[], options?: ResolveRootsOptions): string[];
|
|
53
|
+
/**
|
|
54
|
+
* 判断证据路径是否位于配置路径之下(追加产物 "session-activity" 命中判定,
|
|
55
|
+
* Windows 下大小写不敏感)。
|
|
56
|
+
*
|
|
57
|
+
* @param evidencePath 证据路径
|
|
58
|
+
* @param configPath 配置的产物路径
|
|
59
|
+
* @returns 命中返回 true
|
|
60
|
+
*/
|
|
61
|
+
export declare function isPathInside(evidencePath: string, configPath: string): boolean;
|
|
62
|
+
export interface AndroidProjectNameOptions {
|
|
63
|
+
/** 未探测到包名时的目录兜底(通常传会话工作目录),返回其目录名 */
|
|
64
|
+
fallbackDirectory?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 从会话证据推断安卓项目名:在证据目录及其祖先(≤3 层)定位
|
|
68
|
+
* build.gradle / AndroidManifest.xml,按 applicationId > manifest package > namespace
|
|
69
|
+
* 的优先级提取包名;探测不到时回退目录名,仍无则返回 null。
|
|
70
|
+
*
|
|
71
|
+
* @param seeds 证据路径(文件或目录的绝对路径)
|
|
72
|
+
* @param options fallbackDirectory 提供目录兜底
|
|
73
|
+
* @returns 包名 / 兜底目录名(经名称清洗);无可返回 null
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveAndroidProjectName(seeds: string[], options?: AndroidProjectNameOptions): string | null;
|