@modesto_ma/dsh-guanxiang 0.2.0-beta.3 → 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.
@@ -4,7 +4,7 @@
4
4
  * - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
5
5
  * 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
6
6
  * - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
7
- * 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不提交到
7
+ * 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<改动摘要前缀>`,从不提交到
8
8
  * 已有分支或默认分支。
9
9
  * - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
10
10
  * GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
@@ -24,14 +24,13 @@ import { z } from 'zod'
24
24
  import { describeIssue } from '../config.ts'
25
25
  import { isDelegatedAgent } from '../dispatch/tool.ts'
26
26
  import type { ContentBlock, HostApproval, ToolDefinition, ToolRunContext } from '../host.ts'
27
- import type { GitRunner } from '../repos/git.ts'
27
+ import { messageOf, repoErrorCode, requireRepos } from '../repos/lookup.ts'
28
28
  import type { RepoRegistry } from '../repos/registry.ts'
29
29
  import type { AppManifest, EditableSettings } from '../settings/contract.ts'
30
- import { prepareChange } from './git.ts'
31
- import type { FileStat, PreparedChange } from './git.ts'
30
+ import { prepareChange } from './change.ts'
31
+ import type { FileStat, PreparedChange } from './change.ts'
32
32
  import { DIFF_FILE, FILES_DIR, PROPOSAL_DIR, ProposalError, readProposal, REASON_FILE } from './proposal.ts'
33
33
  import type { ProposedFile } from './proposal.ts'
34
- import { DSH_REPOS_SERVICE, DSH_REPOS_VERSION, lookupRepos, repoErrorCode } from './repos.ts'
35
34
 
36
35
  /** Model-facing tool name. */
37
36
  export const PROPOSE_TOOL_NAME = 'guanxiang_propose_skill'
@@ -105,7 +104,7 @@ const PARAMETERS: Record<string, unknown> = {
105
104
  const FILE_STAT_SCHEMA = {
106
105
  type: 'object',
107
106
  additionalProperties: false,
108
- required: ['path'],
107
+ required: ['path', 'added', 'removed'],
109
108
  properties: { path: { type: 'string' }, added: { type: 'number' }, removed: { type: 'number' } },
110
109
  } as const
111
110
 
@@ -132,7 +131,7 @@ const OUTPUT_SCHEMA = {
132
131
  * @returns one line.
133
132
  */
134
133
  export function describeFiles(files: readonly FileStat[]): string {
135
- return files.map(file => file.added === undefined ? `${file.path}(二进制)` : `${file.path}(+${file.added} −${file.removed ?? 0})`).join('、')
134
+ return files.map(file => `${file.path}(+${file.added} −${file.removed})`).join('、')
136
135
  }
137
136
 
138
137
  /**
@@ -157,8 +156,6 @@ export function renderProposeValue(value: ProposeValue): ContentBlock[] {
157
156
  export interface SkillUpdaterDeps {
158
157
  readonly registry: RepoRegistry
159
158
  settings(): EditableSettings
160
- readonly git: GitRunner
161
- readonly gitTimeoutMs: number
162
159
  readonly author?: CommitAuthor
163
160
  /** DSH's approval service, when the composition has one. */
164
161
  approval(): HostApproval | undefined
@@ -166,10 +163,6 @@ export interface SkillUpdaterDeps {
166
163
  repos(): unknown
167
164
  }
168
165
 
169
- function messageOf(error: unknown): string {
170
- return error instanceof Error ? error.message : String(error)
171
- }
172
-
173
166
  /**
174
167
  * Write a file in an agent-writable directory without following whatever
175
168
  * entry the agent left at its path: the content goes to a new file created
@@ -194,7 +187,7 @@ function skillDirOf(app: AppManifest): string {
194
187
  export class SkillUpdater {
195
188
  private readonly tasks = new Map<string, DispatchedTask>()
196
189
 
197
- /** @param deps - registry, settings, git, and approval access. */
190
+ /** @param deps - registry, settings, approval, and `dshRepos` access. */
198
191
  constructor(private readonly deps: SkillUpdaterDeps) {}
199
192
 
200
193
  /** @returns whether proposals are enabled in the current settings. */
@@ -231,16 +224,13 @@ export class SkillUpdater {
231
224
  /**
232
225
  * Read the proposal, build its tree, and write the diff file.
233
226
  * @returns undefined when the task wrote no proposal.
234
- * @throws ProposalError or a git failure.
227
+ * @throws ProposalError, or an error when dsh-repo is unavailable or the mirror cannot be read.
235
228
  */
236
- private async prepare(task: DispatchedTask): Promise<{ reason: string; files: readonly ProposedFile[]; change: PreparedChange; target: string; diffPath: string } | undefined> {
229
+ private async prepare(task: DispatchedTask, signal?: AbortSignal): Promise<{ reason: string; files: readonly ProposedFile[]; change: PreparedChange; target: string; diffPath: string } | undefined> {
237
230
  const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates)
238
231
  if (proposal === undefined) return undefined
239
- const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
240
- const { change, target } = await this.deps.registry.withClone(task.url, async clone => ({
241
- change: await prepareChange(commands, clone.dir, task.commit, skillDirOf(task.app), proposal.files),
242
- target: clone.branch,
243
- }))
232
+ const target = this.deps.registry.branchOf(task.url)
233
+ const change = await prepareChange(requireRepos(this.deps.repos()), { url: task.url, base: task.commit, skillDir: skillDirOf(task.app) }, proposal.files, signal)
244
234
  if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`)
245
235
  const diffPath = join(task.workDir, DIFF_FILE)
246
236
  await replaceFile(diffPath, change.diff)
@@ -286,18 +276,18 @@ export class SkillUpdater {
286
276
  if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`)
287
277
  const approval = this.deps.approval()
288
278
  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
+ const repos = requireRepos(this.deps.repos())
296
280
 
297
- const prepared = await this.prepare(task)
281
+ let prepared: Awaited<ReturnType<SkillUpdater['prepare']>>
282
+ try {
283
+ prepared = await this.prepare(task, exec.signal)
284
+ } catch (error) {
285
+ exec.signal.throwIfAborted()
286
+ throw error
287
+ }
298
288
  if (prepared === undefined) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`)
299
289
  const { change, target, diffPath, reason } = prepared
300
- const branch = `guanxiang/skill-${task.app.id}-${change.tree.slice(0, 10)}`
290
+ const branch = `guanxiang/skill-${task.app.id}-${change.digest.slice(0, 10)}`
301
291
  const base = { app: task.app.id, repo: task.url, branch, target, files: [...change.files], diffPath }
302
292
 
303
293
  if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== undefined) return { ...base, status: 'exists' }
@@ -1,104 +0,0 @@
1
- /**
2
- * HTTPS 访问令牌:按地址前缀为仓库挑选令牌配置,并生成只对一次 git 进程生效的
3
- * 凭据助手配置。令牌经子进程环境变量交给 git,不出现在命令行参数、仓库的
4
- * `.git/config` 或 git 输出里;只有服务器要求认证时 git 才向助手取令牌,
5
- * 本机为同一主机配置的其他凭据助手在这次调用中不参与。
6
- *
7
- * @module dsh-guanxiang/repos/credentials
8
- */
9
-
10
- import type { GitCredentialSpec } from '../settings/contract.ts'
11
-
12
- export { TOKEN_ENV_PATTERN } from '../settings/contract.ts'
13
-
14
- /** Environment variables the helper reads; set only on git child processes. */
15
- const USERNAME_VARIABLE = 'GUANXIANG_GIT_USERNAME'
16
- const TOKEN_VARIABLE = 'GUANXIANG_GIT_TOKEN'
17
-
18
- /** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
19
- const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`
20
-
21
- /** Longest accepted token; personal access tokens are far shorter. */
22
- export const MAX_TOKEN_LENGTH = 4096
23
-
24
- /**
25
- * Normalize an access-token address prefix to `scheme://host[:port][/path]`
26
- * with a lowercase host and no trailing slash.
27
- * @param value - candidate prefix.
28
- * @returns the normalized prefix, or undefined when it is not an `https`/`http` URL without credentials, query, or fragment.
29
- */
30
- export function normalizeCredentialUrl(value: string): string | undefined {
31
- let url: URL
32
- try {
33
- url = new URL(value)
34
- } catch {
35
- // Not a URL: refused.
36
- return undefined
37
- }
38
- if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined
39
- if (url.host === '' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') return undefined
40
- return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '')}`
41
- }
42
-
43
- /**
44
- * Whether a token value can be handed to git: non-empty, bounded, and free of
45
- * control characters, which would break the credential helper protocol.
46
- * @param value - candidate token.
47
- * @returns whether it is accepted.
48
- */
49
- export function isAcceptedToken(value: string): boolean {
50
- return value.length > 0 && value.length <= MAX_TOKEN_LENGTH && !/[\u0000-\u001f\u007f]/.test(value)
51
- }
52
-
53
- /**
54
- * The access-token configuration for a repository address: the entry whose
55
- * prefix has the same scheme and host and the longest path that contains the
56
- * repository path. scp-like addresses, SSH URLs, and local paths never match.
57
- * @param url - repository address.
58
- * @param specs - configured access tokens with normalized prefixes.
59
- * @returns the matching entry, if any.
60
- */
61
- export function credentialFor(url: string, specs: readonly GitCredentialSpec[]): GitCredentialSpec | undefined {
62
- let repo: URL
63
- try {
64
- repo = new URL(url)
65
- } catch {
66
- // scp-like addresses and local paths use other credentials.
67
- return undefined
68
- }
69
- let best: { spec: GitCredentialSpec; length: number } | undefined
70
- for (const spec of specs) {
71
- const prefix = new URL(spec.url)
72
- if (prefix.protocol !== repo.protocol || prefix.host !== repo.host) continue
73
- const path = prefix.pathname.replace(/\/+$/, '')
74
- if (path !== '' && repo.pathname !== path && !repo.pathname.startsWith(`${path}/`)) continue
75
- if (best === undefined || path.length > best.length) best = { spec, length: path.length }
76
- }
77
- return best?.spec
78
- }
79
-
80
- /**
81
- * Environment that makes one git process answer the credential request for
82
- * the token's host with the given token. Existing `GIT_CONFIG_COUNT` entries
83
- * are kept; the helper list for that host is reset first so a stale password
84
- * from another helper cannot shadow the token.
85
- * @param spec - matching access-token configuration.
86
- * @param token - token value.
87
- * @param env - environment of the git process before this call.
88
- * @returns variables to add to the git process environment.
89
- */
90
- export function credentialEnvironment(spec: GitCredentialSpec, token: string, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
91
- const prefix = new URL(spec.url)
92
- const key = `credential.${prefix.protocol}//${prefix.host}.helper`
93
- const parsed = Number(env.GIT_CONFIG_COUNT ?? '0')
94
- const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0
95
- return {
96
- GIT_CONFIG_COUNT: String(base + 2),
97
- [`GIT_CONFIG_KEY_${base}`]: key,
98
- [`GIT_CONFIG_VALUE_${base}`]: '',
99
- [`GIT_CONFIG_KEY_${base + 1}`]: key,
100
- [`GIT_CONFIG_VALUE_${base + 1}`]: HELPER,
101
- [USERNAME_VARIABLE]: spec.username,
102
- [TOKEN_VARIABLE]: token,
103
- }
104
- }
package/src/repos/git.ts DELETED
@@ -1,74 +0,0 @@
1
- /**
2
- * 本机 git 命令的调用封装:沿用本机已有凭据(SSH key 或 credential helper),
3
- * 或由调用方经环境变量提供的访问令牌;禁止交互式提示,避免后台拉取卡在
4
- * 密码输入上。
5
- *
6
- * @module dsh-guanxiang/repos/git
7
- */
8
-
9
- import { execFile } from 'node:child_process'
10
-
11
- /** A failed git command with its combined output. */
12
- export class GitError extends Error {
13
- /**
14
- * @param args - git arguments that failed.
15
- * @param output - trimmed stderr (or stdout when stderr is empty).
16
- */
17
- constructor(readonly args: readonly string[], readonly output: string) {
18
- super(`git ${args.join(' ')} 失败:${output === '' ? '无输出' : output}`)
19
- }
20
- }
21
-
22
- /** Options of one git command. */
23
- export interface GitRunOptions {
24
- cwd?: string
25
- timeoutMs: number
26
- /** Variables added to the environment of this command only (credentials for network commands). */
27
- env?: Readonly<Record<string, string>>
28
- /** Text written to the command's standard input. */
29
- input?: string
30
- /** `all` resolves, or reports on failure, stdout followed by stderr, for commands such as `push --porcelain` that split their report. */
31
- output?: 'stdout' | 'all'
32
- }
33
-
34
- /** Runs one git command and resolves its stdout (or all output, per {@link GitRunOptions.output}). */
35
- export type GitRunner = (args: readonly string[], options: GitRunOptions) => Promise<string>
36
-
37
- /**
38
- * Environment for non-interactive git: no terminal prompts, and SSH in batch
39
- * mode unless the user configured their own SSH command.
40
- * @param env - base environment.
41
- * @returns environment for git child processes.
42
- */
43
- export function gitEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
44
- const next: NodeJS.ProcessEnv = { ...env, GIT_TERMINAL_PROMPT: '0' }
45
- if (env.GIT_SSH_COMMAND === undefined && env.GIT_SSH === undefined) next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
46
- return next
47
- }
48
-
49
- /**
50
- * Default runner backed by the `git` executable on PATH.
51
- * @param args - git arguments.
52
- * @param options - working directory, timeout, extra environment, input, and output selection.
53
- * @returns stdout, or stdout followed by stderr.
54
- */
55
- export const runGit: GitRunner = (args, options) => new Promise((resolve, reject) => {
56
- const child = execFile('git', [...args], {
57
- cwd: options.cwd,
58
- env: { ...gitEnvironment(), ...options.env },
59
- timeout: options.timeoutMs,
60
- maxBuffer: 16 * 1024 * 1024,
61
- windowsHide: true,
62
- }, (error, stdout, stderr) => {
63
- if (error === null) {
64
- resolve(options.output === 'all' ? `${stdout}${stderr}` : stdout)
65
- return
66
- }
67
- const output = (options.output === 'all' || stderr.trim() === '' ? `${stdout}${stderr}` : stderr).trim()
68
- const killed = (error as { killed?: boolean }).killed === true
69
- reject(new GitError(args, killed ? `超时(${options.timeoutMs} ms)后被终止${output === '' ? '' : `:${output}`}` : output || error.message))
70
- })
71
- // A command that exits without reading its input fails through its exit status, not EPIPE.
72
- child.stdin?.on('error', () => {})
73
- child.stdin?.end(options.input)
74
- })
@@ -1,84 +0,0 @@
1
- /**
2
- * 在拉取仓库中用 git 底层命令检查 SKILL 修改:用临时索引文件在分析时的提交上
3
- * 替换文件、写出树对象,算出逐文件行数和 diff,不改动任何检出、快照或本地
4
- * 分支。树对象的前缀用作新分支名,提交本身经 dsh-repo 完成。
5
- *
6
- * @module dsh-guanxiang/skill-update/git
7
- */
8
-
9
- import { mkdtemp, rm } from 'node:fs/promises'
10
- import { tmpdir } from 'node:os'
11
- import { join } from 'node:path'
12
- import type { GitRunner } from '../repos/git.ts'
13
- import type { ProposedFile } from './proposal.ts'
14
-
15
- /** Line counts of one changed file. */
16
- export interface FileStat {
17
- /** Path relative to the repository root. */
18
- readonly path: string
19
- /** Added lines; undefined for a binary change. */
20
- readonly added?: number
21
- /** Removed lines; undefined for a binary change. */
22
- readonly removed?: number
23
- }
24
-
25
- /** A proposal turned into a tree object on top of its base commit. */
26
- export interface PreparedChange {
27
- readonly base: string
28
- readonly tree: string
29
- readonly files: readonly FileStat[]
30
- /** Unified diff from the base commit to {@link tree}, limited to the skill directory. */
31
- readonly diff: string
32
- }
33
-
34
- /** git runner and the timeout of one command. */
35
- export interface GitCommands {
36
- readonly git: GitRunner
37
- readonly timeoutMs: number
38
- }
39
-
40
- const DIFF_FLAGS = ['--no-color', '--no-ext-diff', '--no-renames']
41
-
42
- /**
43
- * Write the proposed files over the skill directory of `base` into a new tree.
44
- * @param commands - git runner and timeout.
45
- * @param dir - repository directory whose object database receives the objects.
46
- * @param base - commit the analysis ran on.
47
- * @param skillDir - skill directory relative to the repository root, `/`-separated.
48
- * @param files - proposed files relative to `skillDir`.
49
- * @returns the tree, per-file line counts, and the diff; `files` is empty when nothing changes.
50
- * @throws when a proposed path replaces a symbolic link or a directory, or a git command fails.
51
- */
52
- export async function prepareChange(commands: GitCommands, dir: string, base: string, skillDir: string, files: readonly ProposedFile[]): Promise<PreparedChange> {
53
- const { git, timeoutMs } = commands
54
- const scratch = await mkdtemp(join(tmpdir(), 'guanxiang-index-'))
55
- try {
56
- const env = { GIT_INDEX_FILE: join(scratch, 'index') }
57
- await git(['read-tree', base], { cwd: dir, timeoutMs, env })
58
- const listing = await git(['ls-tree', '-r', '-t', '-z', base, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
59
- const modes = new Map<string, string>()
60
- for (const record of listing.split('\0')) {
61
- const tab = record.indexOf('\t')
62
- if (tab < 0) continue
63
- modes.set(record.slice(tab + 1), record.slice(0, record.indexOf(' ')))
64
- }
65
- for (const file of files) {
66
- const path = `${skillDir}/${file.path}`
67
- const existing = modes.get(path)
68
- if (existing === '120000') throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`)
69
- if (existing === '040000') throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`)
70
- const blob = (await git(['hash-object', '-w', '--stdin'], { cwd: dir, timeoutMs, input: file.content })).trim()
71
- await git(['update-index', '--add', '--cacheinfo', `${existing === '100755' ? '100755' : '100644'},${blob},${path}`], { cwd: dir, timeoutMs, env })
72
- }
73
- const tree = (await git(['write-tree'], { cwd: dir, timeoutMs, env })).trim()
74
- const numstat = await git(['diff', ...DIFF_FLAGS, '--numstat', base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
75
- const stats = numstat.split('\n').filter(line => line !== '').map((line): FileStat => {
76
- const [added = '-', removed = '-', ...path] = line.split('\t')
77
- return { path: path.join('\t'), ...added === '-' ? {} : { added: Number(added) }, ...removed === '-' ? {} : { removed: Number(removed) } }
78
- })
79
- const diff = stats.length === 0 ? '' : await git(['diff', ...DIFF_FLAGS, base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
80
- return { base, tree, files: stats, diff }
81
- } finally {
82
- await rm(scratch, { recursive: true, force: true })
83
- }
84
- }
@@ -1,51 +0,0 @@
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
- }