@boostkit-dsh/kunpeng-execution 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +58 -0
- package/cordis.patch.yml +38 -0
- package/evidence.js +37 -0
- package/index.js +47 -0
- package/package.json +61 -0
- package/parsers.js +30 -0
- package/providers.js +122 -0
- package/recipes.js +109 -0
- package/service.js +181 -0
- package/ssh.js +127 -0
- package/tool-impl.js +66 -0
- package/tools.js +46 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.1 - 2026-09-22
|
|
4
|
+
|
|
5
|
+
- 首次发布前将包名统一为 `@boostkit-dsh/kunpeng-execution`,去掉 scope 下重复的 `plugin-` 前缀。
|
|
6
|
+
- 将 DSH 精确兼容基线升级为 `0.1.7-alpha.1`,并同步鉴权依赖到 `@boostkit-dsh/auth-management@0.1.1`。
|
|
7
|
+
|
|
8
|
+
## 0.2.0 - 2026-09-22
|
|
9
|
+
|
|
10
|
+
- 对鉴权与审计管理增加精确硬依赖,Agent 工具的读取和执行动作要求有效 GitCode ActorRef。
|
|
11
|
+
- 运行与取消写入最小化本地审计事件,保留原有远程执行人工审批。
|
|
12
|
+
|
|
13
|
+
## 0.1.0 - 2026-09-20
|
|
14
|
+
|
|
15
|
+
- 提供 `ctx.kunpengExecution` Host 服务:环境注册表(remote-ssh / local / mock)、配方、run/cancel、同环境互斥,以及 `kunpeng-execution/state` 与 `kunpeng-execution/log` 事件。
|
|
16
|
+
- 提供 `kunpeng_execution` Agent 工具(`run` 动作执行前需 danger-full-access 审批)。
|
|
17
|
+
- remote-ssh Provider 把 `omnioperator-remote-build` Skill 插件化:密钥认证 + 系统 `ssh`(BatchMode),代码同步优先 `rsync`、无 rsync 时退化 `tar` 流管道;`build`/`ut`/`sysinfo` 默认配方自 Skill `config.ini` 原样移植。
|
|
18
|
+
- local Provider 用平台 shell 执行配方;mock Provider 产出带 `[SIMULATED]` 标记的模拟结果,证据 `simulated: true`。
|
|
19
|
+
- 执行证据按 `plugins/job-workbench/contract.md` 生成(schema `kunpeng-execution-evidence@1`):退出码、耗时、GTest 统计、`BENCH` 性能行与尾部日志摘录。
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# 鲲鹏执行(Kunpeng Execution)
|
|
2
|
+
|
|
3
|
+
DSH 支撑插件:管理鲲鹏执行环境与配方,执行构建、单元测试和 benchmark,产出结构化执行证据(`plugins/job-workbench/contract.md`)。本插件把 `omnioperator-remote-build` Skill 的远程构建能力(SSH 同步 + 远程编译/UT)插件化,供业务插件程序化消费,也提供 Agent 工具直接调用。
|
|
4
|
+
|
|
5
|
+
- Host 服务:`ctx.kunpengExecution`(`listEnvironments` / `listRecipes` / `run` / `cancel`)
|
|
6
|
+
- Agent 工具:`kunpeng_execution`(run 动作执行前需用户审批)
|
|
7
|
+
- 事件:`kunpeng-execution/state`、`kunpeng-execution/log`(业务插件 `ctx.on` 订阅)
|
|
8
|
+
|
|
9
|
+
`0.2.0` 起要求 GitCode 鉴权;当前版本精确依赖 `@boostkit-dsh/auth-management@0.1.1`。Agent 工具的环境/配方读取、run 和 cancel 都先验证当前 GitCode ActorRef;run 与 cancel 记录最小化审计,run 原有的 danger-full-access 人工审批仍然保留。
|
|
10
|
+
|
|
11
|
+
## 执行环境
|
|
12
|
+
|
|
13
|
+
| provider | 说明 |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `remote-ssh` | 真实远程鲲鹏服务器:密钥认证 + 系统 `ssh`;同步优先 `rsync`,无 rsync(Windows)时退化为 `tar` 流管道 |
|
|
16
|
+
| `local` | 本机 shell 执行配方(`shell: true`),用于本地构建/测试 |
|
|
17
|
+
| `mock` | 模拟环境:输出全部带 `[SIMULATED]` 前缀,证据 `simulated: true`,不与真实结果混淆 |
|
|
18
|
+
|
|
19
|
+
## 首次使用(SSH 密钥配置)
|
|
20
|
+
|
|
21
|
+
本插件只支持密钥认证(`BatchMode=yes`,等价于 Skill 的 `--setup-key` 推荐路径,跨平台且凭据不落盘):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# 1. 生成专用密钥(无口令,或加载进 ssh-agent)
|
|
25
|
+
ssh-keygen -t ed25519 -f ~/.ssh/kunpeng_exec -N ""
|
|
26
|
+
|
|
27
|
+
# 2. 把公钥安装到远程服务器(macOS/Linux)
|
|
28
|
+
ssh-copy-id -i ~/.ssh/kunpeng_exec.pub -p <port> <user>@<host>
|
|
29
|
+
|
|
30
|
+
# 3. 在 DSH 插件 config(或用户层 cordis.patch.yml)中填写
|
|
31
|
+
# host / user / port / identityFile=~/.ssh/kunpeng_exec
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Windows 第 2 步可用:`type $env:USERPROFILE\.ssh\kunpeng_exec.pub | ssh -p <port> <user>@<host> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"`。
|
|
35
|
+
|
|
36
|
+
配置完成后用 `sysinfo` 配方验证连通性,再跑 `build` / `ut`。
|
|
37
|
+
|
|
38
|
+
## 配方
|
|
39
|
+
|
|
40
|
+
默认配方移植自 Skill 的 `config.ini`:`build`(OmniOperator + Gluten 编译,默认先同步)、`ut`(omtest 全量单元测试)、`sysinfo`(系统信息/连通性验证)、`benchmark`(通用模板,见下)。环境可在插件 config 中按 id 覆盖或新增配方。
|
|
41
|
+
|
|
42
|
+
benchmark 约定:命令输出 `BENCH <name> <value> <unit>` 行,解析为结构化性能数据。默认 benchmark 配方只打印配置指引、不产出任何数据行(避免模板数字被当作真实结果);请在 config 中替换为项目真实的 benchmark 命令。
|
|
43
|
+
|
|
44
|
+
## 注意事项
|
|
45
|
+
|
|
46
|
+
- **取消语义**:`cancel` 终止本地 ssh/rsync/tar 进程链;远程命令未用 nohup,随 SSH 会话一同终止。
|
|
47
|
+
- **同步删除**:`rsync --delete` 默认关闭;确需镜像删除时在环境 `sync.deleteMode` 显式开启(tar 模式不支持删除)。
|
|
48
|
+
- **同环境互斥**:一个环境同时只允许一个活动 run,重复请求返回 `busy`。
|
|
49
|
+
- **密钥安全**:命令行只包含 host/user/密钥路径;证据日志仅保留输出尾部 16 KiB,不含凭据。
|
|
50
|
+
- 模拟环境的证据 `simulated: true` 且首行 `[SIMULATED]`,SPEC 引用时必须保留模拟标记。
|
|
51
|
+
|
|
52
|
+
## 开发
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm test # 等价 node --test test/*.test.mjs
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
开发计划见仓库 `docs/kunpeng-execution-dev-plan.md`。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# 鲲鹏执行:环境、配方与结构化执行证据服务(host 服务 + Agent 工具两个入口)。
|
|
2
|
+
- insert:
|
|
3
|
+
- id: kunpeng-execution
|
|
4
|
+
name: '@boostkit-dsh/kunpeng-execution'
|
|
5
|
+
config:
|
|
6
|
+
environments:
|
|
7
|
+
# 真实远程环境:填好 host/user/identityFile 后即可使用(密钥认证,BatchMode)。
|
|
8
|
+
- id: omnioperator
|
|
9
|
+
provider: remote-ssh
|
|
10
|
+
displayName: 鲲鹏 OmniOperator 远程环境
|
|
11
|
+
host: ''
|
|
12
|
+
user: ''
|
|
13
|
+
port: 22
|
|
14
|
+
identityFile: ''
|
|
15
|
+
remoteArch: aarch64
|
|
16
|
+
sync:
|
|
17
|
+
targetPath: /home/omni/
|
|
18
|
+
exclude:
|
|
19
|
+
- .git
|
|
20
|
+
- build
|
|
21
|
+
- dist
|
|
22
|
+
- target
|
|
23
|
+
- node_modules
|
|
24
|
+
- .dsh
|
|
25
|
+
- .agents
|
|
26
|
+
- .vscode
|
|
27
|
+
- .idea
|
|
28
|
+
- .DS_Store
|
|
29
|
+
- '*.log'
|
|
30
|
+
# 模拟环境:输出全部带 [SIMULATED] 标记,证据 simulated: true。
|
|
31
|
+
- id: mock
|
|
32
|
+
provider: mock
|
|
33
|
+
displayName: 模拟鲲鹏环境(明确标记)
|
|
34
|
+
remoteArch: simulated
|
|
35
|
+
|
|
36
|
+
# Agent 工具半区:kunpeng_execution(run 动作前需用户审批)。
|
|
37
|
+
- id: tool-kunpeng-execution
|
|
38
|
+
name: '@boostkit-dsh/kunpeng-execution/tools'
|
package/evidence.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// ExecutionEvidence 组装。契约定义见 plugins/job-workbench/contract.md(§2.0 执行侧)。
|
|
2
|
+
|
|
3
|
+
export const EVIDENCE_SCHEMA = 'kunpeng-execution-evidence@1'
|
|
4
|
+
export const LOG_TAIL_BYTES = 16_384
|
|
5
|
+
const EMPTY_PARSED = {}
|
|
6
|
+
|
|
7
|
+
/** Keep the trailing slice of `text`, bounded by bytes (UTF-8 aware). */
|
|
8
|
+
export function tailText(text, maxBytes = LOG_TAIL_BYTES) {
|
|
9
|
+
const buffer = Buffer.from(text, 'utf8')
|
|
10
|
+
if (buffer.length <= maxBytes) return text
|
|
11
|
+
return buffer.subarray(buffer.length - maxBytes).toString('utf8')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build one execution evidence document.
|
|
16
|
+
* All fields are plain JSON; `simulated` must be true for mock provider runs.
|
|
17
|
+
*/
|
|
18
|
+
export function buildEvidence({ runId, startedAt, finishedAt, environment, recipe, status, exitCode, durationMs, parsed, simulated, logText }) {
|
|
19
|
+
return {
|
|
20
|
+
schema: EVIDENCE_SCHEMA,
|
|
21
|
+
runId,
|
|
22
|
+
startedAt: startedAt.toISOString(),
|
|
23
|
+
finishedAt: finishedAt.toISOString(),
|
|
24
|
+
environment: {
|
|
25
|
+
id: environment.id,
|
|
26
|
+
provider: environment.provider,
|
|
27
|
+
host: environment.provider === 'remote-ssh' ? environment.host : '',
|
|
28
|
+
arch: environment.remoteArch ?? '',
|
|
29
|
+
label: environment.displayName ?? environment.id,
|
|
30
|
+
},
|
|
31
|
+
recipe: { id: recipe.id, kind: recipe.kind, command: recipe.command },
|
|
32
|
+
result: { status, exitCode, durationMs },
|
|
33
|
+
parsed: parsed ?? EMPTY_PARSED,
|
|
34
|
+
simulated: simulated === true,
|
|
35
|
+
logExcerpt: { tailBytes: LOG_TAIL_BYTES, text: tailText(logText ?? '') },
|
|
36
|
+
}
|
|
37
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { createExecutionService } from './service.js'
|
|
5
|
+
|
|
6
|
+
export const name = 'kunpeng-execution'
|
|
7
|
+
export const inject = ['authManagement']
|
|
8
|
+
|
|
9
|
+
/** Match the filesystem Skill provider's upward `.git` project-root discovery. */
|
|
10
|
+
function dshProjectRoot(cwd) {
|
|
11
|
+
let current = cwd
|
|
12
|
+
while (true) {
|
|
13
|
+
if (existsSync(join(current, '.git'))) return current
|
|
14
|
+
const parent = dirname(current)
|
|
15
|
+
if (parent === current) return cwd
|
|
16
|
+
current = parent
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve a workspace id (or the single registered workspace) to a project root. */
|
|
21
|
+
function resolveWorkspaceRoot(ctx, workspaceId) {
|
|
22
|
+
let registry
|
|
23
|
+
try { registry = ctx.get?.('workspaceRegistry') } catch { registry = undefined }
|
|
24
|
+
if (typeof workspaceId === 'string' && workspaceId !== '') {
|
|
25
|
+
const workspace = registry?.get?.(workspaceId)
|
|
26
|
+
if (workspace === undefined || typeof workspace.path !== 'string') throw new Error(`所选 DSH 工作区不存在:${workspaceId}`)
|
|
27
|
+
return dshProjectRoot(workspace.path)
|
|
28
|
+
}
|
|
29
|
+
const workspaces = registry?.list?.()
|
|
30
|
+
if (Array.isArray(workspaces) && workspaces.length === 1 && typeof workspaces[0]?.path === 'string') return dshProjectRoot(workspaces[0].path)
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Register the kunpeng execution service, host events and lifecycle cleanup. */
|
|
35
|
+
export function apply(ctx, config = {}) {
|
|
36
|
+
const auth = ctx.get?.('authManagement') ?? ctx.authManagement
|
|
37
|
+
if (auth === undefined) throw new Error('kunpeng-execution 需要 authManagement 服务')
|
|
38
|
+
const service = createExecutionService({
|
|
39
|
+
environments: config.environments,
|
|
40
|
+
emit: (event, payload) => { if (typeof ctx.emit === 'function') ctx.emit(event, payload) },
|
|
41
|
+
resolveWorkspaceRoot: (workspaceId) => resolveWorkspaceRoot(ctx, workspaceId),
|
|
42
|
+
})
|
|
43
|
+
ctx.provide('kunpengExecution', service)
|
|
44
|
+
ctx.effect?.(() => () => service.dispose(), 'kunpeng-execution.dispose')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const __testing = { dshProjectRoot, resolveWorkspaceRoot }
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boostkit-dsh/kunpeng-execution",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "鲲鹏执行环境、配方与结构化执行证据服务(本地、SSH 远程与明确标记的模拟 Provider)",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"CHANGELOG.md",
|
|
9
|
+
"README.md",
|
|
10
|
+
"cordis.patch.yml",
|
|
11
|
+
"evidence.js",
|
|
12
|
+
"index.js",
|
|
13
|
+
"parsers.js",
|
|
14
|
+
"providers.js",
|
|
15
|
+
"recipes.js",
|
|
16
|
+
"service.js",
|
|
17
|
+
"ssh.js",
|
|
18
|
+
"tool-impl.js",
|
|
19
|
+
"tools.js"
|
|
20
|
+
],
|
|
21
|
+
"license": "UNLICENSED",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://gitcode.com/lujinxing/kunpeng-dsh-demo.git",
|
|
25
|
+
"directory": "plugins/kunpeng-execution"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"registry": "https://registry.npmjs.org"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.js",
|
|
33
|
+
"./tools": "./tools.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test test/*.test.mjs"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@boostkit-dsh/auth-management": "0.1.1"
|
|
41
|
+
},
|
|
42
|
+
"dsh": {
|
|
43
|
+
"bundle": {
|
|
44
|
+
"patch": "./cordis.patch.yml"
|
|
45
|
+
},
|
|
46
|
+
"market": {
|
|
47
|
+
"displayName": {
|
|
48
|
+
"zh": "鲲鹏执行",
|
|
49
|
+
"en": "Kunpeng Execution"
|
|
50
|
+
},
|
|
51
|
+
"category": "kunpeng-rd",
|
|
52
|
+
"maintainers": [
|
|
53
|
+
"@lujinxing"
|
|
54
|
+
],
|
|
55
|
+
"compatibility": {
|
|
56
|
+
"dsh": "0.1.7-alpha.1"
|
|
57
|
+
},
|
|
58
|
+
"allowedBuilds": []
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/parsers.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 远程输出解析:编译耗时(skill 的“编译耗时: N 秒”约定)、GTest/omtest 汇总行、
|
|
2
|
+
// BENCHMARK 约定为命令输出 `BENCH <name> <value> <unit>` 行。
|
|
3
|
+
|
|
4
|
+
const COMPILE_TIME_RE = /编译耗时[::]\s*(\d+)\s*秒/
|
|
5
|
+
const GTEST_TOTAL_RE = /\[==========\]\s+(?:Running\s+)?(\d+)\s+tests?/
|
|
6
|
+
const GTEST_PASSED_RE = /\[\s*PASSED\s*\]\s+(\d+)\s+tests?/
|
|
7
|
+
const GTEST_FAILED_RE = /\[\s*FAILED\s*\]\s+(\d+)\s+tests?/
|
|
8
|
+
const GTEST_SKIPPED_RE = /\[\s*SKIPPED\s*\]\s+(\d+)\s+tests?/
|
|
9
|
+
const BENCH_LINE_RE = /^BENCH\s+(\S+)\s+(-?\d+(?:\.\d+)?)\s+(\S+)\s*$/gm
|
|
10
|
+
|
|
11
|
+
/** Parse command output according to the recipe parse mode. Returns evidence `parsed` fields. */
|
|
12
|
+
export function parseOutput(mode, text) {
|
|
13
|
+
if (mode === 'compile-time') {
|
|
14
|
+
const match = COMPILE_TIME_RE.exec(text)
|
|
15
|
+
return match === null ? {} : { compileTimeMs: Number(match[1]) * 1000 }
|
|
16
|
+
}
|
|
17
|
+
if (mode === 'gtest') {
|
|
18
|
+
const total = GTEST_TOTAL_RE.exec(text)?.[1]
|
|
19
|
+
const passed = GTEST_PASSED_RE.exec(text)?.[1]
|
|
20
|
+
const failed = GTEST_FAILED_RE.exec(text)?.[1]
|
|
21
|
+
const skipped = GTEST_SKIPPED_RE.exec(text)?.[1]
|
|
22
|
+
if (total === undefined && passed === undefined && failed === undefined) return {}
|
|
23
|
+
return { tests: { total: Number(total ?? 0), passed: Number(passed ?? 0), failed: Number(failed ?? 0), skipped: Number(skipped ?? 0) } }
|
|
24
|
+
}
|
|
25
|
+
if (mode === 'benchmark') {
|
|
26
|
+
const benchmark = [...text.matchAll(BENCH_LINE_RE)].map((match) => ({ name: match[1], value: Number(match[2]), unit: match[3] }))
|
|
27
|
+
return benchmark.length === 0 ? {} : { benchmark }
|
|
28
|
+
}
|
|
29
|
+
return {}
|
|
30
|
+
}
|
package/providers.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// 三个执行 Provider:remote-ssh(真实远程)、local(本机 shell)、mock(明确标记的模拟)。
|
|
2
|
+
// Provider 接口:prepare({sourcePath, signal, onLog})?(同步)与 execute({recipe, cwd, signal, onLog})。
|
|
3
|
+
|
|
4
|
+
import { spawn as spawnChild } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
import { createSshTransport } from './ssh.js'
|
|
7
|
+
|
|
8
|
+
const SIMULATED_PREFIX = '[SIMULATED]'
|
|
9
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
10
|
+
|
|
11
|
+
/** Remote provider: sync (optional) and exec over the ssh transport. */
|
|
12
|
+
export function createRemoteSshProvider(environment, { transport }) {
|
|
13
|
+
const ssh = transport ?? createSshTransport()
|
|
14
|
+
return {
|
|
15
|
+
simulated: false,
|
|
16
|
+
async prepare({ sourcePath, signal, onLog }) {
|
|
17
|
+
const result = await ssh.sync(environment, {
|
|
18
|
+
sourcePath,
|
|
19
|
+
deleteMode: environment.sync?.deleteMode === true,
|
|
20
|
+
signal,
|
|
21
|
+
onOutput: onLog,
|
|
22
|
+
})
|
|
23
|
+
return { exitCode: result.exitCode }
|
|
24
|
+
},
|
|
25
|
+
async execute({ recipe, signal, onLog }) {
|
|
26
|
+
const result = await ssh.exec(environment, recipe.command, { signal, onOutput: onLog })
|
|
27
|
+
return { exitCode: result.exitCode }
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LOCAL_KILL_GRACE_MS = 5_000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Kill a local shell command together with its children. `shell: true` spawns a
|
|
36
|
+
* shell whose grandchildren keep the stdio pipes open, so killing only the shell
|
|
37
|
+
* never surfaces a `close`; kill the whole process group instead (taskkill /T on
|
|
38
|
+
* Windows).
|
|
39
|
+
*/
|
|
40
|
+
function killLocalTree(child) {
|
|
41
|
+
if (typeof child.pid !== 'number') { child.kill('SIGTERM'); return }
|
|
42
|
+
if (process.platform === 'win32') {
|
|
43
|
+
spawnChild('taskkill', ['/pid', String(child.pid), '/T', '/F'])
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
try { process.kill(-child.pid, 'SIGTERM') } catch { child.kill('SIGTERM') }
|
|
47
|
+
const killer = setTimeout(() => {
|
|
48
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch { child.kill('SIGKILL') }
|
|
49
|
+
}, LOCAL_KILL_GRACE_MS)
|
|
50
|
+
killer.unref?.()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Local provider: run the recipe command through the platform shell. */
|
|
54
|
+
export function createLocalProvider() {
|
|
55
|
+
return {
|
|
56
|
+
simulated: false,
|
|
57
|
+
async execute({ recipe, cwd, signal, onLog }) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const child = spawnChild(recipe.command, { shell: true, cwd: cwd ?? undefined, detached: process.platform !== 'win32' })
|
|
60
|
+
const onAbort = () => killLocalTree(child)
|
|
61
|
+
if (signal?.aborted) onAbort()
|
|
62
|
+
else signal?.addEventListener('abort', onAbort, { once: true })
|
|
63
|
+
child.stdout?.on('data', (chunk) => onLog?.({ stream: 'stdout', text: chunk.toString('utf8') }))
|
|
64
|
+
child.stderr?.on('data', (chunk) => onLog?.({ stream: 'stderr', text: chunk.toString('utf8') }))
|
|
65
|
+
child.on('error', (error) => {
|
|
66
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
67
|
+
reject(Object.assign(new Error(`本地执行失败:${error.message}`), { code: 'spawn-error' }))
|
|
68
|
+
})
|
|
69
|
+
child.on('close', (exitCode) => {
|
|
70
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
71
|
+
resolve({ exitCode })
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Simulated output lines for the mock provider, per recipe parse mode. */
|
|
79
|
+
export function mockOutputLines(recipe) {
|
|
80
|
+
if (recipe.parse === 'compile-time') {
|
|
81
|
+
return [
|
|
82
|
+
`${SIMULATED_PREFIX} 模拟鲲鹏编译——非真实执行结果`,
|
|
83
|
+
`${SIMULATED_PREFIX} [ 50%] Building CXX object core/CMakeFiles/omniop.dir/ops/expr.cpp.o`,
|
|
84
|
+
'编译耗时: 42 秒',
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
if (recipe.parse === 'gtest') {
|
|
88
|
+
return [
|
|
89
|
+
`${SIMULATED_PREFIX} 模拟鲲鹏单元测试——非真实执行结果`,
|
|
90
|
+
'[==========] Running 128 tests from 12 test suites.',
|
|
91
|
+
'[==========] 128 tests from 12 test suites ran. (35621 ms total)',
|
|
92
|
+
'[ PASSED ] 126 tests.',
|
|
93
|
+
'[ FAILED ] 2 tests, listed below:',
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
if (recipe.parse === 'benchmark') {
|
|
97
|
+
return [
|
|
98
|
+
`${SIMULATED_PREFIX} 模拟 benchmark——非真实执行结果`,
|
|
99
|
+
'BENCH simulated-throughput 1250.5 ops/s',
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
return [`${SIMULATED_PREFIX} 模拟输出——非真实执行结果`]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Mock provider: deterministic simulated output through the same parse pipeline. */
|
|
106
|
+
export function createMockProvider({ delayMs = 1 } = {}) {
|
|
107
|
+
return {
|
|
108
|
+
simulated: true,
|
|
109
|
+
async prepare({ onLog }) {
|
|
110
|
+
onLog?.({ stream: 'stdout', text: `${SIMULATED_PREFIX} 模拟代码同步——非真实执行结果\n` })
|
|
111
|
+
return { exitCode: 0 }
|
|
112
|
+
},
|
|
113
|
+
async execute({ recipe, signal, onLog }) {
|
|
114
|
+
for (const line of mockOutputLines(recipe)) {
|
|
115
|
+
signal?.throwIfAborted()
|
|
116
|
+
if (delayMs > 0) await sleep(delayMs)
|
|
117
|
+
onLog?.({ stream: 'stdout', text: `${line}\n` })
|
|
118
|
+
}
|
|
119
|
+
return { exitCode: 0 }
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
package/recipes.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 配方模型与默认配方。build/ut/sysinfo 的命令自 omnioperator-remote-build Skill
|
|
2
|
+
// 的 config.ini(task_compile/task_ut/task_sysinfo)原样移植,保持远程端语义不变。
|
|
3
|
+
|
|
4
|
+
export const RECIPE_KINDS = ['build', 'test', 'benchmark', 'info']
|
|
5
|
+
export const PARSE_MODES = ['compile-time', 'gtest', 'benchmark', 'raw']
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_RECIPES = [
|
|
8
|
+
{
|
|
9
|
+
id: 'build',
|
|
10
|
+
kind: 'build',
|
|
11
|
+
parse: 'compile-time',
|
|
12
|
+
timeoutMs: 3_600_000,
|
|
13
|
+
syncBefore: true,
|
|
14
|
+
command: [
|
|
15
|
+
'set -ex',
|
|
16
|
+
'start_time=$(date +%s)',
|
|
17
|
+
'export CMAKE_MODULE_PATH=',
|
|
18
|
+
'export OMNI_HOME=/home/omni/omni-operator',
|
|
19
|
+
'export LD_LIBRARY_PATH=/$OMNI_HOME/lib:/usr/local/lib:/usr/local/lib64:$LD_LIBRARY_PATH',
|
|
20
|
+
'export LIBRARY_PATH=$LD_LIBRARY_PATH',
|
|
21
|
+
'export omni_spark_version=spark-3.5',
|
|
22
|
+
'export CPLUS_INCLUDE_PATH=/usr/local/include/orc:/usr/local/include:$CPLUS_INCLUDE_PATH',
|
|
23
|
+
'export C_INCLUDE_PATH=$CPLUS_INCLUDE_PATH',
|
|
24
|
+
'work_dir=/home/omni',
|
|
25
|
+
'JIT_HOME=$work_dir/OmniOperator',
|
|
26
|
+
'GLUTEN_HOME=$work_dir/Gluten',
|
|
27
|
+
'cd $work_dir',
|
|
28
|
+
'rm $OMNI_HOME/lib/libboostkit-omniop-* -rf',
|
|
29
|
+
'rm $OMNI_HOME/lib/include -rf',
|
|
30
|
+
'rm $OMNI_HOME/lib/boostkit-omniop-* -rf',
|
|
31
|
+
'cd $JIT_HOME',
|
|
32
|
+
'bash build_scripts/build.sh release:java',
|
|
33
|
+
'cd $JIT_HOME/bindings/java',
|
|
34
|
+
'mvn clean install -Ddep.os.arch=aarch64 -DskipTests',
|
|
35
|
+
'end_time=$(date +%s)',
|
|
36
|
+
'compile_time=$(($end_time - $start_time))',
|
|
37
|
+
'echo "编译耗时: $compile_time 秒"',
|
|
38
|
+
].join('; '),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'ut',
|
|
42
|
+
kind: 'test',
|
|
43
|
+
parse: 'gtest',
|
|
44
|
+
timeoutMs: 1_800_000,
|
|
45
|
+
syncBefore: false,
|
|
46
|
+
command: [
|
|
47
|
+
'set -ex',
|
|
48
|
+
'export CMAKE_MODULE_PATH=',
|
|
49
|
+
'export OMNI_HOME=/home/omni/omni-operator',
|
|
50
|
+
'export LD_LIBRARY_PATH=/$OMNI_HOME/lib:/usr/local/lib:/usr/local/lib64:$LD_LIBRARY_PATH',
|
|
51
|
+
'export LIBRARY_PATH=$LD_LIBRARY_PATH',
|
|
52
|
+
'export omni_spark_version=spark-3.5',
|
|
53
|
+
'export CPLUS_INCLUDE_PATH=/usr/local/include/orc:/usr/local/include:$CPLUS_INCLUDE_PATH',
|
|
54
|
+
'export C_INCLUDE_PATH=$CPLUS_INCLUDE_PATH',
|
|
55
|
+
'work_dir=/home/omni',
|
|
56
|
+
'JIT_HOME=$work_dir/OmniOperator',
|
|
57
|
+
'GLUTEN_HOME=$work_dir/Gluten',
|
|
58
|
+
'cd $work_dir',
|
|
59
|
+
'./OmniOperator/build/core/test/omtest',
|
|
60
|
+
].join('; '),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'sysinfo',
|
|
64
|
+
kind: 'info',
|
|
65
|
+
parse: 'raw',
|
|
66
|
+
timeoutMs: 30_000,
|
|
67
|
+
syncBefore: false,
|
|
68
|
+
command: 'echo "=== 系统信息 ===" && hostname && whoami && date && uptime',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'benchmark',
|
|
72
|
+
kind: 'benchmark',
|
|
73
|
+
parse: 'benchmark',
|
|
74
|
+
timeoutMs: 1_800_000,
|
|
75
|
+
syncBefore: false,
|
|
76
|
+
// 通用模板:只打印指引,不产出任何 BENCH 数据行(避免模板数字被当成真实结果)。
|
|
77
|
+
// 项目真实的 benchmark 命令在插件 config 中按 id 覆盖,输出 `BENCH <name> <value> <unit>` 行。
|
|
78
|
+
command: 'echo "benchmark 配方未配置:请在插件 config 中覆盖 id 为 benchmark 的配方命令,并让命令输出 BENCH <name> <value> <unit> 行"',
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
/** Validate one recipe definition, returning a normalized copy. */
|
|
83
|
+
export function normalizeRecipe(recipe) {
|
|
84
|
+
if (recipe === null || typeof recipe !== 'object') throw new Error('配方定义必须是对象')
|
|
85
|
+
if (typeof recipe.id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(recipe.id)) throw new Error(`配方 id 非法:${recipe.id}`)
|
|
86
|
+
if (typeof recipe.command !== 'string' || recipe.command.trim() === '') throw new Error(`配方 ${recipe.id} 缺少 command`)
|
|
87
|
+
const kind = recipe.kind ?? 'info'
|
|
88
|
+
if (!RECIPE_KINDS.includes(kind)) throw new Error(`配方 ${recipe.id} 的 kind 非法:${kind}`)
|
|
89
|
+
const parse = recipe.parse ?? 'raw'
|
|
90
|
+
if (!PARSE_MODES.includes(parse)) throw new Error(`配方 ${recipe.id} 的 parse 非法:${parse}`)
|
|
91
|
+
return {
|
|
92
|
+
id: recipe.id,
|
|
93
|
+
kind,
|
|
94
|
+
parse,
|
|
95
|
+
command: recipe.command,
|
|
96
|
+
timeoutMs: recipe.timeoutMs,
|
|
97
|
+
syncBefore: recipe.syncBefore,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Merge environment recipe overrides over the defaults (match by id, extras appended). */
|
|
102
|
+
export function mergeRecipes(defaults, overrides) {
|
|
103
|
+
const merged = new Map(defaults.map((recipe) => [recipe.id, recipe]))
|
|
104
|
+
for (const override of overrides ?? []) {
|
|
105
|
+
const normalized = normalizeRecipe(override)
|
|
106
|
+
merged.set(normalized.id, normalized)
|
|
107
|
+
}
|
|
108
|
+
return [...merged.values()]
|
|
109
|
+
}
|
package/service.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// kunpengExecution 服务:环境注册表、run/cancel、状态与日志事件、同环境互斥。
|
|
2
|
+
// 事件:kunpeng-execution/state(syncing/running/parsing/succeeded/failed/cancelled/timeout)
|
|
3
|
+
// 与 kunpeng-execution/log(流式输出块),业务插件经 ctx.on 消费。
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
import { buildEvidence } from './evidence.js'
|
|
8
|
+
import { parseOutput } from './parsers.js'
|
|
9
|
+
import { createLocalProvider, createMockProvider, createRemoteSshProvider } from './providers.js'
|
|
10
|
+
import { DEFAULT_RECIPES, mergeRecipes } from './recipes.js'
|
|
11
|
+
import { createSshTransport } from './ssh.js'
|
|
12
|
+
|
|
13
|
+
const PROVIDERS = ['remote-ssh', 'local', 'mock']
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 1_800_000
|
|
15
|
+
const LOG_ACCUMULATE_CHARS = 1_000_000
|
|
16
|
+
const ENVIRONMENT_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
17
|
+
|
|
18
|
+
function abortReason(kind, message) {
|
|
19
|
+
return Object.assign(new Error(message), { kind })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function statusFromSignal(signal) {
|
|
23
|
+
return signal?.reason?.kind === 'timeout' ? 'timeout' : 'cancelled'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Create the host execution service. All external effects are injectable for tests. */
|
|
27
|
+
export function createExecutionService(options = {}) {
|
|
28
|
+
const emit = options.emit ?? (() => {})
|
|
29
|
+
const now = options.now ?? (() => new Date())
|
|
30
|
+
const transport = options.sshTransport ?? createSshTransport(options.sshSpawn ? { spawnImpl: options.sshSpawn } : {})
|
|
31
|
+
const environments = new Map()
|
|
32
|
+
const active = new Map()
|
|
33
|
+
const runs = new Map()
|
|
34
|
+
|
|
35
|
+
for (const environment of options.environments ?? []) registerEnvironment(environment)
|
|
36
|
+
|
|
37
|
+
function registerEnvironment(environment) {
|
|
38
|
+
if (environment === null || typeof environment !== 'object') throw new Error('执行环境必须是对象')
|
|
39
|
+
if (typeof environment.id !== 'string' || !ENVIRONMENT_ID_RE.test(environment.id)) throw new Error(`环境 id 非法:${environment.id}`)
|
|
40
|
+
if (!PROVIDERS.includes(environment.provider)) throw new Error(`环境 ${environment.id} 的 provider 非法:${environment.provider}`)
|
|
41
|
+
if (environments.has(environment.id)) throw new Error(`环境 id 重复:${environment.id}`)
|
|
42
|
+
environments.set(environment.id, { port: 22, ...environment, recipes: mergeRecipes(DEFAULT_RECIPES, environment.recipes) })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function providerFor(environment) {
|
|
46
|
+
if (environment.provider === 'remote-ssh') return createRemoteSshProvider(environment, { transport })
|
|
47
|
+
if (environment.provider === 'local') return createLocalProvider()
|
|
48
|
+
return createMockProvider()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function listEnvironments() {
|
|
52
|
+
return [...environments.values()].map((environment) => ({
|
|
53
|
+
id: environment.id,
|
|
54
|
+
provider: environment.provider,
|
|
55
|
+
displayName: environment.displayName ?? environment.id,
|
|
56
|
+
host: environment.provider === 'remote-ssh' ? environment.host : '',
|
|
57
|
+
configured: environment.provider !== 'remote-ssh' || (typeof environment.host === 'string' && environment.host !== '' && typeof environment.user === 'string' && environment.user !== ''),
|
|
58
|
+
activeRunId: active.get(environment.id) ?? null,
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function listRecipes(environmentId) {
|
|
63
|
+
const environment = environments.get(environmentId)
|
|
64
|
+
if (environment === undefined) throw new Error(`未知执行环境:${environmentId}`)
|
|
65
|
+
return environment.recipes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function emitState(runId, environment, recipeId, state) {
|
|
69
|
+
emit('kunpeng-execution/state', { runId, environmentId: environment.id, recipeId, state })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function appendLog(current, text) {
|
|
73
|
+
const merged = current + text
|
|
74
|
+
return merged.length <= LOG_ACCUMULATE_CHARS ? merged : merged.slice(merged.length - LOG_ACCUMULATE_CHARS)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function settle(runId, environment, recipe, startedAt, status, exitCode, logText) {
|
|
78
|
+
const finishedAt = now()
|
|
79
|
+
emitState(runId, environment, recipe.id, 'parsing')
|
|
80
|
+
const parsed = parseOutput(recipe.parse, logText)
|
|
81
|
+
const evidence = buildEvidence({
|
|
82
|
+
runId, startedAt, finishedAt, environment, recipe,
|
|
83
|
+
status, exitCode,
|
|
84
|
+
durationMs: finishedAt - startedAt,
|
|
85
|
+
parsed,
|
|
86
|
+
simulated: environment.provider === 'mock',
|
|
87
|
+
logText,
|
|
88
|
+
})
|
|
89
|
+
emitState(runId, environment, recipe.id, status)
|
|
90
|
+
return evidence
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveRoot(workspaceId) {
|
|
94
|
+
const root = options.resolveWorkspaceRoot?.(workspaceId)
|
|
95
|
+
if (typeof root !== 'string' || root === '') {
|
|
96
|
+
throw new Error(workspaceId === null || workspaceId === undefined
|
|
97
|
+
? '无法确定项目根:请在请求中提供 workspaceId,或保证 DSH 恰好登记了一个工作区'
|
|
98
|
+
: `无法解析工作区:${workspaceId}`)
|
|
99
|
+
}
|
|
100
|
+
return root
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function run(request) {
|
|
104
|
+
const environment = environments.get(request.environmentId)
|
|
105
|
+
if (environment === undefined) throw new Error(`未知执行环境:${request.environmentId}`)
|
|
106
|
+
if (environment.provider === 'remote-ssh' && (typeof environment.host !== 'string' || environment.host === '' || typeof environment.user !== 'string' || environment.user === '')) {
|
|
107
|
+
throw new Error(`环境 ${environment.id} 未配置 host/user(及可选 identityFile),请先在插件 config 中完成配置`)
|
|
108
|
+
}
|
|
109
|
+
const recipe = environment.recipes.find((candidate) => candidate.id === request.recipeId)
|
|
110
|
+
if (recipe === undefined) throw new Error(`环境 ${environment.id} 不存在配方:${request.recipeId}`)
|
|
111
|
+
if (active.has(environment.id)) {
|
|
112
|
+
throw Object.assign(new Error(`环境 ${environment.id} 正在执行 ${active.get(environment.id)},请等待完成或先取消`), { code: 'busy' })
|
|
113
|
+
}
|
|
114
|
+
const runId = `run-${options.makeRunId?.() ?? randomUUID()}`
|
|
115
|
+
const controller = new AbortController()
|
|
116
|
+
active.set(environment.id, runId)
|
|
117
|
+
runs.set(runId, { environmentId: environment.id, controller })
|
|
118
|
+
return { runId, result: executeRun(runId, environment, recipe, request, controller) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function executeRun(runId, environment, recipe, request, controller) {
|
|
122
|
+
const { signal } = controller
|
|
123
|
+
let logText = ''
|
|
124
|
+
const onLog = (chunk) => {
|
|
125
|
+
logText = appendLog(logText, chunk.text)
|
|
126
|
+
emit('kunpeng-execution/log', { runId, environmentId: environment.id, recipeId: recipe.id, ...chunk })
|
|
127
|
+
}
|
|
128
|
+
const startedAt = now()
|
|
129
|
+
let timer = null
|
|
130
|
+
try {
|
|
131
|
+
const provider = providerFor(environment)
|
|
132
|
+
const projectRoot = request.workspaceId != null && request.workspaceId !== ''
|
|
133
|
+
? resolveRoot(request.workspaceId)
|
|
134
|
+
: (options.resolveWorkspaceRoot?.(null) ?? null)
|
|
135
|
+
const sourcePath = environment.sync?.sourcePath ?? projectRoot
|
|
136
|
+
const doSync = request.sync ?? recipe.syncBefore ?? false
|
|
137
|
+
if (doSync) {
|
|
138
|
+
if (provider.prepare === undefined) throw new Error(`环境 ${environment.id} 不支持代码同步`)
|
|
139
|
+
if (environment.provider === 'remote-ssh' && (typeof sourcePath !== 'string' || sourcePath === '')) throw new Error('代码同步需要 workspaceId 或环境 sync.sourcePath')
|
|
140
|
+
emitState(runId, environment, recipe.id, 'syncing')
|
|
141
|
+
const syncResult = await provider.prepare({ sourcePath, signal, onLog })
|
|
142
|
+
if (syncResult.exitCode !== 0) return settle(runId, environment, recipe, startedAt, 'failed', syncResult.exitCode, logText)
|
|
143
|
+
}
|
|
144
|
+
emitState(runId, environment, recipe.id, 'running')
|
|
145
|
+
const timeoutMs = request.timeoutMs ?? recipe.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
146
|
+
timer = setTimeout(() => controller.abort(abortReason('timeout', '执行超时')), timeoutMs)
|
|
147
|
+
timer.unref?.()
|
|
148
|
+
const execResult = await provider.execute({ recipe, cwd: projectRoot, signal, onLog })
|
|
149
|
+
clearTimeout(timer)
|
|
150
|
+
timer = null
|
|
151
|
+
if (execResult.exitCode === 0) return settle(runId, environment, recipe, startedAt, 'succeeded', 0, logText)
|
|
152
|
+
if (execResult.exitCode != null) return settle(runId, environment, recipe, startedAt, 'failed', execResult.exitCode, logText)
|
|
153
|
+
return settle(runId, environment, recipe, startedAt, statusFromSignal(signal), null, logText)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (timer !== null) clearTimeout(timer)
|
|
156
|
+
if (signal.aborted) {
|
|
157
|
+
const status = statusFromSignal(signal)
|
|
158
|
+
return settle(runId, environment, recipe, startedAt, status, null, `${logText}\n执行中止:${error.message}`)
|
|
159
|
+
}
|
|
160
|
+
return settle(runId, environment, recipe, startedAt, 'failed', null, `${logText}\n执行错误:${error.message}`)
|
|
161
|
+
} finally {
|
|
162
|
+
active.delete(environment.id)
|
|
163
|
+
runs.delete(runId)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cancel(runId) {
|
|
168
|
+
const run = runs.get(runId)
|
|
169
|
+
if (run === undefined) return { status: 'not-found' }
|
|
170
|
+
run.controller.abort(abortReason('user', '用户取消'))
|
|
171
|
+
return { status: 'cancelled' }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function dispose() {
|
|
175
|
+
for (const run of runs.values()) run.controller.abort(abortReason('user', '服务释放'))
|
|
176
|
+
runs.clear()
|
|
177
|
+
active.clear()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { listEnvironments, listRecipes, run, cancel, dispose }
|
|
181
|
+
}
|
package/ssh.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SSH CLI 封装:全部 spawn 使用数组参数(不经本地 shell),密钥认证 + BatchMode。
|
|
2
|
+
// 同步优先 rsync(增量),本机无 rsync(典型为 Windows)时退化为 tar 流管道。
|
|
3
|
+
|
|
4
|
+
import { spawn as spawnChild } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
export const SSH_OPTION_ARGS = ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes']
|
|
7
|
+
const KILL_GRACE_MS = 5_000
|
|
8
|
+
|
|
9
|
+
/** Base ssh argv: shared options + port + identity + user@host destination. */
|
|
10
|
+
export function sshBaseArgs(environment) {
|
|
11
|
+
const args = [...SSH_OPTION_ARGS, '-p', String(environment.port ?? 22)]
|
|
12
|
+
if (environment.identityFile) args.push('-i', environment.identityFile)
|
|
13
|
+
args.push(`${environment.user}@${environment.host}`)
|
|
14
|
+
return args
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Wrap a command for remote login-shell execution (POSIX single-quote escaping). */
|
|
18
|
+
export function remoteCommand(command) {
|
|
19
|
+
return `bash -l -c '${command.replace(/'/g, `'\\''`)}'`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The `-e` ssh command string rsync should use (mirrors sshBaseArgs options). */
|
|
23
|
+
export function buildRsyncSshOption(environment) {
|
|
24
|
+
const parts = ['ssh', ...SSH_OPTION_ARGS, '-p', String(environment.port ?? 22)]
|
|
25
|
+
if (environment.identityFile) parts.push('-i', environment.identityFile)
|
|
26
|
+
return parts.join(' ')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** rsync argv for an incremental sync (contents of `source` into `target`). */
|
|
30
|
+
export function buildRsyncArgs(environment, { source, target, excludes = [], deleteMode = false }) {
|
|
31
|
+
const args = ['-az']
|
|
32
|
+
for (const pattern of excludes) args.push(`--exclude=${pattern}`)
|
|
33
|
+
if (deleteMode) args.push('--delete')
|
|
34
|
+
args.push('-e', buildRsyncSshOption(environment), `${source}/`, `${environment.user}@${environment.host}:${target}`)
|
|
35
|
+
return args
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Local tar argv + remote extraction command for the no-rsync fallback. */
|
|
39
|
+
export function buildTarSyncCommands(environment, { source, target, excludes = [] }) {
|
|
40
|
+
const tarArgs = ['-czf', '-']
|
|
41
|
+
for (const pattern of excludes) tarArgs.push('--exclude', pattern)
|
|
42
|
+
tarArgs.push('-C', source, '.')
|
|
43
|
+
const quotedTarget = target.replace(/'/g, `'\\''`)
|
|
44
|
+
return { tarArgs, remote: `mkdir -p '${quotedTarget}' && tar -xzf - -C '${quotedTarget}'` }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function killTree(child) {
|
|
48
|
+
if (child.killed) return
|
|
49
|
+
child.kill('SIGTERM')
|
|
50
|
+
const killer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS)
|
|
51
|
+
killer.unref?.()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run one process, streaming output through onOutput; resolves with its exit code. */
|
|
55
|
+
function runProcess(spawnImpl, file, args, { signal, onOutput, stdin = 'ignore' }) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const child = spawnImpl(file, args, { stdio: [stdin, 'pipe', 'pipe'] })
|
|
58
|
+
let spawnError = null
|
|
59
|
+
const onAbort = () => killTree(child)
|
|
60
|
+
if (signal?.aborted) onAbort()
|
|
61
|
+
else signal?.addEventListener('abort', onAbort, { once: true })
|
|
62
|
+
child.stdout?.on('data', (chunk) => onOutput?.({ stream: 'stdout', text: chunk.toString('utf8') }))
|
|
63
|
+
child.stderr?.on('data', (chunk) => onOutput?.({ stream: 'stderr', text: chunk.toString('utf8') }))
|
|
64
|
+
child.on('error', (error) => { spawnError = error })
|
|
65
|
+
child.on('close', (exitCode) => {
|
|
66
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
67
|
+
if (spawnError !== null) reject(Object.assign(new Error(`无法启动 ${file}:${spawnError.message}`), { code: 'spawn-error' }))
|
|
68
|
+
else resolve({ exitCode })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Create the ssh transport. `spawnImpl` is injectable for tests. */
|
|
74
|
+
export function createSshTransport({ spawnImpl = spawnChild } = {}) {
|
|
75
|
+
let rsyncProbe = null
|
|
76
|
+
const hasRsync = () => {
|
|
77
|
+
if (rsyncProbe === null) {
|
|
78
|
+
rsyncProbe = runProcess(spawnImpl, 'rsync', ['--version'], {}).then(
|
|
79
|
+
({ exitCode }) => exitCode === 0,
|
|
80
|
+
() => false,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return rsyncProbe
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
hasRsync,
|
|
88
|
+
|
|
89
|
+
/** Execute one command on the remote host through a login shell. */
|
|
90
|
+
exec(environment, command, { signal, onOutput } = {}) {
|
|
91
|
+
return runProcess(spawnImpl, 'ssh', [...sshBaseArgs(environment), remoteCommand(command)], { signal, onOutput })
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/** Sync a local directory to the remote target (rsync when available, else tar pipe). */
|
|
95
|
+
async sync(environment, { sourcePath, deleteMode = false, signal, onOutput }) {
|
|
96
|
+
if (await hasRsync()) {
|
|
97
|
+
const args = buildRsyncArgs(environment, { source: sourcePath, target: environment.sync.targetPath, excludes: environment.sync.exclude ?? [], deleteMode })
|
|
98
|
+
const result = await runProcess(spawnImpl, 'rsync', args, { signal, onOutput })
|
|
99
|
+
return { mode: 'rsync', exitCode: result.exitCode }
|
|
100
|
+
}
|
|
101
|
+
const { tarArgs, remote } = buildTarSyncCommands(environment, { source: sourcePath, target: environment.sync.targetPath, excludes: environment.sync.exclude ?? [] })
|
|
102
|
+
const tar = spawnImpl('tar', tarArgs, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
103
|
+
const ssh = spawnImpl('ssh', [...sshBaseArgs(environment), remote], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
104
|
+
let sshExit = null
|
|
105
|
+
let sshError = null
|
|
106
|
+
const onAbort = () => { killTree(tar); killTree(ssh) }
|
|
107
|
+
if (signal?.aborted) onAbort()
|
|
108
|
+
else signal?.addEventListener('abort', onAbort, { once: true })
|
|
109
|
+
ssh.stdout?.on('data', (chunk) => onOutput?.({ stream: 'stdout', text: chunk.toString('utf8') }))
|
|
110
|
+
ssh.stderr?.on('data', (chunk) => onOutput?.({ stream: 'stderr', text: chunk.toString('utf8') }))
|
|
111
|
+
tar.on('error', () => { ssh.stdin?.end?.() })
|
|
112
|
+
ssh.on('error', (error) => { sshError = error })
|
|
113
|
+
tar.stdout.on('data', (chunk) => ssh.stdin.write(chunk))
|
|
114
|
+
tar.stdout.on('end', () => ssh.stdin.end())
|
|
115
|
+
const sshClosed = new Promise((resolve) => ssh.on('close', (code) => { sshExit = code; resolve() }))
|
|
116
|
+
await sshClosed
|
|
117
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
118
|
+
if (sshError !== null) throw Object.assign(new Error(`无法启动 ssh:${sshError.message}`), { code: 'spawn-error' })
|
|
119
|
+
return { mode: 'tar', exitCode: sshExit }
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** Verify connectivity and key auth with a no-op remote command. */
|
|
123
|
+
testConnection(environment, { signal, onOutput } = {}) {
|
|
124
|
+
return this.exec(environment, 'true', { signal, onOutput })
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
package/tool-impl.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Agent 工具的动作逻辑(纯函数,不依赖 DSH 包,便于单测)。
|
|
2
|
+
// 生产装配见 tools.js:defineTool 包装 + approveEscalation 审批。
|
|
3
|
+
|
|
4
|
+
export const TOOL_NAME = 'kunpeng_execution'
|
|
5
|
+
export const TOOL_ACTIONS = ['list_environments', 'list_recipes', 'run', 'cancel']
|
|
6
|
+
|
|
7
|
+
export function toolParameters() {
|
|
8
|
+
return {
|
|
9
|
+
action: { type: 'string', required: true, enum: [...TOOL_ACTIONS], description: '执行操作。先 list_environments / list_recipes 获取标识。' },
|
|
10
|
+
environment: { type: 'string', description: '环境 id,list_recipes 与 run 必填。' },
|
|
11
|
+
recipe: { type: 'string', description: '配方 id,run 必填。' },
|
|
12
|
+
workspaceId: { type: 'string', description: 'DSH 工作区 id;代码同步与本地执行需要,远程不同步的配方可不填。' },
|
|
13
|
+
sync: { type: 'boolean', description: 'run 前强制同步代码;缺省用配方自身的 syncBefore 设置。' },
|
|
14
|
+
timeoutMs: { type: 'number', description: 'run 的超时毫秒数;缺省用配方默认值。' },
|
|
15
|
+
runId: { type: 'string', description: 'cancel 必填:要取消的运行 id。' },
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requireString(args, field) {
|
|
20
|
+
const value = args[field]
|
|
21
|
+
if (typeof value !== 'string' || value === '') throw new Error(`${args.action} 需要参数 ${field}`)
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build the tool execute function over a kunpengExecution service. */
|
|
26
|
+
export function createExecuteAction(service, { approve, authorize, audited } = {}) {
|
|
27
|
+
return async function executeToolAction(args) {
|
|
28
|
+
const action = args?.action
|
|
29
|
+
await authorize?.()
|
|
30
|
+
if (!TOOL_ACTIONS.includes(action)) throw new Error(`未知动作:${String(action)}`)
|
|
31
|
+
if (action === 'list_environments') {
|
|
32
|
+
return JSON.stringify({ environments: service.listEnvironments() }, null, 2)
|
|
33
|
+
}
|
|
34
|
+
if (action === 'list_recipes') {
|
|
35
|
+
const environmentId = requireString(args, 'environment')
|
|
36
|
+
return JSON.stringify({ recipes: service.listRecipes(environmentId) }, null, 2)
|
|
37
|
+
}
|
|
38
|
+
if (action === 'run') {
|
|
39
|
+
const environmentId = requireString(args, 'environment')
|
|
40
|
+
const recipeId = requireString(args, 'recipe')
|
|
41
|
+
await approve?.('鲲鹏执行', `kunpeng_execution run ${environmentId}/${recipeId}${args.sync === true ? '(含代码同步)' : ''}`)
|
|
42
|
+
const run = async () => {
|
|
43
|
+
const handle = await service.run({
|
|
44
|
+
environmentId,
|
|
45
|
+
recipeId,
|
|
46
|
+
workspaceId: args.workspaceId,
|
|
47
|
+
sync: args.sync,
|
|
48
|
+
timeoutMs: args.timeoutMs,
|
|
49
|
+
})
|
|
50
|
+
return handle.result
|
|
51
|
+
}
|
|
52
|
+
const event = {
|
|
53
|
+
action: 'kunpeng.run',
|
|
54
|
+
target: { type: 'recipe', id: `${environmentId}/${recipeId}` },
|
|
55
|
+
workspaceId: args.workspaceId,
|
|
56
|
+
outcome: (value) => value?.result?.status === 'succeeded' ? 'succeeded' : value?.result?.status === 'cancelled' ? 'cancelled' : 'failed',
|
|
57
|
+
}
|
|
58
|
+
const evidence = audited === undefined ? await run() : await audited(event, run)
|
|
59
|
+
return JSON.stringify(evidence, null, 2)
|
|
60
|
+
}
|
|
61
|
+
const runId = requireString(args, 'runId')
|
|
62
|
+
const cancel = async () => service.cancel(runId)
|
|
63
|
+
const result = audited === undefined ? await cancel() : await audited({ action: 'kunpeng.cancel', target: { type: 'run', id: runId } }, cancel)
|
|
64
|
+
return JSON.stringify(result)
|
|
65
|
+
}
|
|
66
|
+
}
|
package/tools.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Agent 工具半区:kunpeng_execution。挂载方式与 dsh-plugin-manager 的工具行一致
|
|
2
|
+
// (cordis.patch.yml 中 '<pkg>/tools' 独立行)。run 动作影响远程服务器或本机,
|
|
3
|
+
// 执行前须经 danger-full-access 审批(与 plugin_manager 工具同一模式)。
|
|
4
|
+
|
|
5
|
+
import { approveEscalation } from '@deepseek-ai/dsh-sandbox'
|
|
6
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
7
|
+
|
|
8
|
+
import { createExecuteAction, TOOL_NAME, toolParameters } from './tool-impl.js'
|
|
9
|
+
|
|
10
|
+
/** Required services for the execution tool. */
|
|
11
|
+
export const inject = ['tools', 'kunpengExecution', 'sandboxPolicy', 'authManagement']
|
|
12
|
+
|
|
13
|
+
/** Register the kunpeng_execution agent tool. */
|
|
14
|
+
export function apply(ctx) {
|
|
15
|
+
ctx.tools.register(defineTool({
|
|
16
|
+
name: TOOL_NAME,
|
|
17
|
+
description: '在鲲鹏执行环境上运行配方(远程 SSH 编译/单元测试/benchmark/系统信息,或本地、或明确标记的模拟环境),返回结构化执行证据。先 list_environments、list_recipes 获取可用标识;远程环境需在插件 config 中配置 host/user/identityFile。run 在真实环境执行命令并可能修改远程状态,每次调用都需要审批。',
|
|
18
|
+
parameters: toolParameters(),
|
|
19
|
+
output: {
|
|
20
|
+
schema: { type: 'string' },
|
|
21
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
22
|
+
},
|
|
23
|
+
async execute(args, exec) {
|
|
24
|
+
const policy = ctx.sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
|
25
|
+
const executeAction = createExecuteAction(ctx.kunpengExecution, {
|
|
26
|
+
authorize: () => ctx.authManagement.requireActor(),
|
|
27
|
+
audited: (event, task) => ctx.authManagement.executeAudited(event, task),
|
|
28
|
+
approve: async (subject, justification) => {
|
|
29
|
+
await approveEscalation({
|
|
30
|
+
requestedMode: 'danger-full-access',
|
|
31
|
+
effectiveMode: policy.mode,
|
|
32
|
+
subject,
|
|
33
|
+
justification,
|
|
34
|
+
}, {
|
|
35
|
+
approver: ctx.get('approval'),
|
|
36
|
+
agent: exec.agent,
|
|
37
|
+
callId: exec.callId,
|
|
38
|
+
toolName: TOOL_NAME,
|
|
39
|
+
signal: exec.signal,
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
return executeAction(args)
|
|
44
|
+
},
|
|
45
|
+
}))
|
|
46
|
+
}
|