tao-shen commited on
Commit
b108a3e
·
1 Parent(s): a0a1b7b

feat: stabilize openclaw with rolling backups, blocking QR wait, and structured logging

Browse files
scripts/entrypoint.sh CHANGED
@@ -66,12 +66,22 @@ done
66
 
67
  echo "[entrypoint] Starting OpenClaw gateway on port 7860..."
68
 
69
- # 启动 WhatsApp 登录守护:仅循环调用 web.login.wait(不调用 web.login.start),
70
- # 这样当用户在 UI 点 Login 并扫码后,由本脚本处理 515 重;UI 调用 web.login.wait 时本脚本为唯一调用者,无竞态。
 
71
  (
72
  sleep 25
73
  NODE_PATH=/app/openclaw/node_modules node /home/node/scripts/wa-login-guardian.cjs &
74
  echo "[entrypoint] WhatsApp login guardian started (PID $!)"
75
  ) &
76
 
 
 
 
 
 
 
 
 
 
77
  exec node openclaw.mjs gateway
 
66
 
67
  echo "[entrypoint] Starting OpenClaw gateway on port 7860..."
68
 
69
+
70
+ # 启 WhatsApp 登录守护:仅循环调用 web.login.wait
71
+ # 这样当用户在 UI 点 Login 并扫码后,由本脚本处理 515 重启
72
  (
73
  sleep 25
74
  NODE_PATH=/app/openclaw/node_modules node /home/node/scripts/wa-login-guardian.cjs &
75
  echo "[entrypoint] WhatsApp login guardian started (PID $!)"
76
  ) &
77
 
78
+ # ── 启动 QR Code Detection Manager (BLOCKING CHECK) ──
79
+ # 必须在 gateway 启动之前或并行启动,监控日志中的 QR 码请求
80
+ # 这里我们让它并行运行,监控文件系统和日志
81
+ (
82
+ sleep 10
83
+ NODE_PATH=/app/openclaw/node_modules node /home/node/scripts/qr-detection-manager.cjs "${SPACE_HOST:-https://huggingface.co/spaces/tao-shen/openclaw-ai}" &
84
+ echo "[entrypoint] QR Detection Manager started (PID $!)"
85
+ ) &
86
+
87
  exec node openclaw.mjs gateway
scripts/logger.js ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Structured Logger for OpenClaw
3
+ * Provides consistent JSON logging for HF Spaces
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ // Ensure logs directory exists
10
+ const LOG_DIR = path.join(process.env.HOME || '/home/node', 'logs');
11
+ if (!fs.existsSync(LOG_DIR)) {
12
+ try {
13
+ fs.mkdirSync(LOG_DIR, { recursive: true });
14
+ } catch (e) {
15
+ // Ignore if we can't create it (might be read-only or race condition)
16
+ }
17
+ }
18
+
19
+ const LOG_FILE = path.join(LOG_DIR, 'app.json.log');
20
+
21
+ class Logger {
22
+ constructor(moduleName) {
23
+ this.module = moduleName;
24
+ }
25
+
26
+ _log(level, message, data = {}) {
27
+ const entry = {
28
+ timestamp: new Date().toISOString(),
29
+ level: level.toUpperCase(),
30
+ module: this.module,
31
+ message,
32
+ ...data
33
+ };
34
+
35
+ const jsonLine = JSON.stringify(entry);
36
+
37
+ // Write to stdout for HF Logs visibility
38
+ console.log(jsonLine);
39
+
40
+ // Also append to local file for persistence within container life
41
+ try {
42
+ fs.appendFileSync(LOG_FILE, jsonLine + '\n');
43
+ } catch (e) {
44
+ // Fallback if file write fails
45
+ console.error(`[LOGGER_FAIL] Could not write to log file: ${e.message}`);
46
+ }
47
+ }
48
+
49
+ info(message, data) { this._log('INFO', message, data); }
50
+ warn(message, data) { this._log('WARN', message, data); }
51
+ error(message, data) { this._log('ERROR', message, data); }
52
+ debug(message, data) { this._log('DEBUG', message, data); }
53
+
54
+ // Special method for critical state changes
55
+ state(stateName, previousState, newState, data) {
56
+ this._log('STATE_CHANGE', `State changed: ${stateName}`, {
57
+ previousState,
58
+ newState,
59
+ ...data
60
+ });
61
+ }
62
+ }
63
+
64
+ module.exports = (moduleName) => new Logger(moduleName);
scripts/restore_from_dataset.py CHANGED
@@ -2,7 +2,7 @@ import os
2
  import tarfile
3
  import sys
4
 
5
- from huggingface_hub import hf_hub_download
6
 
7
 
8
  def main() -> None:
@@ -20,28 +20,51 @@ def main() -> None:
20
  # 未配置就直接跳过,不报错以免阻塞网关启动
21
  return
22
 
23
- filename = "state/openclaw.tar"
24
-
25
  try:
26
- tar_path = hf_hub_download(
27
- repo_id=repo_id,
28
- repo_type="dataset",
29
- filename=filename,
30
- token=token,
31
- )
32
- except Exception:
33
- # 第一次运行或文件不存在时,直接跳过
34
- return
 
 
 
 
 
35
 
36
- state_dir = os.path.expanduser("~/.openclaw")
37
- os.makedirs(state_dir, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- try:
40
- with tarfile.open(tar_path, "r") as tf:
41
- tf.extractall(state_dir)
42
  except Exception as e:
43
- # 打印到 stderr,但不阻塞启动
44
- print(f"[restore_from_dataset] Failed to extract {tar_path}: {e}", file=sys.stderr)
45
  return
46
 
47
  # 重要:不要删除 credentials/whatsapp。恢复的凭证用于自动连接;
 
2
  import tarfile
3
  import sys
4
 
5
+ from huggingface_hub import hf_hub_download, HfApi
6
 
7
 
8
  def main() -> None:
 
20
  # 未配置就直接跳过,不报错以免阻塞网关启动
21
  return
22
 
 
 
23
  try:
24
+ # List all files and find the latest backup
25
+ api = HfApi(token=token)
26
+ files = api.list_repo_files(repo_id=repo_id, repo_type="dataset")
27
+
28
+ # Filter for our backup pattern
29
+ backups = sorted([f for f in files if f.startswith("state/backup-") and f.endswith(".tar")], reverse=True)
30
+
31
+ if not backups:
32
+ # Fallback to legacy filename if no rolling backups exist
33
+ if "state/openclaw.tar" in files:
34
+ backups = ["state/openclaw.tar"]
35
+ else:
36
+ print("[restore_from_dataset] No backups found.", file=sys.stderr)
37
+ return
38
 
39
+ # Try to restore from the latest backup, falling back to older ones if needed
40
+ success = False
41
+ for backup_file in backups:
42
+ print(f"[restore_from_dataset] Attempting to restore from: {backup_file}")
43
+ try:
44
+ tar_path = hf_hub_download(
45
+ repo_id=repo_id,
46
+ repo_type="dataset",
47
+ filename=backup_file,
48
+ token=token,
49
+ )
50
+
51
+ with tarfile.open(tar_path, "r") as tf:
52
+ tf.extractall(state_dir)
53
+
54
+ print(f"[restore_from_dataset] Successfully restored from {backup_file}")
55
+ success = True
56
+ break
57
+ except Exception as e:
58
+ print(f"[restore_from_dataset] Failed to restore {backup_file}: {e}", file=sys.stderr)
59
+ # Continue to next backup
60
+
61
+ if not success:
62
+ print("[restore_from_dataset] All backup restore attempts failed.", file=sys.stderr)
63
+ return
64
 
 
 
 
65
  except Exception as e:
66
+ # General failure (network, auth, etc)
67
+ print(f"[restore_from_dataset] Restore process failed: {e}", file=sys.stderr)
68
  return
69
 
70
  # 重要:不要删除 credentials/whatsapp。恢复的凭证用于自动连接;
scripts/save_to_dataset.py CHANGED
@@ -6,13 +6,23 @@ import sys
6
  from huggingface_hub import HfApi
7
 
8
 
 
 
 
 
 
 
 
 
 
9
  def main() -> None:
10
  """
11
- ~/.openclaw 打包为 tar 并上传到 Hugging Face Dataset
12
-
13
- 依赖环境变量:
14
- - HF_TOKEN: 具有写入/读取权限的 HF Access Token
15
- - OPENCLAW_DATASET_REPO: 数据集 repo_id,例如 "tao-shen/openclaw"
 
16
  """
17
  repo_id = os.environ.get("OPENCLAW_DATASET_REPO")
18
  token = os.environ.get("HF_TOKEN")
@@ -20,33 +30,33 @@ def main() -> None:
20
  state_dir = os.path.expanduser("~/.openclaw")
21
 
22
  if not repo_id or not token:
 
23
  return
24
 
25
  if not os.path.isdir(state_dir):
26
- # 还没有任何状态可保存
27
  return
28
 
29
- # WhatsApp 凭证目录不完整(例如 401 后仅剩 creds.json),不要上传覆盖 dataset,
30
- # 否则会用坏状态覆盖之前的好状态,导致下次恢复后无法连接。
31
  wa_creds_dir = os.path.join(state_dir, "credentials", "whatsapp", "default")
32
  if os.path.isdir(wa_creds_dir):
33
  file_count = len([f for f in os.listdir(wa_creds_dir) if os.path.isfile(os.path.join(wa_creds_dir, f))])
34
- if file_count < 10:
35
- # Baileys 完整认证需要 creds.json + 大量 pre-key-*.json 等,少于 10 个文件视为不完整
36
- print(
37
- f"[save_to_dataset] Skip upload: WhatsApp credentials incomplete ({file_count} files). "
38
- "Keeping previous dataset state.",
39
- file=sys.stderr,
40
- )
41
  return
42
 
43
- api = HfApi()
 
 
 
 
44
 
45
  with tempfile.TemporaryDirectory() as tmpdir:
46
  tar_path = os.path.join(tmpdir, "openclaw.tar")
47
 
48
  def exclude_filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
49
- # 排除 extensions symlink(它指向构建产物目录,无需持久化)
50
  if info.name in ("./extensions", "extensions"):
51
  return None
52
  return info
@@ -55,20 +65,37 @@ def main() -> None:
55
  with tarfile.open(tar_path, "w") as tf:
56
  tf.add(state_dir, arcname=".", filter=exclude_filter)
57
  except Exception as e:
58
- print(f"[save_to_dataset] Failed to create tar from {state_dir}: {e}", file=sys.stderr)
59
  return
60
 
 
61
  try:
62
  api.upload_file(
63
  path_or_fileobj=tar_path,
64
- path_in_repo="state/openclaw.tar",
65
  repo_id=repo_id,
66
  repo_type="dataset",
67
- token=token,
68
  )
69
  except Exception as e:
70
- print(f"[save_to_dataset] Failed to upload {tar_path} to {repo_id}: {e}", file=sys.stderr)
 
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
- if __name__ == "__main__":
74
- main()
 
6
  from huggingface_hub import HfApi
7
 
8
 
9
+ import os
10
+ import tarfile
11
+ import tempfile
12
+ import sys
13
+ import time
14
+ from datetime import datetime
15
+
16
+ from huggingface_hub import HfApi
17
+
18
  def main() -> None:
19
  """
20
+ Backs up ~/.openclaw to Hugging Face Dataset with rolling history.
21
+ Keeps the last 5 backups to prevent data loss from corruption.
22
+
23
+ Env vars:
24
+ - HF_TOKEN
25
+ - OPENCLAW_DATASET_REPO
26
  """
27
  repo_id = os.environ.get("OPENCLAW_DATASET_REPO")
28
  token = os.environ.get("HF_TOKEN")
 
30
  state_dir = os.path.expanduser("~/.openclaw")
31
 
32
  if not repo_id or not token:
33
+ print("[save_to_dataset] Missing configuration.", file=sys.stderr)
34
  return
35
 
36
  if not os.path.isdir(state_dir):
37
+ print("[save_to_dataset] No state to save.", file=sys.stderr)
38
  return
39
 
40
+ # 1. Validation: Ensure we have valid credentials before backing up
 
41
  wa_creds_dir = os.path.join(state_dir, "credentials", "whatsapp", "default")
42
  if os.path.isdir(wa_creds_dir):
43
  file_count = len([f for f in os.listdir(wa_creds_dir) if os.path.isfile(os.path.join(wa_creds_dir, f))])
44
+ if file_count < 2:
45
+ # Basic sanity check: needs at least creds.json + keys.
46
+ # Lowered from 10 to 2 to be less aggressive but still catch empty/broken states.
47
+ print(f"[save_to_dataset] Skip: WhatsApp credentials incomplete ({file_count} files).", file=sys.stderr)
 
 
 
48
  return
49
 
50
+ api = HfApi(token=token)
51
+
52
+ # Generate timestamped filename
53
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
54
+ filename = f"state/backup-{timestamp}.tar"
55
 
56
  with tempfile.TemporaryDirectory() as tmpdir:
57
  tar_path = os.path.join(tmpdir, "openclaw.tar")
58
 
59
  def exclude_filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
 
60
  if info.name in ("./extensions", "extensions"):
61
  return None
62
  return info
 
65
  with tarfile.open(tar_path, "w") as tf:
66
  tf.add(state_dir, arcname=".", filter=exclude_filter)
67
  except Exception as e:
68
+ print(f"[save_to_dataset] Failed to compress: {e}", file=sys.stderr)
69
  return
70
 
71
+ print(f"[save_to_dataset] Uploading backup: {filename}")
72
  try:
73
  api.upload_file(
74
  path_or_fileobj=tar_path,
75
+ path_in_repo=filename,
76
  repo_id=repo_id,
77
  repo_type="dataset",
 
78
  )
79
  except Exception as e:
80
+ print(f"[save_to_dataset] Upload failed: {e}", file=sys.stderr)
81
+ return
82
 
83
+ # 2. Rotation: Delete old backups, keep last 5
84
+ try:
85
+ files = api.list_repo_files(repo_id=repo_id, repo_type="dataset")
86
+ backups = sorted([f for f in files if f.startswith("state/backup-") and f.endswith(".tar")])
87
+
88
+ if len(backups) > 5:
89
+ # Delete oldest
90
+ to_delete = backups[:-5]
91
+ print(f"[save_to_dataset] Rotating backups, deleting: {to_delete}")
92
+ for old_backup in to_delete:
93
+ api.delete_file(
94
+ path_in_repo=old_backup,
95
+ repo_id=repo_id,
96
+ repo_type="dataset",
97
+ token=token
98
+ )
99
+ except Exception as e:
100
+ print(f"[save_to_dataset] Rotation failed (non-fatal): {e}", file=sys.stderr)
101