Spaces:
Running
Running
Commit ·
c3c95c3
1
Parent(s): 6b45d1f
Preserve restored OpenClaw config defaults
Browse files- .env.example +3 -0
- README.md +2 -1
- openclaw-sync.py +80 -6
- start.sh +26 -8
.env.example
CHANGED
|
@@ -285,6 +285,9 @@ KEEP_ALIVE_INTERVAL=300
|
|
| 285 |
# Workspace auto-sync interval (seconds). Default: 180.
|
| 286 |
SYNC_INTERVAL=180
|
| 287 |
|
|
|
|
|
|
|
|
|
|
| 288 |
# Webhooks: Standard POST notifications for lifecycle events
|
| 289 |
# WEBHOOK_URL=https://your-webhook-endpoint.com/log
|
| 290 |
|
|
|
|
| 285 |
# Workspace auto-sync interval (seconds). Default: 180.
|
| 286 |
SYNC_INTERVAL=180
|
| 287 |
|
| 288 |
+
# Check openclaw.json for settings changes this often (seconds). Default: 1.
|
| 289 |
+
OPENCLAW_CONFIG_WATCH_INTERVAL=1
|
| 290 |
+
|
| 291 |
# Webhooks: Standard POST notifications for lifecycle events
|
| 292 |
# WEBHOOK_URL=https://your-webhook-endpoint.com/log
|
| 293 |
|
README.md
CHANGED
|
@@ -162,7 +162,8 @@ HuggingClaw automatically syncs your workspace (chats, settings, sessions) to a
|
|
| 162 |
| Variable | Default | Description |
|
| 163 |
| :--- | :--- | :--- |
|
| 164 |
| `HF_TOKEN` | — | HF token with **Write** access |
|
| 165 |
-
| `SYNC_INTERVAL` | `180` |
|
|
|
|
| 166 |
|
| 167 |
## 📦 Ephemeral Package Re-install *(Optional)*
|
| 168 |
|
|
|
|
| 162 |
| Variable | Default | Description |
|
| 163 |
| :--- | :--- | :--- |
|
| 164 |
| `HF_TOKEN` | — | HF token with **Write** access |
|
| 165 |
+
| `SYNC_INTERVAL` | `180` | Full backup frequency in seconds |
|
| 166 |
+
| `OPENCLAW_CONFIG_WATCH_INTERVAL` | `1` | How often to check `openclaw.json` for immediate settings sync |
|
| 167 |
|
| 168 |
## 📦 Ephemeral Package Re-install *(Optional)*
|
| 169 |
|
openclaw-sync.py
CHANGED
|
@@ -34,10 +34,15 @@ from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError
|
|
| 34 |
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
| 35 |
|
| 36 |
OPENCLAW_HOME = Path("/home/node/.openclaw")
|
|
|
|
| 37 |
WORKSPACE = OPENCLAW_HOME / "workspace"
|
| 38 |
STATUS_FILE = Path("/tmp/sync-status.json")
|
| 39 |
INTERVAL = int(os.environ.get("SYNC_INTERVAL", "180"))
|
| 40 |
INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
|
| 42 |
HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
|
| 43 |
SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
|
|
@@ -78,6 +83,13 @@ def write_status(status: str, message: str) -> None:
|
|
| 78 |
tmp_path.replace(STATUS_FILE)
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def count_files(path: Path) -> int:
|
| 82 |
if not path.exists():
|
| 83 |
return 0
|
|
@@ -250,6 +262,18 @@ def _should_exclude(rel_posix: str, path: Path) -> bool:
|
|
| 250 |
return False
|
| 251 |
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
def metadata_marker(root: Path) -> tuple[int, int, int]:
|
| 254 |
if not root.exists():
|
| 255 |
return (0, 0, 0)
|
|
@@ -416,10 +440,31 @@ def handle_signal(_sig, _frame) -> None:
|
|
| 416 |
STOP_EVENT.set()
|
| 417 |
|
| 418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
def loop() -> int:
|
| 420 |
signal.signal(signal.SIGTERM, handle_signal)
|
| 421 |
signal.signal(signal.SIGINT, handle_signal)
|
| 422 |
|
|
|
|
|
|
|
| 423 |
try:
|
| 424 |
repo_id = resolve_backup_namespace()
|
| 425 |
write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
|
|
@@ -431,24 +476,53 @@ def loop() -> int:
|
|
| 431 |
time.sleep(INITIAL_DELAY)
|
| 432 |
print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
|
| 433 |
|
| 434 |
-
#
|
| 435 |
-
#
|
| 436 |
-
#
|
| 437 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
snapshot_state_into_workspace()
|
| 439 |
last_fingerprint = fingerprint_dir(WORKSPACE)
|
| 440 |
last_marker = metadata_marker(WORKSPACE)
|
| 441 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
while not STOP_EVENT.is_set():
|
| 444 |
try:
|
|
|
|
| 445 |
last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
except Exception as exc:
|
| 447 |
write_status("error", f"Sync failed: {exc}")
|
| 448 |
print(f"Workspace sync failed: {exc}")
|
|
|
|
| 449 |
|
| 450 |
-
|
|
|
|
| 451 |
break
|
|
|
|
|
|
|
| 452 |
|
| 453 |
return 0
|
| 454 |
|
|
|
|
| 34 |
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
| 35 |
|
| 36 |
OPENCLAW_HOME = Path("/home/node/.openclaw")
|
| 37 |
+
OPENCLAW_CONFIG_FILE = OPENCLAW_HOME / "openclaw.json"
|
| 38 |
WORKSPACE = OPENCLAW_HOME / "workspace"
|
| 39 |
STATUS_FILE = Path("/tmp/sync-status.json")
|
| 40 |
INTERVAL = int(os.environ.get("SYNC_INTERVAL", "180"))
|
| 41 |
INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
|
| 42 |
+
CONFIG_WATCH_INTERVAL = max(
|
| 43 |
+
0.5,
|
| 44 |
+
float(os.environ.get("OPENCLAW_CONFIG_WATCH_INTERVAL", "1")),
|
| 45 |
+
)
|
| 46 |
HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
|
| 47 |
HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
|
| 48 |
SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
|
|
|
|
| 83 |
tmp_path.replace(STATUS_FILE)
|
| 84 |
|
| 85 |
|
| 86 |
+
def read_status() -> dict[str, str]:
|
| 87 |
+
try:
|
| 88 |
+
return json.loads(STATUS_FILE.read_text(encoding="utf-8"))
|
| 89 |
+
except Exception:
|
| 90 |
+
return {}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
def count_files(path: Path) -> int:
|
| 94 |
if not path.exists():
|
| 95 |
return 0
|
|
|
|
| 262 |
return False
|
| 263 |
|
| 264 |
|
| 265 |
+
def file_marker(path: Path) -> tuple[int, int, int]:
|
| 266 |
+
try:
|
| 267 |
+
stat = path.stat()
|
| 268 |
+
except OSError:
|
| 269 |
+
return (0, 0, 0)
|
| 270 |
+
|
| 271 |
+
if not path.is_file():
|
| 272 |
+
return (0, 0, 0)
|
| 273 |
+
|
| 274 |
+
return (1, int(stat.st_size), int(stat.st_mtime_ns))
|
| 275 |
+
|
| 276 |
+
|
| 277 |
def metadata_marker(root: Path) -> tuple[int, int, int]:
|
| 278 |
if not root.exists():
|
| 279 |
return (0, 0, 0)
|
|
|
|
| 440 |
STOP_EVENT.set()
|
| 441 |
|
| 442 |
|
| 443 |
+
def wait_for_sync_trigger(config_marker: tuple[int, int, int]) -> tuple[str, tuple[int, int, int]]:
|
| 444 |
+
deadline = time.monotonic() + max(0, INTERVAL)
|
| 445 |
+
|
| 446 |
+
while not STOP_EVENT.is_set():
|
| 447 |
+
current_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 448 |
+
if current_config_marker != config_marker:
|
| 449 |
+
return ("openclaw_config_changed", current_config_marker)
|
| 450 |
+
|
| 451 |
+
remaining = deadline - time.monotonic()
|
| 452 |
+
if remaining <= 0:
|
| 453 |
+
return ("interval", current_config_marker)
|
| 454 |
+
|
| 455 |
+
wait_seconds = min(CONFIG_WATCH_INTERVAL, remaining)
|
| 456 |
+
if STOP_EVENT.wait(wait_seconds):
|
| 457 |
+
return ("stopped", current_config_marker)
|
| 458 |
+
|
| 459 |
+
return ("stopped", config_marker)
|
| 460 |
+
|
| 461 |
+
|
| 462 |
def loop() -> int:
|
| 463 |
signal.signal(signal.SIGTERM, handle_signal)
|
| 464 |
signal.signal(signal.SIGINT, handle_signal)
|
| 465 |
|
| 466 |
+
previous_status = read_status().get("status", "")
|
| 467 |
+
|
| 468 |
try:
|
| 469 |
repo_id = resolve_backup_namespace()
|
| 470 |
write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
|
|
|
|
| 476 |
time.sleep(INITIAL_DELAY)
|
| 477 |
print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
|
| 478 |
|
| 479 |
+
# Capture the restored dataset state before refreshing the embedded
|
| 480 |
+
# /home/node/.openclaw backup. Startup may have patched openclaw.json
|
| 481 |
+
# after restore (token/model/logging/channel toggles), and that patch only
|
| 482 |
+
# becomes part of the dataset once snapshot_state_into_workspace() copies it
|
| 483 |
+
# into workspace/huggingclaw-state/openclaw/. If the snapshot changes the
|
| 484 |
+
# workspace, seed the first sync with the pre-snapshot fingerprint so the
|
| 485 |
+
# updated openclaw.json is uploaded instead of being treated as the baseline.
|
| 486 |
+
pre_snapshot_fingerprint = fingerprint_dir(WORKSPACE)
|
| 487 |
+
pre_snapshot_marker = metadata_marker(WORKSPACE)
|
| 488 |
snapshot_state_into_workspace()
|
| 489 |
last_fingerprint = fingerprint_dir(WORKSPACE)
|
| 490 |
last_marker = metadata_marker(WORKSPACE)
|
| 491 |
+
|
| 492 |
+
if last_fingerprint != pre_snapshot_fingerprint:
|
| 493 |
+
if previous_status == "error":
|
| 494 |
+
print(
|
| 495 |
+
"Initial state snapshot changed, but restore previously failed; "
|
| 496 |
+
"keeping current state as baseline to avoid overwriting the remote backup."
|
| 497 |
+
)
|
| 498 |
+
else:
|
| 499 |
+
last_fingerprint = pre_snapshot_fingerprint
|
| 500 |
+
last_marker = pre_snapshot_marker
|
| 501 |
+
print("Initial state snapshot changed; first sync will upload refreshed OpenClaw state.")
|
| 502 |
+
else:
|
| 503 |
+
print("Initial workspace fingerprint captured.")
|
| 504 |
+
|
| 505 |
+
config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 506 |
|
| 507 |
while not STOP_EVENT.is_set():
|
| 508 |
try:
|
| 509 |
+
sync_started_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 510 |
last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
|
| 511 |
+
config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 512 |
+
|
| 513 |
+
if config_marker != sync_started_config_marker:
|
| 514 |
+
print("OpenClaw config changed during sync; syncing again immediately.")
|
| 515 |
+
continue
|
| 516 |
except Exception as exc:
|
| 517 |
write_status("error", f"Sync failed: {exc}")
|
| 518 |
print(f"Workspace sync failed: {exc}")
|
| 519 |
+
config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 520 |
|
| 521 |
+
trigger, config_marker = wait_for_sync_trigger(config_marker)
|
| 522 |
+
if trigger == "stopped":
|
| 523 |
break
|
| 524 |
+
if trigger == "openclaw_config_changed":
|
| 525 |
+
print("OpenClaw config changed; syncing immediately.")
|
| 526 |
|
| 527 |
return 0
|
| 528 |
|
start.sh
CHANGED
|
@@ -11,6 +11,14 @@ umask 0077
|
|
| 11 |
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
|
| 12 |
OPENCLAW_APP_DIR="/home/node/.openclaw/openclaw-app"
|
| 13 |
OPENCLAW_RUNTIME_VERSION=""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
WHATSAPP_ENABLED="${WHATSAPP_ENABLED:-false}"
|
| 15 |
WHATSAPP_ENABLED_NORMALIZED=$(printf '%s' "$WHATSAPP_ENABLED" | tr '[:upper:]' '[:lower:]')
|
| 16 |
SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
|
|
@@ -445,22 +453,32 @@ if [ -f "$EXISTING_CONFIG" ]; then
|
|
| 445 |
--arg consoleLevel "$OPENCLAW_CONSOLE_LOG_LEVEL" \
|
| 446 |
--arg consoleStyle "$OPENCLAW_CONSOLE_LOG_STYLE" \
|
| 447 |
--argjson desired "$CONFIG_JSON" \
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
--argjson whatsappEnabled "$WHATSAPP_CONFIG_ENABLED" \
|
| 449 |
-
'.
|
|
|
|
| 450 |
| .agents.defaults.model = $model
|
| 451 |
-
| .logging.level = $fileLevel
|
| 452 |
-
| .logging.consoleLevel = $consoleLevel
|
| 453 |
-
| .logging.consoleStyle = $consoleStyle
|
| 454 |
| .channels = ((.channels // {}) * ($desired.channels // {}))
|
| 455 |
| .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
|
| 456 |
| .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
|
| 457 |
-
| .plugins.entries = ((.plugins.entries // {}) * (
|
| 458 |
| if $whatsappEnabled then
|
| 459 |
-
.
|
| 460 |
-
| .
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
| 462 |
.plugins.entries.whatsapp.enabled = false
|
| 463 |
| del(.channels.whatsapp)
|
|
|
|
|
|
|
| 464 |
end' \
|
| 465 |
"$EXISTING_CONFIG" 2>/dev/null)
|
| 466 |
|
|
|
|
| 11 |
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
|
| 12 |
OPENCLAW_APP_DIR="/home/node/.openclaw/openclaw-app"
|
| 13 |
OPENCLAW_RUNTIME_VERSION=""
|
| 14 |
+
OPENCLAW_FILE_LOG_LEVEL_CONFIGURED=false
|
| 15 |
+
OPENCLAW_CONSOLE_LOG_LEVEL_CONFIGURED=false
|
| 16 |
+
OPENCLAW_CONSOLE_LOG_STYLE_CONFIGURED=false
|
| 17 |
+
WHATSAPP_ENABLED_CONFIGURED=false
|
| 18 |
+
[ "${OPENCLAW_FILE_LOG_LEVEL+x}" = "x" ] && OPENCLAW_FILE_LOG_LEVEL_CONFIGURED=true
|
| 19 |
+
[ "${OPENCLAW_CONSOLE_LOG_LEVEL+x}" = "x" ] && OPENCLAW_CONSOLE_LOG_LEVEL_CONFIGURED=true
|
| 20 |
+
[ "${OPENCLAW_CONSOLE_LOG_STYLE+x}" = "x" ] && OPENCLAW_CONSOLE_LOG_STYLE_CONFIGURED=true
|
| 21 |
+
[ "${WHATSAPP_ENABLED+x}" = "x" ] && WHATSAPP_ENABLED_CONFIGURED=true
|
| 22 |
WHATSAPP_ENABLED="${WHATSAPP_ENABLED:-false}"
|
| 23 |
WHATSAPP_ENABLED_NORMALIZED=$(printf '%s' "$WHATSAPP_ENABLED" | tr '[:upper:]' '[:lower:]')
|
| 24 |
SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
|
|
|
|
| 453 |
--arg consoleLevel "$OPENCLAW_CONSOLE_LOG_LEVEL" \
|
| 454 |
--arg consoleStyle "$OPENCLAW_CONSOLE_LOG_STYLE" \
|
| 455 |
--argjson desired "$CONFIG_JSON" \
|
| 456 |
+
--argjson fileLogConfigured "$OPENCLAW_FILE_LOG_LEVEL_CONFIGURED" \
|
| 457 |
+
--argjson consoleLogConfigured "$OPENCLAW_CONSOLE_LOG_LEVEL_CONFIGURED" \
|
| 458 |
+
--argjson consoleStyleConfigured "$OPENCLAW_CONSOLE_LOG_STYLE_CONFIGURED" \
|
| 459 |
+
--argjson whatsappConfigured "$WHATSAPP_ENABLED_CONFIGURED" \
|
| 460 |
--argjson whatsappEnabled "$WHATSAPP_CONFIG_ENABLED" \
|
| 461 |
+
'(.channels.whatsapp // {}) as $existingWhatsapp
|
| 462 |
+
| .gateway.auth.token = $token
|
| 463 |
| .agents.defaults.model = $model
|
| 464 |
+
| if $fileLogConfigured then .logging.level = $fileLevel else . end
|
| 465 |
+
| if $consoleLogConfigured then .logging.consoleLevel = $consoleLevel else . end
|
| 466 |
+
| if $consoleStyleConfigured then .logging.consoleStyle = $consoleStyle else . end
|
| 467 |
| .channels = ((.channels // {}) * ($desired.channels // {}))
|
| 468 |
| .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
|
| 469 |
| .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
|
| 470 |
+
| .plugins.entries = (($desired.plugins.entries // {}) * (.plugins.entries // {}))
|
| 471 |
| if $whatsappEnabled then
|
| 472 |
+
($desired.channels.whatsapp // {"dmPolicy": "pairing"}) as $desiredWhatsapp
|
| 473 |
+
| .plugins.entries.whatsapp.enabled = true
|
| 474 |
+
| .channels.whatsapp = (($existingWhatsapp * $desiredWhatsapp)
|
| 475 |
+
| if ($existingWhatsapp | has("dmPolicy")) then .dmPolicy = $existingWhatsapp.dmPolicy else . end
|
| 476 |
+
| if ($existingWhatsapp | has("allowFrom")) then .allowFrom = $existingWhatsapp.allowFrom else . end)
|
| 477 |
+
elif $whatsappConfigured then
|
| 478 |
.plugins.entries.whatsapp.enabled = false
|
| 479 |
| del(.channels.whatsapp)
|
| 480 |
+
else
|
| 481 |
+
.
|
| 482 |
end' \
|
| 483 |
"$EXISTING_CONFIG" 2>/dev/null)
|
| 484 |
|