@whyshu/argent 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/lib/index.mjs +3 -0
- package/package.json +2 -1
- package/runner/argent-runner.mjs +223 -0
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ npx -y @whyshu/argent service install # 生成 launchd(macOS)/ systemd(L
|
|
|
13
13
|
|
|
14
14
|
- 启动器 **exact pin** `@deepseek-ai/dsh@0.2.1-rc.2`,避免混合版本事故;
|
|
15
15
|
- profile 由启动器生成:bundles(base/web-app/argent-presets,可用 `--bundles a,b` 追加)+ 用户层 patch(默认角色 preset);
|
|
16
|
+
- 发布包内置 `runner/argent-runner.mjs`,`service install` 可直接生成 launchd;
|
|
16
17
|
- `setup` 会自动执行 `dsh plugin --profile argent install` 装配依赖(`--no-install` 可跳过);
|
|
17
18
|
- 两种装配模式:本地仓库(`ARGENT_REPO`/`--repo`,插件用 `file:`)与**发布模式**(无本地 plugins/ 时用启动器 package.json 的 npm 版本);
|
|
18
19
|
- 首跑写入 `$DSH_HOME/argent/runner.json`(600,含 serverUrl/tenantAppId/runnerToken);
|
package/lib/index.mjs
CHANGED
|
@@ -182,6 +182,9 @@ export function runnerConfigPath(dshHome = dshHomeOf()) {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
export function runnerScriptPath({ repo = repoRoot() } = {}) {
|
|
185
|
+
// 发布包内置 runner(0.1.3 起);本地仓库联调回退 scripts/argent-runner.mjs
|
|
186
|
+
const bundled = join(dirname(fileURLToPath(import.meta.url)), '..', 'runner', 'argent-runner.mjs');
|
|
187
|
+
if (existsSync(bundled)) return bundled;
|
|
185
188
|
const candidate = join(repo, 'scripts', 'argent-runner.mjs');
|
|
186
189
|
return existsSync(candidate) ? candidate : '';
|
|
187
190
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyshu/argent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Argent 启动器:一条命令启动「打包好的 dsh + Argent 插件/角色预设」,并管理本机 runner 常驻。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"bin/",
|
|
21
21
|
"lib/",
|
|
22
|
+
"runner/",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
24
25
|
"license": "MIT",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* P4 本机 runner(客户机侧)—— 后台 Pull 派发 + 租约 的执行端。
|
|
4
|
+
*
|
|
5
|
+
* 链路:
|
|
6
|
+
* ① 用 runner token 向数据中心长轮询领取作业(claim)
|
|
7
|
+
* ② 若本机 dsh web 在跑:通过其 task-board HTTP API(同机 loopback,无需 token)
|
|
8
|
+
* 建卡(pin job.preset)→ 运行 → 轮询直到执行结束
|
|
9
|
+
* ③ 把结果 complete 回数据中心(写 Run + 回写卡状态)
|
|
10
|
+
* · 执行期间每 30s 发一次心跳续租;租约超时则由服务端回收、别的 runner 可重领
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* node scripts/argent-runner.mjs --config ~/.dsh/argent/runner.json # 常驻循环
|
|
14
|
+
* node scripts/argent-runner.mjs --config ... --once # 领一个作业就退出(验收/调试)
|
|
15
|
+
* node scripts/argent-runner.mjs --health # 只做连通性自检
|
|
16
|
+
*
|
|
17
|
+
* 配置文件(600,示例):
|
|
18
|
+
* {
|
|
19
|
+
* "serverUrl": "https://whyshu.com",
|
|
20
|
+
* "tenantAppId": "ly-0001",
|
|
21
|
+
* "runnerToken": "eyJ…",
|
|
22
|
+
* "dshWebUrl": "http://127.0.0.1:3080",
|
|
23
|
+
* "leaseSec": 90,
|
|
24
|
+
* "pollWaitMs": 20000,
|
|
25
|
+
* "execTimeoutSec": 900
|
|
26
|
+
* }
|
|
27
|
+
* ⛔ 不打印 token;⛔ 只允许对 dshWebUrl 的本机地址发请求(校验 host 为 loopback)。
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
30
|
+
import { randomUUID } from 'node:crypto';
|
|
31
|
+
import { homedir, hostname } from 'node:os';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
const argv = process.argv.slice(2);
|
|
35
|
+
const has = (f) => argv.includes(f);
|
|
36
|
+
const val = (f, d = '') => { const i = argv.indexOf(f); return i > -1 ? (argv[i + 1] ?? d) : d; };
|
|
37
|
+
|
|
38
|
+
const CONFIG_PATH = val('--config', join(homedir(), '.dsh', 'argent', 'runner.json'));
|
|
39
|
+
const ONCE = has('--once');
|
|
40
|
+
const HEALTH_ONLY = has('--health');
|
|
41
|
+
const DRY_RUN = has('--dry-run');
|
|
42
|
+
const LOG_DIR = join(homedir(), '.dsh', 'argent', 'runner');
|
|
43
|
+
|
|
44
|
+
function log(event, extra = {}) {
|
|
45
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), event, ...extra });
|
|
46
|
+
console.log(line);
|
|
47
|
+
try { mkdirSync(LOG_DIR, { recursive: true }); appendFileSync(join(LOG_DIR, 'runner.ndjson'), line + '\n'); } catch { /* 日志失败不影响执行 */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function loadConfig() {
|
|
51
|
+
let cfg = {};
|
|
52
|
+
try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch (error) {
|
|
53
|
+
console.error(`❌ 读不到配置 ${CONFIG_PATH}: ${error.message}`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
const merged = {
|
|
57
|
+
serverUrl: String(process.env.ARGENT_SERVER_URL || cfg.serverUrl || '').replace(/\/+$/, ''),
|
|
58
|
+
tenantAppId: String(process.env.ARGENT_TENANT_APP_ID || cfg.tenantAppId || ''),
|
|
59
|
+
runnerToken: String(process.env.ARGENT_RUNNER_TOKEN || cfg.runnerToken || ''),
|
|
60
|
+
dshWebUrl: String(process.env.ARGENT_DSH_WEB_URL || cfg.dshWebUrl || 'http://127.0.0.1:3080').replace(/\/+$/, ''),
|
|
61
|
+
leaseSec: Number(cfg.leaseSec) || 90,
|
|
62
|
+
pollWaitMs: Number(cfg.pollWaitMs) || 20000,
|
|
63
|
+
execTimeoutSec: Number(cfg.execTimeoutSec) || 900,
|
|
64
|
+
runnerId: String(cfg.runnerId || `runner-${hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-')}`),
|
|
65
|
+
};
|
|
66
|
+
for (const k of ['serverUrl', 'tenantAppId', 'runnerToken']) {
|
|
67
|
+
if (!merged[k]) { console.error(`❌ 配置缺少 ${k}(${CONFIG_PATH})`); process.exit(2); }
|
|
68
|
+
}
|
|
69
|
+
const web = new URL(merged.dshWebUrl);
|
|
70
|
+
if (!['127.0.0.1', 'localhost', '::1'].includes(web.hostname)) {
|
|
71
|
+
console.error(`❌ dshWebUrl 必须是本机地址(当前 ${web.hostname})`); process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
return merged;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
77
|
+
|
|
78
|
+
function serverHeaders(cfg) {
|
|
79
|
+
return { authorization: `Bearer ${cfg.runnerToken}`, 'content-type': 'application/json' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function serverCall(cfg, path, { method = 'GET', body } = {}) {
|
|
83
|
+
const res = await fetch(`${cfg.serverUrl}/api/argent/v1/tenants/${cfg.tenantAppId}${path}`, {
|
|
84
|
+
method, headers: serverHeaders(cfg), body: body === undefined ? undefined : JSON.stringify(body),
|
|
85
|
+
});
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
let data = null;
|
|
88
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
89
|
+
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${data?.error?.code || ''} ${data?.error?.message || ''}`.trim());
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** task-board 同机 HTTP API(loopback + same-origin marker;不需要 dsh web token) */
|
|
94
|
+
async function tbAction(cfg, action) {
|
|
95
|
+
const res = await fetch(`${cfg.dshWebUrl}/api/task-board/action`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
|
|
98
|
+
body: JSON.stringify({ requestId: randomUUID(), action }),
|
|
99
|
+
});
|
|
100
|
+
const text = await res.text();
|
|
101
|
+
let data = null;
|
|
102
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
103
|
+
return { status: res.status, data };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function tbState(cfg) {
|
|
107
|
+
const res = await fetch(`${cfg.dshWebUrl}/api/task-board/state`, { headers: { 'sec-fetch-site': 'same-origin' } });
|
|
108
|
+
if (!res.ok) throw new Error(`task-board state → ${res.status}`);
|
|
109
|
+
return res.json();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 执行一个作业:建卡 → 运行 → 等待结算;返回 { outcome, summary, sessionRef } */
|
|
113
|
+
async function executeJob(cfg, job) {
|
|
114
|
+
const taskId = randomUUID();
|
|
115
|
+
const create = await tbAction(cfg, {
|
|
116
|
+
kind: 'create', id: taskId,
|
|
117
|
+
input: {
|
|
118
|
+
title: job.title || `后台派发:${job.preset}`,
|
|
119
|
+
description: String(job.prompt || '').slice(0, 500),
|
|
120
|
+
prompt: job.prompt,
|
|
121
|
+
mode: job.preset,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
if (create.status !== 200) {
|
|
125
|
+
return { outcome: 'failed', summary: `task-board create 失败:HTTP ${create.status} ${JSON.stringify(create.data).slice(0, 300)}` };
|
|
126
|
+
}
|
|
127
|
+
log('job.task.created', { jobId: String(job._id), taskId, preset: job.preset });
|
|
128
|
+
|
|
129
|
+
const run = await tbAction(cfg, { kind: 'run', taskId });
|
|
130
|
+
if (run.status !== 200) {
|
|
131
|
+
return { outcome: 'failed', summary: `task-board run 失败:HTTP ${run.status} ${JSON.stringify(run.data).slice(0, 300)}`, taskId };
|
|
132
|
+
}
|
|
133
|
+
log('job.task.running', { jobId: String(job._id), taskId });
|
|
134
|
+
|
|
135
|
+
const deadline = Date.now() + cfg.execTimeoutSec * 1000;
|
|
136
|
+
while (Date.now() < deadline) {
|
|
137
|
+
await sleep(2000);
|
|
138
|
+
const state = await tbState(cfg);
|
|
139
|
+
const task = (state.tasks || []).find((t) => t.id === taskId);
|
|
140
|
+
if (!task) continue;
|
|
141
|
+
const execs = task.executions || [];
|
|
142
|
+
const latest = execs[execs.length - 1];
|
|
143
|
+
if (latest && latest.endedAt) {
|
|
144
|
+
const outcome = latest.result === 'succeeded' ? 'succeeded' : 'failed';
|
|
145
|
+
return { outcome, summary: latest.error ? String(latest.error).slice(0, 500) : '', sessionRef: latest.sessionId || '', taskId };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { outcome: 'failed', summary: `执行超时(>${cfg.execTimeoutSec}s)`, taskId };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function runOnce(cfg) {
|
|
152
|
+
const claim = await serverCall(cfg, '/jobs/claim', { method: 'POST', body: { runnerId: cfg.runnerId, waitMs: ONCE ? 0 : cfg.pollWaitMs, leaseSec: cfg.leaseSec } });
|
|
153
|
+
const job = claim?.data?.job;
|
|
154
|
+
if (!job) return { claimed: false };
|
|
155
|
+
log('job.claimed', { jobId: String(job._id), preset: job.preset, attempts: job.attempts, runnerId: cfg.runnerId });
|
|
156
|
+
if (DRY_RUN) { log('job.dry-run', { jobId: String(job._id) }); return { claimed: true, dryRun: true }; }
|
|
157
|
+
|
|
158
|
+
// 心跳续租(执行期间每 30s)
|
|
159
|
+
let heartbeatTimer = null;
|
|
160
|
+
const startHeartbeat = () => {
|
|
161
|
+
heartbeatTimer = setInterval(async () => {
|
|
162
|
+
try {
|
|
163
|
+
await serverCall(cfg, `/jobs/${job._id}/heartbeat`, { method: 'POST', body: { runnerId: cfg.runnerId, leaseSec: cfg.leaseSec } });
|
|
164
|
+
log('job.heartbeat', { jobId: String(job._id) });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
log('job.heartbeat.error', { jobId: String(job._id), message: error.message });
|
|
167
|
+
}
|
|
168
|
+
}, 30_000);
|
|
169
|
+
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
startHeartbeat();
|
|
173
|
+
let result;
|
|
174
|
+
try {
|
|
175
|
+
result = await executeJob(cfg, job);
|
|
176
|
+
} finally {
|
|
177
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const done = await serverCall(cfg, `/jobs/${job._id}/complete`, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
body: {
|
|
184
|
+
runnerId: cfg.runnerId, outcome: result.outcome,
|
|
185
|
+
summary: result.summary || '', sessionRef: result.sessionRef || '',
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
log('job.completed', { jobId: String(job._id), outcome: result.outcome, runId: done?.data?.run?._id || null, taskId: result.taskId || null });
|
|
189
|
+
return { claimed: true, outcome: result.outcome };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
log('job.complete.error', { jobId: String(job._id), message: error.message });
|
|
192
|
+
return { claimed: true, outcome: result.outcome, completeError: error.message };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function health(cfg) {
|
|
197
|
+
const out = { server: false, dshWeb: false, tenantAppId: cfg.tenantAppId, runnerId: cfg.runnerId };
|
|
198
|
+
try {
|
|
199
|
+
const res = await fetch(`${cfg.serverUrl}/api/argent/v1/health`);
|
|
200
|
+
out.server = res.ok;
|
|
201
|
+
} catch { /* noop */ }
|
|
202
|
+
try {
|
|
203
|
+
const state = await tbState(cfg);
|
|
204
|
+
out.dshWeb = true;
|
|
205
|
+
out.boardRevision = state.revision;
|
|
206
|
+
} catch { /* noop */ }
|
|
207
|
+
console.log(JSON.stringify(out));
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function main() {
|
|
212
|
+
const cfg = loadConfig();
|
|
213
|
+
log('runner.start', { runnerId: cfg.runnerId, tenantAppId: cfg.tenantAppId, server: new URL(cfg.serverUrl).origin, dshWeb: new URL(cfg.dshWebUrl).origin, once: ONCE });
|
|
214
|
+
if (HEALTH_ONLY) { const h = await health(cfg); process.exit(h.server && h.dshWeb ? 0 : 1); }
|
|
215
|
+
let stopping = false;
|
|
216
|
+
process.on('SIGINT', () => { stopping = true; log('runner.stop', {}); process.exit(0); });
|
|
217
|
+
process.on('SIGTERM', () => { stopping = true; log('runner.stop', {}); process.exit(0); });
|
|
218
|
+
do {
|
|
219
|
+
try { await runOnce(cfg); } catch (error) { log('runner.error', { message: error.message }); await sleep(3000); }
|
|
220
|
+
} while (!ONCE && !stopping);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
main().catch((error) => { console.error(`❌ runner 失败: ${error.message}`); process.exit(1); });
|