@whyshu/argent-engine 0.7.5 → 0.7.7
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 +37 -2
- package/engine/scripts/appointment-worker.mjs +72 -15
- package/engine/scripts/appointment_grab.py +141 -20
- 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 +26 -2
- 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 +32 -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('已有预约存在,不重复抢')
|
|
@@ -206,7 +249,24 @@ async def run_task(task: dict) -> dict:
|
|
|
206
249
|
store = task.get('store') or ''
|
|
207
250
|
if not store:
|
|
208
251
|
return {'status': 'failed', 'summary': '任务缺少店铺'}
|
|
252
|
+
# 站别守卫:当前预约页/后台是墨西哥站(myaccount.mercadolibre.com.mx)专用。
|
|
253
|
+
# 巴西/阿根廷等非墨西哥店直接拒绝,避免拿墨西哥后台去找其他站货件。
|
|
254
|
+
try:
|
|
255
|
+
info, _dev = O._resolve_store(store)
|
|
256
|
+
platform = str((info or {}).get('platform') or '')
|
|
257
|
+
except Exception:
|
|
258
|
+
platform = ''
|
|
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'))
|
|
261
|
+
if not is_mx:
|
|
262
|
+
return {'status': 'failed', 'summary': f'抢预约当前仅支持墨西哥站(MLM);{store} 属于 {platform},暂未适配,请勿执行'}
|
|
209
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
|
|
210
270
|
from_date = iso_day(task['dateFrom']) if date_mode == 'range' else None
|
|
211
271
|
to_date = iso_day(task['dateTo']) if date_mode == 'range' else None
|
|
212
272
|
cfg = O.load_config()
|
|
@@ -242,6 +302,7 @@ async def run_task(task: dict) -> dict:
|
|
|
242
302
|
cycle_no = 0
|
|
243
303
|
last_states = {}
|
|
244
304
|
while True:
|
|
305
|
+
cycle_started = time.monotonic()
|
|
245
306
|
cycle_no += 1
|
|
246
307
|
if date_mode == 'available_days':
|
|
247
308
|
need = int(task.get('availableDays') or 0)
|
|
@@ -249,18 +310,31 @@ async def run_task(task: dict) -> dict:
|
|
|
249
310
|
tomorrow = (today + timedelta(days=1)).isoformat()
|
|
250
311
|
states = {k: v for k, v in dict(scan['states']).items() if k >= tomorrow}
|
|
251
312
|
targets = [date.fromisoformat(x) for x in scan['selected']]
|
|
313
|
+
targets = [d for d in targets if weekday_allowed(d)]
|
|
252
314
|
if not targets:
|
|
253
|
-
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)
|
|
254
316
|
for d in targets:
|
|
255
317
|
offset = (d - today).days
|
|
256
318
|
try:
|
|
319
|
+
_bump_page_check(orch)
|
|
257
320
|
await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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'}
|
|
261
332
|
except Exception as exc:
|
|
262
333
|
msg = str(exc)
|
|
263
|
-
if
|
|
334
|
+
if '不可选' in msg or '目标日期' in msg:
|
|
335
|
+
states[d.isoformat()] = 'disabled'
|
|
336
|
+
elif any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间',
|
|
337
|
+
'指定时间范围内无可预约时段')):
|
|
264
338
|
states[d.isoformat()] = 'selectable_no_slot'
|
|
265
339
|
else:
|
|
266
340
|
states[d.isoformat()] = 'selectable_booking_failed'
|
|
@@ -269,41 +343,78 @@ async def run_task(task: dict) -> dict:
|
|
|
269
343
|
targets = []
|
|
270
344
|
d = max(from_date, today + timedelta(days=1))
|
|
271
345
|
while d <= to_date:
|
|
272
|
-
|
|
346
|
+
if weekday_allowed(d):
|
|
347
|
+
targets.append(d)
|
|
348
|
+
d += timedelta(days=1)
|
|
273
349
|
if not targets:
|
|
274
|
-
return {'status': 'failed', 'summary': '
|
|
350
|
+
return {'status': 'failed', 'summary': '日期范围内没有符合星期条件的可预约日期(今天及以前不算)'}
|
|
275
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
|
|
276
357
|
for d in targets:
|
|
277
358
|
offset = (d - today).days
|
|
278
359
|
try:
|
|
279
|
-
|
|
360
|
+
if not prepared:
|
|
361
|
+
await prepare_appointment_page(orch)
|
|
362
|
+
prepared = True
|
|
280
363
|
info = await find_date_cell(orch, d)
|
|
281
364
|
if not info.get('found'):
|
|
282
|
-
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
|
|
283
368
|
if info.get('disabled'):
|
|
284
|
-
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
|
|
285
372
|
except Exception as exc:
|
|
373
|
+
prepared = False
|
|
286
374
|
states[d.isoformat()] = 'page_error'
|
|
287
|
-
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)
|
|
288
379
|
try:
|
|
289
|
-
|
|
290
|
-
|
|
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}(不提交)')
|
|
291
391
|
states[d.isoformat()] = 'booked'
|
|
292
|
-
|
|
392
|
+
slot = str(getattr(orch, '_last_booked_slot', '') or 'first')
|
|
393
|
+
return {'status': 'booked', 'date': actual, 'slot': slot, 'dateStates': states, 'summary': f'{actual} {slot} 预约成功'}
|
|
293
394
|
except Exception as exc:
|
|
395
|
+
prepared = False
|
|
294
396
|
msg = str(exc)
|
|
295
|
-
if
|
|
397
|
+
if '不可选' in msg or '目标日期' in msg:
|
|
398
|
+
states[d.isoformat()] = 'disabled'
|
|
399
|
+
elif any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间',
|
|
400
|
+
'指定时间范围内无可预约时段')):
|
|
296
401
|
states[d.isoformat()] = 'selectable_no_slot'
|
|
297
402
|
else:
|
|
298
403
|
states[d.isoformat()] = 'selectable_booking_failed'
|
|
299
404
|
orch._log(f' ⚠️ {d} 预约失败: {msg[:160]}')
|
|
300
405
|
last_states = states
|
|
301
|
-
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)
|
|
302
407
|
if cycles and cycle_no >= cycles:
|
|
303
408
|
break
|
|
304
|
-
|
|
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))
|
|
305
412
|
return {'status': 'failed', 'dateStates': last_states, 'cycles': cycle_no, 'summary': '本轮日期范围内未抢到预约'}
|
|
306
413
|
finally:
|
|
414
|
+
try:
|
|
415
|
+
await _close_appointment_tab(orch)
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
307
418
|
try:
|
|
308
419
|
await orch.browser.close()
|
|
309
420
|
except Exception:
|
|
@@ -314,10 +425,20 @@ async def run_task(task: dict) -> dict:
|
|
|
314
425
|
pass
|
|
315
426
|
|
|
316
427
|
|
|
428
|
+
def _on_term(signum, frame):
|
|
429
|
+
# SIGTERM 默认直接退出,不会执行 run_task 的 finally ⇒ 预约标签页残留。
|
|
430
|
+
# 抛 KeyboardInterrupt 交给 asyncio.run 取消主任务,确保 finally 里的标签页清理执行。
|
|
431
|
+
raise KeyboardInterrupt
|
|
432
|
+
|
|
433
|
+
|
|
317
434
|
def main():
|
|
318
435
|
task = load_task()
|
|
436
|
+
signal.signal(signal.SIGTERM, _on_term)
|
|
437
|
+
signal.signal(signal.SIGINT, _on_term)
|
|
319
438
|
try:
|
|
320
439
|
out = asyncio.run(run_task(task))
|
|
440
|
+
except KeyboardInterrupt:
|
|
441
|
+
out = {'status': 'stopped', 'summary': '收到停止请求,已关闭预约标签页'}
|
|
321
442
|
except Exception as exc:
|
|
322
443
|
out = {'status': 'failed', 'summary': f'{type(exc).__name__}: {exc}'}
|
|
323
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); });
|