Spaces:
Running
Running
Commit Β·
ad8f514
1
Parent(s): 6b45d1f
Sync state before gateway reload restarts
Browse files- .env.example +6 -0
- README.md +3 -1
- multi-provider-key-rotator.cjs +8 -3
- openclaw-sync.py +147 -7
- start.sh +120 -53
.env.example
CHANGED
|
@@ -285,6 +285,12 @@ 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 |
+
# Wait for openclaw.json to stay valid and unchanged before syncing. Default: 3.
|
| 292 |
+
OPENCLAW_CONFIG_SETTLE_SECONDS=3
|
| 293 |
+
|
| 294 |
# Webhooks: Standard POST notifications for lifecycle events
|
| 295 |
# WEBHOOK_URL=https://your-webhook-endpoint.com/log
|
| 296 |
|
README.md
CHANGED
|
@@ -162,7 +162,9 @@ 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 |
+
| `OPENCLAW_CONFIG_SETTLE_SECONDS` | `3` | How long `openclaw.json` must stay valid and unchanged before syncing |
|
| 168 |
|
| 169 |
## π¦ Ephemeral Package Re-install *(Optional)*
|
| 170 |
|
multi-provider-key-rotator.cjs
CHANGED
|
@@ -19,6 +19,11 @@
|
|
| 19 |
const http = require('node:http');
|
| 20 |
const https = require('node:https');
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
// βββ Provider definitions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
//
|
| 24 |
// hostname β regex tested against the request hostname (case-insensitive)
|
|
@@ -185,7 +190,7 @@ const providerState = PROVIDERS.map(p => {
|
|
| 185 |
: normalizeKeys(process.env.LLM_API_KEY || '');
|
| 186 |
|
| 187 |
if (hasDedicated) {
|
| 188 |
-
|
| 189 |
} else if (!keys.length) {
|
| 190 |
console.warn(`[key-rotator] No keys for provider "${p.name}"`);
|
| 191 |
}
|
|
@@ -202,7 +207,7 @@ const fallbackCount = providerState.filter(p => {
|
|
| 202 |
return dedicated.length === 0 && p.keys.length > 0;
|
| 203 |
}).length;
|
| 204 |
if (fallbackCount > 0) {
|
| 205 |
-
|
| 206 |
}
|
| 207 |
|
| 208 |
// βββ Runtime helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -332,4 +337,4 @@ patchFetch();
|
|
| 332 |
patchHttpModule(http);
|
| 333 |
patchHttpModule(https);
|
| 334 |
|
| 335 |
-
|
|
|
|
| 19 |
const http = require('node:http');
|
| 20 |
const https = require('node:https');
|
| 21 |
|
| 22 |
+
// This file is preloaded through NODE_OPTIONS, so it also runs inside npm and
|
| 23 |
+
// OpenClaw helper subprocesses that may emit machine-readable JSON on stdout.
|
| 24 |
+
// Keep rotator diagnostics on stderr to avoid corrupting those stdout streams.
|
| 25 |
+
const log = (...args) => console.error(...args);
|
| 26 |
+
|
| 27 |
// βββ Provider definitions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
//
|
| 29 |
// hostname β regex tested against the request hostname (case-insensitive)
|
|
|
|
| 190 |
: normalizeKeys(process.env.LLM_API_KEY || '');
|
| 191 |
|
| 192 |
if (hasDedicated) {
|
| 193 |
+
log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
|
| 194 |
} else if (!keys.length) {
|
| 195 |
console.warn(`[key-rotator] No keys for provider "${p.name}"`);
|
| 196 |
}
|
|
|
|
| 207 |
return dedicated.length === 0 && p.keys.length > 0;
|
| 208 |
}).length;
|
| 209 |
if (fallbackCount > 0) {
|
| 210 |
+
log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
|
| 211 |
}
|
| 212 |
|
| 213 |
// βββ Runtime helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 337 |
patchHttpModule(http);
|
| 338 |
patchHttpModule(https);
|
| 339 |
|
| 340 |
+
log('[key-rotator] loaded β all providers active');
|
openclaw-sync.py
CHANGED
|
@@ -7,6 +7,7 @@ credentials inside a private HF dataset without embedding HF tokens in git
|
|
| 7 |
remotes or requiring a manual HF_USERNAME secret.
|
| 8 |
"""
|
| 9 |
|
|
|
|
| 10 |
import hashlib
|
| 11 |
import json
|
| 12 |
import logging
|
|
@@ -34,10 +35,20 @@ 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 +89,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 +268,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)
|
|
@@ -364,7 +394,7 @@ def restore_workspace() -> bool:
|
|
| 364 |
return False
|
| 365 |
|
| 366 |
|
| 367 |
-
def
|
| 368 |
last_fingerprint: str | None = None,
|
| 369 |
last_marker: tuple[int, int, int] | None = None,
|
| 370 |
) -> tuple[str, tuple[int, int, int]]:
|
|
@@ -412,14 +442,81 @@ def sync_once(
|
|
| 412 |
return (current_fingerprint, current_marker)
|
| 413 |
|
| 414 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
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 +528,56 @@ 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 |
|
|
@@ -469,6 +598,17 @@ def main() -> int:
|
|
| 469 |
write_status("error", f"Shutdown sync failed: {exc}")
|
| 470 |
print(f"Workspace sync: shutdown sync failed: {exc}")
|
| 471 |
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
if command == "loop":
|
| 473 |
return loop()
|
| 474 |
|
|
|
|
| 7 |
remotes or requiring a manual HF_USERNAME secret.
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
import fcntl
|
| 11 |
import hashlib
|
| 12 |
import json
|
| 13 |
import logging
|
|
|
|
| 35 |
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
| 36 |
|
| 37 |
OPENCLAW_HOME = Path("/home/node/.openclaw")
|
| 38 |
+
OPENCLAW_CONFIG_FILE = OPENCLAW_HOME / "openclaw.json"
|
| 39 |
WORKSPACE = OPENCLAW_HOME / "workspace"
|
| 40 |
STATUS_FILE = Path("/tmp/sync-status.json")
|
| 41 |
+
SYNC_LOCK_FILE = Path("/tmp/huggingclaw-sync.lock")
|
| 42 |
INTERVAL = int(os.environ.get("SYNC_INTERVAL", "180"))
|
| 43 |
INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
|
| 44 |
+
CONFIG_WATCH_INTERVAL = max(
|
| 45 |
+
0.5,
|
| 46 |
+
float(os.environ.get("OPENCLAW_CONFIG_WATCH_INTERVAL", "1")),
|
| 47 |
+
)
|
| 48 |
+
CONFIG_SETTLE_SECONDS = max(
|
| 49 |
+
0.0,
|
| 50 |
+
float(os.environ.get("OPENCLAW_CONFIG_SETTLE_SECONDS", "3")),
|
| 51 |
+
)
|
| 52 |
HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
|
| 53 |
HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
|
| 54 |
SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
|
|
|
|
| 89 |
tmp_path.replace(STATUS_FILE)
|
| 90 |
|
| 91 |
|
| 92 |
+
def read_status() -> dict[str, str]:
|
| 93 |
+
try:
|
| 94 |
+
return json.loads(STATUS_FILE.read_text(encoding="utf-8"))
|
| 95 |
+
except Exception:
|
| 96 |
+
return {}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
def count_files(path: Path) -> int:
|
| 100 |
if not path.exists():
|
| 101 |
return 0
|
|
|
|
| 268 |
return False
|
| 269 |
|
| 270 |
|
| 271 |
+
def file_marker(path: Path) -> tuple[int, int, int]:
|
| 272 |
+
try:
|
| 273 |
+
stat = path.stat()
|
| 274 |
+
except OSError:
|
| 275 |
+
return (0, 0, 0)
|
| 276 |
+
|
| 277 |
+
if not path.is_file():
|
| 278 |
+
return (0, 0, 0)
|
| 279 |
+
|
| 280 |
+
return (1, int(stat.st_size), int(stat.st_mtime_ns))
|
| 281 |
+
|
| 282 |
+
|
| 283 |
def metadata_marker(root: Path) -> tuple[int, int, int]:
|
| 284 |
if not root.exists():
|
| 285 |
return (0, 0, 0)
|
|
|
|
| 394 |
return False
|
| 395 |
|
| 396 |
|
| 397 |
+
def _sync_once_unlocked(
|
| 398 |
last_fingerprint: str | None = None,
|
| 399 |
last_marker: tuple[int, int, int] | None = None,
|
| 400 |
) -> tuple[str, tuple[int, int, int]]:
|
|
|
|
| 442 |
return (current_fingerprint, current_marker)
|
| 443 |
|
| 444 |
|
| 445 |
+
def sync_once(
|
| 446 |
+
last_fingerprint: str | None = None,
|
| 447 |
+
last_marker: tuple[int, int, int] | None = None,
|
| 448 |
+
) -> tuple[str, tuple[int, int, int]]:
|
| 449 |
+
SYNC_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 450 |
+
with SYNC_LOCK_FILE.open("w", encoding="utf-8") as lock_handle:
|
| 451 |
+
fcntl.flock(lock_handle, fcntl.LOCK_EX)
|
| 452 |
+
try:
|
| 453 |
+
return _sync_once_unlocked(last_fingerprint, last_marker)
|
| 454 |
+
finally:
|
| 455 |
+
fcntl.flock(lock_handle, fcntl.LOCK_UN)
|
| 456 |
+
|
| 457 |
+
|
| 458 |
def handle_signal(_sig, _frame) -> None:
|
| 459 |
STOP_EVENT.set()
|
| 460 |
|
| 461 |
|
| 462 |
+
def is_valid_json_file(path: Path) -> bool:
|
| 463 |
+
if not path.exists():
|
| 464 |
+
return True
|
| 465 |
+
|
| 466 |
+
try:
|
| 467 |
+
json.loads(path.read_text(encoding="utf-8"))
|
| 468 |
+
return True
|
| 469 |
+
except Exception:
|
| 470 |
+
return False
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def wait_for_config_settle(config_marker: tuple[int, int, int]) -> tuple[str, tuple[int, int, int]]:
|
| 474 |
+
stable_since = time.monotonic()
|
| 475 |
+
current_marker = config_marker
|
| 476 |
+
|
| 477 |
+
while not STOP_EVENT.is_set():
|
| 478 |
+
latest_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 479 |
+
if latest_marker != current_marker:
|
| 480 |
+
current_marker = latest_marker
|
| 481 |
+
stable_since = time.monotonic()
|
| 482 |
+
|
| 483 |
+
if (
|
| 484 |
+
time.monotonic() - stable_since >= CONFIG_SETTLE_SECONDS
|
| 485 |
+
and is_valid_json_file(OPENCLAW_CONFIG_FILE)
|
| 486 |
+
):
|
| 487 |
+
return ("settled", current_marker)
|
| 488 |
+
|
| 489 |
+
if STOP_EVENT.wait(CONFIG_WATCH_INTERVAL):
|
| 490 |
+
return ("stopped", current_marker)
|
| 491 |
+
|
| 492 |
+
return ("stopped", current_marker)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def wait_for_sync_trigger(config_marker: tuple[int, int, int]) -> tuple[str, tuple[int, int, int]]:
|
| 496 |
+
deadline = time.monotonic() + max(0, INTERVAL)
|
| 497 |
+
|
| 498 |
+
while not STOP_EVENT.is_set():
|
| 499 |
+
current_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 500 |
+
if current_config_marker != config_marker:
|
| 501 |
+
return wait_for_config_settle(current_config_marker)
|
| 502 |
+
|
| 503 |
+
remaining = deadline - time.monotonic()
|
| 504 |
+
if remaining <= 0:
|
| 505 |
+
return ("interval", current_config_marker)
|
| 506 |
+
|
| 507 |
+
wait_seconds = min(CONFIG_WATCH_INTERVAL, remaining)
|
| 508 |
+
if STOP_EVENT.wait(wait_seconds):
|
| 509 |
+
return ("stopped", current_config_marker)
|
| 510 |
+
|
| 511 |
+
return ("stopped", config_marker)
|
| 512 |
+
|
| 513 |
+
|
| 514 |
def loop() -> int:
|
| 515 |
signal.signal(signal.SIGTERM, handle_signal)
|
| 516 |
signal.signal(signal.SIGINT, handle_signal)
|
| 517 |
|
| 518 |
+
previous_status = read_status().get("status", "")
|
| 519 |
+
|
| 520 |
try:
|
| 521 |
repo_id = resolve_backup_namespace()
|
| 522 |
write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
|
|
|
|
| 528 |
time.sleep(INITIAL_DELAY)
|
| 529 |
print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
|
| 530 |
|
| 531 |
+
# Capture the restored dataset state before refreshing the embedded
|
| 532 |
+
# /home/node/.openclaw backup. Startup may have patched openclaw.json
|
| 533 |
+
# after restore (token/model/logging/channel toggles), and that patch only
|
| 534 |
+
# becomes part of the dataset once snapshot_state_into_workspace() copies it
|
| 535 |
+
# into workspace/huggingclaw-state/openclaw/. If the snapshot changes the
|
| 536 |
+
# workspace, seed the first sync with the pre-snapshot fingerprint so the
|
| 537 |
+
# updated openclaw.json is uploaded instead of being treated as the baseline.
|
| 538 |
+
pre_snapshot_fingerprint = fingerprint_dir(WORKSPACE)
|
| 539 |
+
pre_snapshot_marker = metadata_marker(WORKSPACE)
|
| 540 |
snapshot_state_into_workspace()
|
| 541 |
last_fingerprint = fingerprint_dir(WORKSPACE)
|
| 542 |
last_marker = metadata_marker(WORKSPACE)
|
| 543 |
+
|
| 544 |
+
if last_fingerprint != pre_snapshot_fingerprint:
|
| 545 |
+
if previous_status == "error":
|
| 546 |
+
print(
|
| 547 |
+
"Initial state snapshot changed, but restore previously failed; "
|
| 548 |
+
"keeping current state as baseline to avoid overwriting the remote backup."
|
| 549 |
+
)
|
| 550 |
+
else:
|
| 551 |
+
last_fingerprint = pre_snapshot_fingerprint
|
| 552 |
+
last_marker = pre_snapshot_marker
|
| 553 |
+
print("Initial state snapshot changed; first sync will upload refreshed OpenClaw state.")
|
| 554 |
+
else:
|
| 555 |
+
print("Initial workspace fingerprint captured.")
|
| 556 |
+
|
| 557 |
+
config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 558 |
|
| 559 |
while not STOP_EVENT.is_set():
|
| 560 |
try:
|
| 561 |
+
sync_started_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 562 |
last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
|
| 563 |
+
config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 564 |
+
|
| 565 |
+
if config_marker != sync_started_config_marker:
|
| 566 |
+
trigger, config_marker = wait_for_config_settle(config_marker)
|
| 567 |
+
if trigger == "stopped":
|
| 568 |
+
break
|
| 569 |
+
print("OpenClaw config changed during sync; syncing again after it settled.")
|
| 570 |
+
continue
|
| 571 |
except Exception as exc:
|
| 572 |
write_status("error", f"Sync failed: {exc}")
|
| 573 |
print(f"Workspace sync failed: {exc}")
|
| 574 |
+
config_marker = file_marker(OPENCLAW_CONFIG_FILE)
|
| 575 |
|
| 576 |
+
trigger, config_marker = wait_for_sync_trigger(config_marker)
|
| 577 |
+
if trigger == "stopped":
|
| 578 |
break
|
| 579 |
+
if trigger == "settled":
|
| 580 |
+
print("OpenClaw config changed and settled; syncing immediately.")
|
| 581 |
|
| 582 |
return 0
|
| 583 |
|
|
|
|
| 598 |
write_status("error", f"Shutdown sync failed: {exc}")
|
| 599 |
print(f"Workspace sync: shutdown sync failed: {exc}")
|
| 600 |
return 1
|
| 601 |
+
if command == "sync-once-settled":
|
| 602 |
+
try:
|
| 603 |
+
trigger, _ = wait_for_config_settle(file_marker(OPENCLAW_CONFIG_FILE))
|
| 604 |
+
if trigger == "stopped":
|
| 605 |
+
return 1
|
| 606 |
+
sync_once()
|
| 607 |
+
return 0
|
| 608 |
+
except Exception as exc:
|
| 609 |
+
write_status("error", f"Settled sync failed: {exc}")
|
| 610 |
+
print(f"Workspace sync: settled sync failed: {exc}")
|
| 611 |
+
return 1
|
| 612 |
if command == "loop":
|
| 613 |
return loop()
|
| 614 |
|
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 |
|
|
@@ -1000,59 +1018,108 @@ hc_finish_startup_commands
|
|
| 1000 |
sync_installed_plugins_into_allow
|
| 1001 |
|
| 1002 |
# ββ Launch gateway ββ
|
| 1003 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1004 |
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
GATEWAY_ARGS+=(--verbose)
|
| 1008 |
-
echo "Gateway verbose logging enabled (GATEWAY_VERBOSE=1)"
|
| 1009 |
-
fi
|
| 1010 |
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
# openclaw β fine for passing to `wait` (waits for the whole pipeline to
|
| 1014 |
-
# finish), but kill -0 on it is uninformative. We probe TCP instead.
|
| 1015 |
-
stdbuf -oL -eL openclaw "${GATEWAY_ARGS[@]}" 2>&1 | tee -a /home/node/.openclaw/gateway.log &
|
| 1016 |
-
GATEWAY_PID=$!
|
| 1017 |
-
|
| 1018 |
-
# Poll for the gateway to start listening on 7860. OpenClaw can take 20-30s
|
| 1019 |
-
# on cold start (plugin install + auto-restore). Bail out early if the
|
| 1020 |
-
# pipeline died.
|
| 1021 |
-
GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-90}"
|
| 1022 |
-
ready=false
|
| 1023 |
-
for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
|
| 1024 |
-
if (echo > /dev/tcp/127.0.0.1/7860) 2>/dev/null; then
|
| 1025 |
-
ready=true
|
| 1026 |
-
break
|
| 1027 |
-
fi
|
| 1028 |
-
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
| 1029 |
-
break
|
| 1030 |
fi
|
| 1031 |
-
sleep 1
|
| 1032 |
-
done
|
| 1033 |
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
|
|
|
|
|
|
|
|
|
| 1041 |
|
| 1042 |
-
# 11. Start WhatsApp Guardian after the gateway is accepting connections
|
| 1043 |
-
if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
|
| 1044 |
node /home/node/app/wa-guardian.js &
|
| 1045 |
GUARDIAN_PID=$!
|
| 1046 |
echo "WhatsApp Guardian started (PID: $GUARDIAN_PID)"
|
| 1047 |
-
|
| 1048 |
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
|
| 1052 |
-
|
| 1053 |
-
if [
|
| 1054 |
-
|
| 1055 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1056 |
|
| 1057 |
-
#
|
| 1058 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
|
|
| 1018 |
sync_installed_plugins_into_allow
|
| 1019 |
|
| 1020 |
# ββ Launch gateway ββ
|
| 1021 |
+
GATEWAY_RESTART_DELAY="${GATEWAY_RESTART_DELAY:-2}"
|
| 1022 |
+
GATEWAY_MAX_RESTARTS="${GATEWAY_MAX_RESTARTS:-0}"
|
| 1023 |
+
GATEWAY_RESTART_COUNT=0
|
| 1024 |
+
SYNC_LOOP_PID=""
|
| 1025 |
+
GUARDIAN_PID=""
|
| 1026 |
+
|
| 1027 |
+
sync_before_gateway_restart() {
|
| 1028 |
+
[ -n "${HF_TOKEN:-}" ] || return 0
|
| 1029 |
+
[ -f "/home/node/app/openclaw-sync.py" ] || return 0
|
| 1030 |
+
|
| 1031 |
+
echo "Gateway stopped; saving latest OpenClaw state before restart..."
|
| 1032 |
+
python3 /home/node/app/openclaw-sync.py sync-once-settled || \
|
| 1033 |
+
echo "Warning: could not sync settled state before gateway restart"
|
| 1034 |
+
}
|
| 1035 |
|
| 1036 |
+
start_background_sync_once() {
|
| 1037 |
+
[ -n "${HF_TOKEN:-}" ] || return 0
|
|
|
|
|
|
|
|
|
|
| 1038 |
|
| 1039 |
+
if [ -n "$SYNC_LOOP_PID" ] && kill -0 "$SYNC_LOOP_PID" 2>/dev/null; then
|
| 1040 |
+
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1041 |
fi
|
|
|
|
|
|
|
| 1042 |
|
| 1043 |
+
python3 -u /home/node/app/openclaw-sync.py loop &
|
| 1044 |
+
SYNC_LOOP_PID=$!
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
start_guardian_once() {
|
| 1048 |
+
[ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ] || return 0
|
| 1049 |
+
|
| 1050 |
+
if [ -n "$GUARDIAN_PID" ] && kill -0 "$GUARDIAN_PID" 2>/dev/null; then
|
| 1051 |
+
return 0
|
| 1052 |
+
fi
|
| 1053 |
|
|
|
|
|
|
|
| 1054 |
node /home/node/app/wa-guardian.js &
|
| 1055 |
GUARDIAN_PID=$!
|
| 1056 |
echo "WhatsApp Guardian started (PID: $GUARDIAN_PID)"
|
| 1057 |
+
}
|
| 1058 |
|
| 1059 |
+
while true; do
|
| 1060 |
+
echo "Launching OpenClaw gateway on port 7860..."
|
| 1061 |
|
| 1062 |
+
GATEWAY_ARGS=(gateway run --port 7860 --bind lan)
|
| 1063 |
+
if [ "${GATEWAY_VERBOSE:-0}" = "1" ]; then
|
| 1064 |
+
GATEWAY_ARGS+=(--verbose)
|
| 1065 |
+
echo "Gateway verbose logging enabled (GATEWAY_VERBOSE=1)"
|
| 1066 |
+
fi
|
| 1067 |
+
|
| 1068 |
+
# Use stdbuf -oL -eL to ensure logs are not buffered and appear immediately
|
| 1069 |
+
# in the console. NOTE: $! captures the LAST pipeline element (tee), not
|
| 1070 |
+
# openclaw β fine for passing to `wait` (waits for the whole pipeline to
|
| 1071 |
+
# finish), but kill -0 on it is uninformative. We probe TCP instead.
|
| 1072 |
+
stdbuf -oL -eL openclaw "${GATEWAY_ARGS[@]}" 2>&1 | tee -a /home/node/.openclaw/gateway.log &
|
| 1073 |
+
GATEWAY_PID=$!
|
| 1074 |
+
|
| 1075 |
+
# Poll for the gateway to start listening on 7860. OpenClaw can take 20-30s
|
| 1076 |
+
# on cold start (plugin install + auto-restore). Bail out early if the
|
| 1077 |
+
# pipeline died.
|
| 1078 |
+
GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-90}"
|
| 1079 |
+
ready=false
|
| 1080 |
+
for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
|
| 1081 |
+
if (echo > /dev/tcp/127.0.0.1/7860) 2>/dev/null; then
|
| 1082 |
+
ready=true
|
| 1083 |
+
break
|
| 1084 |
+
fi
|
| 1085 |
+
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
| 1086 |
+
break
|
| 1087 |
+
fi
|
| 1088 |
+
sleep 1
|
| 1089 |
+
done
|
| 1090 |
+
|
| 1091 |
+
if [ "$ready" != "true" ]; then
|
| 1092 |
+
echo ""
|
| 1093 |
+
echo "Gateway failed to start. Last 30 lines of log:"
|
| 1094 |
+
echo "ββββββββββββββββββββββββββββββββββββββββββββ"
|
| 1095 |
+
tail -30 /home/node/.openclaw/gateway.log
|
| 1096 |
+
exit 1
|
| 1097 |
+
fi
|
| 1098 |
|
| 1099 |
+
# 11. Start WhatsApp Guardian after the gateway is accepting connections
|
| 1100 |
+
start_guardian_once
|
| 1101 |
+
|
| 1102 |
+
# 11.5 Warm up the managed browser so first browser actions have a live tab
|
| 1103 |
+
warmup_browser
|
| 1104 |
+
|
| 1105 |
+
# 12. Start Workspace Sync after startup settles. Keep only one loop active;
|
| 1106 |
+
# config edits can make OpenClaw exit/reload, and the gateway loop below will
|
| 1107 |
+
# relaunch it without rerunning all startup code.
|
| 1108 |
+
start_background_sync_once
|
| 1109 |
+
|
| 1110 |
+
set +e
|
| 1111 |
+
wait "$GATEWAY_PID"
|
| 1112 |
+
GATEWAY_EXIT_CODE=$?
|
| 1113 |
+
set -e
|
| 1114 |
+
|
| 1115 |
+
sync_before_gateway_restart
|
| 1116 |
+
|
| 1117 |
+
GATEWAY_RESTART_COUNT=$((GATEWAY_RESTART_COUNT + 1))
|
| 1118 |
+
if [ "$GATEWAY_MAX_RESTARTS" != "0" ] && [ "$GATEWAY_RESTART_COUNT" -ge "$GATEWAY_MAX_RESTARTS" ]; then
|
| 1119 |
+
echo "Gateway exited with code ${GATEWAY_EXIT_CODE}; restart limit (${GATEWAY_MAX_RESTARTS}) reached."
|
| 1120 |
+
exit "$GATEWAY_EXIT_CODE"
|
| 1121 |
+
fi
|
| 1122 |
+
|
| 1123 |
+
echo "Gateway exited with code ${GATEWAY_EXIT_CODE}; restarting in ${GATEWAY_RESTART_DELAY}s..."
|
| 1124 |
+
sleep "$GATEWAY_RESTART_DELAY"
|
| 1125 |
+
done
|