@modesto_ma/dsh-guanxiang 0.2.0-beta.2 → 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 +9 -9
- package/lib/client.js +1 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +109 -138
- package/lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/client/page.tsx +1 -1
- package/src/config.ts +2 -2
- package/src/index.ts +2 -2
- package/src/repos/registry.ts +2 -4
- package/src/service.ts +2 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modesto_ma/dsh-guanxiang",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.3",
|
|
4
4
|
"description": "观象 GuanXiang:DeepSeek Harness (DSH) 日志分析插件。/guanxiang 识别日志所属应用,把分析派发给工作目录为该应用代码仓库的子 Agent;Web 左侧栏提供「观象」设置面板",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"@types/node": "^26.6.5",
|
|
91
91
|
"@types/react": "~18.3.31",
|
|
92
92
|
"conventional-changelog-conventionalcommits": "9.3.1",
|
|
93
|
+
"dsh-repo": "^0.1.0-beta.2",
|
|
93
94
|
"react": "18.3.1",
|
|
94
95
|
"semantic-release": "^25.0.9",
|
|
95
96
|
"tsdown": "0.22.2",
|
package/src/client/page.tsx
CHANGED
|
@@ -595,7 +595,7 @@ function AdvancedTab({ draft, setDraft, overridden, busy, onReset }: TabProps &
|
|
|
595
595
|
<div className="gx__row">
|
|
596
596
|
<div className="gx__rowText">
|
|
597
597
|
<div className="gx__rowLabel">允许提出 SKILL 修改 <Overridden keys={overridden} name="skillUpdates" /></div>
|
|
598
|
-
<div className="gx__rowSummary">子 Agent 发现应用的日志分析 SKILL
|
|
598
|
+
<div className="gx__rowSummary">子 Agent 发现应用的日志分析 SKILL 有欠缺时可以写出修改提议;经用户授权后,观象经 dsh-repo 把它提交到新分支并创建合并请求(GitLab 或 GitHub,需要安装 dsh-repo 并配置该仓库主机的令牌)。</div>
|
|
599
599
|
</div>
|
|
600
600
|
<div className="gx__rowCtl"><input type="checkbox" className="gx__check" aria-label="允许提出 SKILL 修改" checked={draft.skillUpdatesEnabled} onChange={(event) => { setDraft({ ...draft, skillUpdatesEnabled: event.target.checked }) }} /></div>
|
|
601
601
|
</div>
|
package/src/config.ts
CHANGED
|
@@ -137,8 +137,8 @@ const configSchema = z.object({
|
|
|
137
137
|
*/
|
|
138
138
|
analyzeTokenRef: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头').default('GUANXIANG_ANALYZE_TOKEN'),
|
|
139
139
|
/**
|
|
140
|
-
* Author
|
|
141
|
-
*
|
|
140
|
+
* Author of SKILL improvement commits; omitted uses the user of the token
|
|
141
|
+
* dsh-repo holds for the repository's host, who is always the committer.
|
|
142
142
|
*/
|
|
143
143
|
skillUpdateAuthor: authorSchema.optional(),
|
|
144
144
|
/** Registered `ctx.subagents` provider the children start on. */
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* 任务匹配已注册应用时由顶层 Agent 自行加载)和
|
|
6
6
|
* `guanxiang_dispatch` 工具(按应用派发子 Agent,工作目录为该应用的代码
|
|
7
7
|
* 仓库,首条消息用 `/<skill>` 加载仓库里的日志分析 SKILL),以及开启 SKILL
|
|
8
|
-
* 改进时的 `guanxiang_propose_skill`
|
|
9
|
-
*
|
|
8
|
+
* 改进时的 `guanxiang_propose_skill` 工具(经用户授权后经 dsh-repo 把子 Agent
|
|
9
|
+
* 的 SKILL 修改提议提交到新分支并创建合并请求)。应用仓库在设置
|
|
10
10
|
* 面板中只填 git 地址,由插件拉取;应用描述来自各仓库的
|
|
11
11
|
* `.dsh/guanxiang.yaml`。另提供日志平台分析接口 `POST /open-api/logalyze`,
|
|
12
12
|
* 经 dsh-channels 的 `feishuTopics` 服务在飞书群话题中开始分析。
|
package/src/repos/registry.ts
CHANGED
|
@@ -55,14 +55,12 @@ export interface LeasedApp extends ReadyApp {
|
|
|
55
55
|
readonly dir: string
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** A clone opened for writing objects
|
|
58
|
+
/** A clone opened for writing objects; see {@link RepoRegistry.withClone}. */
|
|
59
59
|
export interface CloneAccess {
|
|
60
60
|
/** Fetch-only clone directory. */
|
|
61
61
|
readonly dir: string
|
|
62
62
|
/** The configured branch, or the remote's default branch when none is configured. */
|
|
63
63
|
readonly branch: string
|
|
64
|
-
/** Environment for network commands of this address, such as an access token. */
|
|
65
|
-
readonly env: Readonly<Record<string, string>>
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
/** Held snapshots of one dispatch; release exactly once. */
|
|
@@ -374,7 +372,7 @@ export class RepoRegistry {
|
|
|
374
372
|
if (entry.retired) throw new Error(`仓库 ${url} 已不在配置中`)
|
|
375
373
|
const env = await this.deps.auth?.(url) ?? {}
|
|
376
374
|
const branch = entry.spec.branch ?? (await this.remoteHead(entry.dir, env)).replace(/^refs\/remotes\/origin\//, '')
|
|
377
|
-
return await task({ dir: entry.dir, branch
|
|
375
|
+
return await task({ dir: entry.dir, branch })
|
|
378
376
|
})
|
|
379
377
|
}
|
|
380
378
|
|
package/src/service.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { AnalyzeWire, CredentialsWire, EditableSettings, StateWire, TokenSt
|
|
|
22
22
|
import { diffOverlay, overriddenKeys } from './settings/overlay.ts'
|
|
23
23
|
import type { OverlayStore } from './settings/overlay.ts'
|
|
24
24
|
import { createEntrySkillProvider } from './skill/entry.ts'
|
|
25
|
+
import { DSH_REPOS_SERVICE } from './skill-update/repos.ts'
|
|
25
26
|
import { SkillUpdater } from './skill-update/tool.ts'
|
|
26
27
|
|
|
27
28
|
/** A settings write the panel reports as a field error. */
|
|
@@ -83,6 +84,7 @@ export class GuanxiangService {
|
|
|
83
84
|
gitTimeoutMs: config.gitTimeoutMs,
|
|
84
85
|
...config.skillUpdateAuthor === undefined ? {} : { author: config.skillUpdateAuthor },
|
|
85
86
|
approval: () => ctx.get?.('approval') as HostApproval | undefined,
|
|
87
|
+
repos: () => ctx.get?.(DSH_REPOS_SERVICE),
|
|
86
88
|
})
|
|
87
89
|
this.dispatcher = new Dispatcher({
|
|
88
90
|
registry: this.registry,
|
package/src/skill-update/git.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 在拉取仓库中用 git
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 在拉取仓库中用 git 底层命令检查 SKILL 修改:用临时索引文件在分析时的提交上
|
|
3
|
+
* 替换文件、写出树对象,算出逐文件行数和 diff,不改动任何检出、快照或本地
|
|
4
|
+
* 分支。树对象的前缀用作新分支名,提交本身经 dsh-repo 完成。
|
|
5
5
|
*
|
|
6
6
|
* @module dsh-guanxiang/skill-update/git
|
|
7
7
|
*/
|
|
@@ -37,19 +37,6 @@ export interface GitCommands {
|
|
|
37
37
|
readonly timeoutMs: number
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Author and committer of a commit. */
|
|
41
|
-
export interface CommitAuthor {
|
|
42
|
-
readonly name: string
|
|
43
|
-
readonly email: string
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Merge request fields sent as GitLab push options. */
|
|
47
|
-
export interface MergeRequestOptions {
|
|
48
|
-
readonly target: string
|
|
49
|
-
readonly title: string
|
|
50
|
-
readonly description: string
|
|
51
|
-
}
|
|
52
|
-
|
|
53
40
|
const DIFF_FLAGS = ['--no-color', '--no-ext-diff', '--no-renames']
|
|
54
41
|
|
|
55
42
|
/**
|
|
@@ -95,63 +82,3 @@ export async function prepareChange(commands: GitCommands, dir: string, base: st
|
|
|
95
82
|
await rm(scratch, { recursive: true, force: true })
|
|
96
83
|
}
|
|
97
84
|
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Whether the remote already has a branch.
|
|
101
|
-
* @param commands - git runner and timeout.
|
|
102
|
-
* @param dir - clone directory.
|
|
103
|
-
* @param branch - branch name.
|
|
104
|
-
* @param env - network environment of the clone's address.
|
|
105
|
-
* @returns whether `refs/heads/<branch>` exists on `origin`.
|
|
106
|
-
*/
|
|
107
|
-
export async function remoteBranchExists(commands: GitCommands, dir: string, branch: string, env: Readonly<Record<string, string>>): Promise<boolean> {
|
|
108
|
-
const output = await commands.git(['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env })
|
|
109
|
-
return output.trim() !== ''
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Commit a prepared tree on its base commit without touching any ref.
|
|
114
|
-
* @param commands - git runner and timeout.
|
|
115
|
-
* @param dir - clone directory.
|
|
116
|
-
* @param change - prepared tree and its base.
|
|
117
|
-
* @param message - full commit message.
|
|
118
|
-
* @param author - explicit identity; omitted uses the host's git configuration.
|
|
119
|
-
* @returns the new commit id.
|
|
120
|
-
*/
|
|
121
|
-
export async function commitChange(commands: GitCommands, dir: string, change: PreparedChange, message: string, author: CommitAuthor | undefined): Promise<string> {
|
|
122
|
-
const env = author === undefined
|
|
123
|
-
? undefined
|
|
124
|
-
: { GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email, GIT_COMMITTER_NAME: author.name, GIT_COMMITTER_EMAIL: author.email }
|
|
125
|
-
const output = await commands.git(['commit-tree', change.tree, '-p', change.base, '-F', '-'], { cwd: dir, timeoutMs: commands.timeoutMs, input: message, ...env === undefined ? {} : { env } })
|
|
126
|
-
return output.trim()
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Push options cannot carry line breaks; collapse every whitespace run. */
|
|
130
|
-
function oneLine(text: string): string {
|
|
131
|
-
return text.replace(/\s+/g, ' ').trim()
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Push a commit to a new remote branch and ask GitLab to open a merge request
|
|
136
|
-
* for it (`merge_request.*` push options). The push carries an empty lease on
|
|
137
|
-
* the branch, so it fails instead of updating a branch that appeared after
|
|
138
|
-
* {@link remoteBranchExists} was checked. A server that does not support push
|
|
139
|
-
* options rejects the push.
|
|
140
|
-
* @param commands - git runner and timeout.
|
|
141
|
-
* @param dir - clone directory.
|
|
142
|
-
* @param commit - commit to push.
|
|
143
|
-
* @param branch - new branch name.
|
|
144
|
-
* @param request - merge request target and text.
|
|
145
|
-
* @param env - network environment of the clone's address.
|
|
146
|
-
* @returns the merge request URL the server printed, if any.
|
|
147
|
-
*/
|
|
148
|
-
export async function pushForMergeRequest(commands: GitCommands, dir: string, commit: string, branch: string, request: MergeRequestOptions, env: Readonly<Record<string, string>>): Promise<string | undefined> {
|
|
149
|
-
const options = [
|
|
150
|
-
'merge_request.create',
|
|
151
|
-
`merge_request.target=${request.target}`,
|
|
152
|
-
`merge_request.title=${oneLine(request.title)}`,
|
|
153
|
-
`merge_request.description=${oneLine(request.description)}`,
|
|
154
|
-
].flatMap(option => ['-o', option])
|
|
155
|
-
const output = await commands.git(['push', '--porcelain', `--force-with-lease=refs/heads/${branch}:`, ...options, 'origin', `${commit}:refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env, output: 'all' })
|
|
156
|
-
return /https?:\/\/\S+\/-\/merge_requests\/\d+/.exec(output)?.[0]
|
|
157
|
-
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-repo `dshRepos` 服务的查找:SKILL 修改提议经它在代码托管平台上建分支、
|
|
3
|
+
* 提交并创建合并请求(GitLab MR 或 GitHub PR)。
|
|
4
|
+
*
|
|
5
|
+
* 只从 `dsh-repo/contract` 引入类型;服务在运行时经 `ctx.get('dshRepos')`
|
|
6
|
+
* 取得,版本不符或缺少 `getBranch`(早于 0.1.0-beta.2 的 dsh-repo,提交时会忽略
|
|
7
|
+
* `baseSha` 与 `newBranch`)时不调用。
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-guanxiang/skill-update/repos
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { DshRepoService, DshReposVersion, RepoErrorCode } from 'dsh-repo/contract'
|
|
13
|
+
|
|
14
|
+
/** Name dsh-repo provides the service under. */
|
|
15
|
+
export const DSH_REPOS_SERVICE = 'dshRepos'
|
|
16
|
+
|
|
17
|
+
/** The `dshRepos` contract version this plugin speaks. */
|
|
18
|
+
export const DSH_REPOS_VERSION: DshReposVersion = 1
|
|
19
|
+
|
|
20
|
+
/** Result of looking the service up. */
|
|
21
|
+
export type ReposLookup =
|
|
22
|
+
| { readonly availability: 'ready'; readonly service: DshRepoService }
|
|
23
|
+
| { readonly availability: 'missing' | 'incompatible' }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Classify the value `ctx.get('dshRepos')` returned. The provider is another
|
|
27
|
+
* package that may be older or newer than this one, so its version is checked
|
|
28
|
+
* at run time; `getBranch` marks a provider that also honors `baseSha` and
|
|
29
|
+
* `newBranch` on commits.
|
|
30
|
+
* @param value - the looked-up service, or undefined.
|
|
31
|
+
* @returns the usable service, or why there is none.
|
|
32
|
+
*/
|
|
33
|
+
export function lookupRepos(value: unknown): ReposLookup {
|
|
34
|
+
if (value === undefined || value === null) return { availability: 'missing' }
|
|
35
|
+
const service = value as Partial<Record<keyof DshRepoService, unknown>>
|
|
36
|
+
if (service.version !== DSH_REPOS_VERSION || typeof service.getBranch !== 'function' || typeof service.commitFiles !== 'function' || typeof service.createChange !== 'function') {
|
|
37
|
+
return { availability: 'incompatible' }
|
|
38
|
+
}
|
|
39
|
+
return { availability: 'ready', service: value as DshRepoService }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The failure class of a `dshRepos` rejection.
|
|
44
|
+
* @param error - the rejection.
|
|
45
|
+
* @returns its `code` when it is a `RepoError`.
|
|
46
|
+
*/
|
|
47
|
+
export function repoErrorCode(error: unknown): RepoErrorCode | undefined {
|
|
48
|
+
if (!(error instanceof Error) || error.name !== 'RepoError') return undefined
|
|
49
|
+
const code = (error as Error & { code?: unknown }).code
|
|
50
|
+
return typeof code === 'string' ? code as RepoErrorCode : undefined
|
|
51
|
+
}
|
package/src/skill-update/tool.ts
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
|
|
5
5
|
* 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
|
|
6
6
|
* - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不提交到
|
|
8
|
+
* 已有分支或默认分支。
|
|
9
|
+
* - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
|
|
10
|
+
* GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
|
|
11
|
+
* - 提交前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
|
|
12
|
+
* 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才提交;
|
|
13
|
+
* 没有审批服务时工具报错,不提交。
|
|
12
14
|
* - 子会话中不提供本工具。
|
|
13
15
|
*
|
|
14
16
|
* @module dsh-guanxiang/skill-update/tool
|
|
@@ -17,6 +19,7 @@
|
|
|
17
19
|
import { randomBytes } from 'node:crypto'
|
|
18
20
|
import { rename, rm, writeFile } from 'node:fs/promises'
|
|
19
21
|
import { join, resolve } from 'node:path'
|
|
22
|
+
import type { CommitAuthor } from 'dsh-repo/contract'
|
|
20
23
|
import { z } from 'zod'
|
|
21
24
|
import { describeIssue } from '../config.ts'
|
|
22
25
|
import { isDelegatedAgent } from '../dispatch/tool.ts'
|
|
@@ -24,9 +27,11 @@ import type { ContentBlock, HostApproval, ToolDefinition, ToolRunContext } from
|
|
|
24
27
|
import type { GitRunner } from '../repos/git.ts'
|
|
25
28
|
import type { RepoRegistry } from '../repos/registry.ts'
|
|
26
29
|
import type { AppManifest, EditableSettings } from '../settings/contract.ts'
|
|
27
|
-
import {
|
|
28
|
-
import type {
|
|
30
|
+
import { prepareChange } from './git.ts'
|
|
31
|
+
import type { FileStat, PreparedChange } from './git.ts'
|
|
29
32
|
import { DIFF_FILE, FILES_DIR, PROPOSAL_DIR, ProposalError, readProposal, REASON_FILE } from './proposal.ts'
|
|
33
|
+
import type { ProposedFile } from './proposal.ts'
|
|
34
|
+
import { DSH_REPOS_SERVICE, DSH_REPOS_VERSION, lookupRepos, repoErrorCode } from './repos.ts'
|
|
30
35
|
|
|
31
36
|
/** Model-facing tool name. */
|
|
32
37
|
export const PROPOSE_TOOL_NAME = 'guanxiang_propose_skill'
|
|
@@ -69,7 +74,7 @@ export interface ProposeValue {
|
|
|
69
74
|
status: ProposeStatus
|
|
70
75
|
app: string
|
|
71
76
|
repo: string
|
|
72
|
-
/** New branch the change was or would be
|
|
77
|
+
/** New branch the change was or would be committed to. */
|
|
73
78
|
branch: string
|
|
74
79
|
/** Branch the merge request targets. */
|
|
75
80
|
target: string
|
|
@@ -77,6 +82,8 @@ export interface ProposeValue {
|
|
|
77
82
|
/** Diff file in the task's run directory. */
|
|
78
83
|
diffPath: string
|
|
79
84
|
mergeRequestUrl?: string
|
|
85
|
+
/** Why the merge request was not created; only with `pushed`. */
|
|
86
|
+
mergeRequestError?: string
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
const argsSchema = z.object({
|
|
@@ -115,6 +122,7 @@ const OUTPUT_SCHEMA = {
|
|
|
115
122
|
files: { type: 'array', items: FILE_STAT_SCHEMA },
|
|
116
123
|
diffPath: { type: 'string' },
|
|
117
124
|
mergeRequestUrl: { type: 'string' },
|
|
125
|
+
mergeRequestError: { type: 'string' },
|
|
118
126
|
},
|
|
119
127
|
} as const
|
|
120
128
|
|
|
@@ -135,12 +143,12 @@ export function describeFiles(files: readonly FileStat[]): string {
|
|
|
135
143
|
export function renderProposeValue(value: ProposeValue): ContentBlock[] {
|
|
136
144
|
const where = `${value.repo} 的分支 ${value.branch}(目标 ${value.target})`
|
|
137
145
|
const text = {
|
|
138
|
-
opened:
|
|
139
|
-
pushed:
|
|
140
|
-
exists: `${where}
|
|
141
|
-
rejected: '
|
|
142
|
-
cancelled: '
|
|
143
|
-
unavailable: '
|
|
146
|
+
opened: `已提交到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ''}`,
|
|
147
|
+
pushed: `已提交到 ${where},但创建合并请求失败(${value.mergeRequestError ?? '未知原因'});请在代码托管平台上从该分支手动创建。`,
|
|
148
|
+
exists: `${where} 已存在,同一改动可能已经提交过,本次没有提交。`,
|
|
149
|
+
rejected: '授权被拒绝,没有提交:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。',
|
|
150
|
+
cancelled: '授权请求被撤回,没有提交。',
|
|
151
|
+
unavailable: '当前没有可以回答授权请求的界面,没有提交。',
|
|
144
152
|
}[value.status]
|
|
145
153
|
return [{ type: 'text', text: `${text}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}` }]
|
|
146
154
|
}
|
|
@@ -154,6 +162,8 @@ export interface SkillUpdaterDeps {
|
|
|
154
162
|
readonly author?: CommitAuthor
|
|
155
163
|
/** DSH's approval service, when the composition has one. */
|
|
156
164
|
approval(): HostApproval | undefined
|
|
165
|
+
/** What `ctx.get('dshRepos')` returns now. */
|
|
166
|
+
repos(): unknown
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
function messageOf(error: unknown): string {
|
|
@@ -223,7 +233,7 @@ export class SkillUpdater {
|
|
|
223
233
|
* @returns undefined when the task wrote no proposal.
|
|
224
234
|
* @throws ProposalError or a git failure.
|
|
225
235
|
*/
|
|
226
|
-
private async prepare(task: DispatchedTask): Promise<{ reason: string; change: PreparedChange; target: string; diffPath: string } | undefined> {
|
|
236
|
+
private async prepare(task: DispatchedTask): Promise<{ reason: string; files: readonly ProposedFile[]; change: PreparedChange; target: string; diffPath: string } | undefined> {
|
|
227
237
|
const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates)
|
|
228
238
|
if (proposal === undefined) return undefined
|
|
229
239
|
const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
|
|
@@ -234,7 +244,7 @@ export class SkillUpdater {
|
|
|
234
244
|
if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`)
|
|
235
245
|
const diffPath = join(task.workDir, DIFF_FILE)
|
|
236
246
|
await replaceFile(diffPath, change.diff)
|
|
237
|
-
return { reason: proposal.reason, change, target, diffPath }
|
|
247
|
+
return { reason: proposal.reason, files: proposal.files, change, target, diffPath }
|
|
238
248
|
}
|
|
239
249
|
|
|
240
250
|
/**
|
|
@@ -244,7 +254,7 @@ export class SkillUpdater {
|
|
|
244
254
|
definition(): ToolDefinition<ProposeValue> {
|
|
245
255
|
return {
|
|
246
256
|
name: PROPOSE_TOOL_NAME,
|
|
247
|
-
description: '观象:把派发结果中某个应用的 SKILL
|
|
257
|
+
description: '观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后提交到新分支并创建合并请求,不会直接改动默认分支。'
|
|
248
258
|
+ '仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。',
|
|
249
259
|
parameters: PARAMETERS,
|
|
250
260
|
output: {
|
|
@@ -261,7 +271,7 @@ export class SkillUpdater {
|
|
|
261
271
|
* @param rawArgs - model arguments.
|
|
262
272
|
* @param exec - execution context of the calling agent.
|
|
263
273
|
* @returns how the submission ended.
|
|
264
|
-
* @throws when the arguments, task, proposal, or
|
|
274
|
+
* @throws when the arguments, task, proposal, approval service, or `dshRepos` service are unusable, or the commit fails; nothing is committed then.
|
|
265
275
|
*/
|
|
266
276
|
async execute(rawArgs: unknown, exec: ToolRunContext): Promise<ProposeValue> {
|
|
267
277
|
const parsed = argsSchema.safeParse(rawArgs)
|
|
@@ -275,22 +285,26 @@ export class SkillUpdater {
|
|
|
275
285
|
if (task === undefined) throw new Error(`${parsed.data.workDir} 不是本次 DSH 运行中观象派发任务的运行目录;DSH 重启后需要重新分析`)
|
|
276
286
|
if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`)
|
|
277
287
|
const approval = this.deps.approval()
|
|
278
|
-
if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval
|
|
288
|
+
if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交')
|
|
289
|
+
const lookup = lookupRepos(this.deps.repos())
|
|
290
|
+
if (lookup.availability !== 'ready') {
|
|
291
|
+
throw new Error(lookup.availability === 'missing'
|
|
292
|
+
? `提交 SKILL 修改需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务;请安装 dsh-repo,并在它的 hosts 中配置该应用仓库所在的主机和令牌`
|
|
293
|
+
: `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(观象需要服务版本 ${DSH_REPOS_VERSION} 且提供 getBranch,即 dsh-repo 0.1.0-beta.2 及以上);请升级 dsh-repo`)
|
|
294
|
+
}
|
|
295
|
+
const repos = lookup.service
|
|
279
296
|
|
|
280
297
|
const prepared = await this.prepare(task)
|
|
281
298
|
if (prepared === undefined) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`)
|
|
282
299
|
const { change, target, diffPath, reason } = prepared
|
|
283
300
|
const branch = `guanxiang/skill-${task.app.id}-${change.tree.slice(0, 10)}`
|
|
284
301
|
const base = { app: task.app.id, repo: task.url, branch, target, files: [...change.files], diffPath }
|
|
285
|
-
const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
|
|
286
302
|
|
|
287
|
-
if (await
|
|
288
|
-
return { ...base, status: 'exists' }
|
|
289
|
-
}
|
|
303
|
+
if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== undefined) return { ...base, status: 'exists' }
|
|
290
304
|
|
|
291
305
|
const preview = reason.replace(/\s+/g, ' ').slice(0, REASON_PREVIEW_CHARS)
|
|
292
|
-
const zh =
|
|
293
|
-
const en = `GuanXiang will
|
|
306
|
+
const zh = `观象将在 ${task.url} 提交新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`
|
|
307
|
+
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}`
|
|
294
308
|
const outcome = await approval.request({
|
|
295
309
|
agent,
|
|
296
310
|
toolName: PROPOSE_TOOL_NAME,
|
|
@@ -304,11 +318,32 @@ export class SkillUpdater {
|
|
|
304
318
|
|
|
305
319
|
const message = `${summary}\n\n${reason}\n\nProposed-by: 观象 GuanXiang\nAnalyzed-commit: ${task.commit}\nSession: ${task.sessionId}\n`
|
|
306
320
|
const description = `由观象在日志分析中提出(应用 ${task.app.id},分析提交 ${task.commit.slice(0, 12)},会话 ${task.sessionId})。理由:${reason.slice(0, REASON_DESCRIPTION_CHARS)}`
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
321
|
+
// Only files that differ from the analyzed commit; an unchanged one would be an empty update.
|
|
322
|
+
const changed = new Set(change.files.map(file => file.path))
|
|
323
|
+
const files = prepared.files
|
|
324
|
+
.map(file => ({ action: 'write' as const, path: `${skillDirOf(task.app)}/${file.path}`, content: file.content }))
|
|
325
|
+
.filter(file => changed.has(file.path))
|
|
326
|
+
exec.signal.throwIfAborted()
|
|
327
|
+
try {
|
|
328
|
+
await repos.commitFiles(task.url, {
|
|
329
|
+
branch,
|
|
330
|
+
baseSha: task.commit,
|
|
331
|
+
newBranch: true,
|
|
332
|
+
message,
|
|
333
|
+
files,
|
|
334
|
+
...this.deps.author === undefined ? {} : { author: this.deps.author },
|
|
335
|
+
signal: exec.signal,
|
|
336
|
+
})
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (repoErrorCode(error) === 'conflict') return { ...base, status: 'exists' }
|
|
339
|
+
throw error
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const opened = await repos.createChange(task.url, { title: summary, body: description, sourceBranch: branch, targetBranch: target, signal: exec.signal })
|
|
343
|
+
return { ...base, status: 'opened', mergeRequestUrl: opened.webUrl }
|
|
344
|
+
} catch (error) {
|
|
345
|
+
// The branch exists now; report it so the user can open the request by hand.
|
|
346
|
+
return { ...base, status: 'pushed', mergeRequestError: messageOf(error) }
|
|
347
|
+
}
|
|
313
348
|
}
|
|
314
349
|
}
|