@whyshu/argent 0.1.27 → 0.2.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.
@@ -1,223 +0,0 @@
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); });