anurag008w commited on
Commit
5a73172
·
1 Parent(s): 6b45d1f

Debounce OpenClaw config syncs until stable

Browse files
Files changed (5) hide show
  1. .env.example +6 -0
  2. README.md +3 -1
  3. multi-provider-key-rotator.cjs +8 -3
  4. openclaw-sync.py +120 -6
  5. start.sh +26 -8
.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` | Backup frequency in seconds |
 
 
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
- console.log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
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
- console.log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
206
  }
207
 
208
  // ─── Runtime helpers ─────────────────────────────────────────────────────────
@@ -332,4 +337,4 @@ patchFetch();
332
  patchHttpModule(http);
333
  patchHttpModule(https);
334
 
335
- console.log('[key-rotator] loaded — all providers active');
 
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
@@ -34,10 +34,19 @@ 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 +87,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 +266,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 +444,64 @@ 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 +513,56 @@ def loop() -> int:
431
  time.sleep(INITIAL_DELAY)
432
  print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
433
 
434
- # Take a fingerprint of the workspace AS RESTORED (after snapshotting state)
435
- # so the first loop iteration only uploads if something genuinely changed.
436
- # Previously this was None, which forced an unconditional upload every restart
437
- # even when restore had failed silently and the workspace was empty.
 
 
 
 
 
438
  snapshot_state_into_workspace()
439
  last_fingerprint = fingerprint_dir(WORKSPACE)
440
  last_marker = metadata_marker(WORKSPACE)
441
- print("Initial workspace fingerprint captured.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if STOP_EVENT.wait(INTERVAL):
 
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
+ CONFIG_SETTLE_SECONDS = max(
47
+ 0.0,
48
+ float(os.environ.get("OPENCLAW_CONFIG_SETTLE_SECONDS", "3")),
49
+ )
50
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
51
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
52
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
 
87
  tmp_path.replace(STATUS_FILE)
88
 
89
 
90
+ def read_status() -> dict[str, str]:
91
+ try:
92
+ return json.loads(STATUS_FILE.read_text(encoding="utf-8"))
93
+ except Exception:
94
+ return {}
95
+
96
+
97
  def count_files(path: Path) -> int:
98
  if not path.exists():
99
  return 0
 
266
  return False
267
 
268
 
269
+ def file_marker(path: Path) -> tuple[int, int, int]:
270
+ try:
271
+ stat = path.stat()
272
+ except OSError:
273
+ return (0, 0, 0)
274
+
275
+ if not path.is_file():
276
+ return (0, 0, 0)
277
+
278
+ return (1, int(stat.st_size), int(stat.st_mtime_ns))
279
+
280
+
281
  def metadata_marker(root: Path) -> tuple[int, int, int]:
282
  if not root.exists():
283
  return (0, 0, 0)
 
444
  STOP_EVENT.set()
445
 
446
 
447
+ def is_valid_json_file(path: Path) -> bool:
448
+ if not path.exists():
449
+ return True
450
+
451
+ try:
452
+ json.loads(path.read_text(encoding="utf-8"))
453
+ return True
454
+ except Exception:
455
+ return False
456
+
457
+
458
+ def wait_for_config_settle(config_marker: tuple[int, int, int]) -> tuple[str, tuple[int, int, int]]:
459
+ stable_since = time.monotonic()
460
+ current_marker = config_marker
461
+
462
+ while not STOP_EVENT.is_set():
463
+ latest_marker = file_marker(OPENCLAW_CONFIG_FILE)
464
+ if latest_marker != current_marker:
465
+ current_marker = latest_marker
466
+ stable_since = time.monotonic()
467
+
468
+ if (
469
+ time.monotonic() - stable_since >= CONFIG_SETTLE_SECONDS
470
+ and is_valid_json_file(OPENCLAW_CONFIG_FILE)
471
+ ):
472
+ return ("settled", current_marker)
473
+
474
+ if STOP_EVENT.wait(CONFIG_WATCH_INTERVAL):
475
+ return ("stopped", current_marker)
476
+
477
+ return ("stopped", current_marker)
478
+
479
+
480
+ def wait_for_sync_trigger(config_marker: tuple[int, int, int]) -> tuple[str, tuple[int, int, int]]:
481
+ deadline = time.monotonic() + max(0, INTERVAL)
482
+
483
+ while not STOP_EVENT.is_set():
484
+ current_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
485
+ if current_config_marker != config_marker:
486
+ return wait_for_config_settle(current_config_marker)
487
+
488
+ remaining = deadline - time.monotonic()
489
+ if remaining <= 0:
490
+ return ("interval", current_config_marker)
491
+
492
+ wait_seconds = min(CONFIG_WATCH_INTERVAL, remaining)
493
+ if STOP_EVENT.wait(wait_seconds):
494
+ return ("stopped", current_config_marker)
495
+
496
+ return ("stopped", config_marker)
497
+
498
+
499
  def loop() -> int:
500
  signal.signal(signal.SIGTERM, handle_signal)
501
  signal.signal(signal.SIGINT, handle_signal)
502
 
503
+ previous_status = read_status().get("status", "")
504
+
505
  try:
506
  repo_id = resolve_backup_namespace()
507
  write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
 
513
  time.sleep(INITIAL_DELAY)
514
  print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
515
 
516
+ # Capture the restored dataset state before refreshing the embedded
517
+ # /home/node/.openclaw backup. Startup may have patched openclaw.json
518
+ # after restore (token/model/logging/channel toggles), and that patch only
519
+ # becomes part of the dataset once snapshot_state_into_workspace() copies it
520
+ # into workspace/huggingclaw-state/openclaw/. If the snapshot changes the
521
+ # workspace, seed the first sync with the pre-snapshot fingerprint so the
522
+ # updated openclaw.json is uploaded instead of being treated as the baseline.
523
+ pre_snapshot_fingerprint = fingerprint_dir(WORKSPACE)
524
+ pre_snapshot_marker = metadata_marker(WORKSPACE)
525
  snapshot_state_into_workspace()
526
  last_fingerprint = fingerprint_dir(WORKSPACE)
527
  last_marker = metadata_marker(WORKSPACE)
528
+
529
+ if last_fingerprint != pre_snapshot_fingerprint:
530
+ if previous_status == "error":
531
+ print(
532
+ "Initial state snapshot changed, but restore previously failed; "
533
+ "keeping current state as baseline to avoid overwriting the remote backup."
534
+ )
535
+ else:
536
+ last_fingerprint = pre_snapshot_fingerprint
537
+ last_marker = pre_snapshot_marker
538
+ print("Initial state snapshot changed; first sync will upload refreshed OpenClaw state.")
539
+ else:
540
+ print("Initial workspace fingerprint captured.")
541
+
542
+ config_marker = file_marker(OPENCLAW_CONFIG_FILE)
543
 
544
  while not STOP_EVENT.is_set():
545
  try:
546
+ sync_started_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
547
  last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
548
+ config_marker = file_marker(OPENCLAW_CONFIG_FILE)
549
+
550
+ if config_marker != sync_started_config_marker:
551
+ trigger, config_marker = wait_for_config_settle(config_marker)
552
+ if trigger == "stopped":
553
+ break
554
+ print("OpenClaw config changed during sync; syncing again after it settled.")
555
+ continue
556
  except Exception as exc:
557
  write_status("error", f"Sync failed: {exc}")
558
  print(f"Workspace sync failed: {exc}")
559
+ config_marker = file_marker(OPENCLAW_CONFIG_FILE)
560
 
561
+ trigger, config_marker = wait_for_sync_trigger(config_marker)
562
+ if trigger == "stopped":
563
  break
564
+ if trigger == "settled":
565
+ print("OpenClaw config changed and settled; syncing immediately.")
566
 
567
  return 0
568
 
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
- '.gateway.auth.token = $token
 
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 // {}) * ($desired.plugins.entries // {}))
458
  | if $whatsappEnabled then
459
- .plugins.entries.whatsapp.enabled = true
460
- | .channels.whatsapp = ($desired.channels.whatsapp // {"dmPolicy": "pairing"})
461
- else
 
 
 
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