@modesto_ma/dsh-guanxiang 0.2.0-beta.1 → 0.2.0-beta.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/README.md +74 -10
- package/lib/client.js +243 -7
- package/lib/client.js.map +1 -1
- package/lib/index.js +551 -172
- package/lib/index.js.map +1 -1
- package/package.json +3 -1
- package/src/analyze/contract.ts +124 -0
- package/src/analyze/server.ts +195 -0
- package/src/analyze/topics.ts +109 -0
- package/src/client/page.tsx +90 -6
- package/src/config.ts +16 -2
- package/src/index.ts +12 -6
- package/src/repos/registry.ts +2 -4
- package/src/service.ts +49 -12
- package/src/settings/contract.ts +36 -2
- package/src/skill-update/git.ts +3 -76
- package/src/skill-update/repos.ts +51 -0
- package/src/skill-update/tool.ts +66 -31
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
1
3
|
import { homedir, tmpdir } from "node:os";
|
|
2
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
import { createWriteStream } from "node:fs";
|
|
@@ -21,8 +21,13 @@ const STATE_ROUTE = "/guanxiang/state";
|
|
|
21
21
|
const SETTINGS_ROUTE = "/guanxiang/settings";
|
|
22
22
|
/** POST:立即重新拉取全部仓库,或 body `{ url }` 指定的一个仓库。 */
|
|
23
23
|
const SYNC_ROUTE = "/guanxiang/sync";
|
|
24
|
-
/** PUT:body `{ tokenEnv, value }` 把令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }`
|
|
24
|
+
/** PUT:body `{ tokenEnv, value }` 把令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受已保存的访问令牌配置引用的名称和分析接口令牌的名称。 */
|
|
25
25
|
const TOKEN_ROUTE = "/guanxiang/token";
|
|
26
|
+
/**
|
|
27
|
+
* POST:日志平台分析接口(Bearer 令牌鉴权,不走 DSH 登录)。它在 DSH 的
|
|
28
|
+
* `/api` 命名空间之外,网关可以把它和需要登录的 DSH 接口分开暴露。
|
|
29
|
+
*/
|
|
30
|
+
const ANALYZE_ROUTE = "/open-api/logalyze";
|
|
26
31
|
/** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier), which {@link GitCredentialSpec.tokenEnv} follows. */
|
|
27
32
|
const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
28
33
|
/** Keys of {@link EditableSettings}, in panel order. */
|
|
@@ -34,9 +39,369 @@ const EDITABLE_KEYS = [
|
|
|
34
39
|
"maxAppsPerRequest",
|
|
35
40
|
"dispatchTimeoutMs",
|
|
36
41
|
"download",
|
|
37
|
-
"skillUpdates"
|
|
42
|
+
"skillUpdates",
|
|
43
|
+
"analyze"
|
|
38
44
|
];
|
|
39
45
|
//#endregion
|
|
46
|
+
//#region src/analyze/contract.ts
|
|
47
|
+
/**
|
|
48
|
+
* 日志平台分析接口的请求与响应。
|
|
49
|
+
*
|
|
50
|
+
* `POST /open-api/logalyze`(DSH Web 服务器上),带
|
|
51
|
+
* `Authorization: Bearer <令牌>`、`Idempotency-Key` 请求头和 JSON 请求体:
|
|
52
|
+
* 发起人、触发时间、应用与错误码、日志详情页、目标群和提示词。合法请求把
|
|
53
|
+
* 分析根卡片发进群并回 `202`,分析随后在卡片下的话题里流式进行。日志包由
|
|
54
|
+
* 提示词调用的分析 SKILL 自行获取,详情页只列在卡片上。
|
|
55
|
+
*
|
|
56
|
+
* 接口原由 dsh-channels 0.7 提供;迁到观象后请求、响应和错误文本不变。
|
|
57
|
+
*
|
|
58
|
+
* @module dsh-guanxiang/analyze/contract
|
|
59
|
+
*/
|
|
60
|
+
/** Largest accepted request body (the prompt dominates it). */
|
|
61
|
+
const ANALYZE_MAX_BODY_BYTES = 1024 * 1024;
|
|
62
|
+
/** Largest accepted prompt, in characters. */
|
|
63
|
+
const ANALYZE_MAX_PROMPT_CHARS = 1e5;
|
|
64
|
+
/**
|
|
65
|
+
* `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
|
|
66
|
+
* a platform can use a UUID or its own `type:id` token.
|
|
67
|
+
*/
|
|
68
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u;
|
|
69
|
+
/** Feishu `user_id`: not an `open_id` (`ou_`), `union_id` (`on_`), or chat id (`oc_`). */
|
|
70
|
+
const FEISHU_USER_ID = /^(?!ou_|on_|oc_)[A-Za-z0-9_-]+$/u;
|
|
71
|
+
/**
|
|
72
|
+
* Application / error-code segment: it names a workspace directory
|
|
73
|
+
* (`<baseDir>/analyze/<app>/<errorCode>` below the Feishu channel's
|
|
74
|
+
* `baseDir`), so only path-safe characters pass.
|
|
75
|
+
*/
|
|
76
|
+
const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u;
|
|
77
|
+
/** JSON body of one analyze request. */
|
|
78
|
+
const analyzeRequestSchema = z.object({
|
|
79
|
+
/** Feishu user_id of the person who started the analysis on the platform. */
|
|
80
|
+
requesterUserId: z.string().regex(FEISHU_USER_ID, "must be a Feishu user_id").max(128),
|
|
81
|
+
/** When the analysis was started on the platform, shown verbatim on the root card. */
|
|
82
|
+
triggeredAt: z.string().min(1).max(64).refine((value) => value.trim() !== "", "must not be blank"),
|
|
83
|
+
/** Application the logs belong to (workspace directory segment). */
|
|
84
|
+
app: z.string().regex(SUBJECT_SEGMENT, "must be 1-128 of A-Z a-z 0-9 . _ -"),
|
|
85
|
+
/** Error code of the logs (workspace directory segment). */
|
|
86
|
+
errorCode: z.string().regex(SUBJECT_SEGMENT, "must be 1-128 of A-Z a-z 0-9 . _ -"),
|
|
87
|
+
/** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
|
|
88
|
+
logDetails: z.array(z.object({
|
|
89
|
+
/** Row label. */
|
|
90
|
+
name: z.string().min(1).max(200).refine((value) => value.trim() !== "", "must not be blank"),
|
|
91
|
+
/** Detail page address. */
|
|
92
|
+
url: z.url({
|
|
93
|
+
protocol: /^https?$/u,
|
|
94
|
+
error: "must be an http(s) URL"
|
|
95
|
+
}).max(2048)
|
|
96
|
+
}).strict()).min(1).max(50),
|
|
97
|
+
/** Target group chat (`oc_…`); must be an authorized group. */
|
|
98
|
+
chatId: z.string().regex(/^oc_[A-Za-z0-9_-]{1,128}$/u, "must be a Feishu group chat_id (oc_...)"),
|
|
99
|
+
/** Prompt forwarded verbatim as the analysis Session's first user message. */
|
|
100
|
+
prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS).refine((value) => value.trim() !== "", "must not be blank")
|
|
101
|
+
}).strict();
|
|
102
|
+
/**
|
|
103
|
+
* Fingerprint of one validated request, used to tell a retry of the same
|
|
104
|
+
* request from a reused `Idempotency-Key`. Field order is fixed: dsh-channels
|
|
105
|
+
* 0.7 stored the same digest, so its accepted keys keep replaying.
|
|
106
|
+
* @param request - the validated request.
|
|
107
|
+
* @returns SHA-256 hex digest.
|
|
108
|
+
*/
|
|
109
|
+
function analyzeRequestHash(request) {
|
|
110
|
+
const canonical = JSON.stringify([
|
|
111
|
+
request.requesterUserId,
|
|
112
|
+
request.triggeredAt,
|
|
113
|
+
request.app,
|
|
114
|
+
request.errorCode,
|
|
115
|
+
request.logDetails.map((detail) => [detail.name, detail.url]),
|
|
116
|
+
request.chatId,
|
|
117
|
+
request.prompt
|
|
118
|
+
]);
|
|
119
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Validate one decoded JSON body.
|
|
123
|
+
* @param raw - the decoded body.
|
|
124
|
+
* @returns the request, or a readable list of field errors.
|
|
125
|
+
*/
|
|
126
|
+
function parseAnalyzeRequest(raw) {
|
|
127
|
+
const parsed = analyzeRequestSchema.safeParse(raw);
|
|
128
|
+
if (parsed.success) return {
|
|
129
|
+
ok: true,
|
|
130
|
+
value: parsed.data
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: `请求参数不合法:${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(body)"}: ${issue.message}`).join("; ")}`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/analyze/topics.ts
|
|
139
|
+
/** Name dsh-channels provides the service under. */
|
|
140
|
+
const FEISHU_TOPICS_SERVICE = "feishuTopics";
|
|
141
|
+
/**
|
|
142
|
+
* Classify the value `ctx.get('feishuTopics')` returned. The provider is
|
|
143
|
+
* another package that may be older or newer than this one, so its version
|
|
144
|
+
* is checked at run time.
|
|
145
|
+
* @param value - the looked-up service, or undefined.
|
|
146
|
+
* @returns the usable service, or why there is none.
|
|
147
|
+
*/
|
|
148
|
+
function lookupTopics(value) {
|
|
149
|
+
if (value === void 0 || value === null) return { availability: "missing" };
|
|
150
|
+
const service = value;
|
|
151
|
+
if (service.version !== 1 || typeof service.launch !== "function") return { availability: "incompatible" };
|
|
152
|
+
return {
|
|
153
|
+
availability: "ready",
|
|
154
|
+
service: value
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The topic one analyze request starts.
|
|
159
|
+
* @param request - the validated request.
|
|
160
|
+
* @param idempotencyKey - the request's `Idempotency-Key`, used unchanged as the launch key.
|
|
161
|
+
* @returns the launch spec.
|
|
162
|
+
*/
|
|
163
|
+
function analyzeTopicSpec(request, idempotencyKey) {
|
|
164
|
+
return {
|
|
165
|
+
key: idempotencyKey,
|
|
166
|
+
fingerprint: analyzeRequestHash(request),
|
|
167
|
+
chatId: request.chatId,
|
|
168
|
+
requesterUserId: request.requesterUserId,
|
|
169
|
+
card: {
|
|
170
|
+
title: "日志分析",
|
|
171
|
+
taskName: "分析",
|
|
172
|
+
lead: [
|
|
173
|
+
{ text: `于 ${request.triggeredAt} 发起了对 ` },
|
|
174
|
+
{
|
|
175
|
+
text: request.app,
|
|
176
|
+
bold: true
|
|
177
|
+
},
|
|
178
|
+
{ text: " 应用 " },
|
|
179
|
+
{
|
|
180
|
+
text: request.errorCode,
|
|
181
|
+
bold: true
|
|
182
|
+
},
|
|
183
|
+
{ text: " 错误码不良日志的分析。" }
|
|
184
|
+
],
|
|
185
|
+
links: {
|
|
186
|
+
nameHeader: "日志",
|
|
187
|
+
linkHeader: "详情页",
|
|
188
|
+
rows: request.logDetails
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
workspace: {
|
|
192
|
+
segments: [
|
|
193
|
+
"analyze",
|
|
194
|
+
request.app,
|
|
195
|
+
request.errorCode
|
|
196
|
+
],
|
|
197
|
+
title: `日志分析 · ${request.app} · ${request.errorCode}`
|
|
198
|
+
},
|
|
199
|
+
prompt: request.prompt
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* HTTP answer of one launch result.
|
|
204
|
+
* @param result - what the service answered.
|
|
205
|
+
* @returns status and body.
|
|
206
|
+
*/
|
|
207
|
+
function analyzeResponseOf(result) {
|
|
208
|
+
if (result.ok) return {
|
|
209
|
+
status: 202,
|
|
210
|
+
body: {
|
|
211
|
+
ok: true,
|
|
212
|
+
chatId: result.chatId,
|
|
213
|
+
messageId: result.messageId
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
switch (result.code) {
|
|
217
|
+
case "not-connected": return refused(503, result.message);
|
|
218
|
+
case "forbidden-chat": return refused(403, result.message);
|
|
219
|
+
case "key-conflict": return refused(409, "Idempotency-Key 已被内容不同的请求使用");
|
|
220
|
+
case "send-failed": return refused(502, result.message);
|
|
221
|
+
case "invalid": return refused(500, `分析请求处理失败:${result.message}`);
|
|
222
|
+
default: return refused(500, `分析请求处理失败:${String(result.message)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Start the analysis topic of one validated request.
|
|
227
|
+
* @param topics - the `feishuTopics` service.
|
|
228
|
+
* @param request - the validated request.
|
|
229
|
+
* @param idempotencyKey - the request's `Idempotency-Key`.
|
|
230
|
+
* @returns the HTTP answer.
|
|
231
|
+
*/
|
|
232
|
+
async function launchAnalysis(topics, request, idempotencyKey) {
|
|
233
|
+
return analyzeResponseOf(await topics.launch(analyzeTopicSpec(request, idempotencyKey)));
|
|
234
|
+
}
|
|
235
|
+
function refused(status, error) {
|
|
236
|
+
return {
|
|
237
|
+
status,
|
|
238
|
+
body: {
|
|
239
|
+
ok: false,
|
|
240
|
+
error
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/analyze/server.ts
|
|
246
|
+
/**
|
|
247
|
+
* 日志平台分析接口在 DSH Web 服务器上的路由。
|
|
248
|
+
*
|
|
249
|
+
* 只在宿主同时提供 `webServer` 和版本兼容的 dsh-channels `feishuTopics`
|
|
250
|
+
* 服务时注册精确路由 `POST /open-api/logalyze`;dsh-channels 未加载、重载
|
|
251
|
+
* 中、版本不兼容或为 0.7 及更早版本(自己注册该路由)时观象不注册,两者
|
|
252
|
+
* 不会争用同一路径。
|
|
253
|
+
* 本模块按顺序做传输层检查:路径、开关、方法、Bearer 令牌、
|
|
254
|
+
* `Idempotency-Key`、媒体类型、请求体大小和 JSON 校验;通过后交给
|
|
255
|
+
* `feishuTopics`。请求体、令牌和提示词都不写日志。
|
|
256
|
+
*
|
|
257
|
+
* @module dsh-guanxiang/analyze/server
|
|
258
|
+
*/
|
|
259
|
+
var HttpError$1 = class extends Error {
|
|
260
|
+
status;
|
|
261
|
+
constructor(status, message) {
|
|
262
|
+
super(message);
|
|
263
|
+
this.status = status;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
function send(res, response) {
|
|
267
|
+
const body = JSON.stringify(response.body);
|
|
268
|
+
res.writeHead(response.status, {
|
|
269
|
+
"content-type": "application/json; charset=utf-8",
|
|
270
|
+
"cache-control": "no-store",
|
|
271
|
+
"content-length": Buffer.byteLength(body)
|
|
272
|
+
});
|
|
273
|
+
res.end(body);
|
|
274
|
+
}
|
|
275
|
+
function refuse(res, status, error, headers = {}) {
|
|
276
|
+
for (const [name, value] of Object.entries(headers)) res.setHeader(name, value);
|
|
277
|
+
send(res, {
|
|
278
|
+
status,
|
|
279
|
+
body: {
|
|
280
|
+
ok: false,
|
|
281
|
+
error
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/** Constant-time token comparison over fixed-length digests. */
|
|
286
|
+
function tokenMatches(presented, expected) {
|
|
287
|
+
return timingSafeEqual(createHash("sha256").update(presented).digest(), createHash("sha256").update(expected).digest());
|
|
288
|
+
}
|
|
289
|
+
/** The bearer credential of one request, or undefined. */
|
|
290
|
+
function bearerOf(req) {
|
|
291
|
+
const header = req.headers.authorization;
|
|
292
|
+
if (header === void 0) return void 0;
|
|
293
|
+
return /^Bearer[ \t]+(\S+)[ \t]*$/iu.exec(header)?.[1];
|
|
294
|
+
}
|
|
295
|
+
/** Read the body up to {@link ANALYZE_MAX_BODY_BYTES}; larger bodies fail with 413. */
|
|
296
|
+
async function readBody(req) {
|
|
297
|
+
const declared = Number(req.headers["content-length"]);
|
|
298
|
+
if (Number.isFinite(declared) && declared > 1048576) throw new HttpError$1(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`);
|
|
299
|
+
const chunks = [];
|
|
300
|
+
let size = 0;
|
|
301
|
+
for await (const chunk of req) {
|
|
302
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
303
|
+
size += buffer.length;
|
|
304
|
+
if (size > 1048576) throw new HttpError$1(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`);
|
|
305
|
+
chunks.push(buffer);
|
|
306
|
+
}
|
|
307
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Handle one HTTP request against the analyze API.
|
|
311
|
+
* @param req - the incoming request.
|
|
312
|
+
* @param res - its response.
|
|
313
|
+
* @param gate - switch and token.
|
|
314
|
+
* @param launch - starts the analysis of a validated request.
|
|
315
|
+
*/
|
|
316
|
+
async function handleAnalyzeHttp(req, res, gate, launch) {
|
|
317
|
+
if (new URL(req.url ?? "/", "http://analyze.invalid").pathname !== "/open-api/logalyze") {
|
|
318
|
+
refuse(res, 404, "未知路径");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!gate.enabled()) {
|
|
322
|
+
refuse(res, 404, "日志分析接口未开启,请在观象设置面板「分析接口」打开开关");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if ((req.method ?? "GET").toUpperCase() !== "POST") {
|
|
326
|
+
refuse(res, 405, "只接受 POST", { allow: "POST" });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const expected = await gate.token();
|
|
330
|
+
if (expected === void 0 || expected === "") {
|
|
331
|
+
refuse(res, 503, `分析接口未配置访问令牌,请在观象设置面板「分析接口」保存令牌(凭据名 ${gate.tokenEnv()})`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const presented = bearerOf(req);
|
|
335
|
+
if (presented === void 0 || !tokenMatches(presented, expected)) {
|
|
336
|
+
refuse(res, 401, "访问令牌无效", { "www-authenticate": "Bearer" });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const idempotencyKey = req.headers["idempotency-key"];
|
|
340
|
+
if (typeof idempotencyKey !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
|
|
341
|
+
refuse(res, 400, "缺少或不合法的 Idempotency-Key 请求头(1-200 个可见 ASCII 字符)");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if ((req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase() !== "application/json") {
|
|
345
|
+
refuse(res, 415, "Content-Type 必须是 application/json");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let raw;
|
|
349
|
+
try {
|
|
350
|
+
raw = JSON.parse(await readBody(req));
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error instanceof HttpError$1) {
|
|
353
|
+
refuse(res, error.status, error.message);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
refuse(res, 400, "请求体不是合法的 JSON");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const parsed = parseAnalyzeRequest(raw);
|
|
360
|
+
if (!parsed.ok) {
|
|
361
|
+
refuse(res, 400, parsed.error);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
send(res, await launch(parsed.value, idempotencyKey));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Register the route while the host provides both `webServer` and a
|
|
368
|
+
* compatible `feishuTopics`; it is removed when either goes away. Requests skip the DSH
|
|
369
|
+
* login gate on purpose: the bearer token is this API's only credential.
|
|
370
|
+
* @param ctx - the plugin context.
|
|
371
|
+
* @param gate - switch and token.
|
|
372
|
+
*/
|
|
373
|
+
function mountAnalyzeRoute(ctx, gate) {
|
|
374
|
+
const attach = (scope) => {
|
|
375
|
+
const server = scope.get?.("webServer");
|
|
376
|
+
if (server === void 0) return;
|
|
377
|
+
const topics = lookupTopics(scope.get?.(FEISHU_TOPICS_SERVICE));
|
|
378
|
+
if (topics.availability !== "ready") {
|
|
379
|
+
if (topics.availability === "incompatible") ctx.logger?.warn?.("guanxiang: dsh-channels 的 feishuTopics 服务版本与观象不兼容,分析接口未注册;请把两者升级到相互兼容的版本");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const { service } = topics;
|
|
383
|
+
const launch = (request, key) => launchAnalysis(service, request, key);
|
|
384
|
+
scope.effect(() => server.register({
|
|
385
|
+
kind: "exact",
|
|
386
|
+
path: ANALYZE_ROUTE,
|
|
387
|
+
handler: async (req, res) => {
|
|
388
|
+
try {
|
|
389
|
+
await handleAnalyzeHttp(req, res, gate, launch);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
ctx.logger?.warn?.("guanxiang: 分析请求处理失败:%s", String(error));
|
|
392
|
+
if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`);
|
|
393
|
+
else res.destroy();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}), "guanxiang: analyze route");
|
|
397
|
+
};
|
|
398
|
+
if (typeof ctx.inject === "function") {
|
|
399
|
+
ctx.inject(["webServer", FEISHU_TOPICS_SERVICE], attach);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
attach(ctx);
|
|
403
|
+
}
|
|
404
|
+
//#endregion
|
|
40
405
|
//#region src/repos/credentials.ts
|
|
41
406
|
/** Environment variables the helper reads; set only on git child processes. */
|
|
42
407
|
const USERNAME_VARIABLE = "GUANXIANG_GIT_USERNAME";
|
|
@@ -190,6 +555,7 @@ const skillUpdatesSchema = z.object({
|
|
|
190
555
|
maxFiles: z.number().int().min(1).max(256).default(16),
|
|
191
556
|
maxFileBytes: z.number().int().min(1).max(16 * 1024 * 1024).default(256 * 1024)
|
|
192
557
|
}).strict();
|
|
558
|
+
const analyzeSchema = z.object({ enabled: z.boolean().default(false) }).strict();
|
|
193
559
|
const authorSchema = z.object({
|
|
194
560
|
name: z.string().trim().min(1, "提交作者名不能为空").refine((value) => !/[<>\n]/.test(value), "提交作者名不能包含 < > 或换行"),
|
|
195
561
|
email: z.string().trim().min(1, "提交作者邮箱不能为空").refine((value) => !/[<>\s]/.test(value), "提交作者邮箱不能包含 < > 或空白")
|
|
@@ -223,7 +589,8 @@ const editableFields = {
|
|
|
223
589
|
maxAppsPerRequest: z.number().int().min(1).max(32),
|
|
224
590
|
dispatchTimeoutMs: durationMs(1e3),
|
|
225
591
|
download: downloadSchema,
|
|
226
|
-
skillUpdates: skillUpdatesSchema
|
|
592
|
+
skillUpdates: skillUpdatesSchema,
|
|
593
|
+
analyze: analyzeSchema
|
|
227
594
|
};
|
|
228
595
|
/** Standard Schema the Cordis loader validates `config` with before `apply()`. */
|
|
229
596
|
const pluginConfigSchema = z.object({
|
|
@@ -236,9 +603,15 @@ const pluginConfigSchema = z.object({
|
|
|
236
603
|
dispatchTimeoutMs: editableFields.dispatchTimeoutMs.default(1800 * 1e3),
|
|
237
604
|
download: downloadSchema.prefault({}),
|
|
238
605
|
skillUpdates: skillUpdatesSchema.prefault({}),
|
|
606
|
+
analyze: analyzeSchema.prefault({}),
|
|
607
|
+
/**
|
|
608
|
+
* Credential reference (environment-variable name) whose value is the
|
|
609
|
+
* analyze API's bearer token.
|
|
610
|
+
*/
|
|
611
|
+
analyzeTokenRef: z.string().trim().regex(TOKEN_ENV_PATTERN, "令牌名称只能包含字母、数字和下划线,且不能以数字开头").default("GUANXIANG_ANALYZE_TOKEN"),
|
|
239
612
|
/**
|
|
240
|
-
* Author
|
|
241
|
-
*
|
|
613
|
+
* Author of SKILL improvement commits; omitted uses the user of the token
|
|
614
|
+
* dsh-repo holds for the repository's host, who is always the committer.
|
|
242
615
|
*/
|
|
243
616
|
skillUpdateAuthor: authorSchema.optional(),
|
|
244
617
|
/** Registered `ctx.subagents` provider the children start on. */
|
|
@@ -266,7 +639,8 @@ const overlaySchema = z.object({
|
|
|
266
639
|
maxAppsPerRequest: editableFields.maxAppsPerRequest.optional(),
|
|
267
640
|
dispatchTimeoutMs: editableFields.dispatchTimeoutMs.optional(),
|
|
268
641
|
download: downloadSchema.optional(),
|
|
269
|
-
skillUpdates: skillUpdatesSchema.optional()
|
|
642
|
+
skillUpdates: skillUpdatesSchema.optional(),
|
|
643
|
+
analyze: analyzeSchema.optional()
|
|
270
644
|
}).strict();
|
|
271
645
|
/** A complete editable settings payload, as the panel saves it. */
|
|
272
646
|
const editableSettingsSchema = z.object(editableFields).strict();
|
|
@@ -344,7 +718,8 @@ function defaultsOf(config, env = process.env) {
|
|
|
344
718
|
...config.download,
|
|
345
719
|
allowedHosts: [...config.download.allowedHosts]
|
|
346
720
|
},
|
|
347
|
-
skillUpdates: { ...config.skillUpdates }
|
|
721
|
+
skillUpdates: { ...config.skillUpdates },
|
|
722
|
+
analyze: { ...config.analyze }
|
|
348
723
|
};
|
|
349
724
|
}
|
|
350
725
|
/**
|
|
@@ -363,7 +738,8 @@ function applyOverlay(defaults, overlay) {
|
|
|
363
738
|
maxAppsPerRequest: overlay.maxAppsPerRequest ?? defaults.maxAppsPerRequest,
|
|
364
739
|
dispatchTimeoutMs: overlay.dispatchTimeoutMs ?? defaults.dispatchTimeoutMs,
|
|
365
740
|
download: overlay.download ?? defaults.download,
|
|
366
|
-
skillUpdates: overlay.skillUpdates ?? defaults.skillUpdates
|
|
741
|
+
skillUpdates: overlay.skillUpdates ?? defaults.skillUpdates,
|
|
742
|
+
analyze: overlay.analyze ?? defaults.analyze
|
|
367
743
|
};
|
|
368
744
|
}
|
|
369
745
|
//#endregion
|
|
@@ -398,9 +774,9 @@ async function abortable(promise, signal) {
|
|
|
398
774
|
//#endregion
|
|
399
775
|
//#region src/skill-update/git.ts
|
|
400
776
|
/**
|
|
401
|
-
* 在拉取仓库中用 git
|
|
402
|
-
*
|
|
403
|
-
*
|
|
777
|
+
* 在拉取仓库中用 git 底层命令检查 SKILL 修改:用临时索引文件在分析时的提交上
|
|
778
|
+
* 替换文件、写出树对象,算出逐文件行数和 diff,不改动任何检出、快照或本地
|
|
779
|
+
* 分支。树对象的前缀用作新分支名,提交本身经 dsh-repo 完成。
|
|
404
780
|
*
|
|
405
781
|
* @module dsh-guanxiang/skill-update/git
|
|
406
782
|
*/
|
|
@@ -519,96 +895,6 @@ async function prepareChange(commands, dir, base, skillDir, files) {
|
|
|
519
895
|
});
|
|
520
896
|
}
|
|
521
897
|
}
|
|
522
|
-
/**
|
|
523
|
-
* Whether the remote already has a branch.
|
|
524
|
-
* @param commands - git runner and timeout.
|
|
525
|
-
* @param dir - clone directory.
|
|
526
|
-
* @param branch - branch name.
|
|
527
|
-
* @param env - network environment of the clone's address.
|
|
528
|
-
* @returns whether `refs/heads/<branch>` exists on `origin`.
|
|
529
|
-
*/
|
|
530
|
-
async function remoteBranchExists(commands, dir, branch, env) {
|
|
531
|
-
return (await commands.git([
|
|
532
|
-
"ls-remote",
|
|
533
|
-
"--heads",
|
|
534
|
-
"origin",
|
|
535
|
-
`refs/heads/${branch}`
|
|
536
|
-
], {
|
|
537
|
-
cwd: dir,
|
|
538
|
-
timeoutMs: commands.timeoutMs,
|
|
539
|
-
env
|
|
540
|
-
})).trim() !== "";
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Commit a prepared tree on its base commit without touching any ref.
|
|
544
|
-
* @param commands - git runner and timeout.
|
|
545
|
-
* @param dir - clone directory.
|
|
546
|
-
* @param change - prepared tree and its base.
|
|
547
|
-
* @param message - full commit message.
|
|
548
|
-
* @param author - explicit identity; omitted uses the host's git configuration.
|
|
549
|
-
* @returns the new commit id.
|
|
550
|
-
*/
|
|
551
|
-
async function commitChange(commands, dir, change, message, author) {
|
|
552
|
-
const env = author === void 0 ? void 0 : {
|
|
553
|
-
GIT_AUTHOR_NAME: author.name,
|
|
554
|
-
GIT_AUTHOR_EMAIL: author.email,
|
|
555
|
-
GIT_COMMITTER_NAME: author.name,
|
|
556
|
-
GIT_COMMITTER_EMAIL: author.email
|
|
557
|
-
};
|
|
558
|
-
return (await commands.git([
|
|
559
|
-
"commit-tree",
|
|
560
|
-
change.tree,
|
|
561
|
-
"-p",
|
|
562
|
-
change.base,
|
|
563
|
-
"-F",
|
|
564
|
-
"-"
|
|
565
|
-
], {
|
|
566
|
-
cwd: dir,
|
|
567
|
-
timeoutMs: commands.timeoutMs,
|
|
568
|
-
input: message,
|
|
569
|
-
...env === void 0 ? {} : { env }
|
|
570
|
-
})).trim();
|
|
571
|
-
}
|
|
572
|
-
/** Push options cannot carry line breaks; collapse every whitespace run. */
|
|
573
|
-
function oneLine(text) {
|
|
574
|
-
return text.replace(/\s+/g, " ").trim();
|
|
575
|
-
}
|
|
576
|
-
/**
|
|
577
|
-
* Push a commit to a new remote branch and ask GitLab to open a merge request
|
|
578
|
-
* for it (`merge_request.*` push options). The push carries an empty lease on
|
|
579
|
-
* the branch, so it fails instead of updating a branch that appeared after
|
|
580
|
-
* {@link remoteBranchExists} was checked. A server that does not support push
|
|
581
|
-
* options rejects the push.
|
|
582
|
-
* @param commands - git runner and timeout.
|
|
583
|
-
* @param dir - clone directory.
|
|
584
|
-
* @param commit - commit to push.
|
|
585
|
-
* @param branch - new branch name.
|
|
586
|
-
* @param request - merge request target and text.
|
|
587
|
-
* @param env - network environment of the clone's address.
|
|
588
|
-
* @returns the merge request URL the server printed, if any.
|
|
589
|
-
*/
|
|
590
|
-
async function pushForMergeRequest(commands, dir, commit, branch, request, env) {
|
|
591
|
-
const options = [
|
|
592
|
-
"merge_request.create",
|
|
593
|
-
`merge_request.target=${request.target}`,
|
|
594
|
-
`merge_request.title=${oneLine(request.title)}`,
|
|
595
|
-
`merge_request.description=${oneLine(request.description)}`
|
|
596
|
-
].flatMap((option) => ["-o", option]);
|
|
597
|
-
const output = await commands.git([
|
|
598
|
-
"push",
|
|
599
|
-
"--porcelain",
|
|
600
|
-
`--force-with-lease=refs/heads/${branch}:`,
|
|
601
|
-
...options,
|
|
602
|
-
"origin",
|
|
603
|
-
`${commit}:refs/heads/${branch}`
|
|
604
|
-
], {
|
|
605
|
-
cwd: dir,
|
|
606
|
-
timeoutMs: commands.timeoutMs,
|
|
607
|
-
env,
|
|
608
|
-
output: "all"
|
|
609
|
-
});
|
|
610
|
-
return /https?:\/\/\S+\/-\/merge_requests\/\d+/.exec(output)?.[0];
|
|
611
|
-
}
|
|
612
898
|
//#endregion
|
|
613
899
|
//#region src/repos/manifest.ts
|
|
614
900
|
/**
|
|
@@ -830,6 +1116,37 @@ async function readProposal(workDir, limits) {
|
|
|
830
1116
|
};
|
|
831
1117
|
}
|
|
832
1118
|
//#endregion
|
|
1119
|
+
//#region src/skill-update/repos.ts
|
|
1120
|
+
/** Name dsh-repo provides the service under. */
|
|
1121
|
+
const DSH_REPOS_SERVICE = "dshRepos";
|
|
1122
|
+
/**
|
|
1123
|
+
* Classify the value `ctx.get('dshRepos')` returned. The provider is another
|
|
1124
|
+
* package that may be older or newer than this one, so its version is checked
|
|
1125
|
+
* at run time; `getBranch` marks a provider that also honors `baseSha` and
|
|
1126
|
+
* `newBranch` on commits.
|
|
1127
|
+
* @param value - the looked-up service, or undefined.
|
|
1128
|
+
* @returns the usable service, or why there is none.
|
|
1129
|
+
*/
|
|
1130
|
+
function lookupRepos(value) {
|
|
1131
|
+
if (value === void 0 || value === null) return { availability: "missing" };
|
|
1132
|
+
const service = value;
|
|
1133
|
+
if (service.version !== 1 || typeof service.getBranch !== "function" || typeof service.commitFiles !== "function" || typeof service.createChange !== "function") return { availability: "incompatible" };
|
|
1134
|
+
return {
|
|
1135
|
+
availability: "ready",
|
|
1136
|
+
service: value
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* The failure class of a `dshRepos` rejection.
|
|
1141
|
+
* @param error - the rejection.
|
|
1142
|
+
* @returns its `code` when it is a `RepoError`.
|
|
1143
|
+
*/
|
|
1144
|
+
function repoErrorCode(error) {
|
|
1145
|
+
if (!(error instanceof Error) || error.name !== "RepoError") return void 0;
|
|
1146
|
+
const code = error.code;
|
|
1147
|
+
return typeof code === "string" ? code : void 0;
|
|
1148
|
+
}
|
|
1149
|
+
//#endregion
|
|
833
1150
|
//#region src/skill-update/tool.ts
|
|
834
1151
|
/**
|
|
835
1152
|
* `guanxiang_propose_skill`:把某个派发任务的 SKILL 修改提议提交为合并请求。
|
|
@@ -837,11 +1154,13 @@ async function readProposal(workDir, limits) {
|
|
|
837
1154
|
* - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
|
|
838
1155
|
* 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
|
|
839
1156
|
* - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
* -
|
|
843
|
-
*
|
|
844
|
-
*
|
|
1157
|
+
* 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不提交到
|
|
1158
|
+
* 已有分支或默认分支。
|
|
1159
|
+
* - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
|
|
1160
|
+
* GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
|
|
1161
|
+
* - 提交前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
|
|
1162
|
+
* 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才提交;
|
|
1163
|
+
* 没有审批服务时工具报错,不提交。
|
|
845
1164
|
* - 子会话中不提供本工具。
|
|
846
1165
|
*
|
|
847
1166
|
* @module dsh-guanxiang/skill-update/tool
|
|
@@ -915,7 +1234,8 @@ const OUTPUT_SCHEMA$1 = {
|
|
|
915
1234
|
}
|
|
916
1235
|
},
|
|
917
1236
|
diffPath: { type: "string" },
|
|
918
|
-
mergeRequestUrl: { type: "string" }
|
|
1237
|
+
mergeRequestUrl: { type: "string" },
|
|
1238
|
+
mergeRequestError: { type: "string" }
|
|
919
1239
|
}
|
|
920
1240
|
};
|
|
921
1241
|
/**
|
|
@@ -936,12 +1256,12 @@ function renderProposeValue(value) {
|
|
|
936
1256
|
return [{
|
|
937
1257
|
type: "text",
|
|
938
1258
|
text: `${{
|
|
939
|
-
opened:
|
|
940
|
-
pushed:
|
|
941
|
-
exists: `${where}
|
|
942
|
-
rejected: "
|
|
943
|
-
cancelled: "
|
|
944
|
-
unavailable: "
|
|
1259
|
+
opened: `已提交到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ""}`,
|
|
1260
|
+
pushed: `已提交到 ${where},但创建合并请求失败(${value.mergeRequestError ?? "未知原因"});请在代码托管平台上从该分支手动创建。`,
|
|
1261
|
+
exists: `${where} 已存在,同一改动可能已经提交过,本次没有提交。`,
|
|
1262
|
+
rejected: "授权被拒绝,没有提交:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。",
|
|
1263
|
+
cancelled: "授权请求被撤回,没有提交。",
|
|
1264
|
+
unavailable: "当前没有可以回答授权请求的界面,没有提交。"
|
|
945
1265
|
}[value.status]}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}`
|
|
946
1266
|
}];
|
|
947
1267
|
}
|
|
@@ -1030,6 +1350,7 @@ var SkillUpdater = class {
|
|
|
1030
1350
|
await replaceFile(diffPath, change.diff);
|
|
1031
1351
|
return {
|
|
1032
1352
|
reason: proposal.reason,
|
|
1353
|
+
files: proposal.files,
|
|
1033
1354
|
change,
|
|
1034
1355
|
target,
|
|
1035
1356
|
diffPath
|
|
@@ -1042,7 +1363,7 @@ var SkillUpdater = class {
|
|
|
1042
1363
|
definition() {
|
|
1043
1364
|
return {
|
|
1044
1365
|
name: PROPOSE_TOOL_NAME,
|
|
1045
|
-
description: "观象:把派发结果中某个应用的 SKILL
|
|
1366
|
+
description: "观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后提交到新分支并创建合并请求,不会直接改动默认分支。仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。",
|
|
1046
1367
|
parameters: PARAMETERS,
|
|
1047
1368
|
output: {
|
|
1048
1369
|
schema: OUTPUT_SCHEMA$1,
|
|
@@ -1057,7 +1378,7 @@ var SkillUpdater = class {
|
|
|
1057
1378
|
* @param rawArgs - model arguments.
|
|
1058
1379
|
* @param exec - execution context of the calling agent.
|
|
1059
1380
|
* @returns how the submission ended.
|
|
1060
|
-
* @throws when the arguments, task, proposal, or
|
|
1381
|
+
* @throws when the arguments, task, proposal, approval service, or `dshRepos` service are unusable, or the commit fails; nothing is committed then.
|
|
1061
1382
|
*/
|
|
1062
1383
|
async execute(rawArgs, exec) {
|
|
1063
1384
|
const parsed = argsSchema$1.safeParse(rawArgs);
|
|
@@ -1071,7 +1392,10 @@ var SkillUpdater = class {
|
|
|
1071
1392
|
if (task === void 0) throw new Error(`${parsed.data.workDir} 不是本次 DSH 运行中观象派发任务的运行目录;DSH 重启后需要重新分析`);
|
|
1072
1393
|
if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`);
|
|
1073
1394
|
const approval = this.deps.approval();
|
|
1074
|
-
if (approval === void 0) throw new Error("当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval
|
|
1395
|
+
if (approval === void 0) throw new Error("当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交");
|
|
1396
|
+
const lookup = lookupRepos(this.deps.repos());
|
|
1397
|
+
if (lookup.availability !== "ready") throw new Error(lookup.availability === "missing" ? `提交 SKILL 修改需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务;请安装 dsh-repo,并在它的 hosts 中配置该应用仓库所在的主机和令牌` : `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(观象需要服务版本 1 且提供 getBranch,即 dsh-repo 0.1.0-beta.2 及以上);请升级 dsh-repo`);
|
|
1398
|
+
const repos = lookup.service;
|
|
1075
1399
|
const prepared = await this.prepare(task);
|
|
1076
1400
|
if (prepared === void 0) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`);
|
|
1077
1401
|
const { change, target, diffPath, reason } = prepared;
|
|
@@ -1084,17 +1408,13 @@ var SkillUpdater = class {
|
|
|
1084
1408
|
files: [...change.files],
|
|
1085
1409
|
diffPath
|
|
1086
1410
|
};
|
|
1087
|
-
|
|
1088
|
-
git: this.deps.git,
|
|
1089
|
-
timeoutMs: this.deps.gitTimeoutMs
|
|
1090
|
-
};
|
|
1091
|
-
if (await this.deps.registry.withClone(task.url, (clone) => remoteBranchExists(commands, clone.dir, branch, clone.env))) return {
|
|
1411
|
+
if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== void 0) return {
|
|
1092
1412
|
...base,
|
|
1093
1413
|
status: "exists"
|
|
1094
1414
|
};
|
|
1095
1415
|
const preview = reason.replace(/\s+/g, " ").slice(0, REASON_PREVIEW_CHARS);
|
|
1096
|
-
const zh =
|
|
1097
|
-
const en = `GuanXiang will
|
|
1416
|
+
const zh = `观象将在 ${task.url} 提交新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`;
|
|
1417
|
+
const en = `GuanXiang will commit branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map((file) => file.path).join(", ")}. Summary: ${summary}`;
|
|
1098
1418
|
const outcome = await approval.request({
|
|
1099
1419
|
agent,
|
|
1100
1420
|
toolName: PROPOSE_TOOL_NAME,
|
|
@@ -1113,23 +1433,50 @@ var SkillUpdater = class {
|
|
|
1113
1433
|
};
|
|
1114
1434
|
const message = `${summary}\n\n${reason}\n\nProposed-by: 观象 GuanXiang\nAnalyzed-commit: ${task.commit}\nSession: ${task.sessionId}\n`;
|
|
1115
1435
|
const description = `由观象在日志分析中提出(应用 ${task.app.id},分析提交 ${task.commit.slice(0, 12)},会话 ${task.sessionId})。理由:${reason.slice(0, REASON_DESCRIPTION_CHARS)}`;
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1436
|
+
const changed = new Set(change.files.map((file) => file.path));
|
|
1437
|
+
const files = prepared.files.map((file) => ({
|
|
1438
|
+
action: "write",
|
|
1439
|
+
path: `${skillDirOf(task.app)}/${file.path}`,
|
|
1440
|
+
content: file.content
|
|
1441
|
+
})).filter((file) => changed.has(file.path));
|
|
1442
|
+
exec.signal.throwIfAborted();
|
|
1443
|
+
try {
|
|
1444
|
+
await repos.commitFiles(task.url, {
|
|
1445
|
+
branch,
|
|
1446
|
+
baseSha: task.commit,
|
|
1447
|
+
newBranch: true,
|
|
1448
|
+
message,
|
|
1449
|
+
files,
|
|
1450
|
+
...this.deps.author === void 0 ? {} : { author: this.deps.author },
|
|
1451
|
+
signal: exec.signal
|
|
1452
|
+
});
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
if (repoErrorCode(error) === "conflict") return {
|
|
1455
|
+
...base,
|
|
1456
|
+
status: "exists"
|
|
1457
|
+
};
|
|
1458
|
+
throw error;
|
|
1459
|
+
}
|
|
1460
|
+
try {
|
|
1461
|
+
const opened = await repos.createChange(task.url, {
|
|
1121
1462
|
title: summary,
|
|
1122
|
-
description
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
}
|
|
1463
|
+
body: description,
|
|
1464
|
+
sourceBranch: branch,
|
|
1465
|
+
targetBranch: target,
|
|
1466
|
+
signal: exec.signal
|
|
1467
|
+
});
|
|
1468
|
+
return {
|
|
1469
|
+
...base,
|
|
1470
|
+
status: "opened",
|
|
1471
|
+
mergeRequestUrl: opened.webUrl
|
|
1472
|
+
};
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
return {
|
|
1475
|
+
...base,
|
|
1476
|
+
status: "pushed",
|
|
1477
|
+
mergeRequestError: messageOf$2(error)
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1133
1480
|
}
|
|
1134
1481
|
};
|
|
1135
1482
|
//#endregion
|
|
@@ -2096,8 +2443,7 @@ var RepoRegistry = class {
|
|
|
2096
2443
|
const branch = entry.spec.branch ?? (await this.remoteHead(entry.dir, env)).replace(/^refs\/remotes\/origin\//, "");
|
|
2097
2444
|
return await task({
|
|
2098
2445
|
dir: entry.dir,
|
|
2099
|
-
branch
|
|
2100
|
-
env
|
|
2446
|
+
branch
|
|
2101
2447
|
});
|
|
2102
2448
|
});
|
|
2103
2449
|
}
|
|
@@ -2627,6 +2973,7 @@ var GuanxiangService = class {
|
|
|
2627
2973
|
skill;
|
|
2628
2974
|
active = true;
|
|
2629
2975
|
refreshWaitMs;
|
|
2976
|
+
analyzeTokenRef;
|
|
2630
2977
|
booted = Promise.withResolvers();
|
|
2631
2978
|
/**
|
|
2632
2979
|
* @param ctx - plugin context.
|
|
@@ -2639,6 +2986,7 @@ var GuanxiangService = class {
|
|
|
2639
2986
|
this.store = store;
|
|
2640
2987
|
this.defaults = defaultsOf(config);
|
|
2641
2988
|
this.refreshWaitMs = config.refreshWaitMs;
|
|
2989
|
+
this.analyzeTokenRef = config.analyzeTokenRef;
|
|
2642
2990
|
this.current = this.defaults;
|
|
2643
2991
|
const git = options.git ?? runGit;
|
|
2644
2992
|
this.registry = new RepoRegistry({
|
|
@@ -2655,7 +3003,8 @@ var GuanxiangService = class {
|
|
|
2655
3003
|
git,
|
|
2656
3004
|
gitTimeoutMs: config.gitTimeoutMs,
|
|
2657
3005
|
...config.skillUpdateAuthor === void 0 ? {} : { author: config.skillUpdateAuthor },
|
|
2658
|
-
approval: () => ctx.get?.("approval")
|
|
3006
|
+
approval: () => ctx.get?.("approval"),
|
|
3007
|
+
repos: () => ctx.get?.(DSH_REPOS_SERVICE)
|
|
2659
3008
|
});
|
|
2660
3009
|
this.dispatcher = new Dispatcher({
|
|
2661
3010
|
registry: this.registry,
|
|
@@ -2755,7 +3104,32 @@ var GuanxiangService = class {
|
|
|
2755
3104
|
overridden: overriddenKeys(this.overlay),
|
|
2756
3105
|
...this.overlayError === void 0 ? {} : { overlayError: this.overlayError },
|
|
2757
3106
|
repos: this.registry.status(),
|
|
2758
|
-
credentials: await this.credentialsState()
|
|
3107
|
+
credentials: await this.credentialsState(),
|
|
3108
|
+
analyze: await this.analyzeState()
|
|
3109
|
+
};
|
|
3110
|
+
}
|
|
3111
|
+
/** @returns the analyze route's switch and bearer token, read per request. */
|
|
3112
|
+
analyzeGate() {
|
|
3113
|
+
return {
|
|
3114
|
+
enabled: () => this.current.analyze.enabled,
|
|
3115
|
+
tokenEnv: () => this.analyzeTokenRef,
|
|
3116
|
+
token: async () => {
|
|
3117
|
+
try {
|
|
3118
|
+
return (await this.credentialStore()?.resolve(this.analyzeTokenRef))?.value;
|
|
3119
|
+
} catch (error) {
|
|
3120
|
+
this.ctx.logger?.warn?.("guanxiang: 读取分析接口令牌 %s 失败:%s", this.analyzeTokenRef, error instanceof Error ? error.message : String(error));
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
async analyzeState() {
|
|
3127
|
+
const store = this.credentialStore();
|
|
3128
|
+
return {
|
|
3129
|
+
route: ANALYZE_ROUTE,
|
|
3130
|
+
tokenEnv: this.analyzeTokenRef,
|
|
3131
|
+
...store === void 0 ? {} : { token: await describeToken(store, this.analyzeTokenRef) },
|
|
3132
|
+
topics: lookupTopics(this.ctx.get?.(FEISHU_TOPICS_SERVICE)).availability
|
|
2759
3133
|
};
|
|
2760
3134
|
}
|
|
2761
3135
|
credentialStore() {
|
|
@@ -2770,24 +3144,7 @@ var GuanxiangService = class {
|
|
|
2770
3144
|
const names = [...new Set(this.current.gitCredentials.map((credential) => credential.tokenEnv))];
|
|
2771
3145
|
return {
|
|
2772
3146
|
available: true,
|
|
2773
|
-
tokens: await Promise.all(names.map(
|
|
2774
|
-
try {
|
|
2775
|
-
const info = await store.describe(tokenEnv);
|
|
2776
|
-
return {
|
|
2777
|
-
tokenEnv,
|
|
2778
|
-
configured: info.configured,
|
|
2779
|
-
...info.source === void 0 ? {} : { source: info.source },
|
|
2780
|
-
writable: info.writable
|
|
2781
|
-
};
|
|
2782
|
-
} catch (error) {
|
|
2783
|
-
return {
|
|
2784
|
-
tokenEnv,
|
|
2785
|
-
configured: false,
|
|
2786
|
-
writable: false,
|
|
2787
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2788
|
-
};
|
|
2789
|
-
}
|
|
2790
|
-
}))
|
|
3147
|
+
tokens: await Promise.all(names.map((tokenEnv) => describeToken(store, tokenEnv)))
|
|
2791
3148
|
};
|
|
2792
3149
|
}
|
|
2793
3150
|
/**
|
|
@@ -2809,10 +3166,11 @@ var GuanxiangService = class {
|
|
|
2809
3166
|
}
|
|
2810
3167
|
/**
|
|
2811
3168
|
* Store the token of one access-token configuration in DSH's credential
|
|
2812
|
-
* store, then re-sync the repositories that use it
|
|
3169
|
+
* store, then re-sync the repositories that use it; or store the analyze
|
|
3170
|
+
* API's bearer token (`analyzeTokenRef`), which applies from the next request.
|
|
2813
3171
|
* @param raw - request body `{ tokenEnv, value }`.
|
|
2814
3172
|
* @returns the new panel state.
|
|
2815
|
-
* @throws SettingsError when the body is invalid, the name is
|
|
3173
|
+
* @throws SettingsError when the body is invalid, the name is neither referenced by the saved settings nor `analyzeTokenRef`, or no credential store is loaded.
|
|
2816
3174
|
*/
|
|
2817
3175
|
async setToken(raw) {
|
|
2818
3176
|
const parsed = tokenSetSchema.safeParse(raw);
|
|
@@ -2832,7 +3190,7 @@ var GuanxiangService = class {
|
|
|
2832
3190
|
return await this.writeToken(parsed.data.tokenEnv, void 0);
|
|
2833
3191
|
}
|
|
2834
3192
|
async writeToken(tokenEnv, value) {
|
|
2835
|
-
if (!this.current.gitCredentials.some((credential) => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`);
|
|
3193
|
+
if (tokenEnv !== this.analyzeTokenRef && !this.current.gitCredentials.some((credential) => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`);
|
|
2836
3194
|
const store = this.credentialStore();
|
|
2837
3195
|
if (store === void 0) throw new SettingsError("当前 DSH 组合没有凭据服务(@deepseek-ai/dsh-credentials-local),无法保存令牌");
|
|
2838
3196
|
if (value === void 0) await store.unset(tokenEnv);
|
|
@@ -2893,6 +3251,24 @@ var GuanxiangService = class {
|
|
|
2893
3251
|
this.skill.invalidate();
|
|
2894
3252
|
}
|
|
2895
3253
|
};
|
|
3254
|
+
async function describeToken(store, tokenEnv) {
|
|
3255
|
+
try {
|
|
3256
|
+
const info = await store.describe(tokenEnv);
|
|
3257
|
+
return {
|
|
3258
|
+
tokenEnv,
|
|
3259
|
+
configured: info.configured,
|
|
3260
|
+
...info.source === void 0 ? {} : { source: info.source },
|
|
3261
|
+
writable: info.writable
|
|
3262
|
+
};
|
|
3263
|
+
} catch (error) {
|
|
3264
|
+
return {
|
|
3265
|
+
tokenEnv,
|
|
3266
|
+
configured: false,
|
|
3267
|
+
writable: false,
|
|
3268
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3269
|
+
};
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
2896
3272
|
//#endregion
|
|
2897
3273
|
//#region src/settings/routes.ts
|
|
2898
3274
|
/** Request body cap: the settings payload is small. */
|
|
@@ -3080,10 +3456,11 @@ function mountSettingsRoutes(ctx, service) {
|
|
|
3080
3456
|
* 任务匹配已注册应用时由顶层 Agent 自行加载)和
|
|
3081
3457
|
* `guanxiang_dispatch` 工具(按应用派发子 Agent,工作目录为该应用的代码
|
|
3082
3458
|
* 仓库,首条消息用 `/<skill>` 加载仓库里的日志分析 SKILL),以及开启 SKILL
|
|
3083
|
-
* 改进时的 `guanxiang_propose_skill`
|
|
3084
|
-
*
|
|
3459
|
+
* 改进时的 `guanxiang_propose_skill` 工具(经用户授权后经 dsh-repo 把子 Agent
|
|
3460
|
+
* 的 SKILL 修改提议提交到新分支并创建合并请求)。应用仓库在设置
|
|
3085
3461
|
* 面板中只填 git 地址,由插件拉取;应用描述来自各仓库的
|
|
3086
|
-
* `.dsh/guanxiang.yaml
|
|
3462
|
+
* `.dsh/guanxiang.yaml`。另提供日志平台分析接口 `POST /open-api/logalyze`,
|
|
3463
|
+
* 经 dsh-channels 的 `feishuTopics` 服务在飞书群话题中开始分析。
|
|
3087
3464
|
*
|
|
3088
3465
|
* @module dsh-guanxiang
|
|
3089
3466
|
*/
|
|
@@ -3096,8 +3473,9 @@ const inject = [
|
|
|
3096
3473
|
"subagents"
|
|
3097
3474
|
];
|
|
3098
3475
|
/**
|
|
3099
|
-
* Install the entry skill, the dispatch and SKILL proposal tools,
|
|
3100
|
-
* clone or update the configured
|
|
3476
|
+
* Install the entry skill, the dispatch and SKILL proposal tools, the settings
|
|
3477
|
+
* routes, and the analyze route, then clone or update the configured
|
|
3478
|
+
* repositories in the background.
|
|
3101
3479
|
* @param ctx - plugin context.
|
|
3102
3480
|
* @param config - loader-validated configuration.
|
|
3103
3481
|
* @param options - test seams.
|
|
@@ -3107,6 +3485,7 @@ function apply(ctx, config, options = {}) {
|
|
|
3107
3485
|
service.registerSkill();
|
|
3108
3486
|
service.registerTool();
|
|
3109
3487
|
mountSettingsRoutes(ctx, service);
|
|
3488
|
+
mountAnalyzeRoute(ctx, service.analyzeGate());
|
|
3110
3489
|
ctx.effect(() => {
|
|
3111
3490
|
service.boot().catch((error) => {
|
|
3112
3491
|
ctx.logger?.error?.("guanxiang: 启动失败:%s", error instanceof Error ? error.message : String(error));
|
|
@@ -3117,6 +3496,6 @@ function apply(ctx, config, options = {}) {
|
|
|
3117
3496
|
}, "guanxiang: repositories");
|
|
3118
3497
|
}
|
|
3119
3498
|
//#endregion
|
|
3120
|
-
export { pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, apply, inject, name, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
|
|
3499
|
+
export { ANALYZE_ROUTE, pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, analyzeRequestHash, apply, inject, name, parseAnalyzeRequest, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
|
|
3121
3500
|
|
|
3122
3501
|
//# sourceMappingURL=index.js.map
|