@modesto_ma/dsh-guanxiang 0.2.0-beta.2 → 0.2.0-beta.4
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 +25 -35
- package/lib/client.js +173 -151
- package/lib/client.js.map +1 -1
- package/lib/index.js +493 -891
- package/lib/index.js.map +1 -1
- package/package.json +3 -1
- package/src/client/api.ts +43 -6
- package/src/client/page.tsx +64 -99
- package/src/config.ts +28 -45
- package/src/index.ts +11 -4
- package/src/repos/lookup.ts +92 -0
- package/src/repos/registry.ts +57 -295
- package/src/service.ts +166 -59
- package/src/settings/contract.ts +50 -26
- package/src/settings/routes.ts +19 -1
- package/src/skill-update/change.ts +81 -0
- package/src/skill-update/tool.ts +74 -49
- package/src/repos/credentials.ts +0 -104
- package/src/repos/git.ts +0 -74
- package/src/skill-update/git.ts +0 -157
package/lib/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { homedir
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
-
import { lstat, mkdir,
|
|
5
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { createTwoFilesPatch, structuredPatch } from "diff";
|
|
6
7
|
import { parse } from "yaml";
|
|
7
8
|
import { createWriteStream } from "node:fs";
|
|
8
9
|
import { Readable, Transform } from "node:stream";
|
|
9
10
|
import { pipeline } from "node:stream/promises";
|
|
10
|
-
import { execFile } from "node:child_process";
|
|
11
11
|
//#region src/settings/contract.ts
|
|
12
12
|
/**
|
|
13
13
|
* 设置面板与 Host 路由共用的路由常量和 JSON 结构。浏览器端 bundle 也会
|
|
@@ -21,20 +21,22 @@ 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 }`
|
|
24
|
+
/** PUT:body `{ tokenEnv, value }` 把分析接口令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受分析接口令牌的名称。 */
|
|
25
25
|
const TOKEN_ROUTE = "/guanxiang/token";
|
|
26
|
+
/** POST:body {@link HostAddRequest} 经 dsh-repo 添加主机;DELETE:body `{ url }` 删除面板添加的主机。 */
|
|
27
|
+
const HOSTS_ROUTE = "/guanxiang/hosts";
|
|
28
|
+
/** PUT:body `{ url, value }` 经 dsh-repo 保存主机令牌;DELETE:body `{ url }` 清除已存的令牌。 */
|
|
29
|
+
const HOST_TOKEN_ROUTE = "/guanxiang/host-token";
|
|
26
30
|
/**
|
|
27
31
|
* POST:日志平台分析接口(Bearer 令牌鉴权,不走 DSH 登录)。它在 DSH 的
|
|
28
32
|
* `/api` 命名空间之外,网关可以把它和需要登录的 DSH 接口分开暴露。
|
|
29
33
|
*/
|
|
30
34
|
const ANALYZE_ROUTE = "/open-api/logalyze";
|
|
31
|
-
/** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier)
|
|
35
|
+
/** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier). */
|
|
32
36
|
const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
33
37
|
/** Keys of {@link EditableSettings}, in panel order. */
|
|
34
38
|
const EDITABLE_KEYS = [
|
|
35
|
-
"reposDir",
|
|
36
39
|
"repos",
|
|
37
|
-
"gitCredentials",
|
|
38
40
|
"fetchIntervalMs",
|
|
39
41
|
"maxAppsPerRequest",
|
|
40
42
|
"dispatchTimeoutMs",
|
|
@@ -402,94 +404,6 @@ function mountAnalyzeRoute(ctx, gate) {
|
|
|
402
404
|
attach(ctx);
|
|
403
405
|
}
|
|
404
406
|
//#endregion
|
|
405
|
-
//#region src/repos/credentials.ts
|
|
406
|
-
/** Environment variables the helper reads; set only on git child processes. */
|
|
407
|
-
const USERNAME_VARIABLE = "GUANXIANG_GIT_USERNAME";
|
|
408
|
-
const TOKEN_VARIABLE = "GUANXIANG_GIT_TOKEN";
|
|
409
|
-
/** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
|
|
410
|
-
const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`;
|
|
411
|
-
/** Longest accepted token; personal access tokens are far shorter. */
|
|
412
|
-
const MAX_TOKEN_LENGTH = 4096;
|
|
413
|
-
/**
|
|
414
|
-
* Normalize an access-token address prefix to `scheme://host[:port][/path]`
|
|
415
|
-
* with a lowercase host and no trailing slash.
|
|
416
|
-
* @param value - candidate prefix.
|
|
417
|
-
* @returns the normalized prefix, or undefined when it is not an `https`/`http` URL without credentials, query, or fragment.
|
|
418
|
-
*/
|
|
419
|
-
function normalizeCredentialUrl(value) {
|
|
420
|
-
let url;
|
|
421
|
-
try {
|
|
422
|
-
url = new URL(value);
|
|
423
|
-
} catch {
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
|
|
427
|
-
if (url.host === "" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
|
|
428
|
-
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* Whether a token value can be handed to git: non-empty, bounded, and free of
|
|
432
|
-
* control characters, which would break the credential helper protocol.
|
|
433
|
-
* @param value - candidate token.
|
|
434
|
-
* @returns whether it is accepted.
|
|
435
|
-
*/
|
|
436
|
-
function isAcceptedToken(value) {
|
|
437
|
-
return value.length > 0 && value.length <= 4096 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
438
|
-
}
|
|
439
|
-
/**
|
|
440
|
-
* The access-token configuration for a repository address: the entry whose
|
|
441
|
-
* prefix has the same scheme and host and the longest path that contains the
|
|
442
|
-
* repository path. scp-like addresses, SSH URLs, and local paths never match.
|
|
443
|
-
* @param url - repository address.
|
|
444
|
-
* @param specs - configured access tokens with normalized prefixes.
|
|
445
|
-
* @returns the matching entry, if any.
|
|
446
|
-
*/
|
|
447
|
-
function credentialFor(url, specs) {
|
|
448
|
-
let repo;
|
|
449
|
-
try {
|
|
450
|
-
repo = new URL(url);
|
|
451
|
-
} catch {
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
let best;
|
|
455
|
-
for (const spec of specs) {
|
|
456
|
-
const prefix = new URL(spec.url);
|
|
457
|
-
if (prefix.protocol !== repo.protocol || prefix.host !== repo.host) continue;
|
|
458
|
-
const path = prefix.pathname.replace(/\/+$/, "");
|
|
459
|
-
if (path !== "" && repo.pathname !== path && !repo.pathname.startsWith(`${path}/`)) continue;
|
|
460
|
-
if (best === void 0 || path.length > best.length) best = {
|
|
461
|
-
spec,
|
|
462
|
-
length: path.length
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
return best?.spec;
|
|
466
|
-
}
|
|
467
|
-
/**
|
|
468
|
-
* Environment that makes one git process answer the credential request for
|
|
469
|
-
* the token's host with the given token. Existing `GIT_CONFIG_COUNT` entries
|
|
470
|
-
* are kept; the helper list for that host is reset first so a stale password
|
|
471
|
-
* from another helper cannot shadow the token.
|
|
472
|
-
* @param spec - matching access-token configuration.
|
|
473
|
-
* @param token - token value.
|
|
474
|
-
* @param env - environment of the git process before this call.
|
|
475
|
-
* @returns variables to add to the git process environment.
|
|
476
|
-
*/
|
|
477
|
-
function credentialEnvironment(spec, token, env = process.env) {
|
|
478
|
-
const prefix = new URL(spec.url);
|
|
479
|
-
const key = `credential.${prefix.protocol}//${prefix.host}.helper`;
|
|
480
|
-
const parsed = Number(env.GIT_CONFIG_COUNT ?? "0");
|
|
481
|
-
const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
482
|
-
return {
|
|
483
|
-
GIT_CONFIG_COUNT: String(base + 2),
|
|
484
|
-
[`GIT_CONFIG_KEY_${base}`]: key,
|
|
485
|
-
[`GIT_CONFIG_VALUE_${base}`]: "",
|
|
486
|
-
[`GIT_CONFIG_KEY_${base + 1}`]: key,
|
|
487
|
-
[`GIT_CONFIG_VALUE_${base + 1}`]: HELPER,
|
|
488
|
-
[USERNAME_VARIABLE]: spec.username,
|
|
489
|
-
[TOKEN_VARIABLE]: token
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
//#endregion
|
|
493
407
|
//#region src/config.ts
|
|
494
408
|
/**
|
|
495
409
|
* 插件配置:cordis.yml 中的 `config`(经 Standard Schema 在加载时校验)与
|
|
@@ -537,12 +451,16 @@ const repoSchema = z.object({
|
|
|
537
451
|
url: z.string().trim().min(1, "git 地址不能为空").refine(isAcceptedGitUrl, "不支持的 git 地址(支持 https/ssh/git/file URL、user@host:path 或绝对路径)"),
|
|
538
452
|
branch: z.string().trim().optional().transform((value) => value === "" ? void 0 : value).refine((value) => value === void 0 || isAcceptedBranch(value), "分支名不合法")
|
|
539
453
|
}).strict();
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
454
|
+
/**
|
|
455
|
+
* An access token of earlier versions, which kept HTTPS tokens by address
|
|
456
|
+
* prefix. Read only to move each one to a dsh-repo host once; never written.
|
|
457
|
+
*/
|
|
458
|
+
const legacyCredentialSchema = z.object({
|
|
459
|
+
url: z.string().trim().min(1),
|
|
460
|
+
alias: z.string().trim().optional(),
|
|
461
|
+
username: z.string().optional(),
|
|
462
|
+
tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN)
|
|
463
|
+
}).strip();
|
|
546
464
|
const hostSchema = z.string().trim().toLowerCase().transform((value) => value.replace(/^\*\./, "")).refine((value) => HOST_NAME.test(value), "域名不合法:只填主机名,例如 oss.example.com");
|
|
547
465
|
const durationMs = (min) => z.number().int().min(min).max(MAX_TIMER_DELAY_MS);
|
|
548
466
|
const downloadSchema = z.object({
|
|
@@ -562,7 +480,6 @@ const authorSchema = z.object({
|
|
|
562
480
|
}).strict();
|
|
563
481
|
/** Field validators shared by the cordis config and the panel overlay. */
|
|
564
482
|
const editableFields = {
|
|
565
|
-
reposDir: z.string().trim().min(1, "拉取目录不能为空"),
|
|
566
483
|
repos: z.array(repoSchema).superRefine((repos, issues) => {
|
|
567
484
|
const seen = /* @__PURE__ */ new Set();
|
|
568
485
|
for (const [index, repo] of repos.entries()) {
|
|
@@ -574,17 +491,6 @@ const editableFields = {
|
|
|
574
491
|
seen.add(repo.url);
|
|
575
492
|
}
|
|
576
493
|
}),
|
|
577
|
-
gitCredentials: z.array(credentialSchema).superRefine((credentials, issues) => {
|
|
578
|
-
const seen = /* @__PURE__ */ new Set();
|
|
579
|
-
for (const [index, credential] of credentials.entries()) {
|
|
580
|
-
if (seen.has(credential.url)) issues.addIssue({
|
|
581
|
-
code: "custom",
|
|
582
|
-
path: [index, "url"],
|
|
583
|
-
message: `重复的令牌地址:${credential.url}`
|
|
584
|
-
});
|
|
585
|
-
seen.add(credential.url);
|
|
586
|
-
}
|
|
587
|
-
}),
|
|
588
494
|
fetchIntervalMs: durationMs(0),
|
|
589
495
|
maxAppsPerRequest: z.number().int().min(1).max(32),
|
|
590
496
|
dispatchTimeoutMs: durationMs(1e3),
|
|
@@ -594,10 +500,11 @@ const editableFields = {
|
|
|
594
500
|
};
|
|
595
501
|
/** Standard Schema the Cordis loader validates `config` with before `apply()`. */
|
|
596
502
|
const pluginConfigSchema = z.object({
|
|
597
|
-
|
|
503
|
+
/** Ignored: dsh-repo's `mirror.dir` decides where repositories are mirrored. Accepted so older compositions still load. */
|
|
504
|
+
reposDir: z.string().optional(),
|
|
598
505
|
repos: editableFields.repos.default([]),
|
|
599
|
-
/**
|
|
600
|
-
gitCredentials:
|
|
506
|
+
/** Moved once to dsh-repo hosts at start; see {@link legacyCredentialSchema}. */
|
|
507
|
+
gitCredentials: z.array(legacyCredentialSchema).default([]),
|
|
601
508
|
fetchIntervalMs: editableFields.fetchIntervalMs.default(0),
|
|
602
509
|
maxAppsPerRequest: editableFields.maxAppsPerRequest.default(4),
|
|
603
510
|
dispatchTimeoutMs: editableFields.dispatchTimeoutMs.default(1800 * 1e3),
|
|
@@ -610,14 +517,14 @@ const pluginConfigSchema = z.object({
|
|
|
610
517
|
*/
|
|
611
518
|
analyzeTokenRef: z.string().trim().regex(TOKEN_ENV_PATTERN, "令牌名称只能包含字母、数字和下划线,且不能以数字开头").default("GUANXIANG_ANALYZE_TOKEN"),
|
|
612
519
|
/**
|
|
613
|
-
* Author
|
|
614
|
-
*
|
|
520
|
+
* Author of SKILL improvement commits; omitted uses the user of the token
|
|
521
|
+
* dsh-repo holds for the repository's host, who is always the committer.
|
|
615
522
|
*/
|
|
616
523
|
skillUpdateAuthor: authorSchema.optional(),
|
|
617
524
|
/** Registered `ctx.subagents` provider the children start on. */
|
|
618
525
|
subagentProvider: z.string().trim().min(1).default("spawn"),
|
|
619
|
-
/**
|
|
620
|
-
gitTimeoutMs:
|
|
526
|
+
/** Ignored: dsh-repo's `mirror.gitTimeoutMs` bounds git commands. Accepted so older compositions still load. */
|
|
527
|
+
gitTimeoutMs: z.number().optional(),
|
|
621
528
|
/**
|
|
622
529
|
* How long loading `/guanxiang` waits for the repository refresh before it
|
|
623
530
|
* renders the catalog it already has, in milliseconds; the refresh keeps
|
|
@@ -632,9 +539,11 @@ const pluginConfigSchema = z.object({
|
|
|
632
539
|
}).strict().prefault({});
|
|
633
540
|
/** Panel overlay: any subset of the editable settings. */
|
|
634
541
|
const overlaySchema = z.object({
|
|
635
|
-
|
|
542
|
+
/** Ignored legacy key; the next panel save drops it. */
|
|
543
|
+
reposDir: z.string().optional(),
|
|
636
544
|
repos: editableFields.repos.optional(),
|
|
637
|
-
|
|
545
|
+
/** Legacy access tokens, moved once to dsh-repo hosts; the next panel save drops them. */
|
|
546
|
+
gitCredentials: z.array(legacyCredentialSchema).optional(),
|
|
638
547
|
fetchIntervalMs: editableFields.fetchIntervalMs.optional(),
|
|
639
548
|
maxAppsPerRequest: editableFields.maxAppsPerRequest.optional(),
|
|
640
549
|
dispatchTimeoutMs: editableFields.dispatchTimeoutMs.optional(),
|
|
@@ -675,42 +584,20 @@ function resolveDshHome(env = process.env) {
|
|
|
675
584
|
const configured = env.DSH_HOME?.trim();
|
|
676
585
|
return configured !== void 0 && configured !== "" ? configured : join(homedir(), ".dsh");
|
|
677
586
|
}
|
|
678
|
-
/**
|
|
679
|
-
* Expand `~` and make a configured directory absolute.
|
|
680
|
-
* @param path - configured directory.
|
|
681
|
-
* @returns absolute directory.
|
|
682
|
-
*/
|
|
683
|
-
function expandHome(path) {
|
|
684
|
-
if (path === "~") return homedir();
|
|
685
|
-
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
686
|
-
return resolve(path);
|
|
687
|
-
}
|
|
688
587
|
function copyRepos(repos) {
|
|
689
588
|
return repos.map((repo) => repo.branch === void 0 ? { url: repo.url } : {
|
|
690
589
|
url: repo.url,
|
|
691
590
|
branch: repo.branch
|
|
692
591
|
});
|
|
693
592
|
}
|
|
694
|
-
function copyCredentials(credentials) {
|
|
695
|
-
return credentials.map((credential) => ({
|
|
696
|
-
url: credential.url,
|
|
697
|
-
...credential.alias === void 0 ? {} : { alias: credential.alias },
|
|
698
|
-
username: credential.username,
|
|
699
|
-
tokenEnv: credential.tokenEnv
|
|
700
|
-
}));
|
|
701
|
-
}
|
|
702
593
|
/**
|
|
703
|
-
* The editable settings a configuration implies
|
|
704
|
-
* `$DSH_HOME/guanxiang/repos`.
|
|
594
|
+
* The editable settings a configuration implies.
|
|
705
595
|
* @param config - validated plugin configuration.
|
|
706
|
-
* @param env - process environment.
|
|
707
596
|
* @returns complete editable settings.
|
|
708
597
|
*/
|
|
709
|
-
function defaultsOf(config
|
|
598
|
+
function defaultsOf(config) {
|
|
710
599
|
return {
|
|
711
|
-
reposDir: config.reposDir ?? join(resolveDshHome(env), "guanxiang", "repos"),
|
|
712
600
|
repos: copyRepos(config.repos),
|
|
713
|
-
gitCredentials: copyCredentials(config.gitCredentials),
|
|
714
601
|
fetchIntervalMs: config.fetchIntervalMs,
|
|
715
602
|
maxAppsPerRequest: config.maxAppsPerRequest,
|
|
716
603
|
dispatchTimeoutMs: config.dispatchTimeoutMs,
|
|
@@ -731,9 +618,7 @@ function defaultsOf(config, env = process.env) {
|
|
|
731
618
|
*/
|
|
732
619
|
function applyOverlay(defaults, overlay) {
|
|
733
620
|
return {
|
|
734
|
-
reposDir: overlay.reposDir ?? defaults.reposDir,
|
|
735
621
|
repos: overlay.repos === void 0 ? defaults.repos : copyRepos(overlay.repos),
|
|
736
|
-
gitCredentials: overlay.gitCredentials === void 0 ? defaults.gitCredentials : copyCredentials(overlay.gitCredentials),
|
|
737
622
|
fetchIntervalMs: overlay.fetchIntervalMs ?? defaults.fetchIntervalMs,
|
|
738
623
|
maxAppsPerRequest: overlay.maxAppsPerRequest ?? defaults.maxAppsPerRequest,
|
|
739
624
|
dispatchTimeoutMs: overlay.dispatchTimeoutMs ?? defaults.dispatchTimeoutMs,
|
|
@@ -743,6 +628,80 @@ function applyOverlay(defaults, overlay) {
|
|
|
743
628
|
};
|
|
744
629
|
}
|
|
745
630
|
//#endregion
|
|
631
|
+
//#region src/repos/lookup.ts
|
|
632
|
+
/** Name dsh-repo provides the service under. */
|
|
633
|
+
const DSH_REPOS_SERVICE = "dshRepos";
|
|
634
|
+
/** Oldest dsh-repo release providing every method {@link REQUIRED_METHODS} lists. */
|
|
635
|
+
const DSH_REPO_MIN_RELEASE = "0.1.0-beta.3";
|
|
636
|
+
/** Methods this plugin calls; `getBranch` also marks a provider that honors `baseSha` and `newBranch` on commits. */
|
|
637
|
+
const REQUIRED_METHODS = [
|
|
638
|
+
"getBranch",
|
|
639
|
+
"commitFiles",
|
|
640
|
+
"createChange",
|
|
641
|
+
"listHosts",
|
|
642
|
+
"addHost",
|
|
643
|
+
"removeHost",
|
|
644
|
+
"setHostToken",
|
|
645
|
+
"mirrorSettings",
|
|
646
|
+
"syncMirror",
|
|
647
|
+
"readMirrorFile",
|
|
648
|
+
"listMirrorTree",
|
|
649
|
+
"acquireSnapshot"
|
|
650
|
+
];
|
|
651
|
+
/**
|
|
652
|
+
* Classify the value `ctx.get('dshRepos')` returned. The provider is another
|
|
653
|
+
* package that may be older or newer than this one, so its version and
|
|
654
|
+
* methods are checked at run time.
|
|
655
|
+
* @param value - the looked-up service, or undefined.
|
|
656
|
+
* @returns the usable service, or why there is none.
|
|
657
|
+
*/
|
|
658
|
+
function lookupRepos(value) {
|
|
659
|
+
if (value === void 0 || value === null) return { availability: "missing" };
|
|
660
|
+
const service = value;
|
|
661
|
+
if (service.version !== 1 || REQUIRED_METHODS.some((method) => typeof service[method] !== "function")) return { availability: "incompatible" };
|
|
662
|
+
return {
|
|
663
|
+
availability: "ready",
|
|
664
|
+
service: value
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* The failure class of a `dshRepos` rejection.
|
|
669
|
+
* @param error - the rejection.
|
|
670
|
+
* @returns its `code` when it is a `RepoError`.
|
|
671
|
+
*/
|
|
672
|
+
function repoErrorCode(error) {
|
|
673
|
+
if (!(error instanceof Error) || error.name !== "RepoError") return void 0;
|
|
674
|
+
const code = error.code;
|
|
675
|
+
return typeof code === "string" ? code : void 0;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Why the service cannot be used, as one line for the panel and tool errors.
|
|
679
|
+
* @param availability - lookup outcome other than `ready`.
|
|
680
|
+
* @returns the reason and the fix.
|
|
681
|
+
*/
|
|
682
|
+
function unavailableMessage(availability) {
|
|
683
|
+
return availability === "missing" ? `观象需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务来拉取仓库和提交 SKILL 修改;请安装 dsh-repo` : `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(需要服务版本 1,即 dsh-repo ${DSH_REPO_MIN_RELEASE} 及以上);请升级 dsh-repo`;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* The usable service, or a thrown error saying why there is none.
|
|
687
|
+
* @param value - the looked-up service, or undefined.
|
|
688
|
+
* @returns the service.
|
|
689
|
+
* @throws Error with {@link unavailableMessage} when it is missing or incompatible.
|
|
690
|
+
*/
|
|
691
|
+
function requireRepos(value) {
|
|
692
|
+
const lookup = lookupRepos(value);
|
|
693
|
+
if (lookup.availability !== "ready") throw new Error(unavailableMessage(lookup.availability));
|
|
694
|
+
return lookup.service;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Readable message of a `dshRepos` rejection or any other thrown value.
|
|
698
|
+
* @param error - the rejection.
|
|
699
|
+
* @returns its message.
|
|
700
|
+
*/
|
|
701
|
+
function messageOf$1(error) {
|
|
702
|
+
return error instanceof Error ? error.message : String(error);
|
|
703
|
+
}
|
|
704
|
+
//#endregion
|
|
746
705
|
//#region src/abort.ts
|
|
747
706
|
/**
|
|
748
707
|
* Cancellation helpers shared by the entry skill and the dispatch tool.
|
|
@@ -772,218 +731,58 @@ async function abortable(promise, signal) {
|
|
|
772
731
|
}
|
|
773
732
|
}
|
|
774
733
|
//#endregion
|
|
775
|
-
//#region src/skill-update/
|
|
734
|
+
//#region src/skill-update/change.ts
|
|
776
735
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
736
|
+
* 检查 SKILL 修改:从 dsh-repo 镜像读取分析时的提交中 SKILL 目录的文件,与提议
|
|
737
|
+
* 的文件比较,算出逐文件行数和 unified diff,不改动任何镜像或快照。提议内容的
|
|
738
|
+
* 摘要用作新分支名,提交本身经 dsh-repo 完成。
|
|
780
739
|
*
|
|
781
|
-
* @module dsh-guanxiang/skill-update/
|
|
782
|
-
*/
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
*
|
|
790
|
-
* @
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
`${skillDir}/`
|
|
816
|
-
], {
|
|
817
|
-
cwd: dir,
|
|
818
|
-
timeoutMs
|
|
819
|
-
});
|
|
820
|
-
const modes = /* @__PURE__ */ new Map();
|
|
821
|
-
for (const record of listing.split("\0")) {
|
|
822
|
-
const tab = record.indexOf(" ");
|
|
823
|
-
if (tab < 0) continue;
|
|
824
|
-
modes.set(record.slice(tab + 1), record.slice(0, record.indexOf(" ")));
|
|
825
|
-
}
|
|
826
|
-
for (const file of files) {
|
|
827
|
-
const path = `${skillDir}/${file.path}`;
|
|
828
|
-
const existing = modes.get(path);
|
|
829
|
-
if (existing === "120000") throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`);
|
|
830
|
-
if (existing === "040000") throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`);
|
|
831
|
-
const blob = (await git([
|
|
832
|
-
"hash-object",
|
|
833
|
-
"-w",
|
|
834
|
-
"--stdin"
|
|
835
|
-
], {
|
|
836
|
-
cwd: dir,
|
|
837
|
-
timeoutMs,
|
|
838
|
-
input: file.content
|
|
839
|
-
})).trim();
|
|
840
|
-
await git([
|
|
841
|
-
"update-index",
|
|
842
|
-
"--add",
|
|
843
|
-
"--cacheinfo",
|
|
844
|
-
`${existing === "100755" ? "100755" : "100644"},${blob},${path}`
|
|
845
|
-
], {
|
|
846
|
-
cwd: dir,
|
|
847
|
-
timeoutMs,
|
|
848
|
-
env
|
|
849
|
-
});
|
|
850
|
-
}
|
|
851
|
-
const tree = (await git(["write-tree"], {
|
|
852
|
-
cwd: dir,
|
|
853
|
-
timeoutMs,
|
|
854
|
-
env
|
|
855
|
-
})).trim();
|
|
856
|
-
const stats = (await git([
|
|
857
|
-
"diff",
|
|
858
|
-
...DIFF_FLAGS,
|
|
859
|
-
"--numstat",
|
|
860
|
-
base,
|
|
861
|
-
tree,
|
|
862
|
-
"--",
|
|
863
|
-
`${skillDir}/`
|
|
864
|
-
], {
|
|
865
|
-
cwd: dir,
|
|
866
|
-
timeoutMs
|
|
867
|
-
})).split("\n").filter((line) => line !== "").map((line) => {
|
|
868
|
-
const [added = "-", removed = "-", ...path] = line.split(" ");
|
|
869
|
-
return {
|
|
870
|
-
path: path.join(" "),
|
|
871
|
-
...added === "-" ? {} : { added: Number(added) },
|
|
872
|
-
...removed === "-" ? {} : { removed: Number(removed) }
|
|
873
|
-
};
|
|
874
|
-
});
|
|
875
|
-
return {
|
|
876
|
-
base,
|
|
877
|
-
tree,
|
|
878
|
-
files: stats,
|
|
879
|
-
diff: stats.length === 0 ? "" : await git([
|
|
880
|
-
"diff",
|
|
881
|
-
...DIFF_FLAGS,
|
|
882
|
-
base,
|
|
883
|
-
tree,
|
|
884
|
-
"--",
|
|
885
|
-
`${skillDir}/`
|
|
886
|
-
], {
|
|
887
|
-
cwd: dir,
|
|
888
|
-
timeoutMs
|
|
889
|
-
})
|
|
890
|
-
};
|
|
891
|
-
} finally {
|
|
892
|
-
await rm(scratch, {
|
|
893
|
-
recursive: true,
|
|
894
|
-
force: true
|
|
740
|
+
* @module dsh-guanxiang/skill-update/change
|
|
741
|
+
*/
|
|
742
|
+
/**
|
|
743
|
+
* Compare the proposed files with the skill directory of the base commit.
|
|
744
|
+
* @param repos - the `dshRepos` service holding the mirror.
|
|
745
|
+
* @param target - repository, base commit, and skill directory.
|
|
746
|
+
* @param files - proposed files relative to the skill directory.
|
|
747
|
+
* @param signal - cancellation.
|
|
748
|
+
* @returns per-file line counts, the diff, and the digest; `files` is empty when nothing changes.
|
|
749
|
+
* @throws when a proposed path replaces a symbolic link, a submodule, or a directory, or the mirror cannot be read.
|
|
750
|
+
*/
|
|
751
|
+
async function prepareChange(repos, target, files, signal) {
|
|
752
|
+
const { url, base, skillDir } = target;
|
|
753
|
+
const listing = await repos.listMirrorTree(url, base, skillDir, signal === void 0 ? {} : { signal });
|
|
754
|
+
const types = new Map(listing.map((entry) => [entry.path, entry.type]));
|
|
755
|
+
const stats = [];
|
|
756
|
+
const patches = [];
|
|
757
|
+
const digest = createHash("sha256").update(`${base}\n`);
|
|
758
|
+
for (const file of [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
|
|
759
|
+
const path = `${skillDir}/${file.path}`;
|
|
760
|
+
const type = types.get(path);
|
|
761
|
+
if (type === "symlink") throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`);
|
|
762
|
+
if (type === "dir" || type === "submodule") throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`);
|
|
763
|
+
const before = type === "file" ? await repos.readMirrorFile(url, base, path, signal === void 0 ? {} : { signal }) ?? "" : void 0;
|
|
764
|
+
if (before === file.content) continue;
|
|
765
|
+
const structured = structuredPatch(path, path, before ?? "", file.content, void 0, void 0, { context: 3 });
|
|
766
|
+
let added = 0;
|
|
767
|
+
let removed = 0;
|
|
768
|
+
for (const hunk of structured.hunks) for (const line of hunk.lines) if (line.startsWith("+")) added += 1;
|
|
769
|
+
else if (line.startsWith("-")) removed += 1;
|
|
770
|
+
stats.push({
|
|
771
|
+
path,
|
|
772
|
+
added,
|
|
773
|
+
removed
|
|
895
774
|
});
|
|
775
|
+
const header = `diff --git a/${path} b/${path}\n${before === void 0 ? "new file mode 100644\n" : ""}`;
|
|
776
|
+
const patch = createTwoFilesPatch(before === void 0 ? "/dev/null" : `a/${path}`, `b/${path}`, before ?? "", file.content, void 0, void 0, { context: 3 });
|
|
777
|
+
patches.push(header + patch.replace(/^=+\n/, ""));
|
|
778
|
+
digest.update(`${path}\0${file.content}\0`);
|
|
896
779
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
* @param branch - branch name.
|
|
903
|
-
* @param env - network environment of the clone's address.
|
|
904
|
-
* @returns whether `refs/heads/<branch>` exists on `origin`.
|
|
905
|
-
*/
|
|
906
|
-
async function remoteBranchExists(commands, dir, branch, env) {
|
|
907
|
-
return (await commands.git([
|
|
908
|
-
"ls-remote",
|
|
909
|
-
"--heads",
|
|
910
|
-
"origin",
|
|
911
|
-
`refs/heads/${branch}`
|
|
912
|
-
], {
|
|
913
|
-
cwd: dir,
|
|
914
|
-
timeoutMs: commands.timeoutMs,
|
|
915
|
-
env
|
|
916
|
-
})).trim() !== "";
|
|
917
|
-
}
|
|
918
|
-
/**
|
|
919
|
-
* Commit a prepared tree on its base commit without touching any ref.
|
|
920
|
-
* @param commands - git runner and timeout.
|
|
921
|
-
* @param dir - clone directory.
|
|
922
|
-
* @param change - prepared tree and its base.
|
|
923
|
-
* @param message - full commit message.
|
|
924
|
-
* @param author - explicit identity; omitted uses the host's git configuration.
|
|
925
|
-
* @returns the new commit id.
|
|
926
|
-
*/
|
|
927
|
-
async function commitChange(commands, dir, change, message, author) {
|
|
928
|
-
const env = author === void 0 ? void 0 : {
|
|
929
|
-
GIT_AUTHOR_NAME: author.name,
|
|
930
|
-
GIT_AUTHOR_EMAIL: author.email,
|
|
931
|
-
GIT_COMMITTER_NAME: author.name,
|
|
932
|
-
GIT_COMMITTER_EMAIL: author.email
|
|
780
|
+
return {
|
|
781
|
+
base,
|
|
782
|
+
digest: digest.digest("hex"),
|
|
783
|
+
files: stats,
|
|
784
|
+
diff: patches.join("")
|
|
933
785
|
};
|
|
934
|
-
return (await commands.git([
|
|
935
|
-
"commit-tree",
|
|
936
|
-
change.tree,
|
|
937
|
-
"-p",
|
|
938
|
-
change.base,
|
|
939
|
-
"-F",
|
|
940
|
-
"-"
|
|
941
|
-
], {
|
|
942
|
-
cwd: dir,
|
|
943
|
-
timeoutMs: commands.timeoutMs,
|
|
944
|
-
input: message,
|
|
945
|
-
...env === void 0 ? {} : { env }
|
|
946
|
-
})).trim();
|
|
947
|
-
}
|
|
948
|
-
/** Push options cannot carry line breaks; collapse every whitespace run. */
|
|
949
|
-
function oneLine(text) {
|
|
950
|
-
return text.replace(/\s+/g, " ").trim();
|
|
951
|
-
}
|
|
952
|
-
/**
|
|
953
|
-
* Push a commit to a new remote branch and ask GitLab to open a merge request
|
|
954
|
-
* for it (`merge_request.*` push options). The push carries an empty lease on
|
|
955
|
-
* the branch, so it fails instead of updating a branch that appeared after
|
|
956
|
-
* {@link remoteBranchExists} was checked. A server that does not support push
|
|
957
|
-
* options rejects the push.
|
|
958
|
-
* @param commands - git runner and timeout.
|
|
959
|
-
* @param dir - clone directory.
|
|
960
|
-
* @param commit - commit to push.
|
|
961
|
-
* @param branch - new branch name.
|
|
962
|
-
* @param request - merge request target and text.
|
|
963
|
-
* @param env - network environment of the clone's address.
|
|
964
|
-
* @returns the merge request URL the server printed, if any.
|
|
965
|
-
*/
|
|
966
|
-
async function pushForMergeRequest(commands, dir, commit, branch, request, env) {
|
|
967
|
-
const options = [
|
|
968
|
-
"merge_request.create",
|
|
969
|
-
`merge_request.target=${request.target}`,
|
|
970
|
-
`merge_request.title=${oneLine(request.title)}`,
|
|
971
|
-
`merge_request.description=${oneLine(request.description)}`
|
|
972
|
-
].flatMap((option) => ["-o", option]);
|
|
973
|
-
const output = await commands.git([
|
|
974
|
-
"push",
|
|
975
|
-
"--porcelain",
|
|
976
|
-
`--force-with-lease=refs/heads/${branch}:`,
|
|
977
|
-
...options,
|
|
978
|
-
"origin",
|
|
979
|
-
`${commit}:refs/heads/${branch}`
|
|
980
|
-
], {
|
|
981
|
-
cwd: dir,
|
|
982
|
-
timeoutMs: commands.timeoutMs,
|
|
983
|
-
env,
|
|
984
|
-
output: "all"
|
|
985
|
-
});
|
|
986
|
-
return /https?:\/\/\S+\/-\/merge_requests\/\d+/.exec(output)?.[0];
|
|
987
786
|
}
|
|
988
787
|
//#endregion
|
|
989
788
|
//#region src/repos/manifest.ts
|
|
@@ -1213,11 +1012,13 @@ async function readProposal(workDir, limits) {
|
|
|
1213
1012
|
* - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
|
|
1214
1013
|
* 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
|
|
1215
1014
|
* - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
* -
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1015
|
+
* 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<改动摘要前缀>`,从不提交到
|
|
1016
|
+
* 已有分支或默认分支。
|
|
1017
|
+
* - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
|
|
1018
|
+
* GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
|
|
1019
|
+
* - 提交前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
|
|
1020
|
+
* 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才提交;
|
|
1021
|
+
* 没有审批服务时工具报错,不提交。
|
|
1221
1022
|
* - 子会话中不提供本工具。
|
|
1222
1023
|
*
|
|
1223
1024
|
* @module dsh-guanxiang/skill-update/tool
|
|
@@ -1282,7 +1083,11 @@ const OUTPUT_SCHEMA$1 = {
|
|
|
1282
1083
|
items: {
|
|
1283
1084
|
type: "object",
|
|
1284
1085
|
additionalProperties: false,
|
|
1285
|
-
required: [
|
|
1086
|
+
required: [
|
|
1087
|
+
"path",
|
|
1088
|
+
"added",
|
|
1089
|
+
"removed"
|
|
1090
|
+
],
|
|
1286
1091
|
properties: {
|
|
1287
1092
|
path: { type: "string" },
|
|
1288
1093
|
added: { type: "number" },
|
|
@@ -1291,7 +1096,8 @@ const OUTPUT_SCHEMA$1 = {
|
|
|
1291
1096
|
}
|
|
1292
1097
|
},
|
|
1293
1098
|
diffPath: { type: "string" },
|
|
1294
|
-
mergeRequestUrl: { type: "string" }
|
|
1099
|
+
mergeRequestUrl: { type: "string" },
|
|
1100
|
+
mergeRequestError: { type: "string" }
|
|
1295
1101
|
}
|
|
1296
1102
|
};
|
|
1297
1103
|
/**
|
|
@@ -1300,7 +1106,7 @@ const OUTPUT_SCHEMA$1 = {
|
|
|
1300
1106
|
* @returns one line.
|
|
1301
1107
|
*/
|
|
1302
1108
|
function describeFiles(files) {
|
|
1303
|
-
return files.map((file) =>
|
|
1109
|
+
return files.map((file) => `${file.path}(+${file.added} −${file.removed})`).join("、");
|
|
1304
1110
|
}
|
|
1305
1111
|
/**
|
|
1306
1112
|
* Model-facing rendering of a submission.
|
|
@@ -1312,18 +1118,15 @@ function renderProposeValue(value) {
|
|
|
1312
1118
|
return [{
|
|
1313
1119
|
type: "text",
|
|
1314
1120
|
text: `${{
|
|
1315
|
-
opened:
|
|
1316
|
-
pushed:
|
|
1317
|
-
exists: `${where}
|
|
1318
|
-
rejected: "
|
|
1319
|
-
cancelled: "
|
|
1320
|
-
unavailable: "
|
|
1121
|
+
opened: `已提交到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ""}`,
|
|
1122
|
+
pushed: `已提交到 ${where},但创建合并请求失败(${value.mergeRequestError ?? "未知原因"});请在代码托管平台上从该分支手动创建。`,
|
|
1123
|
+
exists: `${where} 已存在,同一改动可能已经提交过,本次没有提交。`,
|
|
1124
|
+
rejected: "授权被拒绝,没有提交:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。",
|
|
1125
|
+
cancelled: "授权请求被撤回,没有提交。",
|
|
1126
|
+
unavailable: "当前没有可以回答授权请求的界面,没有提交。"
|
|
1321
1127
|
}[value.status]}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}`
|
|
1322
1128
|
}];
|
|
1323
1129
|
}
|
|
1324
|
-
function messageOf$2(error) {
|
|
1325
|
-
return error instanceof Error ? error.message : String(error);
|
|
1326
|
-
}
|
|
1327
1130
|
/**
|
|
1328
1131
|
* Write a file in an agent-writable directory without following whatever
|
|
1329
1132
|
* entry the agent left at its path: the content goes to a new file created
|
|
@@ -1346,7 +1149,7 @@ function skillDirOf(app) {
|
|
|
1346
1149
|
var SkillUpdater = class {
|
|
1347
1150
|
deps;
|
|
1348
1151
|
tasks = /* @__PURE__ */ new Map();
|
|
1349
|
-
/** @param deps - registry, settings,
|
|
1152
|
+
/** @param deps - registry, settings, approval, and `dshRepos` access. */
|
|
1350
1153
|
constructor(deps) {
|
|
1351
1154
|
this.deps = deps;
|
|
1352
1155
|
}
|
|
@@ -1373,7 +1176,7 @@ var SkillUpdater = class {
|
|
|
1373
1176
|
} catch (error) {
|
|
1374
1177
|
return {
|
|
1375
1178
|
ok: false,
|
|
1376
|
-
error: messageOf$
|
|
1179
|
+
error: messageOf$1(error)
|
|
1377
1180
|
};
|
|
1378
1181
|
}
|
|
1379
1182
|
}
|
|
@@ -1388,24 +1191,23 @@ var SkillUpdater = class {
|
|
|
1388
1191
|
/**
|
|
1389
1192
|
* Read the proposal, build its tree, and write the diff file.
|
|
1390
1193
|
* @returns undefined when the task wrote no proposal.
|
|
1391
|
-
* @throws ProposalError or
|
|
1194
|
+
* @throws ProposalError, or an error when dsh-repo is unavailable or the mirror cannot be read.
|
|
1392
1195
|
*/
|
|
1393
|
-
async prepare(task) {
|
|
1196
|
+
async prepare(task, signal) {
|
|
1394
1197
|
const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates);
|
|
1395
1198
|
if (proposal === void 0) return void 0;
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
target: clone.branch
|
|
1403
|
-
}));
|
|
1199
|
+
const target = this.deps.registry.branchOf(task.url);
|
|
1200
|
+
const change = await prepareChange(requireRepos(this.deps.repos()), {
|
|
1201
|
+
url: task.url,
|
|
1202
|
+
base: task.commit,
|
|
1203
|
+
skillDir: skillDirOf(task.app)
|
|
1204
|
+
}, proposal.files, signal);
|
|
1404
1205
|
if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`);
|
|
1405
1206
|
const diffPath = join(task.workDir, DIFF_FILE);
|
|
1406
1207
|
await replaceFile(diffPath, change.diff);
|
|
1407
1208
|
return {
|
|
1408
1209
|
reason: proposal.reason,
|
|
1210
|
+
files: proposal.files,
|
|
1409
1211
|
change,
|
|
1410
1212
|
target,
|
|
1411
1213
|
diffPath
|
|
@@ -1418,7 +1220,7 @@ var SkillUpdater = class {
|
|
|
1418
1220
|
definition() {
|
|
1419
1221
|
return {
|
|
1420
1222
|
name: PROPOSE_TOOL_NAME,
|
|
1421
|
-
description: "观象:把派发结果中某个应用的 SKILL
|
|
1223
|
+
description: "观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后提交到新分支并创建合并请求,不会直接改动默认分支。仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。",
|
|
1422
1224
|
parameters: PARAMETERS,
|
|
1423
1225
|
output: {
|
|
1424
1226
|
schema: OUTPUT_SCHEMA$1,
|
|
@@ -1433,7 +1235,7 @@ var SkillUpdater = class {
|
|
|
1433
1235
|
* @param rawArgs - model arguments.
|
|
1434
1236
|
* @param exec - execution context of the calling agent.
|
|
1435
1237
|
* @returns how the submission ended.
|
|
1436
|
-
* @throws when the arguments, task, proposal, or
|
|
1238
|
+
* @throws when the arguments, task, proposal, approval service, or `dshRepos` service are unusable, or the commit fails; nothing is committed then.
|
|
1437
1239
|
*/
|
|
1438
1240
|
async execute(rawArgs, exec) {
|
|
1439
1241
|
const parsed = argsSchema$1.safeParse(rawArgs);
|
|
@@ -1447,11 +1249,18 @@ var SkillUpdater = class {
|
|
|
1447
1249
|
if (task === void 0) throw new Error(`${parsed.data.workDir} 不是本次 DSH 运行中观象派发任务的运行目录;DSH 重启后需要重新分析`);
|
|
1448
1250
|
if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`);
|
|
1449
1251
|
const approval = this.deps.approval();
|
|
1450
|
-
if (approval === void 0) throw new Error("当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval
|
|
1451
|
-
const
|
|
1252
|
+
if (approval === void 0) throw new Error("当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交");
|
|
1253
|
+
const repos = requireRepos(this.deps.repos());
|
|
1254
|
+
let prepared;
|
|
1255
|
+
try {
|
|
1256
|
+
prepared = await this.prepare(task, exec.signal);
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
exec.signal.throwIfAborted();
|
|
1259
|
+
throw error;
|
|
1260
|
+
}
|
|
1452
1261
|
if (prepared === void 0) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`);
|
|
1453
1262
|
const { change, target, diffPath, reason } = prepared;
|
|
1454
|
-
const branch = `guanxiang/skill-${task.app.id}-${change.
|
|
1263
|
+
const branch = `guanxiang/skill-${task.app.id}-${change.digest.slice(0, 10)}`;
|
|
1455
1264
|
const base = {
|
|
1456
1265
|
app: task.app.id,
|
|
1457
1266
|
repo: task.url,
|
|
@@ -1460,17 +1269,13 @@ var SkillUpdater = class {
|
|
|
1460
1269
|
files: [...change.files],
|
|
1461
1270
|
diffPath
|
|
1462
1271
|
};
|
|
1463
|
-
|
|
1464
|
-
git: this.deps.git,
|
|
1465
|
-
timeoutMs: this.deps.gitTimeoutMs
|
|
1466
|
-
};
|
|
1467
|
-
if (await this.deps.registry.withClone(task.url, (clone) => remoteBranchExists(commands, clone.dir, branch, clone.env))) return {
|
|
1272
|
+
if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== void 0) return {
|
|
1468
1273
|
...base,
|
|
1469
1274
|
status: "exists"
|
|
1470
1275
|
};
|
|
1471
1276
|
const preview = reason.replace(/\s+/g, " ").slice(0, REASON_PREVIEW_CHARS);
|
|
1472
|
-
const zh =
|
|
1473
|
-
const en = `GuanXiang will
|
|
1277
|
+
const zh = `观象将在 ${task.url} 提交新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`;
|
|
1278
|
+
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}`;
|
|
1474
1279
|
const outcome = await approval.request({
|
|
1475
1280
|
agent,
|
|
1476
1281
|
toolName: PROPOSE_TOOL_NAME,
|
|
@@ -1489,23 +1294,50 @@ var SkillUpdater = class {
|
|
|
1489
1294
|
};
|
|
1490
1295
|
const message = `${summary}\n\n${reason}\n\nProposed-by: 观象 GuanXiang\nAnalyzed-commit: ${task.commit}\nSession: ${task.sessionId}\n`;
|
|
1491
1296
|
const description = `由观象在日志分析中提出(应用 ${task.app.id},分析提交 ${task.commit.slice(0, 12)},会话 ${task.sessionId})。理由:${reason.slice(0, REASON_DESCRIPTION_CHARS)}`;
|
|
1492
|
-
const
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1297
|
+
const changed = new Set(change.files.map((file) => file.path));
|
|
1298
|
+
const files = prepared.files.map((file) => ({
|
|
1299
|
+
action: "write",
|
|
1300
|
+
path: `${skillDirOf(task.app)}/${file.path}`,
|
|
1301
|
+
content: file.content
|
|
1302
|
+
})).filter((file) => changed.has(file.path));
|
|
1303
|
+
exec.signal.throwIfAborted();
|
|
1304
|
+
try {
|
|
1305
|
+
await repos.commitFiles(task.url, {
|
|
1306
|
+
branch,
|
|
1307
|
+
baseSha: task.commit,
|
|
1308
|
+
newBranch: true,
|
|
1309
|
+
message,
|
|
1310
|
+
files,
|
|
1311
|
+
...this.deps.author === void 0 ? {} : { author: this.deps.author },
|
|
1312
|
+
signal: exec.signal
|
|
1313
|
+
});
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
if (repoErrorCode(error) === "conflict") return {
|
|
1316
|
+
...base,
|
|
1317
|
+
status: "exists"
|
|
1318
|
+
};
|
|
1319
|
+
throw error;
|
|
1320
|
+
}
|
|
1321
|
+
try {
|
|
1322
|
+
const opened = await repos.createChange(task.url, {
|
|
1497
1323
|
title: summary,
|
|
1498
|
-
description
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
}
|
|
1324
|
+
body: description,
|
|
1325
|
+
sourceBranch: branch,
|
|
1326
|
+
targetBranch: target,
|
|
1327
|
+
signal: exec.signal
|
|
1328
|
+
});
|
|
1329
|
+
return {
|
|
1330
|
+
...base,
|
|
1331
|
+
status: "opened",
|
|
1332
|
+
mergeRequestUrl: opened.webUrl
|
|
1333
|
+
};
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
return {
|
|
1336
|
+
...base,
|
|
1337
|
+
status: "pushed",
|
|
1338
|
+
mergeRequestError: messageOf$1(error)
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1509
1341
|
}
|
|
1510
1342
|
};
|
|
1511
1343
|
//#endregion
|
|
@@ -1903,7 +1735,7 @@ function isInside(child, parent) {
|
|
|
1903
1735
|
function textOf(blocks) {
|
|
1904
1736
|
return blocks.flatMap((block) => block.type === "text" && typeof block.text === "string" ? [block.text] : []).join("\n").trim();
|
|
1905
1737
|
}
|
|
1906
|
-
function messageOf
|
|
1738
|
+
function messageOf(error) {
|
|
1907
1739
|
return error instanceof Error ? error.message : String(error);
|
|
1908
1740
|
}
|
|
1909
1741
|
/** Runs dispatches; one instance serves every registration of the tool. */
|
|
@@ -1959,7 +1791,7 @@ var Dispatcher = class {
|
|
|
1959
1791
|
try {
|
|
1960
1792
|
await stat(path);
|
|
1961
1793
|
} catch (error) {
|
|
1962
|
-
throw new Error(`日志路径不可读:${path}(${error.code ?? messageOf
|
|
1794
|
+
throw new Error(`日志路径不可读:${path}(${error.code ?? messageOf(error)})`);
|
|
1963
1795
|
}
|
|
1964
1796
|
return {
|
|
1965
1797
|
path,
|
|
@@ -2066,7 +1898,7 @@ var Dispatcher = class {
|
|
|
2066
1898
|
...base,
|
|
2067
1899
|
status: stop.aborted && !signal.aborted ? "timeout" : "failed",
|
|
2068
1900
|
output: "",
|
|
2069
|
-
diagnostic: messageOf
|
|
1901
|
+
diagnostic: messageOf(error)
|
|
2070
1902
|
};
|
|
2071
1903
|
}
|
|
2072
1904
|
activations[index] = activation;
|
|
@@ -2098,7 +1930,7 @@ var Dispatcher = class {
|
|
|
2098
1930
|
childId: activation.childId,
|
|
2099
1931
|
status: "error",
|
|
2100
1932
|
output: "",
|
|
2101
|
-
diagnostic: messageOf
|
|
1933
|
+
diagnostic: messageOf(error)
|
|
2102
1934
|
};
|
|
2103
1935
|
}
|
|
2104
1936
|
}));
|
|
@@ -2108,7 +1940,7 @@ var Dispatcher = class {
|
|
|
2108
1940
|
await activation.dispose();
|
|
2109
1941
|
} catch (error) {
|
|
2110
1942
|
const result = results.find((entry) => entry.childId === activation.childId);
|
|
2111
|
-
if (result !== void 0) result.diagnostic = [result.diagnostic, `子 Agent 清理失败:${messageOf
|
|
1943
|
+
if (result !== void 0) result.diagnostic = [result.diagnostic, `子 Agent 清理失败:${messageOf(error)}`].filter(Boolean).join(";");
|
|
2112
1944
|
}
|
|
2113
1945
|
}));
|
|
2114
1946
|
signal.throwIfAborted();
|
|
@@ -2116,201 +1948,40 @@ var Dispatcher = class {
|
|
|
2116
1948
|
}
|
|
2117
1949
|
};
|
|
2118
1950
|
//#endregion
|
|
2119
|
-
//#region src/repos/git.ts
|
|
2120
|
-
/**
|
|
2121
|
-
* 本机 git 命令的调用封装:沿用本机已有凭据(SSH key 或 credential helper),
|
|
2122
|
-
* 或由调用方经环境变量提供的访问令牌;禁止交互式提示,避免后台拉取卡在
|
|
2123
|
-
* 密码输入上。
|
|
2124
|
-
*
|
|
2125
|
-
* @module dsh-guanxiang/repos/git
|
|
2126
|
-
*/
|
|
2127
|
-
/** A failed git command with its combined output. */
|
|
2128
|
-
var GitError = class extends Error {
|
|
2129
|
-
args;
|
|
2130
|
-
output;
|
|
2131
|
-
/**
|
|
2132
|
-
* @param args - git arguments that failed.
|
|
2133
|
-
* @param output - trimmed stderr (or stdout when stderr is empty).
|
|
2134
|
-
*/
|
|
2135
|
-
constructor(args, output) {
|
|
2136
|
-
super(`git ${args.join(" ")} 失败:${output === "" ? "无输出" : output}`);
|
|
2137
|
-
this.args = args;
|
|
2138
|
-
this.output = output;
|
|
2139
|
-
}
|
|
2140
|
-
};
|
|
2141
|
-
/**
|
|
2142
|
-
* Environment for non-interactive git: no terminal prompts, and SSH in batch
|
|
2143
|
-
* mode unless the user configured their own SSH command.
|
|
2144
|
-
* @param env - base environment.
|
|
2145
|
-
* @returns environment for git child processes.
|
|
2146
|
-
*/
|
|
2147
|
-
function gitEnvironment(env = process.env) {
|
|
2148
|
-
const next = {
|
|
2149
|
-
...env,
|
|
2150
|
-
GIT_TERMINAL_PROMPT: "0"
|
|
2151
|
-
};
|
|
2152
|
-
if (env.GIT_SSH_COMMAND === void 0 && env.GIT_SSH === void 0) next.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
|
|
2153
|
-
return next;
|
|
2154
|
-
}
|
|
2155
|
-
/**
|
|
2156
|
-
* Default runner backed by the `git` executable on PATH.
|
|
2157
|
-
* @param args - git arguments.
|
|
2158
|
-
* @param options - working directory, timeout, extra environment, input, and output selection.
|
|
2159
|
-
* @returns stdout, or stdout followed by stderr.
|
|
2160
|
-
*/
|
|
2161
|
-
const runGit = (args, options) => new Promise((resolve, reject) => {
|
|
2162
|
-
const child = execFile("git", [...args], {
|
|
2163
|
-
cwd: options.cwd,
|
|
2164
|
-
env: {
|
|
2165
|
-
...gitEnvironment(),
|
|
2166
|
-
...options.env
|
|
2167
|
-
},
|
|
2168
|
-
timeout: options.timeoutMs,
|
|
2169
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
2170
|
-
windowsHide: true
|
|
2171
|
-
}, (error, stdout, stderr) => {
|
|
2172
|
-
if (error === null) {
|
|
2173
|
-
resolve(options.output === "all" ? `${stdout}${stderr}` : stdout);
|
|
2174
|
-
return;
|
|
2175
|
-
}
|
|
2176
|
-
const output = (options.output === "all" || stderr.trim() === "" ? `${stdout}${stderr}` : stderr).trim();
|
|
2177
|
-
reject(new GitError(args, error.killed === true ? `超时(${options.timeoutMs} ms)后被终止${output === "" ? "" : `:${output}`}` : output || error.message));
|
|
2178
|
-
});
|
|
2179
|
-
child.stdin?.on("error", () => {});
|
|
2180
|
-
child.stdin?.end(options.input);
|
|
2181
|
-
});
|
|
2182
|
-
//#endregion
|
|
2183
1951
|
//#region src/repos/registry.ts
|
|
2184
|
-
/**
|
|
2185
|
-
* 应用注册表:把设置中的 git 地址拉取到 `<reposDir>/<仓库名>`,从远端分支最新
|
|
2186
|
-
* 提交读取各仓库的应用描述,对外提供「已就绪的应用」,并为每次派发提供该
|
|
2187
|
-
* 提交的只读代码快照。
|
|
2188
|
-
*
|
|
2189
|
-
* - 拉取目录只做 fetch,不检出:新地址 `git clone --no-checkout`(先克隆到临时
|
|
2190
|
-
* 目录再改名,半成品不会被当成仓库),已有目录 `git fetch`。应用描述与 SKILL
|
|
2191
|
-
* 直接从提交的 git 对象读取,所以随时可以更新,不受正在进行的分析影响。
|
|
2192
|
-
* - 子 Agent 在 `<reposDir>/.snapshots/<仓库名>/` 下按提交创建的 `git worktree`
|
|
2193
|
-
* 快照里工作。快照创建后不再改动;同一提交的派发共用一份,没有使用者且已不是
|
|
2194
|
-
* 最新提交时删除。进程内首次同步某仓库时清掉上次运行遗留的快照。
|
|
2195
|
-
* - 更新失败时继续使用已有提交,并在状态里给出原因;应用描述或 SKILL 不合法时
|
|
2196
|
-
* 该应用不可用。
|
|
2197
|
-
* - 多个仓库声明同一个应用 id 时,这些仓库全部不可用。
|
|
2198
|
-
*
|
|
2199
|
-
* @module dsh-guanxiang/repos/registry
|
|
2200
|
-
*/
|
|
2201
|
-
/**
|
|
2202
|
-
* Checkout directory name for a git address: its last path segment without
|
|
2203
|
-
* `.git`, restricted to `[A-Za-z0-9._-]`.
|
|
2204
|
-
* @param url - git address.
|
|
2205
|
-
* @returns directory name.
|
|
2206
|
-
*/
|
|
2207
|
-
function repoDirName(url) {
|
|
2208
|
-
const name = (url.replace(/^[^@/]+@[^:/]+:/, "").replace(/[?#].*$/, "").replace(/\/+$/, "").split(/[/\\:]/).pop() ?? "").replace(/\.git$/, "").replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[.-]+/, "");
|
|
2209
|
-
return name === "" ? "repo" : name;
|
|
2210
|
-
}
|
|
2211
|
-
/**
|
|
2212
|
-
* Assign each address a distinct directory; colliding names get a short hash
|
|
2213
|
-
* of their address appended.
|
|
2214
|
-
* @param reposDir - parent directory (may start with `~`).
|
|
2215
|
-
* @param urls - configured addresses.
|
|
2216
|
-
* @returns absolute directory per address.
|
|
2217
|
-
*/
|
|
2218
|
-
function assignDirs(reposDir, urls) {
|
|
2219
|
-
const root = expandHome(reposDir);
|
|
2220
|
-
const counts = /* @__PURE__ */ new Map();
|
|
2221
|
-
for (const url of urls) counts.set(repoDirName(url), (counts.get(repoDirName(url)) ?? 0) + 1);
|
|
2222
|
-
const dirs = /* @__PURE__ */ new Map();
|
|
2223
|
-
for (const url of urls) {
|
|
2224
|
-
const name = repoDirName(url);
|
|
2225
|
-
const unique = (counts.get(name) ?? 0) > 1 ? `${name}-${createHash("sha1").update(url).digest("hex").slice(0, 8)}` : name;
|
|
2226
|
-
dirs.set(url, join(root, unique));
|
|
2227
|
-
}
|
|
2228
|
-
return dirs;
|
|
2229
|
-
}
|
|
2230
|
-
/**
|
|
2231
|
-
* Directory holding the commit snapshots of one clone.
|
|
2232
|
-
* @param dir - clone directory `<reposDir>/<name>`.
|
|
2233
|
-
* @returns `<reposDir>/.snapshots/<name>`.
|
|
2234
|
-
*/
|
|
2235
|
-
function snapshotRoot(dir) {
|
|
2236
|
-
return join(dirname(dir), ".snapshots", basename(dir));
|
|
2237
|
-
}
|
|
2238
|
-
/**
|
|
2239
|
-
* Run a task after every task queued earlier under the same key.
|
|
2240
|
-
* @param locks - tail of each key's queue.
|
|
2241
|
-
* @param key - queue key.
|
|
2242
|
-
* @param task - work to run.
|
|
2243
|
-
* @returns the task's result.
|
|
2244
|
-
*/
|
|
2245
|
-
function serialize(locks, key, task) {
|
|
2246
|
-
const run = (locks.get(key) ?? Promise.resolve()).then(task);
|
|
2247
|
-
const tail = run.then(() => void 0, () => void 0);
|
|
2248
|
-
locks.set(key, tail);
|
|
2249
|
-
tail.then(() => {
|
|
2250
|
-
if (locks.get(key) === tail) locks.delete(key);
|
|
2251
|
-
});
|
|
2252
|
-
return run;
|
|
2253
|
-
}
|
|
2254
|
-
function messageOf(error) {
|
|
2255
|
-
if (error instanceof GitError) return error.output === "" ? error.message : error.output;
|
|
2256
|
-
return error instanceof Error ? error.message : String(error);
|
|
2257
|
-
}
|
|
2258
|
-
async function pathKind(path) {
|
|
2259
|
-
try {
|
|
2260
|
-
return (await stat(path)).isDirectory() ? "directory" : "other";
|
|
2261
|
-
} catch (error) {
|
|
2262
|
-
if (error.code === "ENOENT") return "missing";
|
|
2263
|
-
throw error;
|
|
2264
|
-
}
|
|
2265
|
-
}
|
|
2266
|
-
/** Clones, updates, and indexes the configured application repositories. */
|
|
1952
|
+
/** Mirrors, updates, and indexes the configured application repositories. */
|
|
2267
1953
|
var RepoRegistry = class {
|
|
2268
1954
|
deps;
|
|
2269
1955
|
entries = [];
|
|
2270
|
-
/** Orders clone and fetch work per clone directory. */
|
|
2271
|
-
syncLocks = /* @__PURE__ */ new Map();
|
|
2272
|
-
/** Orders `git worktree` work per clone directory. */
|
|
2273
|
-
worktreeLocks = /* @__PURE__ */ new Map();
|
|
2274
|
-
/** Snapshot roots already cleared of leftovers from an earlier run. */
|
|
2275
|
-
clearedRoots = /* @__PURE__ */ new Set();
|
|
2276
1956
|
now;
|
|
2277
|
-
/** @param deps -
|
|
1957
|
+
/** @param deps - service lookup and change notification. */
|
|
2278
1958
|
constructor(deps) {
|
|
2279
1959
|
this.deps = deps;
|
|
2280
1960
|
this.now = deps.now ?? Date.now;
|
|
2281
1961
|
}
|
|
2282
1962
|
/**
|
|
2283
1963
|
* Replace the configured repositories. An address keeps its status when its
|
|
2284
|
-
* branch
|
|
2285
|
-
* disk, and their snapshots are deleted once no dispatch holds them.
|
|
2286
|
-
* @param reposDir - parent directory of the clones.
|
|
1964
|
+
* branch is unchanged.
|
|
2287
1965
|
* @param repos - configured repositories.
|
|
2288
|
-
* @returns
|
|
1966
|
+
* @returns addresses that are new or changed and therefore need a sync.
|
|
2289
1967
|
*/
|
|
2290
|
-
configure(
|
|
2291
|
-
const dirs = assignDirs(reposDir, repos.map((repo) => repo.url));
|
|
1968
|
+
configure(repos) {
|
|
2292
1969
|
const previous = new Map(this.entries.map((entry) => [entry.spec.url, entry]));
|
|
2293
1970
|
const changed = [];
|
|
2294
1971
|
this.entries = repos.map((spec) => {
|
|
2295
|
-
const dir = dirs.get(spec.url);
|
|
2296
1972
|
const kept = previous.get(spec.url);
|
|
2297
|
-
if (kept !== void 0 && kept.
|
|
1973
|
+
if (kept !== void 0 && kept.spec.branch === spec.branch) {
|
|
2298
1974
|
previous.delete(spec.url);
|
|
2299
1975
|
return kept;
|
|
2300
1976
|
}
|
|
2301
1977
|
changed.push(spec.url);
|
|
2302
1978
|
return {
|
|
2303
1979
|
spec: { ...spec },
|
|
2304
|
-
dir,
|
|
2305
|
-
snapshots: /* @__PURE__ */ new Map(),
|
|
2306
1980
|
activeRuns: 0,
|
|
2307
1981
|
retired: false
|
|
2308
1982
|
};
|
|
2309
1983
|
});
|
|
2310
|
-
for (const entry of previous.values())
|
|
2311
|
-
entry.retired = true;
|
|
2312
|
-
this.pruneSnapshots(entry);
|
|
2313
|
-
}
|
|
1984
|
+
for (const entry of previous.values()) entry.retired = true;
|
|
2314
1985
|
this.deps.onChange();
|
|
2315
1986
|
return changed;
|
|
2316
1987
|
}
|
|
@@ -2331,16 +2002,14 @@ var RepoRegistry = class {
|
|
|
2331
2002
|
await Promise.all(this.entries.filter((entry) => wanted.has(entry.spec.url)).map((entry) => this.syncEntry(entry)));
|
|
2332
2003
|
}
|
|
2333
2004
|
/**
|
|
2334
|
-
* Sync
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
* @param urls - addresses to sync.
|
|
2005
|
+
* Sync every configured repository again after any sync already running for
|
|
2006
|
+
* it, so the new sync sees changes made since that sync started (a host
|
|
2007
|
+
* token, for example). An idle repository starts syncing before this
|
|
2008
|
+
* returns, so its status reads `syncing` at once.
|
|
2339
2009
|
* @returns when those syncs settled.
|
|
2340
2010
|
*/
|
|
2341
|
-
async
|
|
2342
|
-
|
|
2343
|
-
await Promise.all(this.entries.filter((entry) => wanted.has(entry.spec.url)).map((entry) => entry.syncing === void 0 ? this.syncEntry(entry) : entry.syncing.then(() => void 0, () => void 0).then(() => this.syncEntry(entry))));
|
|
2011
|
+
async resyncAll() {
|
|
2012
|
+
await Promise.all(this.entries.map((entry) => entry.syncing === void 0 ? this.syncEntry(entry) : entry.syncing.then(() => void 0, () => void 0).then(() => this.syncEntry(entry))));
|
|
2344
2013
|
}
|
|
2345
2014
|
/**
|
|
2346
2015
|
* Sync the repositories that never synced or whose last sync is at least
|
|
@@ -2371,7 +2040,7 @@ var RepoRegistry = class {
|
|
|
2371
2040
|
return {
|
|
2372
2041
|
url: entry.spec.url,
|
|
2373
2042
|
...entry.spec.branch === void 0 ? {} : { branch: entry.spec.branch },
|
|
2374
|
-
dir: entry.dir,
|
|
2043
|
+
...entry.dir === void 0 ? {} : { dir: entry.dir },
|
|
2375
2044
|
phase,
|
|
2376
2045
|
...entry.commit === void 0 ? {} : { commit: entry.commit },
|
|
2377
2046
|
...entry.commitTime === void 0 ? {} : { commitTime: entry.commitTime },
|
|
@@ -2406,76 +2075,64 @@ var RepoRegistry = class {
|
|
|
2406
2075
|
}
|
|
2407
2076
|
/**
|
|
2408
2077
|
* Hold a snapshot of each requested application's latest synced commit for
|
|
2409
|
-
* one dispatch
|
|
2410
|
-
*
|
|
2078
|
+
* one dispatch. Every id must resolve to a usable application, or nothing is
|
|
2079
|
+
* held.
|
|
2411
2080
|
* @param ids - requested application ids (duplicates allowed).
|
|
2412
2081
|
* @returns the lease.
|
|
2413
|
-
* @throws when an id is unknown, its repository is not usable, or a snapshot cannot be created.
|
|
2082
|
+
* @throws when an id is unknown, its repository is not usable, dsh-repo is unavailable, or a snapshot cannot be created.
|
|
2414
2083
|
*/
|
|
2415
2084
|
async acquire(ids) {
|
|
2416
2085
|
const unique = [...new Set(ids)];
|
|
2417
2086
|
const ready = new Map(this.readyEntries().map((item) => [item.ready.app.id, item]));
|
|
2418
2087
|
const missing = unique.filter((id) => !ready.has(id));
|
|
2419
2088
|
if (missing.length > 0) throw new Error(this.unknownMessage(missing));
|
|
2089
|
+
const service = requireRepos(this.deps.repos());
|
|
2420
2090
|
const held = unique.map((id) => {
|
|
2421
2091
|
const { entry, ready: app } = ready.get(id);
|
|
2422
|
-
const snapshot = this.snapshotFor(entry, app.commit);
|
|
2423
|
-
snapshot.users += 1;
|
|
2424
2092
|
entry.activeRuns += 1;
|
|
2425
2093
|
return {
|
|
2426
2094
|
id,
|
|
2427
2095
|
entry,
|
|
2428
2096
|
app,
|
|
2429
|
-
snapshot
|
|
2097
|
+
snapshot: service.acquireSnapshot(app.url, app.commit)
|
|
2430
2098
|
};
|
|
2431
2099
|
});
|
|
2432
2100
|
this.deps.onChange();
|
|
2101
|
+
const outcomes = await Promise.allSettled(held.map((item) => item.snapshot));
|
|
2102
|
+
const leases = outcomes.flatMap((outcome) => outcome.status === "fulfilled" ? [outcome.value] : []);
|
|
2433
2103
|
let released = false;
|
|
2434
2104
|
const release = () => {
|
|
2435
2105
|
if (released) return;
|
|
2436
2106
|
released = true;
|
|
2437
|
-
for (const
|
|
2438
|
-
|
|
2439
|
-
entry.activeRuns -= 1;
|
|
2440
|
-
}
|
|
2441
|
-
for (const entry of new Set(held.map((item) => item.entry))) this.pruneSnapshots(entry);
|
|
2107
|
+
for (const lease of leases) lease.release();
|
|
2108
|
+
for (const { entry } of held) entry.activeRuns -= 1;
|
|
2442
2109
|
this.deps.onChange();
|
|
2443
2110
|
};
|
|
2444
|
-
const
|
|
2445
|
-
for (const [index, outcome] of created.entries()) {
|
|
2111
|
+
for (const [index, outcome] of outcomes.entries()) {
|
|
2446
2112
|
if (outcome.status === "fulfilled") continue;
|
|
2447
2113
|
release();
|
|
2448
|
-
throw new Error(`应用「${held[index]?.id}」的代码快照创建失败:${messageOf(outcome.reason)}`);
|
|
2114
|
+
throw new Error(`应用「${held[index]?.id}」的代码快照创建失败:${messageOf$1(outcome.reason)}`);
|
|
2449
2115
|
}
|
|
2450
2116
|
return {
|
|
2451
|
-
apps: new Map(held.map(({ id, app,
|
|
2117
|
+
apps: new Map(held.map(({ id, app }, index) => [id, {
|
|
2452
2118
|
...app,
|
|
2453
|
-
dir:
|
|
2119
|
+
dir: leases[index].dir
|
|
2454
2120
|
}])),
|
|
2455
2121
|
release
|
|
2456
2122
|
};
|
|
2457
2123
|
}
|
|
2458
2124
|
/**
|
|
2459
|
-
*
|
|
2460
|
-
*
|
|
2125
|
+
* The branch the last successful sync of a configured address resolved: the
|
|
2126
|
+
* configured branch, or the remote's default branch when none is configured.
|
|
2461
2127
|
* @param url - configured repository address.
|
|
2462
|
-
* @
|
|
2463
|
-
* @
|
|
2464
|
-
* @throws when the address is not configured, its token cannot be read, or the task fails.
|
|
2128
|
+
* @returns the branch.
|
|
2129
|
+
* @throws when the address is not configured or has not synced yet.
|
|
2465
2130
|
*/
|
|
2466
|
-
|
|
2131
|
+
branchOf(url) {
|
|
2467
2132
|
const entry = this.entries.find((item) => item.spec.url === url);
|
|
2468
2133
|
if (entry === void 0) throw new Error(`仓库 ${url} 已不在配置中`);
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
const env = await this.deps.auth?.(url) ?? {};
|
|
2472
|
-
const branch = entry.spec.branch ?? (await this.remoteHead(entry.dir, env)).replace(/^refs\/remotes\/origin\//, "");
|
|
2473
|
-
return await task({
|
|
2474
|
-
dir: entry.dir,
|
|
2475
|
-
branch,
|
|
2476
|
-
env
|
|
2477
|
-
});
|
|
2478
|
-
});
|
|
2134
|
+
if (entry.branch === void 0) throw new Error(`仓库 ${url} 还没有同步成功`);
|
|
2135
|
+
return entry.branch;
|
|
2479
2136
|
}
|
|
2480
2137
|
unknownMessage(ids) {
|
|
2481
2138
|
const status = new Map(this.status().flatMap((repo) => repo.app === void 0 ? [] : [[repo.app.id, repo]]));
|
|
@@ -2496,7 +2153,7 @@ var RepoRegistry = class {
|
|
|
2496
2153
|
}
|
|
2497
2154
|
syncEntry(entry) {
|
|
2498
2155
|
if (entry.syncing !== void 0) return entry.syncing;
|
|
2499
|
-
const run =
|
|
2156
|
+
const run = this.runSync(entry).finally(() => {
|
|
2500
2157
|
entry.syncing = void 0;
|
|
2501
2158
|
this.deps.onChange();
|
|
2502
2159
|
});
|
|
@@ -2506,16 +2163,20 @@ var RepoRegistry = class {
|
|
|
2506
2163
|
}
|
|
2507
2164
|
async runSync(entry) {
|
|
2508
2165
|
if (entry.retired) return;
|
|
2166
|
+
let service;
|
|
2509
2167
|
let head;
|
|
2510
2168
|
try {
|
|
2511
|
-
|
|
2169
|
+
service = requireRepos(this.deps.repos());
|
|
2170
|
+
head = await service.syncMirror(entry.spec.url, entry.spec.branch === void 0 ? {} : { branch: entry.spec.branch });
|
|
2512
2171
|
} catch (error) {
|
|
2513
2172
|
entry.syncedAt = this.now();
|
|
2514
|
-
entry.error = entry.commit !== void 0 && entry.app !== void 0 ? `更新失败,继续使用已有提交 ${entry.commit.slice(0, 8)}:${messageOf(error)}` : messageOf(error);
|
|
2173
|
+
entry.error = entry.commit !== void 0 && entry.app !== void 0 ? `更新失败,继续使用已有提交 ${entry.commit.slice(0, 8)}:${messageOf$1(error)}` : messageOf$1(error);
|
|
2515
2174
|
return;
|
|
2516
2175
|
}
|
|
2517
|
-
const manifest = await checkAppManifest(
|
|
2176
|
+
const manifest = await checkAppManifest((path) => service.readMirrorFile(entry.spec.url, head.commit, path));
|
|
2518
2177
|
entry.syncedAt = this.now();
|
|
2178
|
+
entry.dir = head.dir;
|
|
2179
|
+
entry.branch = head.branch;
|
|
2519
2180
|
entry.commit = head.commit;
|
|
2520
2181
|
entry.commitTime = head.commitTime;
|
|
2521
2182
|
if (manifest.ok) {
|
|
@@ -2525,202 +2186,6 @@ var RepoRegistry = class {
|
|
|
2525
2186
|
entry.app = void 0;
|
|
2526
2187
|
entry.error = manifest.error;
|
|
2527
2188
|
}
|
|
2528
|
-
this.pruneSnapshots(entry);
|
|
2529
|
-
}
|
|
2530
|
-
/**
|
|
2531
|
-
* Clone or fetch, and resolve the commit the configured branch points to.
|
|
2532
|
-
* The first visit of a snapshot root in this process deletes the snapshots
|
|
2533
|
-
* an earlier run left behind.
|
|
2534
|
-
*/
|
|
2535
|
-
async update(entry) {
|
|
2536
|
-
const { git, gitTimeoutMs: timeoutMs } = this.deps;
|
|
2537
|
-
const { dir, spec } = entry;
|
|
2538
|
-
const root = snapshotRoot(dir);
|
|
2539
|
-
const firstVisit = !this.clearedRoots.has(root);
|
|
2540
|
-
if (firstVisit) {
|
|
2541
|
-
this.clearedRoots.add(root);
|
|
2542
|
-
await rm(root, {
|
|
2543
|
-
recursive: true,
|
|
2544
|
-
force: true
|
|
2545
|
-
});
|
|
2546
|
-
}
|
|
2547
|
-
const env = await this.deps.auth?.(spec.url) ?? {};
|
|
2548
|
-
const kind = await pathKind(dir);
|
|
2549
|
-
if (kind === "missing") {
|
|
2550
|
-
await mkdir(dirname(dir), { recursive: true });
|
|
2551
|
-
const partial = `${dir}.partial-${randomBytes(4).toString("hex")}`;
|
|
2552
|
-
try {
|
|
2553
|
-
await git([
|
|
2554
|
-
"clone",
|
|
2555
|
-
"--quiet",
|
|
2556
|
-
"--no-checkout",
|
|
2557
|
-
...spec.branch === void 0 ? [] : ["--branch", spec.branch],
|
|
2558
|
-
"--",
|
|
2559
|
-
spec.url,
|
|
2560
|
-
partial
|
|
2561
|
-
], {
|
|
2562
|
-
timeoutMs,
|
|
2563
|
-
env
|
|
2564
|
-
});
|
|
2565
|
-
await rename(partial, dir);
|
|
2566
|
-
} catch (error) {
|
|
2567
|
-
await rm(partial, {
|
|
2568
|
-
recursive: true,
|
|
2569
|
-
force: true
|
|
2570
|
-
});
|
|
2571
|
-
throw error;
|
|
2572
|
-
}
|
|
2573
|
-
} else {
|
|
2574
|
-
if (kind !== "directory") throw new Error(`${dir} 已存在且不是目录`);
|
|
2575
|
-
if (await pathKind(join(dir, ".git")) === "missing") {
|
|
2576
|
-
if (!((await readdir(dir)).length === 0)) throw new Error(`${dir} 已存在且不是 git 仓库,请移走后重新拉取`);
|
|
2577
|
-
await rm(dir, { recursive: true });
|
|
2578
|
-
return await this.update(entry);
|
|
2579
|
-
}
|
|
2580
|
-
const origin = (await git([
|
|
2581
|
-
"remote",
|
|
2582
|
-
"get-url",
|
|
2583
|
-
"origin"
|
|
2584
|
-
], {
|
|
2585
|
-
cwd: dir,
|
|
2586
|
-
timeoutMs
|
|
2587
|
-
})).trim();
|
|
2588
|
-
if (origin !== spec.url) throw new Error(`${dir} 已被另一个仓库占用(origin 为 ${origin})`);
|
|
2589
|
-
await git([
|
|
2590
|
-
"fetch",
|
|
2591
|
-
"--quiet",
|
|
2592
|
-
"--prune",
|
|
2593
|
-
"origin"
|
|
2594
|
-
], {
|
|
2595
|
-
cwd: dir,
|
|
2596
|
-
timeoutMs,
|
|
2597
|
-
env
|
|
2598
|
-
});
|
|
2599
|
-
if (firstVisit) await git(["worktree", "prune"], {
|
|
2600
|
-
cwd: dir,
|
|
2601
|
-
timeoutMs
|
|
2602
|
-
});
|
|
2603
|
-
}
|
|
2604
|
-
const [commit = "", commitTime = ""] = (await git([
|
|
2605
|
-
"log",
|
|
2606
|
-
"-1",
|
|
2607
|
-
"--format=%H%n%cI",
|
|
2608
|
-
spec.branch === void 0 ? await this.remoteHead(dir, env) : `refs/remotes/origin/${spec.branch}`,
|
|
2609
|
-
"--"
|
|
2610
|
-
], {
|
|
2611
|
-
cwd: dir,
|
|
2612
|
-
timeoutMs
|
|
2613
|
-
})).trim().split("\n");
|
|
2614
|
-
return {
|
|
2615
|
-
commit,
|
|
2616
|
-
commitTime
|
|
2617
|
-
};
|
|
2618
|
-
}
|
|
2619
|
-
/**
|
|
2620
|
-
* Reader for the files of one commit, from the clone's object database.
|
|
2621
|
-
* Only regular files are read; a symbolic link is reported as unreadable.
|
|
2622
|
-
*/
|
|
2623
|
-
commitReader(dir, commit) {
|
|
2624
|
-
const { git, gitTimeoutMs: timeoutMs } = this.deps;
|
|
2625
|
-
return async (path) => {
|
|
2626
|
-
const listing = await git([
|
|
2627
|
-
"ls-tree",
|
|
2628
|
-
"-z",
|
|
2629
|
-
commit,
|
|
2630
|
-
"--",
|
|
2631
|
-
path
|
|
2632
|
-
], {
|
|
2633
|
-
cwd: dir,
|
|
2634
|
-
timeoutMs
|
|
2635
|
-
});
|
|
2636
|
-
if (listing === "") return void 0;
|
|
2637
|
-
const [mode, type, object] = listing.slice(0, listing.indexOf(" ")).split(" ");
|
|
2638
|
-
if (type !== "blob" || mode === "120000" || object === void 0) throw new Error(`${path} 不是普通文件`);
|
|
2639
|
-
return await git([
|
|
2640
|
-
"cat-file",
|
|
2641
|
-
"blob",
|
|
2642
|
-
object
|
|
2643
|
-
], {
|
|
2644
|
-
cwd: dir,
|
|
2645
|
-
timeoutMs
|
|
2646
|
-
});
|
|
2647
|
-
};
|
|
2648
|
-
}
|
|
2649
|
-
/** The snapshot of `commit`, created in the background on first use. */
|
|
2650
|
-
snapshotFor(entry, commit) {
|
|
2651
|
-
const existing = entry.snapshots.get(commit);
|
|
2652
|
-
if (existing !== void 0) return existing;
|
|
2653
|
-
const dir = join(snapshotRoot(entry.dir), `${commit.slice(0, 12)}-${randomBytes(3).toString("hex")}`);
|
|
2654
|
-
const created = serialize(this.worktreeLocks, entry.dir, async () => {
|
|
2655
|
-
await mkdir(dirname(dir), { recursive: true });
|
|
2656
|
-
await this.deps.git([
|
|
2657
|
-
"worktree",
|
|
2658
|
-
"add",
|
|
2659
|
-
"--detach",
|
|
2660
|
-
dir,
|
|
2661
|
-
commit
|
|
2662
|
-
], {
|
|
2663
|
-
cwd: entry.dir,
|
|
2664
|
-
timeoutMs: this.deps.gitTimeoutMs
|
|
2665
|
-
});
|
|
2666
|
-
});
|
|
2667
|
-
const snapshot = {
|
|
2668
|
-
commit,
|
|
2669
|
-
dir,
|
|
2670
|
-
users: 0,
|
|
2671
|
-
created
|
|
2672
|
-
};
|
|
2673
|
-
entry.snapshots.set(commit, snapshot);
|
|
2674
|
-
created.catch(() => {
|
|
2675
|
-
if (entry.snapshots.get(commit) === snapshot) entry.snapshots.delete(commit);
|
|
2676
|
-
this.removeSnapshot(entry.dir, dir);
|
|
2677
|
-
});
|
|
2678
|
-
return snapshot;
|
|
2679
|
-
}
|
|
2680
|
-
/** Delete the snapshots no lease holds, except the latest commit's of a configured repository. */
|
|
2681
|
-
pruneSnapshots(entry) {
|
|
2682
|
-
for (const [commit, snapshot] of entry.snapshots) {
|
|
2683
|
-
if (snapshot.users > 0 || commit === entry.commit && !entry.retired) continue;
|
|
2684
|
-
entry.snapshots.delete(commit);
|
|
2685
|
-
snapshot.created.then(() => this.removeSnapshot(entry.dir, snapshot.dir), () => void 0);
|
|
2686
|
-
}
|
|
2687
|
-
}
|
|
2688
|
-
async removeSnapshot(clone, dir) {
|
|
2689
|
-
try {
|
|
2690
|
-
await serialize(this.worktreeLocks, clone, async () => {
|
|
2691
|
-
await rm(dir, {
|
|
2692
|
-
recursive: true,
|
|
2693
|
-
force: true
|
|
2694
|
-
});
|
|
2695
|
-
await this.deps.git(["worktree", "prune"], {
|
|
2696
|
-
cwd: clone,
|
|
2697
|
-
timeoutMs: this.deps.gitTimeoutMs
|
|
2698
|
-
});
|
|
2699
|
-
});
|
|
2700
|
-
} catch {}
|
|
2701
|
-
}
|
|
2702
|
-
async remoteHead(dir, env) {
|
|
2703
|
-
const { git, gitTimeoutMs: timeoutMs } = this.deps;
|
|
2704
|
-
const read = async () => (await git(["symbolic-ref", "refs/remotes/origin/HEAD"], {
|
|
2705
|
-
cwd: dir,
|
|
2706
|
-
timeoutMs
|
|
2707
|
-
})).trim();
|
|
2708
|
-
try {
|
|
2709
|
-
return await read();
|
|
2710
|
-
} catch (error) {
|
|
2711
|
-
if (!(error instanceof GitError)) throw error;
|
|
2712
|
-
await git([
|
|
2713
|
-
"remote",
|
|
2714
|
-
"set-head",
|
|
2715
|
-
"origin",
|
|
2716
|
-
"--auto"
|
|
2717
|
-
], {
|
|
2718
|
-
cwd: dir,
|
|
2719
|
-
timeoutMs,
|
|
2720
|
-
env
|
|
2721
|
-
});
|
|
2722
|
-
return await read();
|
|
2723
|
-
}
|
|
2724
2189
|
}
|
|
2725
2190
|
};
|
|
2726
2191
|
//#endregion
|
|
@@ -2979,16 +2444,21 @@ function createEntrySkillProvider(source) {
|
|
|
2979
2444
|
}
|
|
2980
2445
|
//#endregion
|
|
2981
2446
|
//#region src/service.ts
|
|
2982
|
-
/**
|
|
2983
|
-
* 观象运行时:持有生效设置(cordis.yml + 面板覆盖)、应用注册表、入口
|
|
2984
|
-
* SKILL、派发工具与 SKILL 提议工具的注册,并为设置面板提供状态与写入口。
|
|
2985
|
-
*
|
|
2986
|
-
* @module dsh-guanxiang/service
|
|
2987
|
-
*/
|
|
2988
2447
|
/** A settings write the panel reports as a field error. */
|
|
2989
2448
|
var SettingsError = class extends Error {};
|
|
2449
|
+
/** Longest accepted token; personal access tokens are far shorter. */
|
|
2450
|
+
const MAX_TOKEN_LENGTH = 4096;
|
|
2451
|
+
const tokenValue = z.string().trim().refine((value) => value.length > 0 && value.length <= MAX_TOKEN_LENGTH && !/[\u0000-\u001f\u007f]/.test(value), `令牌不能为空、超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`);
|
|
2990
2452
|
const tokenClearSchema = z.object({ tokenEnv: z.string() }).strict();
|
|
2991
|
-
const tokenSetSchema = tokenClearSchema.extend({ value:
|
|
2453
|
+
const tokenSetSchema = tokenClearSchema.extend({ value: tokenValue }).strict();
|
|
2454
|
+
const hostUrlSchema = z.object({ url: z.string().trim().min(1, "主机地址不能为空") }).strict();
|
|
2455
|
+
const hostTokenSchema = hostUrlSchema.extend({ value: tokenValue }).strict();
|
|
2456
|
+
const hostAddSchema = z.object({
|
|
2457
|
+
kind: z.enum(["github", "gitlab"]).optional(),
|
|
2458
|
+
url: z.string().trim().min(1, "主机地址不能为空"),
|
|
2459
|
+
alias: z.string().trim().max(64, "别名不能超过 64 个字符").optional().transform((value) => value === "" ? void 0 : value),
|
|
2460
|
+
token: tokenValue.optional()
|
|
2461
|
+
}).strict();
|
|
2992
2462
|
/** The plugin's long-lived state. */
|
|
2993
2463
|
var GuanxiangService = class {
|
|
2994
2464
|
ctx;
|
|
@@ -3004,6 +2474,7 @@ var GuanxiangService = class {
|
|
|
3004
2474
|
active = true;
|
|
3005
2475
|
refreshWaitMs;
|
|
3006
2476
|
analyzeTokenRef;
|
|
2477
|
+
legacyCredentials;
|
|
3007
2478
|
booted = Promise.withResolvers();
|
|
3008
2479
|
/**
|
|
3009
2480
|
* @param ctx - plugin context.
|
|
@@ -3017,12 +2488,10 @@ var GuanxiangService = class {
|
|
|
3017
2488
|
this.defaults = defaultsOf(config);
|
|
3018
2489
|
this.refreshWaitMs = config.refreshWaitMs;
|
|
3019
2490
|
this.analyzeTokenRef = config.analyzeTokenRef;
|
|
2491
|
+
this.legacyCredentials = config.gitCredentials;
|
|
3020
2492
|
this.current = this.defaults;
|
|
3021
|
-
const git = options.git ?? runGit;
|
|
3022
2493
|
this.registry = new RepoRegistry({
|
|
3023
|
-
|
|
3024
|
-
gitTimeoutMs: config.gitTimeoutMs,
|
|
3025
|
-
auth: (url) => this.gitAuth(url),
|
|
2494
|
+
repos: () => ctx.get?.(DSH_REPOS_SERVICE),
|
|
3026
2495
|
onChange: () => {
|
|
3027
2496
|
this.onRegistryChange();
|
|
3028
2497
|
}
|
|
@@ -3030,10 +2499,9 @@ var GuanxiangService = class {
|
|
|
3030
2499
|
this.updater = new SkillUpdater({
|
|
3031
2500
|
registry: this.registry,
|
|
3032
2501
|
settings: () => this.current,
|
|
3033
|
-
git,
|
|
3034
|
-
gitTimeoutMs: config.gitTimeoutMs,
|
|
3035
2502
|
...config.skillUpdateAuthor === void 0 ? {} : { author: config.skillUpdateAuthor },
|
|
3036
|
-
approval: () => ctx.get?.("approval")
|
|
2503
|
+
approval: () => ctx.get?.("approval"),
|
|
2504
|
+
repos: () => ctx.get?.(DSH_REPOS_SERVICE)
|
|
3037
2505
|
});
|
|
3038
2506
|
this.dispatcher = new Dispatcher({
|
|
3039
2507
|
registry: this.registry,
|
|
@@ -3050,7 +2518,10 @@ var GuanxiangService = class {
|
|
|
3050
2518
|
maxAppsPerRequest: () => this.current.maxAppsPerRequest,
|
|
3051
2519
|
skillUpdates: () => this.current.skillUpdates.enabled,
|
|
3052
2520
|
autoInvoke: config.autoInvoke,
|
|
3053
|
-
inRepositories: (cwd) =>
|
|
2521
|
+
inRepositories: (cwd) => {
|
|
2522
|
+
const lookup = lookupRepos(ctx.get?.(DSH_REPOS_SERVICE));
|
|
2523
|
+
return lookup.availability === "ready" && isInside(cwd, lookup.service.mirrorSettings().dir);
|
|
2524
|
+
}
|
|
3054
2525
|
});
|
|
3055
2526
|
}
|
|
3056
2527
|
/** Register the entry skill provider; called once from `apply()`. */
|
|
@@ -3095,7 +2566,8 @@ var GuanxiangService = class {
|
|
|
3095
2566
|
}
|
|
3096
2567
|
}
|
|
3097
2568
|
/**
|
|
3098
|
-
* Load the panel overlay, configure the registry,
|
|
2569
|
+
* Load the panel overlay, configure the registry, move legacy access tokens
|
|
2570
|
+
* to dsh-repo hosts, and sync every repository.
|
|
3099
2571
|
* @returns when the first sync round settled.
|
|
3100
2572
|
*/
|
|
3101
2573
|
async boot() {
|
|
@@ -3113,9 +2585,53 @@ var GuanxiangService = class {
|
|
|
3113
2585
|
this.ctx.logger?.warn?.("guanxiang: 忽略面板设置:%s", read.error);
|
|
3114
2586
|
}
|
|
3115
2587
|
this.current = applyOverlay(this.defaults, this.overlay);
|
|
3116
|
-
this.registry.configure(this.current.
|
|
2588
|
+
this.registry.configure(this.current.repos);
|
|
2589
|
+
await this.migrateCredentials();
|
|
3117
2590
|
await this.registry.syncAll();
|
|
3118
2591
|
}
|
|
2592
|
+
/**
|
|
2593
|
+
* React to dsh-repo becoming available (or reloading): move legacy tokens
|
|
2594
|
+
* and sync every repository again.
|
|
2595
|
+
* @returns when the syncs settled.
|
|
2596
|
+
*/
|
|
2597
|
+
async reposAvailable() {
|
|
2598
|
+
await this.booted.promise;
|
|
2599
|
+
if (!this.active) return;
|
|
2600
|
+
await this.migrateCredentials();
|
|
2601
|
+
await this.registry.resyncAll();
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Add each access token of earlier versions whose address dsh-repo does not
|
|
2605
|
+
* know yet as a dsh-repo host, carrying the stored token value. The panel's
|
|
2606
|
+
* list replaces the cordis.yml list, as it did before. An entry whose token
|
|
2607
|
+
* cannot be resolved yet is left for a later start instead of creating a
|
|
2608
|
+
* host without a token. Failures are logged; the legacy entry stays and the
|
|
2609
|
+
* next start tries again.
|
|
2610
|
+
*/
|
|
2611
|
+
async migrateCredentials() {
|
|
2612
|
+
const legacy = this.overlay.gitCredentials ?? this.legacyCredentials;
|
|
2613
|
+
if (legacy.length === 0) return;
|
|
2614
|
+
const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
|
|
2615
|
+
if (lookup.availability !== "ready") return;
|
|
2616
|
+
const store = this.credentialStore();
|
|
2617
|
+
if (store === void 0) return;
|
|
2618
|
+
for (const credential of legacy) try {
|
|
2619
|
+
const token = (await store.resolve(credential.tokenEnv))?.value;
|
|
2620
|
+
if (token === void 0 || token === "") {
|
|
2621
|
+
this.ctx.logger?.warn?.("guanxiang: 访问令牌 %s 尚未设置,暂不迁移到 dsh-repo", credential.url);
|
|
2622
|
+
continue;
|
|
2623
|
+
}
|
|
2624
|
+
await lookup.service.addHost({
|
|
2625
|
+
url: credential.url,
|
|
2626
|
+
...credential.alias === void 0 || credential.alias === "" ? {} : { alias: credential.alias },
|
|
2627
|
+
token
|
|
2628
|
+
});
|
|
2629
|
+
this.ctx.logger?.info?.("guanxiang: 已把访问令牌 %s 迁移为 dsh-repo 主机", credential.url);
|
|
2630
|
+
} catch (error) {
|
|
2631
|
+
if (repoErrorCode(error) === "conflict") continue;
|
|
2632
|
+
this.ctx.logger?.warn?.("guanxiang: 迁移访问令牌 %s 到 dsh-repo 失败:%s", credential.url, messageOf$1(error));
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
3119
2635
|
/** Stop registering tools; in-flight syncs finish without side effects on the host. */
|
|
3120
2636
|
shutdown() {
|
|
3121
2637
|
this.active = false;
|
|
@@ -3133,7 +2649,8 @@ var GuanxiangService = class {
|
|
|
3133
2649
|
overridden: overriddenKeys(this.overlay),
|
|
3134
2650
|
...this.overlayError === void 0 ? {} : { overlayError: this.overlayError },
|
|
3135
2651
|
repos: this.registry.status(),
|
|
3136
|
-
|
|
2652
|
+
hosts: await this.hostsState(),
|
|
2653
|
+
credentialsAvailable: this.credentialStore() !== void 0,
|
|
3137
2654
|
analyze: await this.analyzeState()
|
|
3138
2655
|
};
|
|
3139
2656
|
}
|
|
@@ -3164,42 +2681,109 @@ var GuanxiangService = class {
|
|
|
3164
2681
|
credentialStore() {
|
|
3165
2682
|
return this.ctx.get?.("credentials");
|
|
3166
2683
|
}
|
|
3167
|
-
async
|
|
3168
|
-
const
|
|
3169
|
-
if (
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
};
|
|
3173
|
-
const names = [...new Set(this.current.gitCredentials.map((credential) => credential.tokenEnv))];
|
|
3174
|
-
return {
|
|
3175
|
-
available: true,
|
|
3176
|
-
tokens: await Promise.all(names.map((tokenEnv) => describeToken(store, tokenEnv)))
|
|
2684
|
+
async hostsState() {
|
|
2685
|
+
const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
|
|
2686
|
+
if (lookup.availability !== "ready") return {
|
|
2687
|
+
availability: lookup.availability,
|
|
2688
|
+
hosts: []
|
|
3177
2689
|
};
|
|
2690
|
+
const mirrorDir = lookup.service.mirrorSettings().dir;
|
|
2691
|
+
try {
|
|
2692
|
+
return {
|
|
2693
|
+
availability: "ready",
|
|
2694
|
+
mirrorDir,
|
|
2695
|
+
hosts: (await lookup.service.listHosts()).map((host) => ({
|
|
2696
|
+
kind: host.kind,
|
|
2697
|
+
url: host.url,
|
|
2698
|
+
...host.alias === void 0 ? {} : { alias: host.alias },
|
|
2699
|
+
origin: host.origin,
|
|
2700
|
+
token: {
|
|
2701
|
+
tokenEnv: host.tokenRef,
|
|
2702
|
+
...host.token
|
|
2703
|
+
}
|
|
2704
|
+
}))
|
|
2705
|
+
};
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
return {
|
|
2708
|
+
availability: "ready",
|
|
2709
|
+
mirrorDir,
|
|
2710
|
+
hosts: [],
|
|
2711
|
+
error: messageOf$1(error)
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
3178
2714
|
}
|
|
3179
2715
|
/**
|
|
3180
|
-
*
|
|
3181
|
-
*
|
|
3182
|
-
* @
|
|
3183
|
-
* @
|
|
3184
|
-
* @throws when a configuration matches but its token cannot be read.
|
|
2716
|
+
* Add a dsh-repo host, optionally with its token, then sync every repository.
|
|
2717
|
+
* @param raw - request body `{ kind?, url, alias?, token? }`.
|
|
2718
|
+
* @returns the new panel state.
|
|
2719
|
+
* @throws SettingsError when the body is invalid, dsh-repo is unavailable, or it refuses the host.
|
|
3185
2720
|
*/
|
|
3186
|
-
async
|
|
3187
|
-
const
|
|
3188
|
-
if (
|
|
3189
|
-
const
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
2721
|
+
async addHost(raw) {
|
|
2722
|
+
const parsed = hostAddSchema.safeParse(raw);
|
|
2723
|
+
if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
|
|
2724
|
+
const { kind, url, alias, token } = parsed.data;
|
|
2725
|
+
await this.withRepos((repos) => repos.addHost({
|
|
2726
|
+
url,
|
|
2727
|
+
...kind === void 0 ? {} : { kind },
|
|
2728
|
+
...alias === void 0 ? {} : { alias },
|
|
2729
|
+
...token === void 0 ? {} : { token }
|
|
2730
|
+
}));
|
|
2731
|
+
return await this.state();
|
|
2732
|
+
}
|
|
2733
|
+
/**
|
|
2734
|
+
* Remove a host added from a panel, and its stored token.
|
|
2735
|
+
* @param raw - request body `{ url }`.
|
|
2736
|
+
* @returns the new panel state.
|
|
2737
|
+
* @throws SettingsError when the body is invalid, dsh-repo is unavailable, or the host is unknown or configured in cordis.yml.
|
|
2738
|
+
*/
|
|
2739
|
+
async removeHost(raw) {
|
|
2740
|
+
const parsed = hostUrlSchema.safeParse(raw);
|
|
2741
|
+
if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
|
|
2742
|
+
await this.withRepos((repos) => repos.removeHost(parsed.data.url));
|
|
2743
|
+
return await this.state();
|
|
2744
|
+
}
|
|
2745
|
+
/**
|
|
2746
|
+
* Store a host's token through dsh-repo, then sync every repository.
|
|
2747
|
+
* @param raw - request body `{ url, value }`.
|
|
2748
|
+
* @returns the new panel state.
|
|
2749
|
+
* @throws SettingsError when the body is invalid, dsh-repo is unavailable, or the host is unknown.
|
|
2750
|
+
*/
|
|
2751
|
+
async setHostToken(raw) {
|
|
2752
|
+
const parsed = hostTokenSchema.safeParse(raw);
|
|
2753
|
+
if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
|
|
2754
|
+
await this.withRepos((repos) => repos.setHostToken(parsed.data.url, parsed.data.value));
|
|
2755
|
+
return await this.state();
|
|
3195
2756
|
}
|
|
3196
2757
|
/**
|
|
3197
|
-
*
|
|
3198
|
-
*
|
|
3199
|
-
*
|
|
2758
|
+
* Remove a host's stored token; an environment variable of the same name applies again.
|
|
2759
|
+
* @param raw - request body `{ url }`.
|
|
2760
|
+
* @returns the new panel state.
|
|
2761
|
+
* @throws SettingsError on the same conditions as {@link setHostToken}.
|
|
2762
|
+
*/
|
|
2763
|
+
async clearHostToken(raw) {
|
|
2764
|
+
const parsed = hostUrlSchema.safeParse(raw);
|
|
2765
|
+
if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
|
|
2766
|
+
await this.withRepos((repos) => repos.setHostToken(parsed.data.url, void 0));
|
|
2767
|
+
return await this.state();
|
|
2768
|
+
}
|
|
2769
|
+
/** Run one host change and start a resync, reporting dsh-repo failures as field errors. */
|
|
2770
|
+
async withRepos(change) {
|
|
2771
|
+
const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
|
|
2772
|
+
if (lookup.availability !== "ready") throw new SettingsError(unavailableMessage(lookup.availability));
|
|
2773
|
+
try {
|
|
2774
|
+
await change(lookup.service);
|
|
2775
|
+
} catch (error) {
|
|
2776
|
+
if (repoErrorCode(error) !== void 0) throw new SettingsError(messageOf$1(error));
|
|
2777
|
+
throw error;
|
|
2778
|
+
}
|
|
2779
|
+
this.registry.resyncAll();
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* Store the analyze API's bearer token (`analyzeTokenRef`) in DSH's
|
|
2783
|
+
* credential store; it applies from the next request.
|
|
3200
2784
|
* @param raw - request body `{ tokenEnv, value }`.
|
|
3201
2785
|
* @returns the new panel state.
|
|
3202
|
-
* @throws SettingsError when the body is invalid, the name is
|
|
2786
|
+
* @throws SettingsError when the body is invalid, the name is not `analyzeTokenRef`, or no credential store is loaded.
|
|
3203
2787
|
*/
|
|
3204
2788
|
async setToken(raw) {
|
|
3205
2789
|
const parsed = tokenSetSchema.safeParse(raw);
|
|
@@ -3207,8 +2791,8 @@ var GuanxiangService = class {
|
|
|
3207
2791
|
return await this.writeToken(parsed.data.tokenEnv, parsed.data.value);
|
|
3208
2792
|
}
|
|
3209
2793
|
/**
|
|
3210
|
-
* Remove the stored token
|
|
3211
|
-
*
|
|
2794
|
+
* Remove the stored analyze token; an environment variable of the same name
|
|
2795
|
+
* applies again afterwards.
|
|
3212
2796
|
* @param raw - request body `{ tokenEnv }`.
|
|
3213
2797
|
* @returns the new panel state.
|
|
3214
2798
|
* @throws SettingsError on the same conditions as {@link setToken}.
|
|
@@ -3219,17 +2803,16 @@ var GuanxiangService = class {
|
|
|
3219
2803
|
return await this.writeToken(parsed.data.tokenEnv, void 0);
|
|
3220
2804
|
}
|
|
3221
2805
|
async writeToken(tokenEnv, value) {
|
|
3222
|
-
if (tokenEnv !== this.analyzeTokenRef
|
|
2806
|
+
if (tokenEnv !== this.analyzeTokenRef) throw new SettingsError(`${tokenEnv} 不是分析接口令牌的名称`);
|
|
3223
2807
|
const store = this.credentialStore();
|
|
3224
2808
|
if (store === void 0) throw new SettingsError("当前 DSH 组合没有凭据服务(@deepseek-ai/dsh-credentials-local),无法保存令牌");
|
|
3225
2809
|
if (value === void 0) await store.unset(tokenEnv);
|
|
3226
2810
|
else await store.set(tokenEnv, value);
|
|
3227
|
-
this.registry.resyncUrls(this.current.repos.map((repo) => repo.url).filter((url) => credentialFor(url, this.current.gitCredentials)?.tokenEnv === tokenEnv));
|
|
3228
2811
|
return await this.state();
|
|
3229
2812
|
}
|
|
3230
2813
|
/**
|
|
3231
2814
|
* Validate and persist a complete settings payload from the panel, then
|
|
3232
|
-
* sync repositories whose address
|
|
2815
|
+
* sync repositories whose address or branch changed.
|
|
3233
2816
|
* @param raw - request body.
|
|
3234
2817
|
* @returns the new panel state.
|
|
3235
2818
|
* @throws SettingsError when the payload is invalid.
|
|
@@ -3260,20 +2843,12 @@ var GuanxiangService = class {
|
|
|
3260
2843
|
if (url !== void 0 && !this.registry.has(url)) throw new SettingsError(`未配置的仓库:${url}`);
|
|
3261
2844
|
url === void 0 ? this.registry.syncAll() : this.registry.syncUrls([url]);
|
|
3262
2845
|
}
|
|
3263
|
-
/**
|
|
3264
|
-
* Make an overlay effective and sync the repositories whose address, branch,
|
|
3265
|
-
* directory, or matching access-token configuration changed.
|
|
3266
|
-
*/
|
|
2846
|
+
/** Make an overlay effective and sync the repositories whose address or branch changed. */
|
|
3267
2847
|
apply(overlay) {
|
|
3268
|
-
const previous = this.current.gitCredentials;
|
|
3269
2848
|
this.overlay = overlay;
|
|
3270
2849
|
this.overlayError = void 0;
|
|
3271
2850
|
this.current = applyOverlay(this.defaults, overlay);
|
|
3272
|
-
|
|
3273
|
-
const next = this.current.gitCredentials;
|
|
3274
|
-
const reauthorized = this.current.repos.map((repo) => repo.url).filter((url) => JSON.stringify(credentialFor(url, previous)) !== JSON.stringify(credentialFor(url, next)));
|
|
3275
|
-
this.registry.syncUrls(changed);
|
|
3276
|
-
this.registry.resyncUrls(reauthorized.filter((url) => !changed.includes(url)));
|
|
2851
|
+
this.registry.syncUrls(this.registry.configure(this.current.repos));
|
|
3277
2852
|
}
|
|
3278
2853
|
onRegistryChange() {
|
|
3279
2854
|
if (!this.active) return;
|
|
@@ -3452,6 +3027,28 @@ function registerSettingsRoutes(server, gate, service) {
|
|
|
3452
3027
|
state: req.method?.toUpperCase() === "DELETE" ? await service.clearToken(body) : await service.setToken(body)
|
|
3453
3028
|
});
|
|
3454
3029
|
})
|
|
3030
|
+
}),
|
|
3031
|
+
server.register({
|
|
3032
|
+
kind: "exact",
|
|
3033
|
+
path: HOSTS_ROUTE,
|
|
3034
|
+
handler: guarded(gate, ["POST", "DELETE"], async (req, res) => {
|
|
3035
|
+
const body = await readJsonBody(req, false);
|
|
3036
|
+
sendJson(res, 200, {
|
|
3037
|
+
ok: true,
|
|
3038
|
+
state: req.method?.toUpperCase() === "DELETE" ? await service.removeHost(body) : await service.addHost(body)
|
|
3039
|
+
});
|
|
3040
|
+
})
|
|
3041
|
+
}),
|
|
3042
|
+
server.register({
|
|
3043
|
+
kind: "exact",
|
|
3044
|
+
path: HOST_TOKEN_ROUTE,
|
|
3045
|
+
handler: guarded(gate, ["PUT", "DELETE"], async (req, res) => {
|
|
3046
|
+
const body = await readJsonBody(req, false);
|
|
3047
|
+
sendJson(res, 200, {
|
|
3048
|
+
ok: true,
|
|
3049
|
+
state: req.method?.toUpperCase() === "DELETE" ? await service.clearHostToken(body) : await service.setHostToken(body)
|
|
3050
|
+
});
|
|
3051
|
+
})
|
|
3455
3052
|
})
|
|
3456
3053
|
];
|
|
3457
3054
|
return () => {
|
|
@@ -3485,8 +3082,8 @@ function mountSettingsRoutes(ctx, service) {
|
|
|
3485
3082
|
* 任务匹配已注册应用时由顶层 Agent 自行加载)和
|
|
3486
3083
|
* `guanxiang_dispatch` 工具(按应用派发子 Agent,工作目录为该应用的代码
|
|
3487
3084
|
* 仓库,首条消息用 `/<skill>` 加载仓库里的日志分析 SKILL),以及开启 SKILL
|
|
3488
|
-
* 改进时的 `guanxiang_propose_skill`
|
|
3489
|
-
*
|
|
3085
|
+
* 改进时的 `guanxiang_propose_skill` 工具(经用户授权后经 dsh-repo 把子 Agent
|
|
3086
|
+
* 的 SKILL 修改提议提交到新分支并创建合并请求)。应用仓库在设置
|
|
3490
3087
|
* 面板中只填 git 地址,由插件拉取;应用描述来自各仓库的
|
|
3491
3088
|
* `.dsh/guanxiang.yaml`。另提供日志平台分析接口 `POST /open-api/logalyze`,
|
|
3492
3089
|
* 经 dsh-channels 的 `feishuTopics` 服务在飞书群话题中开始分析。
|
|
@@ -3503,7 +3100,7 @@ const inject = [
|
|
|
3503
3100
|
];
|
|
3504
3101
|
/**
|
|
3505
3102
|
* Install the entry skill, the dispatch and SKILL proposal tools, the settings
|
|
3506
|
-
* routes, and the analyze route, then
|
|
3103
|
+
* routes, and the analyze route, then mirror or update the configured
|
|
3507
3104
|
* repositories in the background.
|
|
3508
3105
|
* @param ctx - plugin context.
|
|
3509
3106
|
* @param config - loader-validated configuration.
|
|
@@ -3523,8 +3120,13 @@ function apply(ctx, config, options = {}) {
|
|
|
3523
3120
|
service.shutdown();
|
|
3524
3121
|
};
|
|
3525
3122
|
}, "guanxiang: repositories");
|
|
3123
|
+
ctx.inject?.([DSH_REPOS_SERVICE], () => {
|
|
3124
|
+
service.reposAvailable().catch((error) => {
|
|
3125
|
+
ctx.logger?.error?.("guanxiang: 同步仓库失败:%s", error instanceof Error ? error.message : String(error));
|
|
3126
|
+
});
|
|
3127
|
+
});
|
|
3526
3128
|
}
|
|
3527
3129
|
//#endregion
|
|
3528
|
-
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 };
|
|
3130
|
+
export { ANALYZE_ROUTE, pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, HOSTS_ROUTE, HOST_TOKEN_ROUTE, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, analyzeRequestHash, apply, inject, name, parseAnalyzeRequest, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
|
|
3529
3131
|
|
|
3530
3132
|
//# sourceMappingURL=index.js.map
|