opencode-metrics-plugin 0.2.0 → 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/api.d.ts +3 -0
- package/dist/api.js +4 -0
- package/dist/index.d.ts +0 -3
- package/dist/index.js +2 -3
- 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 +8 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as http from "node:http";
|
|
3
|
+
import * as https from "node:https";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { sanitizeNameSegment } from "./types.js";
|
|
8
|
+
/** region → 默认域名映射(未显式配置 api_domain/storage_domain 时使用) */
|
|
9
|
+
const REGION_DOMAINS = {
|
|
10
|
+
CN: { apiDomain: "connect-api.cloud.huawei.com", storageDomain: "ops-server-drcn.agcstorage.link/v0" },
|
|
11
|
+
DE: { apiDomain: "connect-api-dre.cloud.huawei.com", storageDomain: "ops-server-dre.agcstorage.link/v0" },
|
|
12
|
+
SG: { apiDomain: "connect-api-dra.cloud.huawei.com", storageDomain: "ops-server-dra.agcstorage.link/v0" },
|
|
13
|
+
RU: { apiDomain: "connect-api-drru.cloud.huawei.com", storageDomain: "ops-server-drru.agcstorage.link/v0" },
|
|
14
|
+
};
|
|
15
|
+
/** token 有效期安全余量(秒):剩余时间小于此值即重新获取 */
|
|
16
|
+
const TOKEN_MARGIN_SECONDS = 60;
|
|
17
|
+
const TOKEN_TIMEOUT_MS = 30_000;
|
|
18
|
+
const UPLOAD_TIMEOUT_MS = 10 * 60_000;
|
|
19
|
+
/** AGC 凭证默认文件:包内 assets/agc-apiclient.json(opencode.json 未配置 agcConfigPath 时使用) */
|
|
20
|
+
export function defaultAgcConfigPath() {
|
|
21
|
+
return fileURLToPath(new URL("../../../assets/agc-apiclient.json", import.meta.url));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 读取并校验 agc-apiclient.json:必填字段齐全、域名按 region 映射
|
|
25
|
+
* (显式配置的 api_domain/storage_domain 优先)。失败返回原因,不抛异常。
|
|
26
|
+
*
|
|
27
|
+
* @param configPath agc-apiclient.json 绝对路径
|
|
28
|
+
* @param tokenCacheDir token 缓存目录覆盖(测试用)
|
|
29
|
+
* @returns 成功返回完整凭证;失败返回原因
|
|
30
|
+
*/
|
|
31
|
+
export function loadAgcCredentials(configPath, tokenCacheDir) {
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = fs.readFileSync(configPath, "utf-8");
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return { ok: false, reason: `读取配置失败 ${configPath}: ${String(err)}` };
|
|
38
|
+
}
|
|
39
|
+
let config;
|
|
40
|
+
try {
|
|
41
|
+
config = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return { ok: false, reason: `配置不是合法 JSON: ${String(err)}` };
|
|
45
|
+
}
|
|
46
|
+
const client_id = config.client_id;
|
|
47
|
+
const client_secret = config.client_secret;
|
|
48
|
+
const project_id = config.project_id;
|
|
49
|
+
const region = config.region;
|
|
50
|
+
const bucket_name = config.bucket_name;
|
|
51
|
+
if (!client_id || !client_secret || !project_id || !region) {
|
|
52
|
+
return { ok: false, reason: "配置缺少 client_id / client_secret / project_id / region" };
|
|
53
|
+
}
|
|
54
|
+
if (!bucket_name) {
|
|
55
|
+
return { ok: false, reason: "配置缺少 bucket_name" };
|
|
56
|
+
}
|
|
57
|
+
const mapped = REGION_DOMAINS[region];
|
|
58
|
+
const api_domain = config.api_domain || mapped?.apiDomain || "";
|
|
59
|
+
const storage_domain = config.storage_domain || mapped?.storageDomain || "";
|
|
60
|
+
if (!api_domain || !storage_domain) {
|
|
61
|
+
return { ok: false, reason: `无法确定域名(region=${region}),请显式配置 api_domain / storage_domain` };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
credentials: { client_id, client_secret, project_id, region, bucket_name, api_domain, storage_domain, tokenCacheDir },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 创建 AGC 上传器(token 自动获取与缓存 + 流式 PUT)。
|
|
70
|
+
*
|
|
71
|
+
* @param credentials 已解析的凭证
|
|
72
|
+
*/
|
|
73
|
+
export function createAgcUploader(credentials) {
|
|
74
|
+
return {
|
|
75
|
+
async uploadPackage(uploadPackage) {
|
|
76
|
+
const tokenOutcome = await getAccessToken(credentials);
|
|
77
|
+
if (!tokenOutcome.ok)
|
|
78
|
+
return tokenOutcome;
|
|
79
|
+
const remoteBase = composeRemoteBase(uploadPackage);
|
|
80
|
+
const archiveOutcome = await uploadFile(credentials, tokenOutcome.token, uploadPackage.archive.filePath, `${remoteBase}.tar.gz`);
|
|
81
|
+
if (!archiveOutcome.ok)
|
|
82
|
+
return archiveOutcome;
|
|
83
|
+
return uploadFile(credentials, tokenOutcome.token, uploadPackage.snapshot.filePath, `${remoteBase}.json`);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** 获取 access token:优先读缓存(留 60s 余量),否则 client_credentials 换取并写缓存 */
|
|
88
|
+
async function getAccessToken(credentials) {
|
|
89
|
+
const cacheDir = credentials.tokenCacheDir ?? path.join(os.homedir(), ".agc_cache");
|
|
90
|
+
const cacheFile = path.join(cacheDir, `token_${credentials.client_id}.cache`);
|
|
91
|
+
try {
|
|
92
|
+
const cache = JSON.parse(fs.readFileSync(cacheFile, "utf-8"));
|
|
93
|
+
if (cache.access_token && Date.now() / 1000 < (cache.expires_at ?? 0) - TOKEN_MARGIN_SECONDS) {
|
|
94
|
+
return { ok: true, token: cache.access_token };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// 无缓存或缓存损坏,走获取流程
|
|
99
|
+
}
|
|
100
|
+
let response;
|
|
101
|
+
try {
|
|
102
|
+
response = await fetch(`${withScheme(credentials.api_domain)}/api/oauth2/v1/token`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
grant_type: "client_credentials",
|
|
107
|
+
client_id: credentials.client_id,
|
|
108
|
+
client_secret: credentials.client_secret,
|
|
109
|
+
}),
|
|
110
|
+
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return { ok: false, retryable: true, reason: `token 请求失败: ${describeError(err)}` };
|
|
115
|
+
}
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
return { ok: false, retryable: response.status >= 500, reason: `token 响应 ${response.status}: ${await safeText(response)}` };
|
|
118
|
+
}
|
|
119
|
+
const result = (await response.json().catch(() => null));
|
|
120
|
+
if (!result?.access_token) {
|
|
121
|
+
return { ok: false, retryable: false, reason: "token 响应缺少 access_token" };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
125
|
+
fs.writeFileSync(cacheFile, JSON.stringify({
|
|
126
|
+
access_token: result.access_token,
|
|
127
|
+
expires_at: Date.now() / 1000 + (result.expires_in ?? 172800),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// 缓存写失败不影响本次上传
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, token: result.access_token };
|
|
134
|
+
}
|
|
135
|
+
/** 流式上传单文件到云存储对象 */
|
|
136
|
+
/** 流式上传单文件到云存储对象(原生 http/https:AGC 网关硬性要求显式 Content-Length,拒绝 chunked;fetch 规范禁止手动设置该头) */
|
|
137
|
+
async function uploadFile(credentials, token, localPath, remoteObject) {
|
|
138
|
+
let sizeBytes;
|
|
139
|
+
try {
|
|
140
|
+
sizeBytes = fs.statSync(localPath).size;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return { ok: false, retryable: false, reason: `文件不存在 ${localPath}: ${String(err)}` };
|
|
144
|
+
}
|
|
145
|
+
const uploadUrl = `${withScheme(credentials.storage_domain)}/${credentials.bucket_name}/${encodeURI(remoteObject)}`;
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
let url;
|
|
148
|
+
try {
|
|
149
|
+
url = new URL(uploadUrl);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return resolve({ ok: false, retryable: false, reason: `上传地址无效: ${uploadUrl}` });
|
|
153
|
+
}
|
|
154
|
+
const transport = url.protocol === "http:" ? http : https;
|
|
155
|
+
const request = transport.request({
|
|
156
|
+
hostname: url.hostname,
|
|
157
|
+
port: url.port || (url.protocol === "http:" ? 80 : 443),
|
|
158
|
+
path: `${url.pathname}${url.search}`,
|
|
159
|
+
method: "PUT",
|
|
160
|
+
headers: {
|
|
161
|
+
client_id: credentials.client_id,
|
|
162
|
+
productId: credentials.project_id,
|
|
163
|
+
Authorization: `Bearer ${token}`,
|
|
164
|
+
"Content-Type": "application/octet-stream",
|
|
165
|
+
"X-Agc-File-Size": String(sizeBytes),
|
|
166
|
+
"Content-Length": String(sizeBytes),
|
|
167
|
+
},
|
|
168
|
+
}, (response) => {
|
|
169
|
+
let bodyText = "";
|
|
170
|
+
response.setEncoding("utf-8");
|
|
171
|
+
response.on("data", (chunk) => {
|
|
172
|
+
if (bodyText.length < 512)
|
|
173
|
+
bodyText += chunk;
|
|
174
|
+
});
|
|
175
|
+
response.on("end", () => {
|
|
176
|
+
const status = response.statusCode ?? 0;
|
|
177
|
+
if (status >= 200 && status < 300) {
|
|
178
|
+
resolve({ ok: true });
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
resolve({
|
|
182
|
+
ok: false,
|
|
183
|
+
retryable: status >= 500 || status === 429,
|
|
184
|
+
reason: `上传响应 ${status}: ${bodyText.trim().slice(0, 512)}`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
response.on("error", (err) => resolve({ ok: false, retryable: true, reason: `上传失败: ${describeError(err)}` }));
|
|
189
|
+
});
|
|
190
|
+
request.setTimeout(UPLOAD_TIMEOUT_MS, () => {
|
|
191
|
+
request.destroy(new Error(`上传超时(${UPLOAD_TIMEOUT_MS}ms)`));
|
|
192
|
+
});
|
|
193
|
+
request.on("error", (err) => resolve({ ok: false, retryable: true, reason: `上传失败: ${describeError(err)}` }));
|
|
194
|
+
const fileStream = fs.createReadStream(localPath);
|
|
195
|
+
fileStream.on("error", (err) => request.destroy(err));
|
|
196
|
+
fileStream.pipe(request);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 缩写 agent 名用于云端路径段:按 "-" 分段取各段首字母(harmonyos-convert→hc),
|
|
201
|
+
* 单段名取前 3 字符(explore→exp),空名兜底 agent。
|
|
202
|
+
*
|
|
203
|
+
* @param agentName 触发时会话的 agent 名
|
|
204
|
+
* @returns 缩写后的安全路径段
|
|
205
|
+
*/
|
|
206
|
+
export function abbreviateAgentName(agentName) {
|
|
207
|
+
const segments = agentName.split("-").filter((segment) => segment.length > 0);
|
|
208
|
+
if (segments.length === 0)
|
|
209
|
+
return "agent";
|
|
210
|
+
const abbreviation = segments.length === 1 ? segments[0].slice(0, 3) : segments.map((segment) => segment[0]).join("");
|
|
211
|
+
return sanitizeNameSegment(abbreviation, "agent");
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 组装云端对象路径前缀(含版本目录,双文件归拢其内):
|
|
215
|
+
* YYYYMMDD/<projectName>-<sessionId>/<agent缩写>-<trigger>-v<n>/<agent缩写>-<trigger>-v<n>
|
|
216
|
+
*/
|
|
217
|
+
function composeRemoteBase(uploadPackage) {
|
|
218
|
+
const now = new Date();
|
|
219
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
220
|
+
const dateDirectory = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
221
|
+
const fileBase = `${abbreviateAgentName(uploadPackage.agentName)}-${uploadPackage.trigger}-v${uploadPackage.sequence}`;
|
|
222
|
+
return `${dateDirectory}/${uploadPackage.projectName}-${uploadPackage.sessionId}/${fileBase}/${fileBase}`;
|
|
223
|
+
}
|
|
224
|
+
/** 归档包云端对象完整路径(上传成功日志用) */
|
|
225
|
+
export function remoteArchiveKey(uploadPackage) {
|
|
226
|
+
return `${composeRemoteBase(uploadPackage)}.tar.gz`;
|
|
227
|
+
}
|
|
228
|
+
/** 域名字段兼容完整 URL(http:// 前缀用于本地联调),裸域名补 https:// */
|
|
229
|
+
function withScheme(domain) {
|
|
230
|
+
return domain.startsWith("http://") || domain.startsWith("https://") ? domain : `https://${domain}`;
|
|
231
|
+
}
|
|
232
|
+
async function safeText(response) {
|
|
233
|
+
try {
|
|
234
|
+
return (await response.text()).slice(0, 512);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return "";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function describeError(err) {
|
|
241
|
+
if (err instanceof Error)
|
|
242
|
+
return `${err.name}: ${err.message}`;
|
|
243
|
+
return String(err);
|
|
244
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ArchiveResult } from "./types.js";
|
|
2
|
+
export interface ArchiveSessionOptions {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
/** 触发场景标签(文件名的一段) */
|
|
5
|
+
trigger: string;
|
|
6
|
+
/** 触发时会话的 agent 名(文件名的一段) */
|
|
7
|
+
agentName: string;
|
|
8
|
+
/** 安卓项目名(包名或目录名兜底,文件名的一段) */
|
|
9
|
+
projectName: string;
|
|
10
|
+
/** workspace 产物根(空数组 = 只打恒含项) */
|
|
11
|
+
roots: string[];
|
|
12
|
+
/** 快照目录(恒含 <sessionId>.json) */
|
|
13
|
+
metricsDir: string;
|
|
14
|
+
/** 事件日志目录(恒含 <sessionId>.log 及轮转 <sessionId>.*.log) */
|
|
15
|
+
eventsDir: string;
|
|
16
|
+
/** 暂存目录(双文件输出位置) */
|
|
17
|
+
stagingDir: string;
|
|
18
|
+
/** 追加黑名单(与默认合并) */
|
|
19
|
+
blacklist?: string[];
|
|
20
|
+
/** 剔除根内嵌套的安卓工程子目录(深度 ≤2),缺省 true */
|
|
21
|
+
excludeAndroidProjects?: boolean;
|
|
22
|
+
/** 单包体积上限,超限该次失败 */
|
|
23
|
+
maxArtifactBytes?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 打包一次上传产物:恒含项(快照 + 事件日志)与各 workspace 根打成单个 tar.gz,
|
|
27
|
+
* 同时把时点快照单独复制一份,两个文件输出到暂存目录。
|
|
28
|
+
* 命名 <sessionId>-<agentName>-<trigger>-v<n>.tar.gz 与同名 .json。
|
|
29
|
+
* 失败不抛异常,返回 { ok: false, reason }。
|
|
30
|
+
*
|
|
31
|
+
* @param options 归档参数(会话、根、目录、限制项)
|
|
32
|
+
* @returns 成功返回产物包信息;失败返回原因
|
|
33
|
+
*/
|
|
34
|
+
export declare function archiveSessionPackage(options: ArchiveSessionOptions): Promise<ArchiveResult>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { log } from "../../shared/log.js";
|
|
6
|
+
import { looksLikeAndroidProject } from "./workspaceResolver.js";
|
|
7
|
+
import { DEFAULT_BLACKLIST, DEFAULT_MAX_ARTIFACT_BYTES, sanitizeNameSegment, } from "./types.js";
|
|
8
|
+
// 会话维度单调序号:v1, v2, ...(进程内;硬杀丢失为 v1 已知限制)
|
|
9
|
+
const sessionSequence = new Map();
|
|
10
|
+
let tarAvailable = null;
|
|
11
|
+
/**
|
|
12
|
+
* 打包一次上传产物:恒含项(快照 + 事件日志)与各 workspace 根打成单个 tar.gz,
|
|
13
|
+
* 同时把时点快照单独复制一份,两个文件输出到暂存目录。
|
|
14
|
+
* 命名 <sessionId>-<agentName>-<trigger>-v<n>.tar.gz 与同名 .json。
|
|
15
|
+
* 失败不抛异常,返回 { ok: false, reason }。
|
|
16
|
+
*
|
|
17
|
+
* @param options 归档参数(会话、根、目录、限制项)
|
|
18
|
+
* @returns 成功返回产物包信息;失败返回原因
|
|
19
|
+
*/
|
|
20
|
+
export async function archiveSessionPackage(options) {
|
|
21
|
+
const { sessionId, roots, metricsDir, eventsDir } = options;
|
|
22
|
+
if (!(await isTarAvailable())) {
|
|
23
|
+
return fail("tar-unavailable", "系统 tar 不可用");
|
|
24
|
+
}
|
|
25
|
+
const snapshotSource = path.join(metricsDir, `${sessionId}.json`);
|
|
26
|
+
if (!fs.existsSync(snapshotSource)) {
|
|
27
|
+
return fail("snapshot-missing", `快照不存在: ${snapshotSource}`);
|
|
28
|
+
}
|
|
29
|
+
const stagingDir = options.stagingDir;
|
|
30
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
31
|
+
const sequence = (sessionSequence.get(sessionId) ?? 0) + 1;
|
|
32
|
+
sessionSequence.set(sessionId, sequence);
|
|
33
|
+
const agentLabel = sanitizeNameSegment(options.agentName, "agent");
|
|
34
|
+
const projectLabel = sanitizeNameSegment(options.projectName);
|
|
35
|
+
const triggerLabel = sanitizeNameSegment(options.trigger, "upload");
|
|
36
|
+
const baseName = `${sessionId}-${agentLabel}-${triggerLabel}-v${sequence}`;
|
|
37
|
+
const archivePath = path.join(stagingDir, `${baseName}.tar.gz`);
|
|
38
|
+
const snapshotPath = path.join(stagingDir, `${baseName}.json`);
|
|
39
|
+
const tmpPath = `${archivePath}.tmp`;
|
|
40
|
+
// 组装恒含项(复制小文件,workspace 根原位引用)
|
|
41
|
+
const assemblyDir = path.join(stagingDir, `.assembly-${sessionId}-v${sequence}`);
|
|
42
|
+
try {
|
|
43
|
+
fs.rmSync(assemblyDir, { recursive: true, force: true });
|
|
44
|
+
fs.mkdirSync(path.join(assemblyDir, "snapshot"), { recursive: true });
|
|
45
|
+
fs.mkdirSync(path.join(assemblyDir, "events"), { recursive: true });
|
|
46
|
+
fs.copyFileSync(snapshotSource, path.join(assemblyDir, "snapshot", `${sessionId}.json`));
|
|
47
|
+
for (const logFile of collectEventLogs(sessionId, eventsDir)) {
|
|
48
|
+
fs.copyFileSync(logFile, path.join(assemblyDir, "events", path.basename(logFile)));
|
|
49
|
+
}
|
|
50
|
+
const { args, excludedAndroid } = buildTarArgs(tmpPath, assemblyDir, roots, options.blacklist ?? [], options.excludeAndroidProjects !== false);
|
|
51
|
+
const tarError = await runTar(args);
|
|
52
|
+
if (tarError) {
|
|
53
|
+
return fail("tar-failed", tarError);
|
|
54
|
+
}
|
|
55
|
+
fs.renameSync(tmpPath, archivePath);
|
|
56
|
+
const sizeBytes = fs.statSync(archivePath).size;
|
|
57
|
+
const maxBytes = options.maxArtifactBytes ?? DEFAULT_MAX_ARTIFACT_BYTES;
|
|
58
|
+
if (sizeBytes > maxBytes) {
|
|
59
|
+
fs.rmSync(archivePath, { force: true });
|
|
60
|
+
return fail("too-large", `包体积 ${sizeBytes} 超过上限 ${maxBytes}`);
|
|
61
|
+
}
|
|
62
|
+
// 快照单独一份(与包内时点一致)
|
|
63
|
+
fs.copyFileSync(path.join(assemblyDir, "snapshot", `${sessionId}.json`), snapshotPath);
|
|
64
|
+
const sha256 = await computeSha256(archivePath);
|
|
65
|
+
log.info("[Metrics] archived", { sessionId, trigger: triggerLabel, baseName, sizeBytes, excludedAndroid });
|
|
66
|
+
const uploadPackage = {
|
|
67
|
+
sessionId,
|
|
68
|
+
trigger: triggerLabel,
|
|
69
|
+
sequence,
|
|
70
|
+
agentName: agentLabel,
|
|
71
|
+
projectName: projectLabel,
|
|
72
|
+
archive: { fileName: `${baseName}.tar.gz`, filePath: archivePath, sizeBytes },
|
|
73
|
+
snapshot: {
|
|
74
|
+
fileName: `${baseName}.json`,
|
|
75
|
+
filePath: snapshotPath,
|
|
76
|
+
sizeBytes: fs.statSync(snapshotPath).size,
|
|
77
|
+
},
|
|
78
|
+
sha256,
|
|
79
|
+
roots: [...roots],
|
|
80
|
+
createdAt: Date.now(),
|
|
81
|
+
};
|
|
82
|
+
return { ok: true, package: uploadPackage };
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
// 失败时清掉已产生的半成品,不留脏文件
|
|
86
|
+
fs.rmSync(archivePath, { force: true });
|
|
87
|
+
fs.rmSync(snapshotPath, { force: true });
|
|
88
|
+
return fail("archive-error", String(err));
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
fs.rmSync(assemblyDir, { recursive: true, force: true });
|
|
92
|
+
if (fs.existsSync(tmpPath))
|
|
93
|
+
fs.rmSync(tmpPath, { force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** 组装 tar 参数:黑名单排除 + 嵌套安卓工程剔除 + 恒含项 + 各 workspace 根(按 basename 入包,重名加序号后缀);全部 --exclude 须在任何操作数之前(bsdtar 不解析操作数之后的选项) */
|
|
97
|
+
function buildTarArgs(output, assemblyDir, roots, extraBlacklist, excludeAndroidProjects) {
|
|
98
|
+
const blacklist = [...DEFAULT_BLACKLIST, ...extraBlacklist];
|
|
99
|
+
const excludedAndroid = [];
|
|
100
|
+
// 根条目名先就位(重名加序号),安卓剔除以条目名锚定
|
|
101
|
+
const usedNames = new Set();
|
|
102
|
+
const rootEntries = roots.map((root) => {
|
|
103
|
+
let entryName = path.basename(path.resolve(root)) || "workspace";
|
|
104
|
+
if (usedNames.has(entryName.toLowerCase())) {
|
|
105
|
+
entryName = `${entryName}-${usedNames.size + 1}`;
|
|
106
|
+
}
|
|
107
|
+
usedNames.add(entryName.toLowerCase());
|
|
108
|
+
return { root, entryName };
|
|
109
|
+
});
|
|
110
|
+
const args = ["-czf", output];
|
|
111
|
+
for (const name of blacklist) {
|
|
112
|
+
// 裸名匹配末段(GNU tar),*/名匹配任意深度(bsdtar)
|
|
113
|
+
args.push("--exclude", name, "--exclude", `*/${name}`);
|
|
114
|
+
}
|
|
115
|
+
if (excludeAndroidProjects) {
|
|
116
|
+
for (const { root, entryName } of rootEntries) {
|
|
117
|
+
for (const relativePath of detectNestedAndroidDirs(root, blacklist)) {
|
|
118
|
+
excludedAndroid.push(`${entryName}/${relativePath}`);
|
|
119
|
+
// 锚定到该根条目下,避免误伤其他根的同名目录
|
|
120
|
+
args.push("--exclude", `${entryName}/${relativePath}`, "--exclude", `*/${entryName}/${relativePath}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
args.push("-C", assemblyDir, ".");
|
|
125
|
+
for (const { root, entryName } of rootEntries) {
|
|
126
|
+
args.push("-C", path.dirname(path.resolve(root)), entryName);
|
|
127
|
+
}
|
|
128
|
+
return { args, excludedAndroid };
|
|
129
|
+
}
|
|
130
|
+
/** 扫描根的直接与二级子目录,返回识别为安卓工程的相对路径(命中即止,不再深入) */
|
|
131
|
+
function detectNestedAndroidDirs(root, blacklist) {
|
|
132
|
+
const found = [];
|
|
133
|
+
const queue = [
|
|
134
|
+
{ directory: root, relativePath: "", depth: 0 },
|
|
135
|
+
];
|
|
136
|
+
const skipped = new Set(blacklist.map((name) => name.toLowerCase()));
|
|
137
|
+
while (queue.length > 0) {
|
|
138
|
+
const { directory, relativePath, depth } = queue.shift();
|
|
139
|
+
let entries;
|
|
140
|
+
try {
|
|
141
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (!entry.isDirectory() || skipped.has(entry.name.toLowerCase()))
|
|
148
|
+
continue;
|
|
149
|
+
const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
150
|
+
const childDirectory = path.join(directory, entry.name);
|
|
151
|
+
if (looksLikeAndroidProject(childDirectory)) {
|
|
152
|
+
found.push(childRelative);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (depth + 1 < 2) {
|
|
156
|
+
queue.push({ directory: childDirectory, relativePath: childRelative, depth: depth + 1 });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
/** 收集会话事件日志:当前日志 + 轮转副本 */
|
|
163
|
+
function collectEventLogs(sessionId, eventsDir) {
|
|
164
|
+
try {
|
|
165
|
+
return fs
|
|
166
|
+
.readdirSync(eventsDir)
|
|
167
|
+
.filter((file) => file === `${sessionId}.log` ||
|
|
168
|
+
(file.startsWith(`${sessionId}.`) && file.endsWith(".log")))
|
|
169
|
+
.map((file) => path.join(eventsDir, file));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** 运行 tar,返回错误描述(null = 成功) */
|
|
176
|
+
function runTar(args) {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const child = spawn("tar", args, { windowsHide: true });
|
|
179
|
+
let stderr = "";
|
|
180
|
+
child.stderr.on("data", (chunk) => {
|
|
181
|
+
stderr += chunk.toString();
|
|
182
|
+
if (stderr.length > 4096)
|
|
183
|
+
stderr = stderr.slice(-4096);
|
|
184
|
+
});
|
|
185
|
+
child.on("error", (err) => resolve(String(err)));
|
|
186
|
+
child.on("close", (code) => resolve(code === 0 ? null : `tar 退出码 ${code}: ${stderr.trim()}`));
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/** 探测系统 tar 是否可用(结果缓存) */
|
|
190
|
+
async function isTarAvailable() {
|
|
191
|
+
if (tarAvailable !== null)
|
|
192
|
+
return tarAvailable;
|
|
193
|
+
tarAvailable = await new Promise((resolve) => {
|
|
194
|
+
const child = spawn("tar", ["--version"], { windowsHide: true });
|
|
195
|
+
child.on("error", () => resolve(false));
|
|
196
|
+
child.on("close", (code) => resolve(code === 0));
|
|
197
|
+
});
|
|
198
|
+
return tarAvailable;
|
|
199
|
+
}
|
|
200
|
+
/** 流式计算文件 sha256 */
|
|
201
|
+
async function computeSha256(filePath) {
|
|
202
|
+
const hash = createHash("sha256");
|
|
203
|
+
const stream = fs.createReadStream(filePath);
|
|
204
|
+
for await (const chunk of stream) {
|
|
205
|
+
hash.update(chunk);
|
|
206
|
+
}
|
|
207
|
+
return hash.digest("hex");
|
|
208
|
+
}
|
|
209
|
+
/** 统一失败出口:记日志并返回结果对象 */
|
|
210
|
+
function fail(code, reason) {
|
|
211
|
+
log.error("[Metrics] archive failed", { code, reason });
|
|
212
|
+
return { ok: false, reason: `${code}: ${reason}` };
|
|
213
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ScenarioConfig } from "./types.js";
|
|
2
|
+
/** 解析后的触发场景:label 用于文件命名,commands/events 为两类匹配源 */
|
|
3
|
+
export interface ResolvedScenario {
|
|
4
|
+
key: string;
|
|
5
|
+
/** 命名标签(文件名 <sessionId>-<agentName>-<label>-v<n> 的一段) */
|
|
6
|
+
label: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
/** bash 命令正则(工具 completed 时判定) */
|
|
9
|
+
commandRegexes: RegExp[];
|
|
10
|
+
/** 匹配的事件名(含特殊值 "dispose",由管理器结束时触发) */
|
|
11
|
+
events: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events 可覆盖、
|
|
15
|
+
* enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
|
|
16
|
+
*
|
|
17
|
+
* @param configs opencode.json upload.scenarios
|
|
18
|
+
* @returns 全部场景(含禁用的,调用方按 enabled 过滤)
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveScenarios(configs?: Record<string, ScenarioConfig>): ResolvedScenario[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { log } from "../../shared/log.js";
|
|
2
|
+
/** 内置默认场景(opencode.json scenarios 按 key 合并覆盖) */
|
|
3
|
+
const BUILTIN_SCENARIOS = {
|
|
4
|
+
"session-end": { label: "session-end", events: ["dispose", "session.deleted"] },
|
|
5
|
+
"git-commit": { label: "git", commands: ["\\bgit\\s+commit\\b"] },
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events 可覆盖、
|
|
9
|
+
* enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
|
|
10
|
+
*
|
|
11
|
+
* @param configs opencode.json upload.scenarios
|
|
12
|
+
* @returns 全部场景(含禁用的,调用方按 enabled 过滤)
|
|
13
|
+
*/
|
|
14
|
+
export function resolveScenarios(configs) {
|
|
15
|
+
const merged = new Map();
|
|
16
|
+
for (const [key, builtin] of Object.entries(BUILTIN_SCENARIOS)) {
|
|
17
|
+
merged.set(key, { ...builtin, ...(configs?.[key] ?? {}) });
|
|
18
|
+
}
|
|
19
|
+
for (const [key, config] of Object.entries(configs ?? {})) {
|
|
20
|
+
if (!merged.has(key))
|
|
21
|
+
merged.set(key, config);
|
|
22
|
+
}
|
|
23
|
+
const result = [];
|
|
24
|
+
for (const [key, config] of merged) {
|
|
25
|
+
const commandRegexes = [];
|
|
26
|
+
for (const pattern of config.commands ?? []) {
|
|
27
|
+
try {
|
|
28
|
+
commandRegexes.push(new RegExp(pattern));
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
log.warn("[Metrics] 场景正则无效,已跳过", { key, pattern, error: String(err) });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
result.push({
|
|
35
|
+
key,
|
|
36
|
+
label: config.label ?? key,
|
|
37
|
+
enabled: config.enabled !== false,
|
|
38
|
+
commandRegexes,
|
|
39
|
+
events: config.events ?? [],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** 追加产物的命中方式 */
|
|
2
|
+
export type ArtifactMatchBy = "session-activity" | "existence";
|
|
3
|
+
/**
|
|
4
|
+
* opencode.json upload.appendArtifacts 的单条规则。
|
|
5
|
+
* matchBy = "session-activity" 时须被会话修改过程命中才纳入;
|
|
6
|
+
* "existence" 时触发时刻路径存在即纳入。
|
|
7
|
+
*/
|
|
8
|
+
export interface ArtifactRule {
|
|
9
|
+
/** 产物标识,用于日志与包内条目命名 */
|
|
10
|
+
name: string;
|
|
11
|
+
/** 显式产物路径(绝对或相对会话工作目录) */
|
|
12
|
+
path?: string;
|
|
13
|
+
/** 追加目录标记(探测规则同内置 dirMarkers) */
|
|
14
|
+
dirMarkers?: string[];
|
|
15
|
+
/** 追加文件标记(探测规则同内置 fileMarkers) */
|
|
16
|
+
fileMarkers?: string[];
|
|
17
|
+
matchBy?: ArtifactMatchBy;
|
|
18
|
+
}
|
|
19
|
+
/** 触发场景配置:按 key 与内置场景合并,enabled:false 禁用 */
|
|
20
|
+
export interface ScenarioConfig {
|
|
21
|
+
enabled?: boolean;
|
|
22
|
+
/** 命名标签,用于包名 <sessionId>-<label>-v<n> */
|
|
23
|
+
label?: string;
|
|
24
|
+
/** bash 工具命令正则(completed 时判定) */
|
|
25
|
+
commands?: string[];
|
|
26
|
+
/** TRACKED_EVENT_TYPES 内事件名 */
|
|
27
|
+
events?: string[];
|
|
28
|
+
}
|
|
29
|
+
/** 上传通道配置(当前仅 agc) */
|
|
30
|
+
export interface UploaderConfig {
|
|
31
|
+
type?: string;
|
|
32
|
+
/** agc-apiclient.json 绝对路径 */
|
|
33
|
+
agcConfigPath?: string;
|
|
34
|
+
}
|
|
35
|
+
/** 重试策略:指数退避 backoffMs × 4^n,4xx 不重试 */
|
|
36
|
+
export interface UploadRetryConfig {
|
|
37
|
+
maxAttempts?: number;
|
|
38
|
+
backoffMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** opencode.json 的 upload 配置整块(§13.8) */
|
|
41
|
+
export interface UploadConfig {
|
|
42
|
+
/** 上传总开关,缺省 false(行为与 0.2.x 零差异) */
|
|
43
|
+
enabled?: boolean;
|
|
44
|
+
uploader?: UploaderConfig;
|
|
45
|
+
scenarios?: Record<string, ScenarioConfig>;
|
|
46
|
+
appendArtifacts?: ArtifactRule[];
|
|
47
|
+
/** 打包排除的目录/文件名,追加到默认黑名单 */
|
|
48
|
+
blacklist?: string[];
|
|
49
|
+
/** 剔除安卓工程(根级剔除 + 根内子树剔除),缺省 true */
|
|
50
|
+
excludeAndroidProjects?: boolean;
|
|
51
|
+
/** 单包体积上限,超限该次标 failed */
|
|
52
|
+
maxArtifactBytes?: number;
|
|
53
|
+
retry?: UploadRetryConfig;
|
|
54
|
+
}
|
|
55
|
+
/** 暂存目录内的单个上传文件(归档包或快照) */
|
|
56
|
+
export interface UploadPackageFile {
|
|
57
|
+
fileName: string;
|
|
58
|
+
/** 暂存目录内绝对路径 */
|
|
59
|
+
filePath: string;
|
|
60
|
+
sizeBytes: number;
|
|
61
|
+
}
|
|
62
|
+
/** 一次归档的结果:进上传队列的单元,一个任务上传两个文件(tar.gz + 快照 JSON) */
|
|
63
|
+
export interface UploadPackage {
|
|
64
|
+
sessionId: string;
|
|
65
|
+
/** 触发场景名(文件名中的 label 段) */
|
|
66
|
+
trigger: string;
|
|
67
|
+
/** 会话内单调递增序号,从 1 起 */
|
|
68
|
+
sequence: number;
|
|
69
|
+
/** 触发时会话的 agent 名(云端路径一段) */
|
|
70
|
+
agentName: string;
|
|
71
|
+
/** 安卓包名;探测失败时为目录名兜底(云端路径一段) */
|
|
72
|
+
projectName: string;
|
|
73
|
+
/** 归档包(tar.gz) */
|
|
74
|
+
archive: UploadPackageFile;
|
|
75
|
+
/** 快照 JSON(单独上传的一份) */
|
|
76
|
+
snapshot: UploadPackageFile;
|
|
77
|
+
/** 归档包 sha256 */
|
|
78
|
+
sha256: string;
|
|
79
|
+
/** 纳入的 workspace 产物根 */
|
|
80
|
+
roots: string[];
|
|
81
|
+
createdAt: number;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 清洗名称为安全的路径段:非法字符(含空格)替换为 "-",保留中文与内部点号,
|
|
85
|
+
* 首尾修剪 "." "-",结果为空时返回 fallback。
|
|
86
|
+
*
|
|
87
|
+
* @param name 原始名称
|
|
88
|
+
* @param fallback 清洗后为空时的替代值
|
|
89
|
+
* @returns 可安全用于文件名/云端路径的段
|
|
90
|
+
*/
|
|
91
|
+
export declare function sanitizeNameSegment(name: string, fallback?: string): string;
|
|
92
|
+
/** 归档结果:成功返回产物包,失败返回原因(不抛异常) */
|
|
93
|
+
export type ArchiveResult = {
|
|
94
|
+
ok: true;
|
|
95
|
+
package: UploadPackage;
|
|
96
|
+
} | {
|
|
97
|
+
ok: false;
|
|
98
|
+
reason: string;
|
|
99
|
+
};
|
|
100
|
+
/** 目录内存在这些子目录 → 该目录是产物根 */
|
|
101
|
+
export declare const DEFAULT_DIR_MARKERS: readonly string[];
|
|
102
|
+
/** 目录内存在这些文件/目录 → 该目录是产物根 */
|
|
103
|
+
export declare const DEFAULT_FILE_MARKERS: readonly string[];
|
|
104
|
+
/** 打包排除的目录/文件名(按路径段匹配) */
|
|
105
|
+
export declare const DEFAULT_BLACKLIST: readonly string[];
|
|
106
|
+
/** 单包体积上限默认 500MB */
|
|
107
|
+
export declare const DEFAULT_MAX_ARTIFACT_BYTES: number;
|
|
108
|
+
/** 自证据路径向上探测标记的最大层级 */
|
|
109
|
+
export declare const MAX_PROBE_DEPTH = 5;
|
|
110
|
+
/** 产物根数量上限 */
|
|
111
|
+
export declare const MAX_ROOTS = 3;
|