@whyshu/argent-engine 0.7.6 → 0.7.8
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/MANIFEST.json +39 -35
- package/engine/VERSION +1 -1
- package/engine/core/contracts/argent.contracts.json +12 -4
- package/engine/scripts/appointment-create.mjs +10 -2
- package/engine/scripts/appointment-worker.mjs +72 -15
- package/engine/scripts/appointment_grab.py +132 -21
- package/engine/scripts/argent-machine-setup.mjs +118 -0
- package/engine/scripts/argent-runner.mjs +63 -10
- package/engine/scripts/capability-packs.lock.json +54 -51
- package/engine/scripts/ci-run.sh +14 -2
- package/engine/scripts/collect-evidence.mjs +1 -1
- package/engine/scripts/customer-preflight.py +33 -0
- package/engine/scripts/flow-auto-retry.py +3 -0
- package/engine/scripts/flow-requirements.json +23 -13
- package/engine/scripts/flow-session-retention.mjs +88 -0
- package/engine/scripts/flow-trigger-wake.mjs +13 -1
- package/engine/scripts/flow-trigger.sh +2 -1
- package/engine/scripts/flow_config.py +4 -0
- package/engine/scripts/flow_failure.py +9 -4
- package/engine/scripts/flow_preflight.py +30 -0
- package/engine/scripts/flow_retry.py +22 -7
- package/engine/scripts/gen-agent-bundles.mjs +1 -1
- package/engine/scripts/gen-preset-rows.mjs +2 -2
- package/engine/scripts/invoice_lip_runner.py +30 -2
- package/engine/scripts/invoice_stageb.py +101 -0
- package/engine/scripts/login-readiness.py +220 -0
- package/engine/scripts/ml-oauth-setup.mjs +1 -1
- package/engine/scripts/orchestrator.py +416 -30
- package/engine/scripts/probe_ml_site.py +103 -0
- package/engine/scripts/release-argent.sh +15 -1
- package/engine/skills/argent-machine-onboarding/SKILL.md +143 -0
- package/engine/skills/internal.json +4 -0
- package/engine/skills/ml-full-appointment/SKILL.md +21 -13
- package/engine/skills/ml-invoice/SKILL.md +1 -1
- package/engine/skills/ml-invoice-xzh/SKILL.md +2 -2
- package/engine/version.mjs +1 -1
- package/engine/verticals/_engine-words.baseline.txt +42 -23
- package/engine/verticals/mercadolibre/flows.json +9 -5
- package/engine/verticals/mercadolibre/manifest.json +5 -4
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +1 -1
- package/engine/verticals/mercadolibre/prompts/daily-report.md +7 -10
- package/package.json +1 -1
- package/engine/scripts/ml-ads.mjs +0 -181
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
"""
|
|
9
9
|
import argparse
|
|
10
10
|
import asyncio
|
|
11
|
+
import time
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
14
|
+
import signal
|
|
13
15
|
import sys
|
|
14
|
-
import
|
|
15
|
-
from datetime import date, datetime, timedelta
|
|
16
|
+
from datetime import date, datetime, timedelta, timezone
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from types import SimpleNamespace
|
|
18
19
|
|
|
@@ -86,7 +87,48 @@ def load_task() -> dict:
|
|
|
86
87
|
|
|
87
88
|
|
|
88
89
|
def iso_day(d) -> date:
|
|
89
|
-
|
|
90
|
+
# 任务日期由服务端按 Asia/Shanghai 00:00 存成 UTC(如 10/21 → 10/20T16:00Z);按中国日期还原,
|
|
91
|
+
# ⛔ 不能用 str(d)[:10] 直接截 UTC,否则会整体早一天。
|
|
92
|
+
dt = datetime.fromisoformat(str(d).replace('Z', '+00:00'))
|
|
93
|
+
if dt.tzinfo is None:
|
|
94
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
95
|
+
return dt.astimezone(timezone(timedelta(hours=8))).date()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _close_appointment_tab(orch) -> int:
|
|
99
|
+
"""停止/失败时关闭本任务用的预约标签页;若它是窗口最后 1 个标签,先开一个占位页再关。
|
|
100
|
+
|
|
101
|
+
紫鸟窗口守卫要求不能把最后标签关掉(会退出 Chromium 窗口、丢 CDP)。
|
|
102
|
+
这里用 about:blank 占位保持窗口存活,再关闭预约页,避免用户看到残留任务标签。
|
|
103
|
+
"""
|
|
104
|
+
closed = 0
|
|
105
|
+
try:
|
|
106
|
+
page = getattr(orch.browser, 'page', None)
|
|
107
|
+
if page is None or page.is_closed():
|
|
108
|
+
return 0
|
|
109
|
+
url = str(getattr(page, 'url', '') or '')
|
|
110
|
+
# 只清理本任务/预约页,避免误关用户主门户标签
|
|
111
|
+
if ('appointment-v2' not in url) and (str(getattr(orch.state, 'shipment_id', '') or '') not in url):
|
|
112
|
+
return 0
|
|
113
|
+
ctx = page.context
|
|
114
|
+
if len(ctx.pages) <= 1:
|
|
115
|
+
try:
|
|
116
|
+
await ctx.new_page() # 占位,保持窗口不退出
|
|
117
|
+
except Exception:
|
|
118
|
+
return 0
|
|
119
|
+
await page.close()
|
|
120
|
+
closed = 1
|
|
121
|
+
orch._log(' 🗂️ 已关闭抢预约任务标签页(保留占位页,防窗口退出)')
|
|
122
|
+
except Exception as e:
|
|
123
|
+
try: orch._log(f' ⚠️ 关闭预约标签页失败:{str(e)[:120]}')
|
|
124
|
+
except Exception: pass
|
|
125
|
+
return closed
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _bump_page_check(orch) -> int:
|
|
129
|
+
n = int(getattr(orch, '_appt_page_checks', 0)) + 1
|
|
130
|
+
setattr(orch, '_appt_page_checks', n)
|
|
131
|
+
return n
|
|
90
132
|
|
|
91
133
|
|
|
92
134
|
async def prepare_appointment_page(orch) -> None:
|
|
@@ -94,6 +136,7 @@ async def prepare_appointment_page(orch) -> None:
|
|
|
94
136
|
last = None
|
|
95
137
|
for attempt in range(3):
|
|
96
138
|
try:
|
|
139
|
+
_bump_page_check(orch)
|
|
97
140
|
await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
|
|
98
141
|
if await orch._has_existing_appointment():
|
|
99
142
|
raise RuntimeError('已有预约存在,不重复抢')
|
|
@@ -213,10 +256,17 @@ async def run_task(task: dict) -> dict:
|
|
|
213
256
|
platform = str((info or {}).get('platform') or '')
|
|
214
257
|
except Exception:
|
|
215
258
|
platform = ''
|
|
216
|
-
|
|
259
|
+
_pf = str(platform or '').lower()
|
|
260
|
+
is_mx = (not platform) or any(k in _pf for k in ('墨西哥', 'mexico', 'méxico', 'mlm', 'mercadolibre', 'mercado libre'))
|
|
217
261
|
if not is_mx:
|
|
218
262
|
return {'status': 'failed', 'summary': f'抢预约当前仅支持墨西哥站(MLM);{store} 属于 {platform},暂未适配,请勿执行'}
|
|
219
263
|
date_mode = task.get('dateMode') or 'range'
|
|
264
|
+
weekdays = {int(x) for x in (task.get('weekdays') or []) if str(x).isdigit() and 1 <= int(x) <= 7}
|
|
265
|
+
time_from = str(task.get('timeFrom') or '').strip()
|
|
266
|
+
time_to = str(task.get('timeTo') or '').strip()
|
|
267
|
+
|
|
268
|
+
def weekday_allowed(d: date) -> bool:
|
|
269
|
+
return (not weekdays) or d.isoweekday() in weekdays
|
|
220
270
|
from_date = iso_day(task['dateFrom']) if date_mode == 'range' else None
|
|
221
271
|
to_date = iso_day(task['dateTo']) if date_mode == 'range' else None
|
|
222
272
|
cfg = O.load_config()
|
|
@@ -252,6 +302,7 @@ async def run_task(task: dict) -> dict:
|
|
|
252
302
|
cycle_no = 0
|
|
253
303
|
last_states = {}
|
|
254
304
|
while True:
|
|
305
|
+
cycle_started = time.monotonic()
|
|
255
306
|
cycle_no += 1
|
|
256
307
|
if date_mode == 'available_days':
|
|
257
308
|
need = int(task.get('availableDays') or 0)
|
|
@@ -259,18 +310,31 @@ async def run_task(task: dict) -> dict:
|
|
|
259
310
|
tomorrow = (today + timedelta(days=1)).isoformat()
|
|
260
311
|
states = {k: v for k, v in dict(scan['states']).items() if k >= tomorrow}
|
|
261
312
|
targets = [date.fromisoformat(x) for x in scan['selected']]
|
|
313
|
+
targets = [d for d in targets if weekday_allowed(d)]
|
|
262
314
|
if not targets:
|
|
263
|
-
print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'dateStates': states, 'summary': '未发现可选日'}, ensure_ascii=False), flush=True)
|
|
315
|
+
print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states, 'summary': '未发现可选日'}, ensure_ascii=False), flush=True)
|
|
264
316
|
for d in targets:
|
|
265
317
|
offset = (d - today).days
|
|
266
318
|
try:
|
|
319
|
+
_bump_page_check(orch)
|
|
267
320
|
await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
321
|
+
# 与 FULL 步骤4、其他抢预约任务共用同店预约锁:只串行化“提交预约”,不占整个流程
|
|
322
|
+
orch._acquire_store_step4_lock(force=True)
|
|
323
|
+
try:
|
|
324
|
+
await orch._book_appointment(hour_idx=0, date_offset=offset,
|
|
325
|
+
time_from=time_from, time_to=time_to)
|
|
326
|
+
finally:
|
|
327
|
+
orch._release_store_step4_lock()
|
|
328
|
+
actual = str(getattr(orch, '_last_booked_date', '') or d.isoformat())
|
|
329
|
+
states[actual] = 'booked'
|
|
330
|
+
slot = str(getattr(orch, '_last_booked_slot', '') or 'first')
|
|
331
|
+
return {'status': 'booked', 'date': actual, 'slot': slot, 'dateStates': states, 'summary': f'{actual} {slot} 预约成功', 'windowMode': 'available_days'}
|
|
271
332
|
except Exception as exc:
|
|
272
333
|
msg = str(exc)
|
|
273
|
-
if
|
|
334
|
+
if '不可选' in msg or '目标日期' in msg:
|
|
335
|
+
states[d.isoformat()] = 'disabled'
|
|
336
|
+
elif any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间',
|
|
337
|
+
'指定时间范围内无可预约时段')):
|
|
274
338
|
states[d.isoformat()] = 'selectable_no_slot'
|
|
275
339
|
else:
|
|
276
340
|
states[d.isoformat()] = 'selectable_booking_failed'
|
|
@@ -279,41 +343,78 @@ async def run_task(task: dict) -> dict:
|
|
|
279
343
|
targets = []
|
|
280
344
|
d = max(from_date, today + timedelta(days=1))
|
|
281
345
|
while d <= to_date:
|
|
282
|
-
|
|
346
|
+
if weekday_allowed(d):
|
|
347
|
+
targets.append(d)
|
|
348
|
+
d += timedelta(days=1)
|
|
283
349
|
if not targets:
|
|
284
|
-
return {'status': 'failed', 'summary': '
|
|
350
|
+
return {'status': 'failed', 'summary': '日期范围内没有符合星期条件的可预约日期(今天及以前不算)'}
|
|
285
351
|
states = {d.isoformat(): 'pending' for d in targets}
|
|
352
|
+
# 2026-10-21:range 模式也改为**每轮只打开一次日历**。
|
|
353
|
+
# 先在同一页面上扫完本轮所有目标日期;只有日期可选时才进入预约。
|
|
354
|
+
# 旧实现每个日期都 prepare_appointment_page() 重新打开页面,导致
|
|
355
|
+
# 一轮 3 个日期产生 6~10 次页面检查(用户看到的“轮次对不上”根因)。
|
|
356
|
+
prepared = False
|
|
286
357
|
for d in targets:
|
|
287
358
|
offset = (d - today).days
|
|
288
359
|
try:
|
|
289
|
-
|
|
360
|
+
if not prepared:
|
|
361
|
+
await prepare_appointment_page(orch)
|
|
362
|
+
prepared = True
|
|
290
363
|
info = await find_date_cell(orch, d)
|
|
291
364
|
if not info.get('found'):
|
|
292
|
-
states[d.isoformat()] = 'date_not_found'
|
|
365
|
+
states[d.isoformat()] = 'date_not_found'
|
|
366
|
+
print(json.dumps({'event': 'progress', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
|
|
367
|
+
continue
|
|
293
368
|
if info.get('disabled'):
|
|
294
|
-
states[d.isoformat()] = 'disabled'
|
|
369
|
+
states[d.isoformat()] = 'disabled'
|
|
370
|
+
print(json.dumps({'event': 'progress', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
|
|
371
|
+
continue
|
|
295
372
|
except Exception as exc:
|
|
373
|
+
prepared = False
|
|
296
374
|
states[d.isoformat()] = 'page_error'
|
|
297
|
-
orch._log(f' ⚠️ {d} 分类失败: {str(exc)[:120]}')
|
|
375
|
+
orch._log(f' ⚠️ {d} 分类失败: {str(exc)[:120]}')
|
|
376
|
+
print(json.dumps({'event': 'progress', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
|
|
377
|
+
continue
|
|
378
|
+
print(json.dumps({'event': 'progress', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
|
|
298
379
|
try:
|
|
299
|
-
|
|
300
|
-
|
|
380
|
+
# 与 FULL 步骤4、其他抢预约任务共用同店预约锁:只串行化“提交预约”
|
|
381
|
+
orch._acquire_store_step4_lock(force=True)
|
|
382
|
+
try:
|
|
383
|
+
# range 模式严格目标日期:只点运营给的日期,⛔ 不允许 _pick_date 跳到范围外
|
|
384
|
+
await orch._book_appointment(hour_idx=0, date_offset=offset, exact_date=d.isoformat(),
|
|
385
|
+
time_from=time_from, time_to=time_to)
|
|
386
|
+
finally:
|
|
387
|
+
orch._release_store_step4_lock()
|
|
388
|
+
actual = str(getattr(orch, '_last_booked_date', '') or d.isoformat())
|
|
389
|
+
if actual != d.isoformat():
|
|
390
|
+
raise RuntimeError(f'严格日期校验失败:目标 {d.isoformat()},实际页面选中 {actual}(不提交)')
|
|
301
391
|
states[d.isoformat()] = 'booked'
|
|
302
|
-
|
|
392
|
+
slot = str(getattr(orch, '_last_booked_slot', '') or 'first')
|
|
393
|
+
return {'status': 'booked', 'date': actual, 'slot': slot, 'dateStates': states, 'summary': f'{actual} {slot} 预约成功'}
|
|
303
394
|
except Exception as exc:
|
|
395
|
+
prepared = False
|
|
304
396
|
msg = str(exc)
|
|
305
|
-
if
|
|
397
|
+
if '不可选' in msg or '目标日期' in msg:
|
|
398
|
+
states[d.isoformat()] = 'disabled'
|
|
399
|
+
elif any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间',
|
|
400
|
+
'指定时间范围内无可预约时段')):
|
|
306
401
|
states[d.isoformat()] = 'selectable_no_slot'
|
|
307
402
|
else:
|
|
308
403
|
states[d.isoformat()] = 'selectable_booking_failed'
|
|
309
404
|
orch._log(f' ⚠️ {d} 预约失败: {msg[:160]}')
|
|
310
405
|
last_states = states
|
|
311
|
-
print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'dateStates': states}, ensure_ascii=False), flush=True)
|
|
406
|
+
print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
|
|
312
407
|
if cycles and cycle_no >= cycles:
|
|
313
408
|
break
|
|
314
|
-
|
|
409
|
+
interval = max(30, min(600, int(task.get('pollIntervalSec') or 30)))
|
|
410
|
+
elapsed = time.monotonic() - cycle_started
|
|
411
|
+
await asyncio.sleep(max(10, interval - elapsed))
|
|
315
412
|
return {'status': 'failed', 'dateStates': last_states, 'cycles': cycle_no, 'summary': '本轮日期范围内未抢到预约'}
|
|
316
413
|
finally:
|
|
414
|
+
try:
|
|
415
|
+
await _close_appointment_tab(orch)
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
317
418
|
try:
|
|
318
419
|
await orch.browser.close()
|
|
319
420
|
except Exception:
|
|
@@ -324,10 +425,20 @@ async def run_task(task: dict) -> dict:
|
|
|
324
425
|
pass
|
|
325
426
|
|
|
326
427
|
|
|
428
|
+
def _on_term(signum, frame):
|
|
429
|
+
# SIGTERM 默认直接退出,不会执行 run_task 的 finally ⇒ 预约标签页残留。
|
|
430
|
+
# 抛 KeyboardInterrupt 交给 asyncio.run 取消主任务,确保 finally 里的标签页清理执行。
|
|
431
|
+
raise KeyboardInterrupt
|
|
432
|
+
|
|
433
|
+
|
|
327
434
|
def main():
|
|
328
435
|
task = load_task()
|
|
436
|
+
signal.signal(signal.SIGTERM, _on_term)
|
|
437
|
+
signal.signal(signal.SIGINT, _on_term)
|
|
329
438
|
try:
|
|
330
439
|
out = asyncio.run(run_task(task))
|
|
440
|
+
except KeyboardInterrupt:
|
|
441
|
+
out = {'status': 'stopped', 'summary': '收到停止请求,已关闭预约标签页'}
|
|
331
442
|
except Exception as exc:
|
|
332
443
|
out = {'status': 'failed', 'summary': f'{type(exc).__name__}: {exc}'}
|
|
333
444
|
print(json.dumps(out, ensure_ascii=False), flush=True)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 本机接入 ERP 机器执行面:从剪贴板读取 machine token → 校验 → 写入 agent-report.json。
|
|
4
|
+
*
|
|
5
|
+
* 用法(推荐,token 不进入聊天/命令行历史):
|
|
6
|
+
* node scripts/argent-machine-setup.mjs --check
|
|
7
|
+
* node scripts/argent-machine-setup.mjs --from-clipboard
|
|
8
|
+
*
|
|
9
|
+
* 其他:
|
|
10
|
+
* --config <path> 默认 ~/.dsh/argent/agent-report.json
|
|
11
|
+
* --machine-id <id> 覆盖配置里的 machineId
|
|
12
|
+
* --app-id <id> 覆盖配置里的 tenantAppId
|
|
13
|
+
* --server <url> 覆盖配置里的 serverUrl
|
|
14
|
+
* --token <jwt> ⛔ 不推荐:会进入 shell 历史;仅调试用
|
|
15
|
+
*
|
|
16
|
+
* 红线:不打印 token;不改其他字段;写入前备份;配置文件 600。
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const has = (name) => args.includes(name);
|
|
25
|
+
const val = (name, fallback = '') => {
|
|
26
|
+
const i = args.indexOf(name);
|
|
27
|
+
return i >= 0 && args[i + 1] ? String(args[i + 1]) : fallback;
|
|
28
|
+
};
|
|
29
|
+
const CONFIG = resolve(val('--config', join(homedir(), '.dsh', 'argent', 'agent-report.json')));
|
|
30
|
+
const CHECK = has('--check');
|
|
31
|
+
const FROM_CLIPBOARD = has('--from-clipboard');
|
|
32
|
+
const TOKEN_ARG = val('--token');
|
|
33
|
+
|
|
34
|
+
function fail(message, code = 1) {
|
|
35
|
+
console.error(`❌ ${message}`);
|
|
36
|
+
process.exit(code);
|
|
37
|
+
}
|
|
38
|
+
function readConfig() {
|
|
39
|
+
if (!existsSync(CONFIG)) fail(`配置文件不存在:${CONFIG}`);
|
|
40
|
+
try { return JSON.parse(readFileSync(CONFIG, 'utf8')); } catch (e) { fail(`配置文件不是合法 JSON:${e.message}`); }
|
|
41
|
+
}
|
|
42
|
+
function clipboard() {
|
|
43
|
+
try {
|
|
44
|
+
if (process.platform === 'darwin') return execFileSync('pbpaste', [], { encoding: 'utf8' }).trim();
|
|
45
|
+
if (process.platform === 'win32') return execFileSync('powershell.exe', ['-NoProfile', '-Command', 'Get-Clipboard'], { encoding: 'utf8' }).trim();
|
|
46
|
+
try { return execFileSync('wl-paste', [], { encoding: 'utf8' }).trim(); } catch { /* try xclip */ }
|
|
47
|
+
return execFileSync('xclip', ['-selection', 'clipboard', '-o'], { encoding: 'utf8' }).trim();
|
|
48
|
+
} catch { fail('读取剪贴板失败;请确认已复制 token,或使用 --token(不推荐)'); }
|
|
49
|
+
}
|
|
50
|
+
function decodeJwt(token) {
|
|
51
|
+
const parts = String(token).split('.');
|
|
52
|
+
if (parts.length !== 3) fail('token 不是 JWT 格式');
|
|
53
|
+
try {
|
|
54
|
+
const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
55
|
+
return payload;
|
|
56
|
+
} catch { fail('token payload 解析失败'); }
|
|
57
|
+
}
|
|
58
|
+
async function verify({ token, serverUrl, appId, machineId }) {
|
|
59
|
+
let res;
|
|
60
|
+
try {
|
|
61
|
+
res = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/argent/v1/tenants/${encodeURIComponent(appId)}/machines/${encodeURIComponent(machineId)}/commands?limit=1`, {
|
|
62
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
63
|
+
});
|
|
64
|
+
} catch (e) { fail(`无法连接 ERP:${e.message}`); }
|
|
65
|
+
if (res.status === 401) fail('token 无效或已过期');
|
|
66
|
+
if (res.status === 403) fail('token 与机器/租户不匹配,或机器已被吊销');
|
|
67
|
+
if (!res.ok) fail(`ERP 校验失败:HTTP ${res.status}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cfg = readConfig();
|
|
71
|
+
const machineId = String(val('--machine-id', cfg.machineId || '')).trim();
|
|
72
|
+
const appId = String(val('--app-id', cfg.tenantAppId || '')).trim();
|
|
73
|
+
const serverUrl = String(val('--server', cfg.serverUrl || 'https://whyshu.com')).replace(/\/+$/, '');
|
|
74
|
+
if (!machineId || !appId || !serverUrl) fail('配置缺少 machineId / tenantAppId / serverUrl,请先用 --machine-id/--app-id/--server 补齐');
|
|
75
|
+
|
|
76
|
+
let token = '';
|
|
77
|
+
if (CHECK) {
|
|
78
|
+
token = String(cfg.machineToken || cfg.runnerToken || '').trim();
|
|
79
|
+
if (!token) fail('当前配置没有 machineToken/runnerToken');
|
|
80
|
+
} else if (FROM_CLIPBOARD) {
|
|
81
|
+
token = clipboard();
|
|
82
|
+
if (!token) fail('剪贴板为空;请先在 ERP 复制新 token');
|
|
83
|
+
} else if (TOKEN_ARG) {
|
|
84
|
+
token = String(TOKEN_ARG).trim();
|
|
85
|
+
} else {
|
|
86
|
+
fail('请用 --from-clipboard(推荐)或 --token;详见脚本注释');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const claims = decodeJwt(token);
|
|
90
|
+
if (claims.scope !== 'machine') fail(`token scope 不是 machine(实际:${claims.scope || '空'})`);
|
|
91
|
+
if (claims.appId && claims.appId !== appId) fail(`token appId=${claims.appId} 与配置 ${appId} 不一致`);
|
|
92
|
+
if (claims.machineId && claims.machineId !== machineId) fail(`token machineId=${claims.machineId} 与配置 ${machineId} 不一致`);
|
|
93
|
+
if (claims.exp && Number(claims.exp) * 1000 <= Date.now()) fail('token 已过期,请重新签发');
|
|
94
|
+
|
|
95
|
+
await verify({ token, serverUrl, appId, machineId });
|
|
96
|
+
if (CHECK) {
|
|
97
|
+
console.log(`✅ token 有效:${appId} / ${machineId},配置无需修改`);
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const backup = `${CONFIG}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
102
|
+
try { copyFileSync(CONFIG, backup); } catch { /* 配置不存在时无需备份 */ }
|
|
103
|
+
const next = {
|
|
104
|
+
...cfg,
|
|
105
|
+
serverUrl,
|
|
106
|
+
tenantAppId: appId,
|
|
107
|
+
machineId,
|
|
108
|
+
machineToken: token,
|
|
109
|
+
enabled: true,
|
|
110
|
+
chatEnabled: true,
|
|
111
|
+
};
|
|
112
|
+
delete next.runnerToken;
|
|
113
|
+
writeFileSync(CONFIG, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
114
|
+
chmodSync(CONFIG, 0o600);
|
|
115
|
+
console.log(`✅ 已写入 ${CONFIG}(600)`);
|
|
116
|
+
console.log(`✅ 已校验:${appId} / ${machineId}`);
|
|
117
|
+
console.log(`📦 旧配置备份:${backup}`);
|
|
118
|
+
console.log('下一步:手动重启本机 dsh(脚本不重启服务),然后在 ERP 确认机器在线。');
|
|
@@ -61,6 +61,8 @@ function loadConfig() {
|
|
|
61
61
|
leaseSec: Number(cfg.leaseSec) || 90,
|
|
62
62
|
pollWaitMs: Number(cfg.pollWaitMs) || 20000,
|
|
63
63
|
execTimeoutSec: Number(cfg.execTimeoutSec) || 900,
|
|
64
|
+
batchExecTimeoutSec: Number(process.env.ARGENT_RUNNER_BATCH_TIMEOUT_SEC || cfg.batchExecTimeoutSec) || 43200,
|
|
65
|
+
batchIdleTimeoutSec: Number(process.env.ARGENT_RUNNER_BATCH_IDLE_TIMEOUT_SEC || cfg.batchIdleTimeoutSec) || 1800,
|
|
64
66
|
runnerId: String(cfg.runnerId || `runner-${hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-')}`),
|
|
65
67
|
};
|
|
66
68
|
for (const k of ['serverUrl', 'tenantAppId', 'runnerToken']) {
|
|
@@ -132,28 +134,54 @@ async function executeJob(cfg, job) {
|
|
|
132
134
|
}
|
|
133
135
|
log('job.task.running', { jobId: String(job._id), taskId });
|
|
134
136
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
+
const isBatch = /--records\b/.test(String(job.prompt || ''));
|
|
138
|
+
const timeoutSec = isBatch ? Math.max(Number(cfg.execTimeoutSec) || 900, Number(cfg.batchExecTimeoutSec) || 43200) : (Number(cfg.execTimeoutSec) || 900);
|
|
139
|
+
const startedAt = Date.now();
|
|
140
|
+
const hardDeadline = startedAt + timeoutSec * 1000;
|
|
141
|
+
let lastProgressAt = startedAt;
|
|
142
|
+
let lastServerPollAt = 0;
|
|
143
|
+
while (Date.now() < hardDeadline) {
|
|
137
144
|
await sleep(2000);
|
|
138
145
|
const state = await tbState(cfg);
|
|
139
146
|
const task = (state.tasks || []).find((t) => t.id === taskId);
|
|
140
147
|
if (!task) continue;
|
|
148
|
+
// 本地任务自身更新也视为活跃
|
|
149
|
+
if (task.updatedAt) {
|
|
150
|
+
const t = new Date(task.updatedAt).getTime();
|
|
151
|
+
if (Number.isFinite(t) && t > lastProgressAt) lastProgressAt = t;
|
|
152
|
+
}
|
|
141
153
|
const execs = task.executions || [];
|
|
142
154
|
const latest = execs[execs.length - 1];
|
|
143
155
|
if (latest && latest.endedAt) {
|
|
144
156
|
const outcome = latest.result === 'succeeded' ? 'succeeded' : 'failed';
|
|
145
157
|
return { outcome, summary: latest.error ? String(latest.error).slice(0, 500) : '', sessionRef: latest.sessionId || '', taskId };
|
|
146
158
|
}
|
|
159
|
+
if (isBatch && Date.now() - lastServerPollAt > 15000) {
|
|
160
|
+
lastServerPollAt = Date.now();
|
|
161
|
+
try {
|
|
162
|
+
const p = await serverCall(cfg, `/jobs/${job._id}/progress`);
|
|
163
|
+
const at = p?.data?.lastProgressAt ? new Date(p.data.lastProgressAt).getTime() : 0;
|
|
164
|
+
if (Number.isFinite(at) && at > lastProgressAt) lastProgressAt = at;
|
|
165
|
+
} catch { /* 进度探针暂不可用不判失败 */ }
|
|
166
|
+
}
|
|
167
|
+
if (isBatch) {
|
|
168
|
+
const idleSec = Number(cfg.batchIdleTimeoutSec) || 1800;
|
|
169
|
+
if (Date.now() - lastProgressAt > idleSec * 1000) {
|
|
170
|
+
return { outcome: 'failed', summary: `批任务无步骤进度超过 ${Math.round(idleSec / 60)} 分钟(lastProgress=${new Date(lastProgressAt).toISOString()})`, taskId };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
147
173
|
}
|
|
148
|
-
return { outcome: 'failed', summary:
|
|
174
|
+
return { outcome: 'failed', summary: `执行超过硬上限(>${Math.round(timeoutSec / 60)} 分钟)`, taskId };
|
|
149
175
|
}
|
|
150
176
|
|
|
151
|
-
async function
|
|
177
|
+
async function claimJob(cfg) {
|
|
152
178
|
const claim = await serverCall(cfg, '/jobs/claim', { method: 'POST', body: { runnerId: cfg.runnerId, waitMs: ONCE ? 0 : cfg.pollWaitMs, leaseSec: cfg.leaseSec } });
|
|
153
|
-
|
|
154
|
-
|
|
179
|
+
return claim?.data?.job || null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function runJob(cfg, job) {
|
|
155
183
|
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 {
|
|
184
|
+
if (DRY_RUN) { log('job.dry-run', { jobId: String(job._id) }); return { outcome: 'skipped', dryRun: true }; }
|
|
157
185
|
|
|
158
186
|
// 心跳续租(执行期间每 30s)
|
|
159
187
|
let heartbeatTimer = null;
|
|
@@ -193,6 +221,13 @@ async function runOnce(cfg) {
|
|
|
193
221
|
}
|
|
194
222
|
}
|
|
195
223
|
|
|
224
|
+
async function runOnce(cfg) {
|
|
225
|
+
const job = await claimJob(cfg);
|
|
226
|
+
if (!job) return { claimed: false };
|
|
227
|
+
if (DRY_RUN) { log('job.dry-run', { jobId: String(job._id) }); return { claimed: true, dryRun: true }; }
|
|
228
|
+
return runJob(cfg, job);
|
|
229
|
+
}
|
|
230
|
+
|
|
196
231
|
async function health(cfg) {
|
|
197
232
|
const out = { server: false, dshWeb: false, tenantAppId: cfg.tenantAppId, runnerId: cfg.runnerId };
|
|
198
233
|
try {
|
|
@@ -215,9 +250,27 @@ async function main() {
|
|
|
215
250
|
let stopping = false;
|
|
216
251
|
process.on('SIGINT', () => { stopping = true; log('runner.stop', {}); process.exit(0); });
|
|
217
252
|
process.on('SIGTERM', () => { stopping = true; log('runner.stop', {}); process.exit(0); });
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
253
|
+
const concurrency = Math.max(1, Math.min(5, Number(process.env.ARGENT_RUNNER_CONCURRENCY || cfg.concurrency || 2) || 2));
|
|
254
|
+
log('runner.concurrency', { runnerId: cfg.runnerId, concurrency });
|
|
255
|
+
if (ONCE) {
|
|
256
|
+
try { await runOnce(cfg); } catch (error) { log('runner.error', { message: error.message }); }
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const active = new Set();
|
|
260
|
+
for (;;) {
|
|
261
|
+
while (active.size < concurrency) {
|
|
262
|
+
let job;
|
|
263
|
+
try { job = await claimJob(cfg); } catch (error) { log('runner.error', { message: error.message }); await sleep(3000); break; }
|
|
264
|
+
if (!job) break;
|
|
265
|
+
const p = runJob(cfg, job)
|
|
266
|
+
.catch((error) => log('runner.error', { jobId: String(job._id), message: error.message }))
|
|
267
|
+
.finally(() => active.delete(p));
|
|
268
|
+
active.add(p);
|
|
269
|
+
}
|
|
270
|
+
if (!active.size) await sleep(3000);
|
|
271
|
+
else await Promise.race([...active, sleep(3000)]);
|
|
272
|
+
if (stopping) process.exit(0);
|
|
273
|
+
}
|
|
221
274
|
}
|
|
222
275
|
|
|
223
276
|
main().catch((error) => { console.error(`❌ runner 失败: ${error.message}`); process.exit(1); });
|