Spaces:
Paused
Paused
feat: stabilize openclaw with rolling backups, blocking QR wait, and structured logging
Browse files- scripts/entrypoint.sh +12 -2
- scripts/logger.js +64 -0
- scripts/restore_from_dataset.py +42 -19
- scripts/save_to_dataset.py +50 -23
scripts/entrypoint.sh
CHANGED
|
@@ -66,12 +66,22 @@ done
|
|
| 66 |
|
| 67 |
echo "[entrypoint] Starting OpenClaw gateway on port 7860..."
|
| 68 |
|
| 69 |
-
|
| 70 |
-
#
|
|
|
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
try:
|
| 40 |
-
with tarfile.open(tar_path, "r") as tf:
|
| 41 |
-
tf.extractall(state_dir)
|
| 42 |
except Exception as e:
|
| 43 |
-
#
|
| 44 |
-
print(f"[restore_from_dataset]
|
| 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 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
-
|
|
|
|
| 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 |
-
#
|
| 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 <
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 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
|
| 59 |
return
|
| 60 |
|
|
|
|
| 61 |
try:
|
| 62 |
api.upload_file(
|
| 63 |
path_or_fileobj=tar_path,
|
| 64 |
-
path_in_repo=
|
| 65 |
repo_id=repo_id,
|
| 66 |
repo_type="dataset",
|
| 67 |
-
token=token,
|
| 68 |
)
|
| 69 |
except Exception as e:
|
| 70 |
-
print(f"[save_to_dataset]
|
|
|
|
| 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 |
|
|
|
|
|
|