@whyshu/argent-engine 0.7.3 → 0.7.5
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 -28
- package/engine/VERSION +1 -1
- package/engine/scripts/appointment-create.mjs +34 -0
- package/engine/scripts/appointment-stop.mjs +18 -0
- package/engine/scripts/appointment-worker.mjs +96 -0
- package/engine/scripts/appointment_grab.py +327 -0
- package/engine/scripts/argent-runner.mjs +223 -0
- package/engine/scripts/argent-setup-check.mjs +3 -3
- package/engine/scripts/capability-packs.lock.json +33 -39
- package/engine/scripts/check-agent-catalog.mjs +1 -1
- package/engine/scripts/check-config-catalog.mjs +0 -1
- package/engine/scripts/collect-evidence.mjs +2 -5
- package/engine/scripts/daily-report.mjs +315 -12
- package/engine/scripts/dsh-upgrade.mjs +18 -5
- package/engine/scripts/flow-requirements.json +5 -7
- package/engine/scripts/flow-timers.sh +1 -1
- package/engine/scripts/flow-trigger.sh +1 -1
- package/engine/scripts/gen-preset-rows.mjs +20 -0
- package/engine/scripts/invoice_stageb.py +20 -1
- package/engine/scripts/lib/agent-install.mjs +10 -1
- package/engine/scripts/lib/daily-report-core.mjs +124 -16
- package/engine/scripts/lib/dsh-versions.mjs +17 -1
- package/engine/scripts/lib/ml-ads-core.mjs +204 -0
- package/engine/scripts/lib/plugin-packages.mjs +1 -0
- package/engine/scripts/ml-ads.mjs +181 -0
- package/engine/scripts/ml-oauth-setup.mjs +1 -1
- package/engine/scripts/p2-report-run.mjs +102 -0
- package/engine/scripts/patches/dsh-session--append-message-shape.patch +34 -9
- package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +44 -0
- package/engine/scripts/patches/verify-hardening.mjs +4 -1
- package/engine/scripts/patches/verify-turn-outside-replace.mjs +64 -0
- package/engine/scripts/poll-fulfillment.sh +1 -1
- package/engine/scripts/profile-doctor.mjs +1 -1
- package/engine/scripts/python-runtime.sh +16 -4
- package/engine/scripts/release-argent.sh +8 -7
- package/engine/scripts/session-load-check.mjs +105 -0
- package/engine/scripts/sync-scripts.sh +1 -1
- package/engine/scripts/xzh-browser.sh +37 -0
- package/engine/scripts/xzh_browser.py +49 -0
- package/engine/skills/ml-full-appointment/SKILL.md +98 -0
- package/engine/version.mjs +1 -1
- package/package.json +1 -1
- package/engine/scripts/drill-clean-machine.sh +0 -315
- package/engine/scripts/install-internal-role.sh +0 -190
- package/engine/scripts/install-one-shot.mjs +0 -665
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""FULL 抢预约 worker 的浏览器执行器(参数全部来自 AppointmentTask,不读 lip)。
|
|
3
|
+
|
|
4
|
+
- 日期范围严格使用任务 dateFrom/dateTo;
|
|
5
|
+
- 逐日三态:disabled(不可选)/ selectable_no_slot(可选但无时段)/ selectable_booking_failed / booked;
|
|
6
|
+
- 预约成功即停,不执行 FULL 步骤 5-8;
|
|
7
|
+
- 由 appointment-worker.mjs 启动;stdout 最后一行 JSON 为结果。
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import date, datetime, timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import SimpleNamespace
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parent
|
|
20
|
+
sys.path.insert(0, str(ROOT))
|
|
21
|
+
|
|
22
|
+
import orchestrator as O # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DATE_CELL_JS = r"""(targetIso) => {
|
|
26
|
+
const days = document.querySelectorAll('div.day');
|
|
27
|
+
const MONTHS = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];
|
|
28
|
+
const my = document.querySelector('.focus-ui-datepicker__content__month-year');
|
|
29
|
+
const header = my ? (my.textContent || '').trim().toLowerCase() : '';
|
|
30
|
+
const target = new Date(targetIso + 'T12:00:00');
|
|
31
|
+
const viewMonths = MONTHS.map((m, mi) => header.includes(m) ? mi : -1).filter(x => x >= 0);
|
|
32
|
+
const ym = header.match(/(\d{4})/);
|
|
33
|
+
if (!viewMonths.length || !ym) return {found:false, reason:'header_unreliable', header};
|
|
34
|
+
const viewYear = parseInt(ym[1], 10);
|
|
35
|
+
let month = viewMonths[0] - 1, prev = 0, started = false;
|
|
36
|
+
for (let i = 0; i < days.length; i++) {
|
|
37
|
+
const t = (days[i].textContent || '').trim();
|
|
38
|
+
if (!/^\d+$/.test(t)) continue;
|
|
39
|
+
const d = parseInt(t, 10);
|
|
40
|
+
if (d === 1 && (prev > 1 || prev === 0)) {
|
|
41
|
+
if (!started) { started = true; month = viewMonths[0]; } else { month++; }
|
|
42
|
+
}
|
|
43
|
+
prev = d;
|
|
44
|
+
if (started && month === target.getMonth() && d === target.getDate()) {
|
|
45
|
+
const cls = days[i].className || '';
|
|
46
|
+
return {found:true, idx:i, disabled: cls.includes('day--disabled'), cls, header};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {found:false, reason:'not_in_view', header};
|
|
50
|
+
}"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
SCAN_DAYS_JS = r"""() => {
|
|
54
|
+
const days = [...document.querySelectorAll('div.day')];
|
|
55
|
+
const MONTHS = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];
|
|
56
|
+
const my = document.querySelector('.focus-ui-datepicker__content__month-year');
|
|
57
|
+
const header = my ? (my.textContent || '').trim().toLowerCase() : '';
|
|
58
|
+
const viewMonths = MONTHS.map((m, mi) => header.includes(m) ? mi : -1).filter(x => x >= 0);
|
|
59
|
+
const ym = header.match(/(\d{4})/);
|
|
60
|
+
if (!viewMonths.length || !ym) return {header, dates: []};
|
|
61
|
+
const viewYear = parseInt(ym[1], 10);
|
|
62
|
+
let month = viewMonths[0] - 1, year = viewYear, prev = 0, started = false;
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const el of days) {
|
|
65
|
+
const t = (el.textContent || '').trim();
|
|
66
|
+
if (!/^\d+$/.test(t)) continue;
|
|
67
|
+
const d = parseInt(t, 10);
|
|
68
|
+
if (d === 1 && (prev > 1 || prev === 0)) {
|
|
69
|
+
if (!started) { started = true; month = viewMonths[0]; year = viewYear; }
|
|
70
|
+
else { month++; if (month > 11) { month = 0; year++; } }
|
|
71
|
+
}
|
|
72
|
+
prev = d;
|
|
73
|
+
if (!started) continue;
|
|
74
|
+
out.push({date: year + '-' + String(month+1).padStart(2,'0') + '-' + String(d).padStart(2,'0'), disabled: (el.className||'').includes('day--disabled')});
|
|
75
|
+
}
|
|
76
|
+
return {header, dates: out};
|
|
77
|
+
}"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_task() -> dict:
|
|
81
|
+
ap = argparse.ArgumentParser()
|
|
82
|
+
ap.add_argument('--task-json', required=True)
|
|
83
|
+
ap.add_argument('--cdp-hint', default='')
|
|
84
|
+
args = ap.parse_args()
|
|
85
|
+
return json.loads(args.task_json)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def iso_day(d) -> date:
|
|
89
|
+
return datetime.fromisoformat(str(d)[:10]).date()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def prepare_appointment_page(orch) -> None:
|
|
93
|
+
"""进入预约页并选好运输方式/地址,打开日期日历;页面慢/弹窗允许重试。"""
|
|
94
|
+
last = None
|
|
95
|
+
for attempt in range(3):
|
|
96
|
+
try:
|
|
97
|
+
await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
|
|
98
|
+
if await orch._has_existing_appointment():
|
|
99
|
+
raise RuntimeError('已有预约存在,不重复抢')
|
|
100
|
+
page = orch.browser.page
|
|
101
|
+
trigger = page.locator('#shipment-type-selection-dropdown-id-trigger')
|
|
102
|
+
await trigger.wait_for(state='visible', timeout=30000)
|
|
103
|
+
await trigger.click()
|
|
104
|
+
await asyncio.sleep(0.8)
|
|
105
|
+
option = page.get_by_role('option', name='Vehículo particular', exact=False).first
|
|
106
|
+
if await option.count() == 0:
|
|
107
|
+
option = page.get_by_role('option', name='Vehículo', exact=False).first
|
|
108
|
+
if await option.count() == 0:
|
|
109
|
+
raise RuntimeError('未找到 Vehículo 运输选项')
|
|
110
|
+
await option.click()
|
|
111
|
+
await asyncio.sleep(1.2)
|
|
112
|
+
try:
|
|
113
|
+
await orch.browser.page.wait_for_selector('.andes-list__item-action', timeout=8000)
|
|
114
|
+
btns = orch.browser.page.locator('.andes-list__item-action')
|
|
115
|
+
target = None
|
|
116
|
+
for i in range(await btns.count()):
|
|
117
|
+
txt = (await btns.nth(i).text_content() or '')
|
|
118
|
+
if 'MXCD05' in txt:
|
|
119
|
+
target = (btns.nth(i), txt); break
|
|
120
|
+
if target is None:
|
|
121
|
+
raise RuntimeError('未找到指定仓库 MXCD05')
|
|
122
|
+
await target[0].scroll_into_view_if_needed(timeout=5000)
|
|
123
|
+
await asyncio.sleep(0.4)
|
|
124
|
+
await target[0].click()
|
|
125
|
+
try:
|
|
126
|
+
cont = orch.browser.page.locator('button', has_text='Continuar').first
|
|
127
|
+
await cont.click(force=True, timeout=5000)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
await asyncio.sleep(1)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
await orch.browser.wait_for_selector('input[readonly]', timeout=15, step=4, action='date_input')
|
|
134
|
+
await orch.browser.click_selector_with_fallback(4, 'date_input')
|
|
135
|
+
await asyncio.sleep(1)
|
|
136
|
+
return
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
last = exc
|
|
139
|
+
orch._log(f' ⚠️ 预约页准备第 {attempt + 1}/3 次失败: {str(exc)[:100]}')
|
|
140
|
+
await asyncio.sleep(3)
|
|
141
|
+
raise RuntimeError(f'预约页准备失败:{last}')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def calendar_month_state(orch) -> dict:
|
|
145
|
+
return await orch._calendar_state()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def find_date_cell(orch, target: date) -> dict:
|
|
149
|
+
"""返回目标 date 的 {found,idx,disabled},必要时翻月。"""
|
|
150
|
+
target_month = target.month - 1
|
|
151
|
+
target_year = target.year
|
|
152
|
+
for _ in range(4):
|
|
153
|
+
st = await calendar_month_state(orch)
|
|
154
|
+
header = (st.get('header') or '').lower()
|
|
155
|
+
months = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']
|
|
156
|
+
vis = [i for i, m in enumerate(months) if m in header]
|
|
157
|
+
yr = year_of(header)
|
|
158
|
+
if target_month in vis and (yr is None or target_year == yr):
|
|
159
|
+
r = await orch.browser.page.evaluate(DATE_CELL_JS, target.isoformat())
|
|
160
|
+
return r if isinstance(r, dict) else json.loads(r)
|
|
161
|
+
await orch._flip_month(st, target_month, target_year)
|
|
162
|
+
await asyncio.sleep(0.8)
|
|
163
|
+
return {'found': False, 'reason': 'flip_failed'}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def scan_available_days(orch, need: int, max_months: int = 4) -> dict:
|
|
167
|
+
await prepare_appointment_page(orch)
|
|
168
|
+
collected = {}; order = []; selected = []
|
|
169
|
+
for _ in range(max_months):
|
|
170
|
+
r = await orch.browser.page.evaluate(SCAN_DAYS_JS)
|
|
171
|
+
r = r if isinstance(r, dict) else json.loads(r)
|
|
172
|
+
for item in r.get('dates') or []:
|
|
173
|
+
d = item.get('date')
|
|
174
|
+
if not d or d in collected:
|
|
175
|
+
continue
|
|
176
|
+
collected[d] = 'disabled' if item.get('disabled') else 'selectable'
|
|
177
|
+
order.append(d)
|
|
178
|
+
if not item.get('disabled'):
|
|
179
|
+
selected.append(d)
|
|
180
|
+
if len(selected) >= need:
|
|
181
|
+
break
|
|
182
|
+
if len(selected) >= need:
|
|
183
|
+
break
|
|
184
|
+
import re
|
|
185
|
+
header = (r.get('header') or '').lower()
|
|
186
|
+
months = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']
|
|
187
|
+
vis = [i for i, m in enumerate(months) if m in header]
|
|
188
|
+
yr = year_of(header)
|
|
189
|
+
if not vis or yr is None:
|
|
190
|
+
break
|
|
191
|
+
nm = max(vis) + 1
|
|
192
|
+
ny = yr + (1 if nm > 11 else 0)
|
|
193
|
+
nm = nm % 12
|
|
194
|
+
await orch._flip_month(await calendar_month_state(orch), nm, ny)
|
|
195
|
+
await asyncio.sleep(0.8)
|
|
196
|
+
return {'states': collected, 'order': order, 'selected': selected[:need]}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def year_of(header: str):
|
|
200
|
+
import re
|
|
201
|
+
m = re.search(r'(\d{4})', header or '')
|
|
202
|
+
return int(m.group(1)) if m else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def run_task(task: dict) -> dict:
|
|
206
|
+
store = task.get('store') or ''
|
|
207
|
+
if not store:
|
|
208
|
+
return {'status': 'failed', 'summary': '任务缺少店铺'}
|
|
209
|
+
date_mode = task.get('dateMode') or 'range'
|
|
210
|
+
from_date = iso_day(task['dateFrom']) if date_mode == 'range' else None
|
|
211
|
+
to_date = iso_day(task['dateTo']) if date_mode == 'range' else None
|
|
212
|
+
cfg = O.load_config()
|
|
213
|
+
cfg.store_name = store
|
|
214
|
+
sel = O.Selectors()
|
|
215
|
+
args = SimpleNamespace(record_id='', sku=task.get('sku', ''), qty=task.get('quantity'), box=task.get('boxCount'),
|
|
216
|
+
shipment_id=task.get('shipmentNo') or '', store_name=store, no_lock=True)
|
|
217
|
+
orch = O.Orchestrator(cfg, sel, args)
|
|
218
|
+
orch.state.store_name = store
|
|
219
|
+
orch.state.record_id = ''
|
|
220
|
+
orch.state.sku = str(task.get('sku') or '')
|
|
221
|
+
orch.state.qty = str(task.get('quantity') or '')
|
|
222
|
+
orch.state.box = str(task.get('boxCount') or '')
|
|
223
|
+
if task.get('shipmentNo'):
|
|
224
|
+
orch.state.shipment_id = str(task['shipmentNo'])
|
|
225
|
+
try:
|
|
226
|
+
if not orch.acquire_store_executor_lock():
|
|
227
|
+
return {'status': 'failed', 'summary': f'同店执行者锁冲突:{orch._store_exec_holder}'}
|
|
228
|
+
_dl, port = orch._open_store()
|
|
229
|
+
if not port:
|
|
230
|
+
return {'status': 'failed', 'summary': 'CDP 端口未发现(店铺窗口未就绪)'}
|
|
231
|
+
orch.browser.cdp_url = f'http://127.0.0.1:{port}'
|
|
232
|
+
await orch._connect_with_port_rediscovery()
|
|
233
|
+
await orch._preflight_store_window(phase='appointment-grab')
|
|
234
|
+
if not orch.state.shipment_id:
|
|
235
|
+
if not orch.state.sku or not orch.state.qty or not orch.state.box:
|
|
236
|
+
return {'status': 'failed', 'summary': 'SKU-only 缺少 sku/qty/box(任务参数不完整)'}
|
|
237
|
+
await orch.step1_prepare()
|
|
238
|
+
await orch.step2_entry()
|
|
239
|
+
await orch.step3_select_product()
|
|
240
|
+
today = (await orch._platform_today()).date()
|
|
241
|
+
cycles = int(task.get('maxCycles') or 0)
|
|
242
|
+
cycle_no = 0
|
|
243
|
+
last_states = {}
|
|
244
|
+
while True:
|
|
245
|
+
cycle_no += 1
|
|
246
|
+
if date_mode == 'available_days':
|
|
247
|
+
need = int(task.get('availableDays') or 0)
|
|
248
|
+
scan = await scan_available_days(orch, need)
|
|
249
|
+
tomorrow = (today + timedelta(days=1)).isoformat()
|
|
250
|
+
states = {k: v for k, v in dict(scan['states']).items() if k >= tomorrow}
|
|
251
|
+
targets = [date.fromisoformat(x) for x in scan['selected']]
|
|
252
|
+
if not targets:
|
|
253
|
+
print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'dateStates': states, 'summary': '未发现可选日'}, ensure_ascii=False), flush=True)
|
|
254
|
+
for d in targets:
|
|
255
|
+
offset = (d - today).days
|
|
256
|
+
try:
|
|
257
|
+
await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
|
|
258
|
+
await orch._book_appointment(hour_idx=0, date_offset=offset)
|
|
259
|
+
states[d.isoformat()] = 'booked'
|
|
260
|
+
return {'status': 'booked', 'date': d.isoformat(), 'slot': 'first', 'dateStates': states, 'summary': f'{d.isoformat()} 预约成功', 'windowMode': 'available_days'}
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
msg = str(exc)
|
|
263
|
+
if any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间')):
|
|
264
|
+
states[d.isoformat()] = 'selectable_no_slot'
|
|
265
|
+
else:
|
|
266
|
+
states[d.isoformat()] = 'selectable_booking_failed'
|
|
267
|
+
orch._log(f' ⚠️ {d} 预约失败: {msg[:160]}')
|
|
268
|
+
else:
|
|
269
|
+
targets = []
|
|
270
|
+
d = max(from_date, today + timedelta(days=1))
|
|
271
|
+
while d <= to_date:
|
|
272
|
+
targets.append(d); d += timedelta(days=1)
|
|
273
|
+
if not targets:
|
|
274
|
+
return {'status': 'failed', 'summary': '日期范围内没有可预约日期(今天及以前不算)'}
|
|
275
|
+
states = {d.isoformat(): 'pending' for d in targets}
|
|
276
|
+
for d in targets:
|
|
277
|
+
offset = (d - today).days
|
|
278
|
+
try:
|
|
279
|
+
await prepare_appointment_page(orch)
|
|
280
|
+
info = await find_date_cell(orch, d)
|
|
281
|
+
if not info.get('found'):
|
|
282
|
+
states[d.isoformat()] = 'date_not_found'; continue
|
|
283
|
+
if info.get('disabled'):
|
|
284
|
+
states[d.isoformat()] = 'disabled'; continue
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
states[d.isoformat()] = 'page_error'
|
|
287
|
+
orch._log(f' ⚠️ {d} 分类失败: {str(exc)[:120]}'); continue
|
|
288
|
+
try:
|
|
289
|
+
await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
|
|
290
|
+
await orch._book_appointment(hour_idx=0, date_offset=offset)
|
|
291
|
+
states[d.isoformat()] = 'booked'
|
|
292
|
+
return {'status': 'booked', 'date': d.isoformat(), 'slot': 'first', 'dateStates': states, 'summary': f'{d.isoformat()} 预约成功'}
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
msg = str(exc)
|
|
295
|
+
if any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间')):
|
|
296
|
+
states[d.isoformat()] = 'selectable_no_slot'
|
|
297
|
+
else:
|
|
298
|
+
states[d.isoformat()] = 'selectable_booking_failed'
|
|
299
|
+
orch._log(f' ⚠️ {d} 预约失败: {msg[:160]}')
|
|
300
|
+
last_states = states
|
|
301
|
+
print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'dateStates': states}, ensure_ascii=False), flush=True)
|
|
302
|
+
if cycles and cycle_no >= cycles:
|
|
303
|
+
break
|
|
304
|
+
await asyncio.sleep(max(10, int(task.get('pollIntervalSec') or 60)))
|
|
305
|
+
return {'status': 'failed', 'dateStates': last_states, 'cycles': cycle_no, 'summary': '本轮日期范围内未抢到预约'}
|
|
306
|
+
finally:
|
|
307
|
+
try:
|
|
308
|
+
await orch.browser.close()
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
try:
|
|
312
|
+
orch.release_store_executor_lock()
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def main():
|
|
318
|
+
task = load_task()
|
|
319
|
+
try:
|
|
320
|
+
out = asyncio.run(run_task(task))
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
out = {'status': 'failed', 'summary': f'{type(exc).__name__}: {exc}'}
|
|
323
|
+
print(json.dumps(out, ensure_ascii=False), flush=True)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == '__main__':
|
|
327
|
+
main()
|
|
@@ -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); });
|
|
@@ -127,7 +127,7 @@ export function buildChecks({ engine, dataDir, state, scopeDirs = [], stateOnly
|
|
|
127
127
|
: (has
|
|
128
128
|
? `状态文件在但**形状不认识**(${engine.stateFile(dataDir)})——版本或字段对不上这份契约`
|
|
129
129
|
: `证据不足:没有状态文件(${engine.stateFile(dataDir)})⇒ 这份安装没跑过安装后 setup`),
|
|
130
|
-
fix: state ? [] : ['重启宿主(插件启动期会写一次)', '
|
|
130
|
+
fix: state ? [] : ['重启宿主(插件启动期会写一次)', '或运行启动器自检(@whyshu/argent 的自检子命令)修复'],
|
|
131
131
|
})
|
|
132
132
|
}
|
|
133
133
|
|
|
@@ -140,7 +140,7 @@ export function buildChecks({ engine, dataDir, state, scopeDirs = [], stateOnly
|
|
|
140
140
|
const step = state && state.steps ? state.steps['python-runtime'] : null
|
|
141
141
|
if (verdict) {
|
|
142
142
|
if (verdict.state === 'ready') {
|
|
143
|
-
checks.push({ id: 'python-runtime', ok: true, detail: `venv 可跑(${venvPython};${engine.VENV_PKGS.join(' / ')} 可 import)`, fix: [] })
|
|
143
|
+
checks.push({ id: 'python-runtime', ok: true, detail: `venv 可跑(${venvPython};${(engine.VENV_IMPORT_PKGS || engine.VENV_PKGS).join(' / ')} 可 import)`, fix: [] })
|
|
144
144
|
} else if (verdict.state === 'timeout') {
|
|
145
145
|
checks.push({
|
|
146
146
|
id: 'python-runtime',
|
|
@@ -157,7 +157,7 @@ export function buildChecks({ engine, dataDir, state, scopeDirs = [], stateOnly
|
|
|
157
157
|
id: 'python-runtime',
|
|
158
158
|
ok: null,
|
|
159
159
|
detail: `证据不足:${verdict.reason}${ver}(${at}),且没有状态文件 ⇒ 无法判断这份安装是否该有它`,
|
|
160
|
-
fix: ['
|
|
160
|
+
fix: ['运行 `bash scripts/python-runtime.sh ensure` 补齐运行时', '或重启宿主让插件后台补建'],
|
|
161
161
|
})
|
|
162
162
|
} else {
|
|
163
163
|
const repair = step && step.repair && Array.isArray(step.repair.lines) ? step.repair.lines : engine.repairHint({ dataDir }).lines
|