@jacksontian/kite-worker 0.1.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/config.example.yaml +43 -0
- package/config.js +259 -0
- package/package.json +39 -0
- package/scripts/hello.sh +8 -0
- package/worker.js +522 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# kite Worker 配置模板(Mac Mini)。
|
|
2
|
+
# 复制为实际配置文件(如 worker.yaml)后填写,启动时显式传入:
|
|
3
|
+
# node worker.js --config /path/to/worker.yaml
|
|
4
|
+
# 实际配置文件含密钥,不要提交到 git。
|
|
5
|
+
|
|
6
|
+
# 必填:云端 API 根地址(https;无证书的 VPS 可临时用 http,启动时会打印明文传输警告)
|
|
7
|
+
apiUrl: https://your-vps.example.com
|
|
8
|
+
|
|
9
|
+
# 必填:与云端 server 相同的签名密钥(永不在网络上传输,只用于本地 HMAC 签名)
|
|
10
|
+
apiSecret: 换成你生成的密钥
|
|
11
|
+
|
|
12
|
+
# Worker 标识,默认取 hostname
|
|
13
|
+
workerId: mac-mini
|
|
14
|
+
|
|
15
|
+
# 兜底轮询间隔(秒);通知通道在线时任务即时送达,此值仅断线兜底用
|
|
16
|
+
pollIntervalSec: 5
|
|
17
|
+
|
|
18
|
+
# script 任务的白名单脚本目录;建议写绝对路径;相对路径相对启动工作目录解析
|
|
19
|
+
scriptsDir: ./scripts
|
|
20
|
+
|
|
21
|
+
# agent / shell / pty 任务的工作根目录;~ 展开为 HOME,同样建议绝对路径
|
|
22
|
+
workspaceDir: ~/agent-workspace
|
|
23
|
+
|
|
24
|
+
# Agent 注册表:commands 为「Agent 名: 命令模板」映射,{prompt} 会被替换为
|
|
25
|
+
# $RW_PROMPT。注意:占位符必须放在引号内(否则带空格的 prompt 会被 shell 拆词)。
|
|
26
|
+
# Agent 列表由心跳上报,手机端自动出现在下拉选择中
|
|
27
|
+
agents:
|
|
28
|
+
# 任务未指定 Agent 时的默认值,必须存在于 commands 中
|
|
29
|
+
default: claude
|
|
30
|
+
commands:
|
|
31
|
+
claude: claude -p "{prompt}"
|
|
32
|
+
qoder: qodercli -p "{prompt}"
|
|
33
|
+
|
|
34
|
+
# chat 任务的可选钩子,{message} 会被替换为 $RW_MESSAGE(同样要放引号内);
|
|
35
|
+
# 留空则只记入 workspace 的 inbox.log
|
|
36
|
+
chatHook: ""
|
|
37
|
+
|
|
38
|
+
# 单任务超时(秒)
|
|
39
|
+
taskTimeoutSec: 1800
|
|
40
|
+
|
|
41
|
+
# pty 交互终端会话空闲(无任何输入输出)回收秒数;
|
|
42
|
+
# 交互会话不走 taskTimeoutSec,只按空闲超时回收
|
|
43
|
+
ptyIdleTimeoutSec: 3600
|
package/config.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mac Mini Worker 配置加载与校验。
|
|
3
|
+
*
|
|
4
|
+
* 只认 --config 指定的 YAML 配置文件(不再读取环境变量或 .env 文件):
|
|
5
|
+
* node worker.js --config /path/to/worker.yaml
|
|
6
|
+
* scriptsDir/workspaceDir 支持 ~ 展开;相对路径相对启动工作目录解析
|
|
7
|
+
* (launchd 请在 plist 里固定 WorkingDirectory),建议直接写绝对路径。
|
|
8
|
+
* 结构与类型校验由 zod schema 完成(CONFIG_SCHEMA):未知配置项、
|
|
9
|
+
* 类型错误、缺必填项一次性报全后 exit 1;两个目录的可创建/可写校验
|
|
10
|
+
* 是运行时行为,仍在校验通过后单独执行。模板见包内 config.example.yaml。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import YAML from 'yaml';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
/** 解析启动参数;--config 必填,缺失/重复/取不到值立即退出 */
|
|
20
|
+
function parseArgs() {
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const values = [];
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
if (args[i] === '--config' || args[i] === '-c') {
|
|
25
|
+
values.push(args[i + 1]);
|
|
26
|
+
i++;
|
|
27
|
+
} else if (args[i].startsWith('--config=')) {
|
|
28
|
+
values.push(args[i].slice('--config='.length));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (values.length === 0) {
|
|
32
|
+
console.error('[worker] 缺少启动参数:node worker.js --config <配置文件路径>');
|
|
33
|
+
console.error('[worker] 配置模板见包内 config.example.yaml');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
if (values.length > 1) {
|
|
37
|
+
console.error('[worker] --config 只能指定一次');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const file = String(values[0] ?? '').trim();
|
|
41
|
+
if (!file || file.startsWith('-')) {
|
|
42
|
+
console.error('[worker] --config 后需要给出配置文件路径');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
return path.resolve(file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fail(errors, tag) {
|
|
49
|
+
console.error(`[${tag}] 配置错误,无法启动:`);
|
|
50
|
+
for (const e of errors) console.error(' - ' + e);
|
|
51
|
+
console.error(`[${tag}] 请检查 --config 指定的 YAML 文件,模板见包内 config.example.yaml`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPlainObject(v) {
|
|
56
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 可选字符串:缺省/给 null 都按空串处理,统一 trim
|
|
60
|
+
* (zod v4 中 union(z.undefined()) 不能让对象键变可选,须用 .optional()) */
|
|
61
|
+
const optStr = (errMsg) =>
|
|
62
|
+
z.string({ error: errMsg }).trim().optional().nullable().transform((v) => v ?? '');
|
|
63
|
+
|
|
64
|
+
/** 正数字段(宽容接受数字字符串) */
|
|
65
|
+
const posNum = (name) =>
|
|
66
|
+
z.coerce.number({ error: `${name} 必须是数字` }).positive(`${name} 必须是正数`);
|
|
67
|
+
|
|
68
|
+
/** 把 zod issues 转成错误行:未知配置项逐条展开并附该层级可用项清单;
|
|
69
|
+
* custom/各叶子的报错文案均已自带字段名,直接透传 */
|
|
70
|
+
function formatIssues(issues, knownByPath) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const iss of issues) {
|
|
73
|
+
if (iss.code === 'unrecognized_keys') {
|
|
74
|
+
const known = knownByPath[iss.path.join('.')] || [];
|
|
75
|
+
for (const key of iss.keys) out.push(`未知配置项 "${key}"(可用:${known.join(', ')})`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// agents.commands 下的值错误补出 Agent 名上下文(文案本身不含字段名)
|
|
79
|
+
if (iss.path.length === 3 && iss.path[0] === 'agents' && iss.path[1] === 'commands') {
|
|
80
|
+
out.push(`agents.commands.${iss.path[2]} ${iss.message}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
out.push(iss.message);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const AGENT_KNOWN = ['default', 'commands'];
|
|
89
|
+
|
|
90
|
+
const CONFIG_SCHEMA = z.strictObject({
|
|
91
|
+
apiUrl: z
|
|
92
|
+
.string({
|
|
93
|
+
error: (iss) =>
|
|
94
|
+
iss.input === undefined || iss.input === null
|
|
95
|
+
? '缺少 apiUrl(云端 API 根地址,如 https://api.example.com)'
|
|
96
|
+
: 'apiUrl 必须是字符串',
|
|
97
|
+
})
|
|
98
|
+
.trim()
|
|
99
|
+
.min(1, '缺少 apiUrl(云端 API 根地址,如 https://api.example.com)'),
|
|
100
|
+
apiSecret: z
|
|
101
|
+
.string({
|
|
102
|
+
error: (iss) =>
|
|
103
|
+
iss.input === undefined || iss.input === null
|
|
104
|
+
? '缺少 apiSecret(与云端 server 相同的签名密钥)'
|
|
105
|
+
: 'apiSecret 必须是字符串(建议加引号,避免被 YAML 解析为数字)',
|
|
106
|
+
})
|
|
107
|
+
.trim()
|
|
108
|
+
.min(1, '缺少 apiSecret(与云端 server 相同的签名密钥)'),
|
|
109
|
+
workerId: optStr('workerId 必须是字符串'),
|
|
110
|
+
pollIntervalSec: posNum('pollIntervalSec').default(5),
|
|
111
|
+
// 路径语义(~ 展开 / 相对启动工作目录 / 可写校验)无法纯声明式表达,
|
|
112
|
+
// schema 只约束类型,解析后命令式处理
|
|
113
|
+
scriptsDir: optStr('scriptsDir 必须是字符串'),
|
|
114
|
+
workspaceDir: optStr('workspaceDir 必须是字符串'),
|
|
115
|
+
// 多 Agent 注册表:commands 为「Agent 名 -> 命令模板」映射,
|
|
116
|
+
// default 指定任务未指明 Agent 时的默认项
|
|
117
|
+
agents: z.strictObject(
|
|
118
|
+
{
|
|
119
|
+
default: z
|
|
120
|
+
.string({
|
|
121
|
+
error: (iss) =>
|
|
122
|
+
iss.input === undefined || iss.input === null
|
|
123
|
+
? '缺少 agents.default(任务未指定 Agent 时的默认值)'
|
|
124
|
+
: 'agents.default 必须是字符串',
|
|
125
|
+
})
|
|
126
|
+
.trim()
|
|
127
|
+
.min(1, '缺少 agents.default(任务未指定 Agent 时的默认值)'),
|
|
128
|
+
commands: z
|
|
129
|
+
.record(z.string(), z.string({ error: '命令模板必须是字符串' }).trim(), {
|
|
130
|
+
error: 'agents.commands 必须是「Agent 名: 命令模板」的映射',
|
|
131
|
+
})
|
|
132
|
+
.superRefine((map, ctx) => {
|
|
133
|
+
for (const [name, tpl] of Object.entries(map)) {
|
|
134
|
+
if (!tpl) ctx.addIssue({ code: 'custom', message: `agents.commands.${name} 命令模板不能为空` });
|
|
135
|
+
}
|
|
136
|
+
if (!Object.keys(map).length) {
|
|
137
|
+
ctx.addIssue({ code: 'custom', message: 'agents.commands 至少要配置一个 Agent' });
|
|
138
|
+
}
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
error: (iss) => {
|
|
143
|
+
if (iss.code === 'unrecognized_keys') return undefined; // 交给 formatIssues 出中文清单
|
|
144
|
+
return iss.input === undefined || iss.input === null
|
|
145
|
+
? '缺少 agents 配置(至少包含 default 与 commands,见 config.example.yaml)'
|
|
146
|
+
: 'agents 必须是映射(含 default 与 commands 两个键)';
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
chatHook: optStr('chatHook 必须是字符串'),
|
|
151
|
+
taskTimeoutSec: posNum('taskTimeoutSec').default(1800),
|
|
152
|
+
ptyIdleTimeoutSec: posNum('ptyIdleTimeoutSec').default(3600),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** 加载并校验配置,返回归一化后的常量对象 */
|
|
156
|
+
export async function loadWorkerConfig() {
|
|
157
|
+
const tag = 'worker';
|
|
158
|
+
const configFile = parseArgs();
|
|
159
|
+
|
|
160
|
+
let raw;
|
|
161
|
+
try {
|
|
162
|
+
raw = await fs.promises.readFile(configFile, 'utf8');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(`[${tag}] 无法读取配置文件 ${configFile}: ${err.message}`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const doc = YAML.parseDocument(raw);
|
|
169
|
+
if (doc.errors.length) {
|
|
170
|
+
console.error(`[${tag}] 配置文件 YAML 语法错误(${configFile}):`);
|
|
171
|
+
for (const e of doc.errors) console.error(' - ' + e.message);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const cfg = doc.toJS();
|
|
175
|
+
if (!isPlainObject(cfg)) {
|
|
176
|
+
console.error(`[${tag}] 配置文件内容必须是一个键值映射(${configFile})`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const parsed = CONFIG_SCHEMA.safeParse(cfg);
|
|
181
|
+
if (!parsed.success) {
|
|
182
|
+
fail(
|
|
183
|
+
formatIssues(parsed.error.issues, { '': Object.keys(CONFIG_SCHEMA.shape), agents: AGENT_KNOWN }),
|
|
184
|
+
tag,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const v = parsed.data;
|
|
188
|
+
const errors = [];
|
|
189
|
+
|
|
190
|
+
const apiUrl = v.apiUrl.replace(/\/+$/, '');
|
|
191
|
+
let parsedUrl = null;
|
|
192
|
+
try {
|
|
193
|
+
parsedUrl = new URL(apiUrl);
|
|
194
|
+
} catch {
|
|
195
|
+
errors.push(`apiUrl 不是合法 URL: "${apiUrl}"(检查是否带 https:// 前缀、有无多余空格)`);
|
|
196
|
+
}
|
|
197
|
+
if (parsedUrl && !['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
198
|
+
// 非 http(s) 协议(如 htp: 笔误)会被 URL 解析器宽容地接受,必须显式拦截
|
|
199
|
+
errors.push(`apiUrl 协议必须是 http/https,当前是 "${parsedUrl.protocol}"(检查是否笔误)`);
|
|
200
|
+
}
|
|
201
|
+
if (parsedUrl && parsedUrl.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(parsedUrl.hostname)) {
|
|
202
|
+
// 无 HTTPS 证书的 VPS 只能走 HTTP:允许启动,但风险必须被看见。
|
|
203
|
+
// 密钥与任务内容在网络上明文传输,缓解措施见 README「仅 HTTP 部署」一节。
|
|
204
|
+
console.warn(
|
|
205
|
+
`[worker] 警告:apiUrl 是明文 HTTP(${apiUrl}),密钥签名可被链路上任意节点窃听。` +
|
|
206
|
+
'条件允许时请优先配置 HTTPS(如 Caddy 自动证书)。',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Agent 名统一小写归一化;default 必须存在于 commands 中
|
|
211
|
+
const defaultAgent = v.agents.default.toLowerCase();
|
|
212
|
+
const agents = {};
|
|
213
|
+
for (const [name, tpl] of Object.entries(v.agents.commands)) {
|
|
214
|
+
agents[name.toLowerCase()] = tpl;
|
|
215
|
+
}
|
|
216
|
+
if (!agents[defaultAgent]) {
|
|
217
|
+
errors.push(`agents.default "${defaultAgent}" 不在 agents.commands 中(可用:${Object.keys(agents).join(', ')})`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ~ 展开为主目录(YAML 不会替我们做这件事);
|
|
221
|
+
// 相对路径相对启动工作目录解析(path.resolve 默认行为),建议写绝对路径
|
|
222
|
+
const expandHome = (p) =>
|
|
223
|
+
p === '~' ? os.homedir() : p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
|
|
224
|
+
const scriptsDir = path.resolve(expandHome(v.scriptsDir || 'scripts'));
|
|
225
|
+
const workspaceDir = path.resolve(expandHome(v.workspaceDir || path.join(os.homedir(), 'agent-workspace')));
|
|
226
|
+
// 启动前校验可创建、可写:误配在这里一次性报清(workspace 要写 inbox.log、
|
|
227
|
+
// agent 项目目录),而不是任务执行时才失败
|
|
228
|
+
for (const [name, dir] of [['scriptsDir', scriptsDir], ['workspaceDir', workspaceDir]]) {
|
|
229
|
+
try {
|
|
230
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
231
|
+
const probe = path.join(dir, '.rw-write-probe');
|
|
232
|
+
await fs.promises.writeFile(probe, '');
|
|
233
|
+
await fs.promises.rm(probe);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if (err.code === 'EEXIST' || err.code === 'ENOTDIR') {
|
|
236
|
+
errors.push(`${name} 指向的路径已存在但不是目录:${dir}`);
|
|
237
|
+
} else {
|
|
238
|
+
errors.push(`${name} 无法创建或不可写(${dir}):${err.message};请确认运行用户对该目录有读写权限`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (errors.length) fail(errors, tag);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
configFile,
|
|
247
|
+
apiUrl,
|
|
248
|
+
apiSecret: v.apiSecret,
|
|
249
|
+
workerId: v.workerId || os.hostname(),
|
|
250
|
+
pollIntervalMs: v.pollIntervalSec * 1000,
|
|
251
|
+
scriptsDir,
|
|
252
|
+
workspaceDir,
|
|
253
|
+
agents,
|
|
254
|
+
defaultAgent,
|
|
255
|
+
chatHook: v.chatHook,
|
|
256
|
+
taskTimeoutMs: v.taskTimeoutSec * 1000,
|
|
257
|
+
ptyIdleTimeoutMs: v.ptyIdleTimeoutSec * 1000,
|
|
258
|
+
};
|
|
259
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jacksontian/kite-worker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "kite Mac Mini Worker:出站通知长连接 + 轮询兜底领取任务并执行(Agent CLI / Shell / 白名单脚本),不监听任何端口",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24.0.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"kitew": "worker.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "worker.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"worker.js",
|
|
16
|
+
"config.js",
|
|
17
|
+
"scripts/",
|
|
18
|
+
"config.example.yaml"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"remote",
|
|
22
|
+
"agent",
|
|
23
|
+
"worker",
|
|
24
|
+
"polling",
|
|
25
|
+
"hmac"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"postinstall": "chmod +x node_modules/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"node-pty": "^1.1.0",
|
|
32
|
+
"yaml": "^2.8.2",
|
|
33
|
+
"zod": "^4.4.3"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"registry": "https://registry.npmjs.org/",
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/scripts/hello.sh
ADDED
package/worker.js
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kite Mac Mini Worker
|
|
4
|
+
*
|
|
5
|
+
* 只向云端发起出站连接:任务到达通知走 /worker/ws 长连接(断线自动重连,
|
|
6
|
+
* 轮询兜底不丢任务),心跳 + 日志/结果上报走 HTTP 短连接。不监听任何端口。Node >= 24。
|
|
7
|
+
*
|
|
8
|
+
* 配置文件(--config 传入的 YAML 文件,唯一配置来源,不再读取环境变量或
|
|
9
|
+
* .env 文件;字段说明与模板见包内 config.example.yaml):
|
|
10
|
+
* apiUrl 必填,云端 API 根地址,如 https://api.example.com
|
|
11
|
+
* apiSecret 必填,与云端一致的签名密钥(永不在网络上传输)
|
|
12
|
+
* workerId 可选,默认取 hostname
|
|
13
|
+
* pollIntervalSec 可选,兜底轮询间隔秒数(通知通道在线时任务即时送达),默认 5
|
|
14
|
+
* scriptsDir 可选,script 任务的白名单脚本目录,默认 ./scripts;
|
|
15
|
+
* 相对路径相对启动工作目录解析,支持 ~ 展开,建议写绝对路径
|
|
16
|
+
* workspaceDir 可选,agent/shell 任务的工作目录,默认 ~/agent-workspace;
|
|
17
|
+
* 两个目录启动前校验可创建、可写,不满足直接报错退出
|
|
18
|
+
* agents 必填,Agent 注册表:commands 为「Agent 名: 命令模板」映射
|
|
19
|
+
* ({prompt} 占位符),default 指定任务未指明 Agent 时的默认项
|
|
20
|
+
* chatHook 可选,chat 任务的自定义处理命令模板,
|
|
21
|
+
* 未配置时仅记录到 workspace 的 inbox.log
|
|
22
|
+
* taskTimeoutSec 可选,单任务超时秒数,默认 1800
|
|
23
|
+
* ptyIdleTimeoutSec 可选,pty 交互终端会话空闲(无任何输入输出)回收秒数,默认 3600;
|
|
24
|
+
* 交互会话不走 taskTimeoutSec,只按空闲超时回收
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
import { spawn } from 'node:child_process';
|
|
32
|
+
import { loadWorkerConfig } from './config.js';
|
|
33
|
+
|
|
34
|
+
// pty 任务依赖 node-pty(原生模块);装不上时其余任务类型不受影响,
|
|
35
|
+
// 只是 pty 任务会以明确的错误信息失败
|
|
36
|
+
let pty = null;
|
|
37
|
+
try {
|
|
38
|
+
pty = await import('node-pty');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.warn(`[worker] node-pty 不可用(${err.message}),pty 交互终端任务将被拒绝;` +
|
|
41
|
+
'请执行 npm i node-pty 安装');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const {
|
|
45
|
+
configFile: CONFIG_FILE,
|
|
46
|
+
apiUrl: API_URL,
|
|
47
|
+
apiSecret: API_SECRET,
|
|
48
|
+
workerId: WORKER_ID,
|
|
49
|
+
pollIntervalMs: POLL_INTERVAL,
|
|
50
|
+
scriptsDir: SCRIPTS_DIR,
|
|
51
|
+
workspaceDir: WORKSPACE_DIR,
|
|
52
|
+
agents: AGENTS,
|
|
53
|
+
defaultAgent: DEFAULT_AGENT,
|
|
54
|
+
chatHook: CHAT_HOOK,
|
|
55
|
+
taskTimeoutMs: TASK_TIMEOUT,
|
|
56
|
+
ptyIdleTimeoutMs: PTY_IDLE_TIMEOUT,
|
|
57
|
+
} = await loadWorkerConfig();
|
|
58
|
+
|
|
59
|
+
// 非致命提醒:模板缺 {prompt} 占位符时任务仍会执行,但 prompt 会被静默丢弃
|
|
60
|
+
for (const [name, tpl] of Object.entries(AGENTS)) {
|
|
61
|
+
if (!tpl.includes('{prompt}')) {
|
|
62
|
+
console.warn(`[worker] 警告:Agent "${name}" 的命令模板缺少 {prompt} 占位符,任务 prompt 将不会传给 CLI`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// SCRIPTS_DIR/WORKSPACE_DIR 的存在性与可写性已由 config.js 启动校验保证
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// HTTP 客户端:每个请求 HMAC 签名,密钥本身永不上网
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function signHeaders(method, pathname, bodyStr) {
|
|
73
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
74
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
75
|
+
const bodySha = crypto.createHash('sha256').update(bodyStr).digest('hex');
|
|
76
|
+
// 签名串必须与 server 一致:方法、完整路径、时间戳、nonce、请求体摘要
|
|
77
|
+
const sig = crypto
|
|
78
|
+
.createHmac('sha256', API_SECRET)
|
|
79
|
+
.update([method, pathname, ts, nonce, bodySha].join('\n'))
|
|
80
|
+
.digest('hex');
|
|
81
|
+
return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function api(pathname, body) {
|
|
85
|
+
const method = body === undefined ? 'GET' : 'POST';
|
|
86
|
+
const bodyStr = body === undefined ? '' : JSON.stringify(body);
|
|
87
|
+
const res = await fetch(API_URL + pathname, {
|
|
88
|
+
method,
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
...signHeaders(method, pathname, bodyStr),
|
|
92
|
+
},
|
|
93
|
+
body: body === undefined ? undefined : bodyStr,
|
|
94
|
+
// 避免云端/反代挂住连接时整个任务循环停摆
|
|
95
|
+
signal: AbortSignal.timeout(30_000),
|
|
96
|
+
});
|
|
97
|
+
const data = await res.json().catch(() => ({}));
|
|
98
|
+
if (!res.ok) throw new Error(`API ${res.status}: ${data.error || res.statusText}`);
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// 日志回传:缓冲 + 定时批量推送
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function createLogSender(taskId) {
|
|
107
|
+
let buffer = [];
|
|
108
|
+
let timer = null;
|
|
109
|
+
|
|
110
|
+
async function flush() {
|
|
111
|
+
if (!buffer.length) return;
|
|
112
|
+
const lines = buffer;
|
|
113
|
+
buffer = [];
|
|
114
|
+
try {
|
|
115
|
+
await api(`/api/tasks/${taskId}/logs`, { lines });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`[worker] 日志推送失败: ${err.message}`);
|
|
118
|
+
// 推送失败时把日志放回队列头部,避免丢失
|
|
119
|
+
buffer = [...lines.slice(-500), ...buffer].slice(-1000);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
push(line) {
|
|
125
|
+
buffer.push(String(line).slice(0, 4000));
|
|
126
|
+
if (!timer) timer = setTimeout(() => { timer = null; flush(); }, 1500);
|
|
127
|
+
},
|
|
128
|
+
async done() {
|
|
129
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
130
|
+
// 任务结束前最后一段日志有限次重试,避免静默丢失
|
|
131
|
+
for (let i = 0; i < 3 && buffer.length; i++) {
|
|
132
|
+
await flush();
|
|
133
|
+
if (buffer.length) await new Promise((r) => setTimeout(r, 1000));
|
|
134
|
+
}
|
|
135
|
+
buffer = [];
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// 通用命令执行:流式收集 stdout/stderr,支持超时
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function runCommand(command, { cwd, onLine, timeoutMs, extraEnv }) {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
const child = spawn('bash', ['-lc', command], {
|
|
147
|
+
cwd,
|
|
148
|
+
env: { ...process.env, ...extraEnv },
|
|
149
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
150
|
+
// detached:成为进程组组长,超时时可以整组杀掉(否则会留下孤儿孙进程)
|
|
151
|
+
detached: true,
|
|
152
|
+
});
|
|
153
|
+
let settled = false;
|
|
154
|
+
const killGroup = () => {
|
|
155
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* 进程组已退出 */ }
|
|
156
|
+
};
|
|
157
|
+
const timeout = setTimeout(() => {
|
|
158
|
+
onLine(`[worker] 任务超时(${Math.round(timeoutMs / 1000)}s),终止进程组`);
|
|
159
|
+
killGroup();
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
|
|
162
|
+
const pipeLines = (stream) => {
|
|
163
|
+
let tail = '';
|
|
164
|
+
stream.setEncoding('utf8');
|
|
165
|
+
stream.on('data', (chunk) => {
|
|
166
|
+
const parts = (tail + chunk).split('\n');
|
|
167
|
+
tail = parts.pop();
|
|
168
|
+
for (const line of parts) onLine(line);
|
|
169
|
+
});
|
|
170
|
+
stream.on('end', () => { if (tail) onLine(tail); });
|
|
171
|
+
};
|
|
172
|
+
pipeLines(child.stdout);
|
|
173
|
+
pipeLines(child.stderr);
|
|
174
|
+
|
|
175
|
+
child.on('close', (code) => {
|
|
176
|
+
if (settled) return;
|
|
177
|
+
settled = true;
|
|
178
|
+
clearTimeout(timeout);
|
|
179
|
+
resolve(code ?? 1);
|
|
180
|
+
});
|
|
181
|
+
child.on('error', (err) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
clearTimeout(timeout);
|
|
185
|
+
onLine(`[worker] 启动失败: ${err.message}`);
|
|
186
|
+
killGroup();
|
|
187
|
+
resolve(127);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// 任务执行器
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/** script 任务:只允许执行 SCRIPTS_DIR 下的白名单脚本 */
|
|
197
|
+
async function execScript(task, log) {
|
|
198
|
+
const name = String(task.payload.script || '');
|
|
199
|
+
const args = Array.isArray(task.payload.args) ? task.payload.args : [];
|
|
200
|
+
if (!/^[\w.-]+$/.test(name)) throw new Error('非法脚本名');
|
|
201
|
+
const scriptPath = path.join(SCRIPTS_DIR, name);
|
|
202
|
+
if (!scriptPath.startsWith(SCRIPTS_DIR)) {
|
|
203
|
+
throw new Error(`脚本不存在或不在白名单目录: ${name}`);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await fs.promises.access(scriptPath);
|
|
207
|
+
} catch {
|
|
208
|
+
throw new Error(`脚本不存在或不在白名单目录: ${name}`);
|
|
209
|
+
}
|
|
210
|
+
// 统一用 bash 执行,避免脚本缺少执行位导致 Permission denied
|
|
211
|
+
const quoted = ['bash', scriptPath, ...args.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)].join(' ');
|
|
212
|
+
log.push(`$ ${quoted}`);
|
|
213
|
+
const code = await runCommand(quoted, {
|
|
214
|
+
cwd: WORKSPACE_DIR,
|
|
215
|
+
onLine: (l) => log.push(l),
|
|
216
|
+
timeoutMs: TASK_TIMEOUT,
|
|
217
|
+
});
|
|
218
|
+
return { exitCode: code, ok: code === 0 };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** shell 任务:在 WORKSPACE_DIR 执行任意命令 */
|
|
222
|
+
async function execShell(task, log) {
|
|
223
|
+
const command = String(task.payload.command || '').trim();
|
|
224
|
+
if (!command) throw new Error('command 不能为空');
|
|
225
|
+
log.push(`$ ${command}`);
|
|
226
|
+
const code = await runCommand(command, {
|
|
227
|
+
cwd: WORKSPACE_DIR,
|
|
228
|
+
onLine: (l) => log.push(l),
|
|
229
|
+
timeoutMs: TASK_TIMEOUT,
|
|
230
|
+
});
|
|
231
|
+
return { exitCode: code, ok: code === 0 };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** agent 任务:调用编码 Agent CLI(无头模式),prompt 由手机端下发 */
|
|
235
|
+
async function execAgent(task, log) {
|
|
236
|
+
const prompt = String(task.payload.prompt || '').trim();
|
|
237
|
+
if (!prompt) throw new Error('prompt 不能为空');
|
|
238
|
+
const agent = String(task.payload.agent || DEFAULT_AGENT).toLowerCase();
|
|
239
|
+
const template = AGENTS[agent];
|
|
240
|
+
if (!template) {
|
|
241
|
+
throw new Error(`未配置的 Agent: ${agent}(可用:${Object.keys(AGENTS).join(', ')})`);
|
|
242
|
+
}
|
|
243
|
+
const project = String(task.payload.project || 'default');
|
|
244
|
+
// 防止 ../../xxx 之类的目录名逃逸出 workspace
|
|
245
|
+
if (!/^[\w.-]+$/.test(project) || project.includes('..')) {
|
|
246
|
+
throw new Error(`非法项目目录名: ${project}`);
|
|
247
|
+
}
|
|
248
|
+
const workdir = path.join(WORKSPACE_DIR, project);
|
|
249
|
+
await fs.promises.mkdir(workdir, { recursive: true });
|
|
250
|
+
// 替换时不再加引号:由模板自身的引号保护(默认模板为 claude -p "{prompt}"),
|
|
251
|
+
// 否则会出现双重引号导致 $RW_PROMPT 落在引号外被 bash 拆词
|
|
252
|
+
const command = template.replace(/\{prompt\}/g, '$RW_PROMPT');
|
|
253
|
+
log.push(`[worker] cwd=${workdir}`);
|
|
254
|
+
log.push(`[worker] agent=${agent} command: ${template}`);
|
|
255
|
+
const code = await runCommand(command, {
|
|
256
|
+
cwd: workdir,
|
|
257
|
+
onLine: (l) => log.push(l),
|
|
258
|
+
timeoutMs: TASK_TIMEOUT,
|
|
259
|
+
extraEnv: { RW_PROMPT: prompt },
|
|
260
|
+
});
|
|
261
|
+
return { exitCode: code, ok: code === 0, workdir };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** chat 任务:记入 inbox.log;配置了 CHAT_HOOK 则同时执行钩子 */
|
|
265
|
+
async function execChat(task, log) {
|
|
266
|
+
const message = String(task.payload.message || '').trim();
|
|
267
|
+
if (!message) throw new Error('message 不能为空');
|
|
268
|
+
const stamp = new Date().toISOString();
|
|
269
|
+
await fs.promises.appendFile(path.join(WORKSPACE_DIR, 'inbox.log'), `[${stamp}] ${message}\n`);
|
|
270
|
+
log.push(`[worker] 已记入 inbox.log: ${message}`);
|
|
271
|
+
if (CHAT_HOOK) {
|
|
272
|
+
const command = CHAT_HOOK.replace(/\{message\}/g, '$RW_MESSAGE');
|
|
273
|
+
const code = await runCommand(command, {
|
|
274
|
+
cwd: WORKSPACE_DIR,
|
|
275
|
+
onLine: (l) => log.push(l),
|
|
276
|
+
timeoutMs: TASK_TIMEOUT,
|
|
277
|
+
extraEnv: { RW_MESSAGE: message },
|
|
278
|
+
});
|
|
279
|
+
return { exitCode: code, ok: code === 0 };
|
|
280
|
+
}
|
|
281
|
+
return { ok: true, note: '未配置 CHAT_HOOK,仅记录' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* pty 任务:node-pty 起真终端(bash -il),IO 走 /worker/ws 双向中继成
|
|
286
|
+
* 控制台上的 xterm.js 交互终端。与其他任务不同:
|
|
287
|
+
* - 不套 TASK_TIMEOUT(交互式会话时长不可预期),改按空闲超时 PTY_IDLE_TIMEOUT 回收
|
|
288
|
+
* - Promise 在 PTY 退出时才 resolve;期间输入/改尺寸/关闭由通知通道消息驱动
|
|
289
|
+
* 通知通道断开期间会话不杀:PTY 继续跑(输出暂存丢弃),重连后输入恢复可用
|
|
290
|
+
*/
|
|
291
|
+
const ptySessions = new Map(); // taskId -> { ptyProc, lastActiveAt }
|
|
292
|
+
|
|
293
|
+
function execPty(task, log) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
if (!pty) {
|
|
296
|
+
log.push('[worker] node-pty 未安装,无法启动交互终端(在 Worker 上执行 npm i node-pty)');
|
|
297
|
+
return resolve({ ok: false, error: 'node-pty 未安装' });
|
|
298
|
+
}
|
|
299
|
+
const project = String(task.payload.project || 'default');
|
|
300
|
+
// 与 agent 任务同规则:防目录名逃逸出 workspace
|
|
301
|
+
if (!/^[\w.-]+$/.test(project) || project.includes('..')) {
|
|
302
|
+
log.push(`[worker] 非法项目目录名: ${project}`);
|
|
303
|
+
return resolve({ ok: false, error: `非法项目目录名: ${project}` });
|
|
304
|
+
}
|
|
305
|
+
const workdir = path.join(WORKSPACE_DIR, project);
|
|
306
|
+
const cols = Math.max(2, Math.min(Number(task.payload.cols) || 80, 500));
|
|
307
|
+
const rows = Math.max(2, Math.min(Number(task.payload.rows) || 24, 200));
|
|
308
|
+
|
|
309
|
+
let proc;
|
|
310
|
+
try {
|
|
311
|
+
fs.promises.mkdir(workdir, { recursive: true }).catch(() => {});
|
|
312
|
+
proc = pty.spawn('bash', ['-il'], {
|
|
313
|
+
name: 'xterm-256color',
|
|
314
|
+
cols,
|
|
315
|
+
rows,
|
|
316
|
+
cwd: workdir,
|
|
317
|
+
env: { ...process.env, TERM: 'xterm-256color' },
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
log.push(`[worker] PTY 启动失败: ${err.message}`);
|
|
321
|
+
return resolve({ ok: false, error: err.message });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const session = { ptyProc: proc, lastActiveAt: Date.now() };
|
|
325
|
+
ptySessions.set(task.id, session);
|
|
326
|
+
log.push(`[worker] 交互终端已启动: pid=${proc.pid} cwd=${workdir}`);
|
|
327
|
+
wsSend({ type: 'session.started', taskId: task.id, pid: proc.pid });
|
|
328
|
+
|
|
329
|
+
proc.onData((data) => {
|
|
330
|
+
session.lastActiveAt = Date.now();
|
|
331
|
+
// 通道断开时丢弃输出(无缓冲回放设计,重连后终端从当前画面继续)
|
|
332
|
+
wsSend({ type: 'session.output', taskId: task.id, data });
|
|
333
|
+
});
|
|
334
|
+
proc.onExit(({ exitCode }) => {
|
|
335
|
+
ptySessions.delete(task.id);
|
|
336
|
+
wsSend({ type: 'session.exited', taskId: task.id, exitCode });
|
|
337
|
+
log.push(`[worker] 终端会话结束,exit=${exitCode}`);
|
|
338
|
+
resolve({ ok: exitCode === 0, exitCode });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// 空闲回收:长时间无任何输入输出才杀,避免忘关的会话常驻
|
|
342
|
+
const idleTimer = setInterval(() => {
|
|
343
|
+
if (Date.now() - session.lastActiveAt > PTY_IDLE_TIMEOUT) {
|
|
344
|
+
clearInterval(idleTimer);
|
|
345
|
+
log.push(`[worker] 会话空闲超过 ${Math.round(PTY_IDLE_TIMEOUT / 1000)}s,已回收`);
|
|
346
|
+
try { proc.kill(); } catch { /* 已退出 */ }
|
|
347
|
+
}
|
|
348
|
+
}, 30 * 1000).unref();
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 通知通道消息驱动的会话控制(输入/改尺寸/关闭),见 connectNotifyChannel */
|
|
353
|
+
function handleSessionMessage(msg) {
|
|
354
|
+
const session = ptySessions.get(msg.taskId);
|
|
355
|
+
if (!session) return;
|
|
356
|
+
session.lastActiveAt = Date.now();
|
|
357
|
+
if (msg.type === 'session.input' && typeof msg.data === 'string') {
|
|
358
|
+
try { session.ptyProc.write(msg.data.slice(0, 65536)); } catch { /* 进程已退出 */ }
|
|
359
|
+
} else if (msg.type === 'session.resize') {
|
|
360
|
+
const cols = Math.max(2, Math.min(Number(msg.cols) || 80, 500));
|
|
361
|
+
const rows = Math.max(2, Math.min(Number(msg.rows) || 24, 200));
|
|
362
|
+
try { session.ptyProc.resize(cols, rows); } catch { /* 进程已退出 */ }
|
|
363
|
+
} else if (msg.type === 'session.close') {
|
|
364
|
+
try { session.ptyProc.kill(); } catch { /* 已退出 */ }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const EXECUTORS = { script: execScript, shell: execShell, agent: execAgent, chat: execChat, pty: execPty };
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// 任务通知长连接:服务端建新任务即推 task.available,收到后立刻走 HTTP claim
|
|
372
|
+
// 领取(原子性仍以 HTTP 为准)。心跳/日志/结果保持短连接。断线指数退避重连,
|
|
373
|
+
// 断线窗口内的任务由轮询兜底,不丢任务。用 Node 24 内置 WebSocket,零依赖。
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
const WS_URL = API_URL.replace(/^http(s?):/, 'ws$1:') + '/worker/ws';
|
|
377
|
+
let wsReconnectDelay = 1000;
|
|
378
|
+
let wake = null; // 主循环休眠的唤醒信号
|
|
379
|
+
let notifyWs = null; // 当前已鉴权的通知通道,pty 会话输出经它上行
|
|
380
|
+
|
|
381
|
+
/** 向服务端发送通知通道消息;通道未就绪时静默丢弃(pty 输出可丢,不堆积背压) */
|
|
382
|
+
function wsSend(msg) {
|
|
383
|
+
if (notifyWs && notifyWs.readyState === WebSocket.OPEN) {
|
|
384
|
+
notifyWs.send(JSON.stringify(msg));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 休眠至多 ms;期间收到任务通知(或通道断开需要补拉)时提前唤醒 */
|
|
389
|
+
function sleepUntilWakeup(ms) {
|
|
390
|
+
return new Promise((resolve) => {
|
|
391
|
+
const timer = setTimeout(done, ms);
|
|
392
|
+
wake = done;
|
|
393
|
+
function done() {
|
|
394
|
+
clearTimeout(timer);
|
|
395
|
+
if (wake === done) wake = null;
|
|
396
|
+
resolve();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function connectNotifyChannel() {
|
|
402
|
+
const ws = new WebSocket(WS_URL);
|
|
403
|
+
ws.addEventListener('open', () => {
|
|
404
|
+
wsReconnectDelay = 1000;
|
|
405
|
+
notifyWs = ws; // 鉴权是首条消息,后续会话消息服务端按序处理,可提前挂载
|
|
406
|
+
// 鉴权方式与控制台 /ws 相同:sig = HMAC(密钥, "WS\nTS\nNONCE")
|
|
407
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
408
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
409
|
+
const sig = crypto
|
|
410
|
+
.createHmac('sha256', API_SECRET)
|
|
411
|
+
.update(['WS', ts, nonce].join('\n'))
|
|
412
|
+
.digest('hex');
|
|
413
|
+
ws.send(JSON.stringify({ type: 'auth', workerId: WORKER_ID, ts, nonce, sig }));
|
|
414
|
+
console.log(`[worker] 通知通道已连接: ${WS_URL}`);
|
|
415
|
+
// 重连场景:把仍存活的 pty 会话重新上报,服务端将其挂到本条新连接
|
|
416
|
+
for (const taskId of ptySessions.keys()) {
|
|
417
|
+
ws.send(JSON.stringify({ type: 'session.started', taskId, reattach: true }));
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
ws.addEventListener('message', (ev) => {
|
|
421
|
+
let msg;
|
|
422
|
+
try {
|
|
423
|
+
msg = JSON.parse(String(ev.data));
|
|
424
|
+
} catch {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (msg.type === 'task.available') return wake?.();
|
|
428
|
+
// pty 会话的控制消息(输入/改尺寸/关闭)由服务端从控制台路由过来
|
|
429
|
+
if (msg.type && msg.type.startsWith('session.')) return handleSessionMessage(msg);
|
|
430
|
+
});
|
|
431
|
+
ws.addEventListener('close', () => {
|
|
432
|
+
if (notifyWs === ws) notifyWs = null;
|
|
433
|
+
console.warn(`[worker] 通知通道断开,${wsReconnectDelay / 1000}s 后重连(期间靠轮询兜底)`);
|
|
434
|
+
setTimeout(connectNotifyChannel, wsReconnectDelay);
|
|
435
|
+
wsReconnectDelay = Math.min(wsReconnectDelay * 2, 30_000);
|
|
436
|
+
// 断线前一刻可能刚建了任务,立即唤醒主循环去领一次
|
|
437
|
+
wake?.();
|
|
438
|
+
});
|
|
439
|
+
// 必须监听 error 事件:连接失败时未监听会让进程直接崩溃;
|
|
440
|
+
// 出错后主动 close,统一走 close 里的重连路径
|
|
441
|
+
ws.addEventListener('error', (ev) => {
|
|
442
|
+
console.error(`[worker] 通知通道错误: ${ev.error?.message || 'unknown'}`);
|
|
443
|
+
ws.close();
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// 主循环
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
async function runTask(task) {
|
|
452
|
+
const log = createLogSender(task.id);
|
|
453
|
+
const started = Date.now();
|
|
454
|
+
log.push(`[worker] 开始执行任务 ${task.type}: ${task.title || task.id}`);
|
|
455
|
+
|
|
456
|
+
// 执行与结果上报解耦:执行成功但上报抖动时重试即可,不能反转为 failed
|
|
457
|
+
let status;
|
|
458
|
+
let result;
|
|
459
|
+
try {
|
|
460
|
+
const r = await EXECUTORS[task.type](task, log);
|
|
461
|
+
status = r.ok ? 'done' : 'failed';
|
|
462
|
+
result = { ...r, durationMs: Date.now() - started };
|
|
463
|
+
} catch (err) {
|
|
464
|
+
log.push(`[worker] 错误: ${err.message}`);
|
|
465
|
+
status = 'failed';
|
|
466
|
+
result = { error: err.message, durationMs: Date.now() - started };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
await log.done();
|
|
470
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
471
|
+
try {
|
|
472
|
+
await api(`/api/tasks/${task.id}/finish`, { status, result });
|
|
473
|
+
return;
|
|
474
|
+
} catch (err) {
|
|
475
|
+
console.error(`[worker] finish 上报失败(${attempt}/3): ${err.message}`);
|
|
476
|
+
await new Promise((r) => setTimeout(r, 2000 * attempt));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
console.error(`[worker] finish 上报最终失败,任务 ${task.id} 实际状态: ${status}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function heartbeat() {
|
|
483
|
+
try {
|
|
484
|
+
await api('/api/worker/heartbeat', {
|
|
485
|
+
workerId: WORKER_ID,
|
|
486
|
+
hostname: os.hostname(),
|
|
487
|
+
node: process.version,
|
|
488
|
+
agents: Object.keys(AGENTS),
|
|
489
|
+
});
|
|
490
|
+
} catch {
|
|
491
|
+
// 网络抖动忽略,下一轮重试
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function main() {
|
|
496
|
+
console.log(`[worker] ${WORKER_ID} 启动,API=${API_URL},兜底轮询间隔 ${POLL_INTERVAL / 1000}s`);
|
|
497
|
+
console.log(`[worker] 配置文件: ${CONFIG_FILE}`);
|
|
498
|
+
await heartbeat();
|
|
499
|
+
setInterval(heartbeat, 30 * 1000);
|
|
500
|
+
connectNotifyChannel();
|
|
501
|
+
|
|
502
|
+
for (;;) {
|
|
503
|
+
try {
|
|
504
|
+
const { task } = await api('/api/worker/claim', { workerId: WORKER_ID });
|
|
505
|
+
if (task) {
|
|
506
|
+
if (task.type === 'pty') {
|
|
507
|
+
// 交互会话可能长时间挂着,异步执行,不阻塞领取其他任务
|
|
508
|
+
runTask(task).catch((err) => console.error(`[worker] pty 任务异常: ${err.message}`));
|
|
509
|
+
} else {
|
|
510
|
+
await runTask(task);
|
|
511
|
+
}
|
|
512
|
+
continue; // 领到任务就立刻看下一个
|
|
513
|
+
}
|
|
514
|
+
} catch (err) {
|
|
515
|
+
console.error(`[worker] 轮询失败: ${err.message}`);
|
|
516
|
+
}
|
|
517
|
+
// 通知通道在线时,新任务推送会提前唤醒;断线则按轮询间隔兜底
|
|
518
|
+
await sleepUntilWakeup(POLL_INTERVAL);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
main();
|