dsh-remote-agent 0.4.3 → 0.6.0
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 +69 -0
- package/README.en.md +49 -1
- package/README.md +40 -1
- package/SECURITY.md +24 -0
- package/lib/hub/node-registry.js +73 -0
- package/lib/hub/registry-ingest.js +18 -0
- package/lib/hub/tools.js +261 -4
- package/lib/node/fs-service.js +504 -0
- package/lib/node/index.js +44 -2
- package/lib/node/task-engine.js +51 -4
- package/lib/node/transport.js +2 -0
- package/lib/protocol.js +13 -0
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +18 -3
- package/scripts/diag-inject.mjs +3 -3
package/lib/hub/tools.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* 主机侧 remote_* 工具集(零外部依赖)。
|
|
3
3
|
*
|
|
4
4
|
* 设计原则:**主电脑只消费 handoff,不消费副机的原始日志。**
|
|
5
|
-
*
|
|
5
|
+
* 工具的职责边界刻意切得很窄:
|
|
6
6
|
*
|
|
7
|
+
* 任务链路:
|
|
7
8
|
* remote_nodes 看有哪些副机在线(信息汇聚的入口)
|
|
8
9
|
* remote_task 派一条任务(幂等 taskId,立即返回,不阻塞等结果)
|
|
9
10
|
* remote_status 看一条任务现在到哪了(进度尾巴 + 日志尾巴)
|
|
@@ -11,6 +12,13 @@
|
|
|
11
12
|
* remote_manifest 取产物清单(先看清单,再决定拉什么)
|
|
12
13
|
* remote_pull 显式拉取成品(只有 role:final 能过河)
|
|
13
14
|
* remote_cancel 打断一条任务
|
|
15
|
+
* remote_purge 清理 hub 侧任务视图(逃生舱)
|
|
16
|
+
*
|
|
17
|
+
* 远程文件(提案 #25,只读,走 fs/req↔fs/res 独立通道,不进任务队列):
|
|
18
|
+
* remote_ls 列副机目录(分页)
|
|
19
|
+
* remote_read 读文件(文本按行分段 / 二进制只回元数据)
|
|
20
|
+
* remote_grep 副机本地全文搜索(纯 JS,不 spawn)
|
|
21
|
+
* remote_tree 目录树摘要(扫描在副机跑完只回结构)
|
|
14
22
|
*
|
|
15
23
|
* 所有 execute 都返回普通 JSON(output.schema 描述它),错误以
|
|
16
24
|
* RemoteAgentError 抛出,由框架统一materialize 成错误结果。
|
|
@@ -26,6 +34,9 @@ import {
|
|
|
26
34
|
newTaskId,
|
|
27
35
|
validateTaskStart,
|
|
28
36
|
} from '../protocol.js'
|
|
37
|
+
// exec/agent 分开的默认超时(提案 #26-1)——与副机兜底**同源**,
|
|
38
|
+
// 避免"工具说 10 分钟、副机兜底 1 小时"的分裂。
|
|
39
|
+
import { AGENT_DEFAULT_TIMEOUT_MS, EXEC_DEFAULT_TIMEOUT_MS } from '../node/task-engine.js'
|
|
29
40
|
// 视图投影共享层:与 UI 路由用**同一份**白名单(见 lib/hub/views.js 的说明)。
|
|
30
41
|
import { handoffView } from './views.js'
|
|
31
42
|
|
|
@@ -340,7 +351,7 @@ export function handoffSummaryText(task) {
|
|
|
340
351
|
*/
|
|
341
352
|
export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults = {} }) {
|
|
342
353
|
const disposers = []
|
|
343
|
-
const
|
|
354
|
+
const explicitTimeoutMs = num(defaults.timeoutMs)
|
|
344
355
|
|
|
345
356
|
/**
|
|
346
357
|
* 注册一个工具,并给它的 `execute` **统一套上返回值净化**。
|
|
@@ -411,6 +422,18 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
|
|
|
411
422
|
at: { type: 'number' },
|
|
412
423
|
},
|
|
413
424
|
},
|
|
425
|
+
// 队列健康快照(提案 #26-3):副机未上报(旧版本)时为 null,
|
|
426
|
+
// 由 dropNullForSchema 剥掉。字段全部有值(0/false 兜底)。
|
|
427
|
+
queue: {
|
|
428
|
+
type: 'object',
|
|
429
|
+
properties: {
|
|
430
|
+
queued: { type: 'number' },
|
|
431
|
+
running: { type: 'number' },
|
|
432
|
+
maxConcurrent: { type: 'number' },
|
|
433
|
+
oldestQueuedMs: { type: 'number' },
|
|
434
|
+
starved: { type: 'boolean' },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
414
437
|
},
|
|
415
438
|
required: ['nodeId', 'online'],
|
|
416
439
|
},
|
|
@@ -433,6 +456,15 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
|
|
|
433
456
|
if (n.resource) {
|
|
434
457
|
lines.push(` 资源: CPU ${n.resource.cpuPct ?? '—'}% · 内存 ${n.resource.memUsedMb ?? '—'}/${n.resource.memTotalMb ?? '—'}MB · load1 ${n.resource.load1 ?? '—'}`)
|
|
435
458
|
}
|
|
459
|
+
// 队列健康(提案 #26-3):starved 是"该去副机看看卡在哪"的强信号
|
|
460
|
+
if (n.queue) {
|
|
461
|
+
if (n.queue.starved === true) {
|
|
462
|
+
const waited = Math.round((n.queue.oldestQueuedMs ?? 0) / 60000)
|
|
463
|
+
lines.push(` ⚠ 队列疑似堵死:${n.queue.running}/${n.queue.maxConcurrent} 并发占满,${n.queue.queued} 条排队,最久已等 ${waited >= 1 ? `${waited} 分钟` : `${n.queue.oldestQueuedMs}ms`}。用 remote_status 逐条查 running 任务,必要时 remote_cancel。`)
|
|
464
|
+
} else if ((n.queue.queued ?? 0) > 0) {
|
|
465
|
+
lines.push(` 队列: ${n.queue.running}/${n.queue.maxConcurrent} 在跑,${n.queue.queued} 条排队`)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
436
468
|
}
|
|
437
469
|
return lines.join('\n')
|
|
438
470
|
}),
|
|
@@ -471,7 +503,7 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
|
|
|
471
503
|
additionalProperties: false,
|
|
472
504
|
},
|
|
473
505
|
outputs: { type: 'array', items: { type: 'string' }, description: '期望产出物的 glob(相对工作区),用于产物清单筛选。' },
|
|
474
|
-
timeoutMs: { type: 'number', description:
|
|
506
|
+
timeoutMs: { type: 'number', description: `副机侧任务超时(毫秒)。缺省按模式:exec ${EXEC_DEFAULT_TIMEOUT_MS / 60000} 分钟、agent ${AGENT_DEFAULT_TIMEOUT_MS / 60000} 分钟。` },
|
|
475
507
|
denyPaths: { type: 'array', items: { type: 'string' }, description: '副机侧额外禁止访问的路径(绝对路径)。' },
|
|
476
508
|
artifactRules: {
|
|
477
509
|
type: 'object',
|
|
@@ -581,7 +613,10 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
|
|
|
581
613
|
// 只有消费者没有生产者,用户无法覆盖内置的产物角色判定)
|
|
582
614
|
artifactRules: args?.artifactRules,
|
|
583
615
|
constraints: {
|
|
584
|
-
|
|
616
|
+
// 提案 #26-1:缺省按模式分开(exec 10min / agent 1h),与副机兜底
|
|
617
|
+
// 同源常量;defaults.timeoutMs(配置显式指定)优先于一切缺省。
|
|
618
|
+
timeoutMs: num(args?.timeoutMs) ?? explicitTimeoutMs
|
|
619
|
+
?? (v.spec.mode === TASK_MODE.EXEC ? EXEC_DEFAULT_TIMEOUT_MS : AGENT_DEFAULT_TIMEOUT_MS),
|
|
585
620
|
denyPaths: Array.isArray(args?.denyPaths) ? args.denyPaths : [],
|
|
586
621
|
},
|
|
587
622
|
origin: 'hub',
|
|
@@ -1016,6 +1051,228 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
|
|
|
1016
1051
|
},
|
|
1017
1052
|
})
|
|
1018
1053
|
|
|
1054
|
+
// ── remote_ls / remote_read / remote_grep / remote_tree(提案 #25)──
|
|
1055
|
+
//
|
|
1056
|
+
// 四个只读文件工具共用同一条 RPC 通道:registry.fsRequest(nodeId, op, params)
|
|
1057
|
+
// (fs/req ↔ fs/res,15s 超时,断连即失败)。与任务链路的本质区别:
|
|
1058
|
+
// **不进副机任务队列、不 spawn、不占并发槽** —— 一条 grep 卡住锁死队列
|
|
1059
|
+
// 的形态在这里结构性不存在(fs-service 自带 10s/2000 文件等扫描上限)。
|
|
1060
|
+
//
|
|
1061
|
+
// 副机 reject 的形态是 { code, message }(node/index.js handleFsReq 从
|
|
1062
|
+
// FsError.code 透传),这里转成 RemoteAgentError 让 DSH 框架统一物化,
|
|
1063
|
+
// 同时保留 code(workspace-denied / artifact-missing / artifact-too-large)
|
|
1064
|
+
// —— 调用方可以区分"越界""不存在""太大"三种最常见的失败。
|
|
1065
|
+
const fsOp = (name, description, parameters, output, op) => {
|
|
1066
|
+
reg({
|
|
1067
|
+
name,
|
|
1068
|
+
description,
|
|
1069
|
+
parameters,
|
|
1070
|
+
output,
|
|
1071
|
+
async execute(args) {
|
|
1072
|
+
// resolveNodeId 会抛 unknown node / node offline(RemoteAgentError)
|
|
1073
|
+
const nodeId = resolveNodeId(registry, args?.node)
|
|
1074
|
+
// 只有 node 是路由概念不透传;path 是副机 op 的正式参数(fs-service 签名)
|
|
1075
|
+
const params = { ...(args ?? {}) }
|
|
1076
|
+
delete params.node
|
|
1077
|
+
try {
|
|
1078
|
+
const result = await registry.fsRequest(nodeId, op, params)
|
|
1079
|
+
return { node: nodeId, ...(result && typeof result === 'object' ? result : {}) }
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
const code = typeof err?.code === 'string' ? err.code : ERROR_CODE.INTERNAL
|
|
1082
|
+
throw new RemoteAgentError(`remote ${op} on ${nodeId} failed: ${String(err?.message ?? err)}`, code)
|
|
1083
|
+
}
|
|
1084
|
+
},
|
|
1085
|
+
})
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// 通用参数块:path 必填(副机上的绝对路径);node 可选(自动路由)。
|
|
1089
|
+
const pathParam = { type: 'string', description: '副机上的绝对路径,必须落在该节点允许的 roots 之内(超出会被副机以 workspace-denied 拒绝)。' }
|
|
1090
|
+
const nodeParam = { type: 'string', description: '目标节点 id(或主机名)。省略时自动选在线节点。' }
|
|
1091
|
+
const fsReq = (props, required) => ({ type: 'object', properties: { node: nodeParam, ...props }, required: ['path', ...required], additionalProperties: false })
|
|
1092
|
+
|
|
1093
|
+
fsOp(
|
|
1094
|
+
'remote_ls',
|
|
1095
|
+
'列出远程副机上的一个目录(单层、分页,按名称/大小/修改时间/类型)。用于"看看副机这个文件夹里有什么"。安全模型:只读;路径必须在该节点允许的 roots 内;denyPaths 生效;符号链接只列条目不解引用。',
|
|
1096
|
+
fsReq({
|
|
1097
|
+
path: pathParam,
|
|
1098
|
+
limit: { type: 'number', description: '单页条目数(1-2000,默认 500)。' },
|
|
1099
|
+
offset: { type: 'number', description: '起始偏移(配合 limit 翻页,默认 0)。' },
|
|
1100
|
+
}, []),
|
|
1101
|
+
{
|
|
1102
|
+
schema: {
|
|
1103
|
+
type: 'object',
|
|
1104
|
+
properties: {
|
|
1105
|
+
node: { type: 'string' },
|
|
1106
|
+
path: { type: 'string' },
|
|
1107
|
+
total: { type: 'number' },
|
|
1108
|
+
offset: { type: 'number' },
|
|
1109
|
+
truncated: { type: 'boolean' },
|
|
1110
|
+
entries: {
|
|
1111
|
+
type: 'array',
|
|
1112
|
+
items: {
|
|
1113
|
+
type: 'object',
|
|
1114
|
+
properties: {
|
|
1115
|
+
name: { type: 'string' },
|
|
1116
|
+
type: { type: 'string' },
|
|
1117
|
+
size: { type: 'number' },
|
|
1118
|
+
mtimeMs: { type: 'number' },
|
|
1119
|
+
},
|
|
1120
|
+
required: ['name', 'type'],
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
required: ['node', 'path', 'entries'],
|
|
1125
|
+
},
|
|
1126
|
+
render: renderText((_args, value) => {
|
|
1127
|
+
const entries = arr(value?.entries)
|
|
1128
|
+
if (entries.length === 0) return `${value?.path ?? '?'} 是空目录(或条目被 denyPaths 全部过滤)。`
|
|
1129
|
+
const lines = [`${value.path}(共 ${value.total} 项${value.truncated ? ',本页截断' : ''}):`, '']
|
|
1130
|
+
for (const e of entries.slice(0, 200)) {
|
|
1131
|
+
lines.push(`- ${e.type === 'dir' ? '[目录]' : e.type === 'link' ? '[链接]' : '[文件]'} ${e.name}${e.type === 'file' ? ` (${human(e.size)})` : ''}`)
|
|
1132
|
+
}
|
|
1133
|
+
if (value.truncated) lines.push('', '还有更多:用 offset/limit 翻页。')
|
|
1134
|
+
return lines.join('\n')
|
|
1135
|
+
}),
|
|
1136
|
+
},
|
|
1137
|
+
'ls',
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
fsOp(
|
|
1141
|
+
'remote_read',
|
|
1142
|
+
'读取远程副机上的一个文件。文本文件(≤8MB)按行分段返回(offset+limit 翻页,单次响应 ≤256KB,超长行截断);二进制文件只回元数据(sha256、大小、头部 hex 预览),不回原始字节。用于"读一眼副机上的某个文件"。只读;路径必须在 roots 内。',
|
|
1143
|
+
fsReq({
|
|
1144
|
+
path: pathParam,
|
|
1145
|
+
offset: { type: 'number', description: '起始行号(0 起,默认 0)。' },
|
|
1146
|
+
limit: { type: 'number', description: '本次读取行数(1-5000,默认 500)。' },
|
|
1147
|
+
}, []),
|
|
1148
|
+
{
|
|
1149
|
+
schema: {
|
|
1150
|
+
type: 'object',
|
|
1151
|
+
properties: {
|
|
1152
|
+
node: { type: 'string' },
|
|
1153
|
+
path: { type: 'string' },
|
|
1154
|
+
binary: { type: 'boolean' },
|
|
1155
|
+
size: { type: 'number' },
|
|
1156
|
+
sha256: { type: 'string' },
|
|
1157
|
+
ext: { type: 'string' },
|
|
1158
|
+
head: { type: 'string' },
|
|
1159
|
+
totalLines: { type: 'number' },
|
|
1160
|
+
offset: { type: 'number' },
|
|
1161
|
+
truncated: { type: 'boolean' },
|
|
1162
|
+
lines: { type: 'array', items: { type: 'string' } },
|
|
1163
|
+
},
|
|
1164
|
+
required: ['node', 'path', 'binary'],
|
|
1165
|
+
},
|
|
1166
|
+
render: renderText((_args, value) => {
|
|
1167
|
+
if (value?.binary === true) {
|
|
1168
|
+
return [
|
|
1169
|
+
`二进制文件 ${value.path}(${human(value.size)})`,
|
|
1170
|
+
`sha256: ${value.sha256 ?? 'n/a'}`,
|
|
1171
|
+
value.head ? `头部: ${String(value.head).slice(0, 64)}…` : '',
|
|
1172
|
+
].filter((l) => l !== '').join('\n')
|
|
1173
|
+
}
|
|
1174
|
+
const lines = arr(value?.lines)
|
|
1175
|
+
if (lines.length === 0) return `${value?.path ?? '?'} 无内容(空文件或翻页越界)。`
|
|
1176
|
+
const out = [`${value.path} 第 ${value.offset} 行起(全文 ${value.totalLines} 行${value.truncated ? ',截断' : ''}):`, '']
|
|
1177
|
+
for (const l of lines.slice(0, 400)) out.push(`│ ${l}`)
|
|
1178
|
+
if (value.truncated) out.push('', `还有更多:用 offset=${(value.offset ?? 0) + lines.length} 继续读。`)
|
|
1179
|
+
return out.join('\n')
|
|
1180
|
+
}),
|
|
1181
|
+
},
|
|
1182
|
+
'read',
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
fsOp(
|
|
1186
|
+
'remote_grep',
|
|
1187
|
+
'在远程副机上做全文搜索:递归扫描目录下的文本文件(可按 glob 过滤文件名),返回命中的文件/行号/内容。扫描在副机本地完成,只回命中结果——50GB 目录也不搬数据。上限:2000 文件 / 单文件 2MB / 命中 ≤200 / 总时长 10s,达到上限如实报告 truncated。只读;路径必须在 roots 内。',
|
|
1188
|
+
fsReq({
|
|
1189
|
+
path: pathParam,
|
|
1190
|
+
pattern: { type: 'string', description: '搜索内容。默认按字面量匹配;regex:true 时按正则解释。' },
|
|
1191
|
+
regex: { type: 'boolean', description: '是否把 pattern 当正则(默认 false=字面量)。' },
|
|
1192
|
+
ignoreCase: { type: 'boolean', description: '忽略大小写(默认 true)。' },
|
|
1193
|
+
glob: { type: 'string', description: '文件名过滤(如 "*.txt"、"log*"),默认扫全部文本文件。' },
|
|
1194
|
+
maxMatches: { type: 'number', description: '命中上限(1-200,默认 200)。想快就调小。' },
|
|
1195
|
+
}, ['pattern']),
|
|
1196
|
+
{
|
|
1197
|
+
schema: {
|
|
1198
|
+
type: 'object',
|
|
1199
|
+
properties: {
|
|
1200
|
+
node: { type: 'string' },
|
|
1201
|
+
path: { type: 'string' },
|
|
1202
|
+
pattern: { type: 'string' },
|
|
1203
|
+
filesScanned: { type: 'number' },
|
|
1204
|
+
filesSkipped: { type: 'number' },
|
|
1205
|
+
matchCount: { type: 'number' },
|
|
1206
|
+
truncated: { type: 'boolean' },
|
|
1207
|
+
truncatedBy: { type: 'string' },
|
|
1208
|
+
matches: {
|
|
1209
|
+
type: 'array',
|
|
1210
|
+
items: {
|
|
1211
|
+
type: 'object',
|
|
1212
|
+
properties: {
|
|
1213
|
+
file: { type: 'string' },
|
|
1214
|
+
line: { type: 'number' },
|
|
1215
|
+
text: { type: 'string' },
|
|
1216
|
+
},
|
|
1217
|
+
required: ['file', 'line'],
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1221
|
+
required: ['node', 'path', 'matches'],
|
|
1222
|
+
},
|
|
1223
|
+
render: renderText((_args, value) => {
|
|
1224
|
+
const matches = arr(value?.matches)
|
|
1225
|
+
if (matches.length === 0) {
|
|
1226
|
+
return `无命中:pattern="${value?.pattern ?? ''}"(扫描 ${value?.filesScanned ?? 0} 个文件,跳过 ${value?.filesSkipped ?? 0} 个)。`
|
|
1227
|
+
}
|
|
1228
|
+
const lines = [
|
|
1229
|
+
`"${value.pattern}" 命中 ${value.matchCount} 处(扫描 ${value.filesScanned} 文件${value.truncated ? `,按${value.truncatedBy === 'time' ? '超时' : '命中上限'}截断` : ''}):`,
|
|
1230
|
+
'',
|
|
1231
|
+
]
|
|
1232
|
+
for (const m of matches.slice(0, 100)) lines.push(`${m.file}:${m.line}: ${String(m.text ?? '').slice(0, 300)}`)
|
|
1233
|
+
if (value.truncated) lines.push('', '(结果被截断——缩小搜索范围或减小 maxMatches 可拿到更完整的结果)')
|
|
1234
|
+
return lines.join('\n')
|
|
1235
|
+
}),
|
|
1236
|
+
},
|
|
1237
|
+
'grep',
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
fsOp(
|
|
1241
|
+
'remote_tree',
|
|
1242
|
+
'查看远程副机上一个目录树的结构摘要(深度与条目数可配,默认 3 层 / 最多 2000 条)。扫描在副机本地完成,只回结构不回内容——适合"50GB 文件夹里到底长什么样"这类问题。只读;路径必须在 roots 内;符号链接只列条目不深入。',
|
|
1243
|
+
fsReq({
|
|
1244
|
+
path: pathParam,
|
|
1245
|
+
depth: { type: 'number', description: '最大深度(1-6,默认 3)。' },
|
|
1246
|
+
}, []),
|
|
1247
|
+
{
|
|
1248
|
+
schema: {
|
|
1249
|
+
type: 'object',
|
|
1250
|
+
properties: {
|
|
1251
|
+
node: { type: 'string' },
|
|
1252
|
+
path: { type: 'string' },
|
|
1253
|
+
depth: { type: 'number' },
|
|
1254
|
+
entries: { type: 'number' },
|
|
1255
|
+
maxEntries: { type: 'number' },
|
|
1256
|
+
truncated: { type: 'boolean' },
|
|
1257
|
+
items: { type: 'array', items: { type: 'object' } },
|
|
1258
|
+
},
|
|
1259
|
+
required: ['node', 'path', 'items'],
|
|
1260
|
+
},
|
|
1261
|
+
render: renderText((_args, value) => {
|
|
1262
|
+
const renderItems = (items, prefix, sink) => {
|
|
1263
|
+
for (const it of arr(items).slice(0, 120)) {
|
|
1264
|
+
sink.push(`${prefix}${it.type === 'dir' ? '📁' : '·'} ${it.name}`)
|
|
1265
|
+
if (it.type === 'dir' && prefix.length <= 6) renderItems(it.children, `${prefix} `, sink)
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const lines = [`${value?.path ?? '?'}(深度 ${value?.depth},${value?.entries} 条目${value?.truncated ? ',截断' : ''})`, '']
|
|
1269
|
+
renderItems(value?.items, '', lines)
|
|
1270
|
+
return lines.join('\n')
|
|
1271
|
+
}),
|
|
1272
|
+
},
|
|
1273
|
+
'tree',
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1019
1276
|
return disposers
|
|
1020
1277
|
}
|
|
1021
1278
|
|