@aiyiran/myclaw 1.1.126 → 1.1.128
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/index.js +2 -2
- package/package.json +1 -1
- package/server/sync_workspace.py +195 -114
package/index.js
CHANGED
|
@@ -1383,8 +1383,8 @@ function runUpdate() {
|
|
|
1383
1383
|
|
|
1384
1384
|
if (detectPlatform() === 'wsl' || detectPlatform() === 'linux' || detectPlatform() === 'mac') {
|
|
1385
1385
|
console.log('');
|
|
1386
|
-
console.log('
|
|
1387
|
-
console.log(' ' + colors.yellow + '
|
|
1386
|
+
console.log('如需重装 OpenClaw 核心(锁定版本 ' + OPENCLAW_VERSION + '),请运行:');
|
|
1387
|
+
console.log(' ' + colors.yellow + 'mc longxia' + colors.nc);
|
|
1388
1388
|
}
|
|
1389
1389
|
break;
|
|
1390
1390
|
} catch (err) {
|
package/package.json
CHANGED
package/server/sync_workspace.py
CHANGED
|
@@ -13,8 +13,6 @@ import threading
|
|
|
13
13
|
import socketserver
|
|
14
14
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
15
15
|
from urllib.parse import urlparse, parse_qs, unquote
|
|
16
|
-
from watchdog.observers import Observer
|
|
17
|
-
from watchdog.events import FileSystemEventHandler
|
|
18
16
|
from qiniu import Auth, put_file_v2, CdnManager, BucketManager
|
|
19
17
|
|
|
20
18
|
# 确保同目录的共享模块可导入
|
|
@@ -63,7 +61,9 @@ HISTORY_FILENAME = "history.json"
|
|
|
63
61
|
# 回滚压制表:{ abs_path: 写入时间戳 }
|
|
64
62
|
# 回滚时往 workspace 写文件会触发 on_modified,用此表跳过快照,避免产生多余版本
|
|
65
63
|
_rollback_suppressed: dict = {}
|
|
66
|
-
|
|
64
|
+
# 轮询模式下,回滚写盘后要等到下一轮扫描才会被发现,故 TTL 必须 > 轮询间隔,
|
|
65
|
+
# 否则压制提前过期会导致回滚被误判为"修改"而多生成一个历史快照。
|
|
66
|
+
_ROLLBACK_SUPPRESS_TTL = 30.0 # 秒
|
|
67
67
|
|
|
68
68
|
# 时间窗口合并:同一文件在此时间窗口内的多次变更只保留最新快照(覆盖,不新增版本)
|
|
69
69
|
_HISTORY_MERGE_WINDOW = 20.0 # 秒
|
|
@@ -226,53 +226,8 @@ def _path_has_ignored_dir(filepath):
|
|
|
226
226
|
return any(part in IGNORE_DIR_PATTERNS for part in parts)
|
|
227
227
|
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _is_file_event(self, event):
|
|
232
|
-
return not getattr(event, "is_directory", False)
|
|
233
|
-
|
|
234
|
-
def _should_ignore(self, filepath):
|
|
235
|
-
basename = os.path.basename(filepath)
|
|
236
|
-
if basename in IGNORE_FILE_PATTERNS:
|
|
237
|
-
return True
|
|
238
|
-
if _path_has_ignored_dir(filepath):
|
|
239
|
-
return True
|
|
240
|
-
# 忽略 .openclaw 根目录下的隐藏文件(非 workspace 目录内的)
|
|
241
|
-
if basename.startswith('.') and 'workspace' not in filepath:
|
|
242
|
-
return True
|
|
243
|
-
return False
|
|
244
|
-
|
|
245
|
-
# def on_created(self, event):
|
|
246
|
-
# if not self._is_file_event(event):
|
|
247
|
-
# return
|
|
248
|
-
# print(f"🟢 新建: {event.src_path}")
|
|
249
|
-
# file_gen(event.src_path, "add")
|
|
250
|
-
|
|
251
|
-
def on_modified(self, event):
|
|
252
|
-
if not self._is_file_event(event):
|
|
253
|
-
return
|
|
254
|
-
if self._should_ignore(event.src_path):
|
|
255
|
-
return
|
|
256
|
-
print(f"🟡 修改: {event.src_path}")
|
|
257
|
-
file_gen(event.src_path, "add")
|
|
258
|
-
|
|
259
|
-
def on_deleted(self, event):
|
|
260
|
-
if not self._is_file_event(event):
|
|
261
|
-
return
|
|
262
|
-
if self._should_ignore(event.src_path):
|
|
263
|
-
return
|
|
264
|
-
print(f"🔴 删除: {event.src_path}")
|
|
265
|
-
file_gen(event.src_path, "delete")
|
|
266
|
-
|
|
267
|
-
def on_moved(self, event):
|
|
268
|
-
if not self._is_file_event(event):
|
|
269
|
-
return
|
|
270
|
-
if self._should_ignore(event.src_path):
|
|
271
|
-
return
|
|
272
|
-
print(f"🔵 移动: {event.src_path} -> {getattr(event, 'dest_path', '')}")
|
|
273
|
-
file_gen(event.src_path, "delete")
|
|
274
|
-
if getattr(event, 'dest_path', ''):
|
|
275
|
-
file_gen(event.dest_path, "add")
|
|
229
|
+
# 注:原 watchdog 事件监听器 MyHandler 已移除,
|
|
230
|
+
# 改为轮询对账引擎(见下方「轮询对账同步引擎」段落 / reconcile_loop)。
|
|
276
231
|
|
|
277
232
|
|
|
278
233
|
def now_iso():
|
|
@@ -347,65 +302,201 @@ def init_config(workspace_id, file_path, method="add"):
|
|
|
347
302
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
348
303
|
|
|
349
304
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
305
|
+
# ═══════════════════════════════════════════════════════════════
|
|
306
|
+
# 轮询对账同步引擎(替代 watchdog 事件监听)
|
|
307
|
+
#
|
|
308
|
+
# 设计原则(AK47:少零件、自愈、可排查):
|
|
309
|
+
# · 磁盘是唯一真相,七牛是镜像,manifest.json 只是加速缓存
|
|
310
|
+
# · 每轮:扫描 → 对比 size+mtime → 变化的顺序上传(同 key 覆盖,天然去重)
|
|
311
|
+
# · 服务中断不丢:重启后扫描发现「磁盘有 / manifest 无」自动补传
|
|
312
|
+
# · manifest 损坏/丢失 → 当空清单 → 自动全量重传(覆盖无副作用)
|
|
313
|
+
# · 单文件出错只跳过 + 退避重试,循环永不退出
|
|
314
|
+
# · 本地删除:七牛永久保留,仅从 manifest 和 artifacts.json 移除本地记录
|
|
315
|
+
# ═══════════════════════════════════════════════════════════════
|
|
354
316
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
317
|
+
# 扫描间隔(秒),可用环境变量覆盖;扫描本身极快(约 1.8 万文件 < 0.3s)
|
|
318
|
+
SYNC_INTERVAL = float(os.environ.get("MYCLAW_SYNC_INTERVAL", "5"))
|
|
319
|
+
|
|
320
|
+
# manifest:单一状态文件,记录已上传文件的 [size, mtime]
|
|
321
|
+
MANIFEST_PATH = os.path.join(get_openclaw_path(), ".myclaw-sync-manifest.json")
|
|
322
|
+
|
|
323
|
+
# 每上传 N 个文件 checkpoint 一次 manifest(抗崩溃,首次全量时尤其重要)
|
|
324
|
+
_MANIFEST_CHECKPOINT_EVERY = 50
|
|
325
|
+
|
|
326
|
+
# 失败退避表(内存,进程重启即清空 = 重启等于自然重试一遍)
|
|
327
|
+
# { rel: {"retries": n, "next_try": ts} }
|
|
328
|
+
_upload_failures = {}
|
|
329
|
+
_RETRY_BACKOFF = [10, 30, 60, 120, 300] # 秒,指数退避封顶 5 分钟
|
|
330
|
+
|
|
331
|
+
# 连续无变化时,每隔多少轮打一次心跳(既不刷屏,又能用时间戳证明进程活着)
|
|
332
|
+
_HEARTBEAT_EVERY = 12
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _eng_log(tag, msg):
|
|
336
|
+
"""结构化单行日志,便于 AI 排查(grep BOOT / SCAN / PUT / HEAL / ERR)"""
|
|
337
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] {tag:<5} {msg}", flush=True)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load_manifest():
|
|
341
|
+
"""读取 manifest;任何异常都当空清单 → 触发全量重传(自愈,覆盖无副作用)"""
|
|
342
|
+
try:
|
|
343
|
+
with open(MANIFEST_PATH, "r", encoding="utf-8") as f:
|
|
344
|
+
data = json.load(f)
|
|
345
|
+
if isinstance(data, dict):
|
|
346
|
+
return data
|
|
347
|
+
_eng_log("HEAL", "manifest 格式异常 → 当空清单,本轮全量重传")
|
|
348
|
+
except FileNotFoundError:
|
|
349
|
+
pass
|
|
350
|
+
except Exception as e:
|
|
351
|
+
_eng_log("HEAL", f"manifest 损坏({e})→ 当空清单,本轮全量重传")
|
|
352
|
+
return {}
|
|
361
353
|
|
|
362
|
-
if space_idx == -1:
|
|
363
|
-
# 路径中不包含 workspace,不处理
|
|
364
|
-
return
|
|
365
354
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
355
|
+
def save_manifest(manifest):
|
|
356
|
+
"""原子写:临时文件 + os.replace,断电也不会写出半个文件"""
|
|
357
|
+
tmp = MANIFEST_PATH + ".tmp"
|
|
358
|
+
try:
|
|
359
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
360
|
+
json.dump(manifest, f, ensure_ascii=False)
|
|
361
|
+
os.replace(tmp, MANIFEST_PATH)
|
|
362
|
+
except Exception as e:
|
|
363
|
+
_eng_log("ERR", f"manifest 写入失败: {e}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def scan_workspace_files():
|
|
367
|
+
"""扫描所有 workspace* 目录,返回 { rel_path: [size, mtime] }。
|
|
368
|
+
rel_path 相对 .openclaw 根、含 workspace 段,例如 workspace/学生作品报表/x.html"""
|
|
369
|
+
base_path = get_openclaw_path()
|
|
370
|
+
result = {}
|
|
371
|
+
for entry in os.scandir(base_path):
|
|
372
|
+
if not (entry.is_dir() and "workspace" in entry.name):
|
|
373
|
+
continue
|
|
374
|
+
for root, dirs, files in os.walk(entry.path):
|
|
375
|
+
# 剪枝:跳过忽略目录(.git / node_modules / __history__ 等),不递归进入
|
|
376
|
+
dirs[:] = [d for d in dirs if d not in IGNORE_DIR_PATTERNS]
|
|
377
|
+
for fn in files:
|
|
378
|
+
if fn == ARTIFACTS_FILENAME or fn in IGNORE_FILE_PATTERNS:
|
|
379
|
+
continue
|
|
380
|
+
abs_path = os.path.join(root, fn)
|
|
381
|
+
try:
|
|
382
|
+
st = os.stat(abs_path)
|
|
383
|
+
except OSError:
|
|
384
|
+
continue
|
|
385
|
+
rel = os.path.relpath(abs_path, base_path).replace(os.sep, "/")
|
|
386
|
+
result[rel] = [st.st_size, int(st.st_mtime)]
|
|
387
|
+
return result
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def upload_one(rel_path, save_history):
|
|
391
|
+
"""上传单个文件到七牛 + 写入 artifacts.json 记录。
|
|
392
|
+
rel_path 相对 .openclaw 根(含 workspace 段)。失败时抛异常由调用方处理。"""
|
|
393
|
+
base_path = get_openclaw_path()
|
|
394
|
+
abs_path = os.path.join(base_path, rel_path)
|
|
395
|
+
parts = rel_path.split("/")
|
|
396
|
+
space_id = parts[0]
|
|
397
|
+
relative_path = "/".join(parts[1:])
|
|
398
|
+
if not relative_path:
|
|
371
399
|
return
|
|
372
|
-
|
|
400
|
+
key = f"{claw}/{rel_path}"
|
|
401
|
+
|
|
402
|
+
# 历史快照:仅对「已存在又变化」的文件存档(首次全量不存,避免快照爆炸);
|
|
403
|
+
# 回滚写盘触发的变更通过压制表跳过,避免多生成版本。
|
|
404
|
+
is_artifacts = os.path.basename(abs_path) == ARTIFACTS_FILENAME
|
|
405
|
+
if save_history and not is_artifacts and not _is_rollback_suppressed(abs_path):
|
|
406
|
+
try:
|
|
407
|
+
save_history_version(space_id, relative_path, abs_path)
|
|
408
|
+
except Exception as e:
|
|
409
|
+
_eng_log("ERR", f"history 快照失败 {relative_path}: {e}")
|
|
373
410
|
|
|
374
|
-
#
|
|
375
|
-
|
|
376
|
-
|
|
411
|
+
# 上传到七牛(同 key 覆盖 = 天然去重,不会产生多份)
|
|
412
|
+
token = q.upload_token(bucket_name, key, 3600)
|
|
413
|
+
ret, info = put_file_v2(token, key, abs_path)
|
|
414
|
+
if not (info and getattr(info, "status_code", None) == 200):
|
|
415
|
+
raise RuntimeError(f"七牛上传失败 status={getattr(info, 'status_code', '?')}")
|
|
416
|
+
cdn_manager.refresh_urls([f"https://cdn.yiranlaoshi.com/{key}"])
|
|
417
|
+
|
|
418
|
+
# __MY_ARTIFACTS__.json 本身不写入记录(防循环);普通文件写入 artifacts 列表
|
|
419
|
+
if not is_artifacts:
|
|
420
|
+
init_config(space_id, relative_path, method="add")
|
|
377
421
|
|
|
378
|
-
if method == "delete":
|
|
379
|
-
# 删除时不需要上传,只清理配置文件记录,传入的 file_path 不包含 space_id(relative_path)
|
|
380
|
-
init_config(space_id, relative_path, method="delete")
|
|
381
|
-
print(f"🗑️ 已删除配置记录: {relative_path}")
|
|
382
|
-
else:
|
|
383
|
-
# ── 历史版本:把当前内容作为快照存档(仅覆盖行为,非配置文件)──
|
|
384
|
-
# 回滚写盘触发的 on_modified 跳过,避免产生多余版本
|
|
385
|
-
if os.path.basename(path) != ARTIFACTS_FILENAME and not _is_rollback_suppressed(path):
|
|
386
|
-
save_history_version(space_id, relative_path, path)
|
|
387
422
|
|
|
388
|
-
|
|
423
|
+
def reconcile_loop():
|
|
424
|
+
"""主循环:每 SYNC_INTERVAL 秒对账一次。整个循环包 try,任何异常都不退出。"""
|
|
425
|
+
base_path = get_openclaw_path()
|
|
426
|
+
manifest = load_manifest()
|
|
427
|
+
_eng_log("BOOT",
|
|
428
|
+
f"claw={claw} interval={SYNC_INTERVAL}s 监听={base_path}/workspace* "
|
|
429
|
+
f"忽略={','.join(sorted(IGNORE_DIR_PATTERNS))} manifest={len(manifest)}条")
|
|
430
|
+
|
|
431
|
+
idle_rounds = 0
|
|
432
|
+
while True:
|
|
433
|
+
t0 = time.time()
|
|
389
434
|
try:
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
435
|
+
current = scan_workspace_files()
|
|
436
|
+
now = time.time()
|
|
437
|
+
|
|
438
|
+
# 1) 找出需要上传的:新文件 or size/mtime 变化
|
|
439
|
+
changed = [rel for rel, sm in current.items() if manifest.get(rel) != sm]
|
|
440
|
+
|
|
441
|
+
# 2) 顺序上传(单线程,日志线性可读,无锁无竞态)
|
|
442
|
+
uploaded = failed = skipped = 0
|
|
443
|
+
since_ckpt = 0
|
|
444
|
+
for rel in changed:
|
|
445
|
+
fb = _upload_failures.get(rel)
|
|
446
|
+
if fb and now < fb["next_try"]:
|
|
447
|
+
skipped += 1 # 退避中,本轮先跳过
|
|
448
|
+
continue
|
|
449
|
+
is_update = rel in manifest # 已在清单 = 修改,触发 history 快照
|
|
450
|
+
try:
|
|
451
|
+
upload_one(rel, save_history=is_update)
|
|
452
|
+
manifest[rel] = current[rel]
|
|
453
|
+
_upload_failures.pop(rel, None)
|
|
454
|
+
uploaded += 1
|
|
455
|
+
since_ckpt += 1
|
|
456
|
+
_eng_log("PUT", f"ok {rel} {current[rel][0]}B")
|
|
457
|
+
if since_ckpt >= _MANIFEST_CHECKPOINT_EVERY:
|
|
458
|
+
save_manifest(manifest)
|
|
459
|
+
since_ckpt = 0
|
|
460
|
+
except Exception as e:
|
|
461
|
+
failed += 1
|
|
462
|
+
n = _upload_failures.get(rel, {}).get("retries", 0) + 1
|
|
463
|
+
backoff = _RETRY_BACKOFF[min(n - 1, len(_RETRY_BACKOFF) - 1)]
|
|
464
|
+
_upload_failures[rel] = {"retries": n, "next_try": now + backoff}
|
|
465
|
+
_eng_log("PUT", f"FAIL {rel} 原因={e} 重试={n} 下次+{backoff}s")
|
|
466
|
+
|
|
467
|
+
# 3) 清理 manifest 中磁盘已删的条目:七牛永久保留,仅同步本地视图
|
|
468
|
+
# (从 manifest 移除 + 从 artifacts.json 剔除该记录,但不删七牛)
|
|
469
|
+
stale = [k for k in manifest if k not in current]
|
|
470
|
+
for k in stale:
|
|
471
|
+
manifest.pop(k, None)
|
|
472
|
+
_upload_failures.pop(k, None)
|
|
473
|
+
p = k.split("/")
|
|
474
|
+
if len(p) >= 2:
|
|
475
|
+
try:
|
|
476
|
+
init_config(p[0], "/".join(p[1:]), method="delete")
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
if uploaded or stale:
|
|
481
|
+
save_manifest(manifest)
|
|
482
|
+
|
|
483
|
+
dt = (time.time() - t0) * 1000
|
|
484
|
+
if changed or stale:
|
|
485
|
+
idle_rounds = 0
|
|
486
|
+
_eng_log("SCAN",
|
|
487
|
+
f"文件={len(current)} 变化={len(changed)} 上传={uploaded} "
|
|
488
|
+
f"失败={failed} 跳过={skipped} 删除={len(stale)} 耗时={dt:.0f}ms")
|
|
402
489
|
else:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
490
|
+
idle_rounds += 1
|
|
491
|
+
if idle_rounds % _HEARTBEAT_EVERY == 0:
|
|
492
|
+
_eng_log("SCAN", f"文件={len(current)} 变化=0 (心跳) 耗时={dt:.0f}ms")
|
|
493
|
+
|
|
407
494
|
except Exception as e:
|
|
408
|
-
|
|
495
|
+
import traceback
|
|
496
|
+
_eng_log("ERR", f"对账循环异常(已忽略,循环继续): {e}")
|
|
497
|
+
_eng_log("ERR", traceback.format_exc().replace("\n", " | "))
|
|
498
|
+
|
|
499
|
+
time.sleep(SYNC_INTERVAL)
|
|
409
500
|
|
|
410
501
|
|
|
411
502
|
def sync_all(workspace_id):
|
|
@@ -1491,33 +1582,23 @@ if __name__ == "__main__":
|
|
|
1491
1582
|
API_PORT = args.port
|
|
1492
1583
|
|
|
1493
1584
|
base_path = get_openclaw_path()
|
|
1494
|
-
path = base_path
|
|
1495
1585
|
|
|
1496
1586
|
# 启动时清理所有 workspace 中的忽略文件记录
|
|
1497
1587
|
for entry in os.scandir(base_path):
|
|
1498
1588
|
if entry.is_dir() and "workspace" in entry.name:
|
|
1499
1589
|
cleanup_ignored_assets(entry.name)
|
|
1500
1590
|
|
|
1501
|
-
# 如果指定了 --agent
|
|
1591
|
+
# 如果指定了 --agent,先全量同步(向后兼容旧调用方式)
|
|
1502
1592
|
if args.agent:
|
|
1503
1593
|
if not sync_all(args.agent):
|
|
1504
1594
|
sys.exit(1)
|
|
1505
1595
|
print("")
|
|
1506
1596
|
|
|
1507
|
-
# 启动 HTTP API
|
|
1597
|
+
# 启动 HTTP API 服务(前端业务接口,原样保留)
|
|
1508
1598
|
start_api_server()
|
|
1509
1599
|
|
|
1510
|
-
|
|
1511
|
-
observer = Observer()
|
|
1512
|
-
observer.schedule(event_handler, path, recursive=True)
|
|
1513
|
-
|
|
1514
|
-
observer.start()
|
|
1515
|
-
print(f"开始监听目录: {path}")
|
|
1516
|
-
|
|
1600
|
+
# 启动轮询对账引擎(前台主循环,替代 watchdog 事件监听)
|
|
1517
1601
|
try:
|
|
1518
|
-
|
|
1519
|
-
time.sleep(1)
|
|
1602
|
+
reconcile_loop()
|
|
1520
1603
|
except KeyboardInterrupt:
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
observer.join()
|
|
1604
|
+
print("\n[exit] 收到中断,退出")
|