feat(openclaw): modularize scripts, implement crons-as-code and EaC governance

This commit is contained in:
Moritz Graf
2026-10-16 21:25:33 +02:00
parent f4d8210cc4
commit ed3529a9d7
16 changed files with 1924 additions and 1 deletions
+205
View File
@@ -0,0 +1,205 @@
#!/bin/sh
set -e
echo "=== OpenClaw Dependency & Runtime Bootstrap ==="
export PATH="/home/node/.openclaw/bin:/home/node/.openclaw/.local/bin:$PATH"
SCRIPTS_DIR="/home/node/.openclaw/scripts"
mkdir -p /home/node/.openclaw/bin "$SCRIPTS_DIR" /home/node/.openclaw/.config
touch /home/node/.openclaw/.config/keep
# 1. Sanitize legacy config keys in openclaw.json
node -e '
const fs = require("fs");
for (const file of ["/home/node/.openclaw/openclaw.json", "/home/openclaw/.openclaw/openclaw.json"]) {
if (fs.existsSync(file)) {
try {
const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
if (cfg.browser) {
delete cfg.browser.remoteCdpTimeoutMs;
if (cfg.browser.profiles) {
if (cfg.browser.profiles.default) delete cfg.browser.profiles.default.color;
if (cfg.browser.profiles.chrome) delete cfg.browser.profiles.chrome;
}
}
if (cfg.gateway && cfg.gateway.controlUi) {
delete cfg.gateway.controlUi.dangerouslyDisableDeviceAuth;
}
if (!cfg.agents) cfg.agents = {};
cfg.agents.ownership = "explicit";
if (cfg.agents.entries && cfg.agents.entries.main) {
delete cfg.agents.entries.main.default;
}
fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
console.log("Sanitized config file:", file);
} catch (e) {
console.error("Failed to sanitize", file, e.message);
}
}
}
' || true
# 2. Run pre-flight session recovery
echo "Running pre-flight self-healing..."
python3 "$SCRIPTS_DIR/recover_sessions.py" || true
# 3. Doctor fix
echo "Running openclaw doctor --fix to migrate runtime config on PVC..."
openclaw doctor --fix || true
# 4. Garmin OAuth tokens bootstrap / preserve
if [ ! -s /home/node/.openclaw/garmin_tokens/garmin_tokens.json ] && [ -n "$GARMIN_TOKENS_BASE64" ]; then
echo "Bootstrapping initial Garmin OAuth tokens from secret..."
mkdir -p /home/node/.openclaw/garmin_tokens
echo "$GARMIN_TOKENS_BASE64" | base64 -d > /home/node/.openclaw/garmin_tokens/garmin_tokens.json
chmod 700 /home/node/.openclaw/garmin_tokens
chmod 600 /home/node/.openclaw/garmin_tokens/garmin_tokens.json
elif [ -s /home/node/.openclaw/garmin_tokens/garmin_tokens.json ]; then
echo "Preserving live Garmin OAuth tokens on PVC..."
chmod 700 /home/node/.openclaw/garmin_tokens
chmod 600 /home/node/.openclaw/garmin_tokens/garmin_tokens.json
fi
# 5. Cleanup legacy binaries and superseded local skills
rm -rf /home/node/.openclaw/bin/whisper* /home/node/.openclaw/bin/.whisper* /home/node/.openclaw/scripts/whisper* /home/node/.openclaw/models/whisper || true
rm -rf /home/node/.openclaw/skills/memobird /home/node/.openclaw/skills/nerd-report /home/node/.openclaw/skills/rvv-regensburg /home/node/.openclaw/skills/skill-creator || true
rm -rf /home/node/.openclaw/workspace/skills /home/node/.openclaw/workspace-koch/skills /home/node/.openclaw/workspace-recherche/skills || true
# 6. Ensure tools.allow and models.providers.openrouter.apiKey unset
openclaw config unset tools.allow || true
openclaw config unset models.providers.openrouter.apiKey || true
openclaw config unset agents.entries.mila_tracker.tools.profile || true
openclaw config unset agents.entries.nutrition_tracker.tools.profile || true
# 7. Decouple hardlinked bootstrap files
for dir in /home/node/.openclaw/workspace-*; do
if [ -d "$dir" ]; then
for file in USER.md MEMORY.md; do
if [ -f "/home/node/.openclaw/workspace/$file" ]; then
cp --remove-destination "/home/node/.openclaw/workspace/$file" "$dir/$file"
chown node:node "$dir/$file" 2>/dev/null || true
chmod 664 "$dir/$file" 2>/dev/null || true
fi
done
fi
done
# 8. Install CLI binaries
if [ ! -f "/home/node/.openclaw/bin/openhue.real" ]; then
echo "Installing openhue CLI binary..."
curl -sL https://github.com/openhue/openhue-cli/releases/download/0.24/openhue_Linux_x86_64.tar.gz | tar -xz -C /home/node/.openclaw/bin openhue
mv /home/node/.openclaw/bin/openhue /home/node/.openclaw/bin/openhue.real
printf '#!/bin/sh\nexport HOME=/home/node/.openclaw\nexec /home/node/.openclaw/bin/openhue.real "$@"\n' > /home/node/.openclaw/bin/openhue
chmod +x /home/node/.openclaw/bin/openhue /home/node/.openclaw/bin/openhue.real
fi
if [ ! -f "/home/node/.openclaw/bin/summarize" ]; then
echo "Installing summarize CLI via npm..."
npm install -g --prefix /home/node/.openclaw @steipete/summarize || true
fi
if [ ! -f "/home/node/.openclaw/bin/mcp-server-github" ]; then
echo "Installing @modelcontextprotocol/server-github via npm..."
npm install -g --prefix /home/node/.openclaw @modelcontextprotocol/server-github || true
fi
if [ ! -f "/home/node/.openclaw/bin/kubectl" ]; then
echo "Installing kubectl CLI binary..."
curl -fsSL https://dl.k8s.io/release/v1.31.0/bin/linux/amd64/kubectl -o /home/node/.openclaw/bin/kubectl
chmod +x /home/node/.openclaw/bin/kubectl
fi
if [ ! -f "/home/node/.openclaw/bin/jq" ]; then
echo "Installing jq CLI binary..."
curl -fsSL https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 -o /home/node/.openclaw/bin/jq
chmod +x /home/node/.openclaw/bin/jq
fi
if [ ! -f "/home/node/.openclaw/bin/file" ]; then
echo "Setting up file CLI helper..."
cp "$SCRIPTS_DIR/file_cli.py" /home/node/.openclaw/bin/file
chmod +x /home/node/.openclaw/bin/file
fi
if [ -x "/home/node/.openclaw/.local/bin/python3" ]; then
/home/node/.openclaw/bin/uv pip install --break-system-packages --python /home/node/.openclaw/.local/bin/python3 pillow google-api-python-client pypdf || true
fi
# 9. Clone or pull infrapuzzle repository to PVC
mkdir -p /home/node/.openclaw/repos
if [ ! -d "/home/node/.openclaw/repos/infrapuzzle/.git" ]; then
echo "Cloning infrapuzzle repository to PVC..."
git clone --depth 1 "https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/infrapuzzle.git" /home/node/.openclaw/repos/infrapuzzle || true
else
echo "Pulling latest infrapuzzle commits on PVC..."
git -C /home/node/.openclaw/repos/infrapuzzle pull origin master || git -C /home/node/.openclaw/repos/infrapuzzle pull origin main || true
fi
# 10. Seed and maintain persistent mcp-venv
if [ ! -d "/home/node/.openclaw/mcp-venv" ]; then
echo "Seeding persistent mcp-venv..."
uv venv --python 3.12 /home/node/.openclaw/mcp-venv
uv pip install --python /home/node/.openclaw/mcp-venv \
prometheus-mcp-server \
ha-mcp \
chuk-mcp-open-meteo \
gitea-mcp \
git+https://github.com/Taxuspt/garmin_mcp \
"mcp>=2.0" \
filetype \
google-api-python-client \
google-auth \
bring-api \
requests \
beautifulsoup4 \
youtube-transcript-api
fi
if [ -d "/home/node/.openclaw/mcp-venv" ]; then
python3 -c 'import os; p="/home/node/.openclaw/mcp-venv/lib/python3.12/site-packages/mcp/server/fastmcp.py"; open(p, "w").write("import os\nfrom fastmcp import FastMCP as _FastMCP, Context\n\nclass FastMCP(_FastMCP):\n def __init__(self, name=\"MCP Server\", host=None, port=None, **kwargs):\n if host is not None: os.environ[\"FASTMCP_HOST\"] = str(host)\n if port is not None: os.environ[\"FASTMCP_PORT\"] = str(port)\n super().__init__(name=name, **kwargs)\n")' || true
python3 "$SCRIPTS_DIR/patch_garmin_mcp.py" || true
fi
# 11. Extensions & Plugins
if [ ! -d "/home/node/.openclaw/extensions/perplexity" ]; then
echo "Installing perplexity plugin..."
openclaw plugins install clawhub:@openclaw/perplexity-plugin --accept-capabilities || true
fi
echo "Enabling memory-wiki plugin..."
openclaw plugins enable memory-wiki || true
echo "Patching perplexity web search runtime for chat completions compatibility..."
node "$SCRIPTS_DIR/patch_perplexity.mjs" || true
# 12. Ensure OpenRouter auth profile in state DB
echo "Ensuring OpenRouter auth profile in state DB..."
node --input-type=module -e '
import { DatabaseSync } from "node:sqlite";
const key = process.env.OPENROUTER_API_KEY;
if (key) {
try {
const dbPath = "/home/node/.openclaw/state/openclaw.sqlite";
const db = new DatabaseSync(dbPath);
db.prepare("CREATE TABLE IF NOT EXISTS config_machine_state (state_key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL)").run();
const row = db.prepare("SELECT value_json FROM config_machine_state WHERE state_key = \x27authProfiles.store\x27").get();
let store = row?.value_json ? JSON.parse(row.value_json) : { version: 1, profiles: {} };
if (!store.profiles) store.profiles = {};
store.profiles["openrouter:default"] = {
type: "api_key",
provider: "openrouter",
key: key
};
db.prepare("INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (\x27authProfiles.store\x27, ?, ?) ON CONFLICT(state_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
.run(JSON.stringify(store), Date.now());
console.log("Synchronized openrouter auth profile in state DB.");
} catch (e) {
console.error("Could not sync auth profile:", e.message);
}
}
' || true
# 13. Pre-warm SQLite page cache
echo "Pre-warming SQLite database files into OS page cache for instant cold start..."
cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true
echo "=== Bootstrap Complete ==="
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import glob, os, sqlite3, sys
vec_path = "/app/node_modules/sqlite-vec-linux-x64/vec0.so"
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
agent = os.path.basename(os.path.dirname(os.path.dirname(db)))
if agent == "main":
continue
try:
conn = sqlite3.connect(db, timeout=10.0)
if os.path.exists(vec_path):
conn.enable_load_extension(True)
conn.load_extension(vec_path)
cur = conn.cursor()
chk = cur.execute("SELECT count(*) FROM memory_index_chunks").fetchone()[0]
if chk > 0:
print(f"[dedup_subagents] Purging {chk} duplicated vector chunks from agent {agent}...")
for t in [
"memory_index_sources",
"memory_index_chunks",
"memory_index_chunk_recall_metadata",
"memory_index_chunk_provenance",
"memory_entry_origins",
"memory_embedding_cache",
"memory_index_chunks_vec"
]:
try: cur.execute(f"DELETE FROM {t}")
except Exception: pass
conn.commit()
cur.execute("VACUUM")
print(f"[dedup_subagents] Reclaimed disk space for agent {agent}")
conn.close()
except Exception as e:
print(f"[dedup_subagents] Notice for {agent}: {e}", file=sys.stderr)
+50
View File
@@ -0,0 +1,50 @@
#!/home/node/.openclaw/mcp-venv/bin/python3
import sys, os, mimetypes, filetype
def get_file_info(filepath, mime_only=False):
if not os.path.exists(filepath):
return "cannot open (No such file or directory)", "cannot open (No such file or directory)"
if os.path.isdir(filepath):
return "directory", "inode/directory"
kind = filetype.guess(filepath)
if kind is not None:
return f"{kind.extension.upper()} image/media data ({kind.mime})", kind.mime
guessed_mime, _ = mimetypes.guess_type(filepath)
try:
with open(filepath, "rb") as f:
sample = f.read(4096)
if len(sample) == 0:
return "empty", "application/x-empty"
try:
sample.decode("utf-8")
if guessed_mime:
return f"{guessed_mime} text", guessed_mime
return "ASCII text", "text/plain"
except UnicodeDecodeError:
return "data", "application/octet-stream"
except Exception as e:
return f"cannot read ({e})", "application/octet-stream"
def main():
args = sys.argv[1:]
brief, mime_only, files = False, False, []
for arg in args:
if arg in ("-b", "--brief"):
brief = True
elif arg in ("-i", "--mime", "--mime-type"):
mime_only = True
elif arg.startswith("-") and len(arg) > 1 and not arg.startswith("--"):
if "b" in arg: brief = True
if "i" in arg: mime_only = True
else:
files.append(arg)
if not files:
sys.stderr.write("Usage: file [-b] [-i|--mime-type] <file>...\n")
sys.exit(1)
for f in files:
desc, mime = get_file_info(f, mime_only)
res = mime if mime_only else desc
print(res if brief else f"{f}: {res}")
if __name__ == "__main__":
main()
@@ -0,0 +1,78 @@
import fs from "node:fs";
import path from "node:path";
import { execSync, spawn } from "node:child_process";
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
if (isGateway) {
const t0 = Date.now();
console.log("[gateway-preflight] Initializing container self-healing preflight...");
// 1. Clean up stale gateway lock files from ungraceful kills
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith("gateway") && file.endsWith(".lock")) {
const fullPath = path.join(dir, file);
fs.unlinkSync(fullPath);
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
}
}
} catch (e) {
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
}
}
}
// 2. Clear false database quarantines
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
if (fs.existsSync(qPath)) {
try {
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
console.log("[gateway-preflight] Cleared database quarantine.");
} catch (_) {}
}
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
}
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
try {
const pw0 = Date.now();
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true");
console.log(`[gateway-preflight] Pre-warmed SQLite databases in ${Date.now() - pw0}ms`);
} catch (_) {}
// 5. Recover any wedged running sessions left over from ungraceful container restart
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Session recovery warning:", e.message);
}
// 6. Spawn background turn replay daemon if interrupted turns were detected
try {
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
console.log("[gateway-preflight] Spawning background turn replay daemon...");
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
detached: true,
stdio: "ignore"
});
child.unref();
}
} catch (e) {
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
}
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
}
}
+64
View File
@@ -0,0 +1,64 @@
import os
p = "/home/node/.openclaw/mcp-venv/lib/python3.12/site-packages/garmin_mcp/workouts.py"
if os.path.exists(p):
with open(p, "r") as f:
c = f.read()
if "_normalize_sport_type" not in c:
patch = '''
SPORT_TYPE_MAPPINGS = {
"running": {"sportTypeId": 1, "sportTypeKey": "running", "displayOrder": 1},
"cycling": {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2},
"other": {"sportTypeId": 3, "sportTypeKey": "other", "displayOrder": 3},
"lap_swimming": {"sportTypeId": 4, "sportTypeKey": "lap_swimming", "displayOrder": 4},
"swimming": {"sportTypeId": 4, "sportTypeKey": "lap_swimming", "displayOrder": 4},
"strength_training": {"sportTypeId": 5, "sportTypeKey": "strength_training", "displayOrder": 5},
"cardio_training": {"sportTypeId": 6, "sportTypeKey": "cardio_training", "displayOrder": 6},
"cardio": {"sportTypeId": 6, "sportTypeKey": "cardio_training", "displayOrder": 6},
"yoga": {"sportTypeId": 7, "sportTypeKey": "yoga", "displayOrder": 8},
"pilates": {"sportTypeId": 8, "sportTypeKey": "pilates", "displayOrder": 9},
"hiit": {"sportTypeId": 9, "sportTypeKey": "hiit", "displayOrder": 7},
"mobility": {"sportTypeId": 11, "sportTypeKey": "mobility", "displayOrder": 11},
"walking": {"sportTypeId": 12, "sportTypeKey": "walking", "displayOrder": 10},
"rucking": {"sportTypeId": 13, "sportTypeKey": "rucking", "displayOrder": 12},
}
SPORT_ID_MAPPINGS = {v["sportTypeId"]: v for v in SPORT_TYPE_MAPPINGS.values()}
def _resolve_sport_block(st):
if isinstance(st, str):
k = st.strip().lower()
if k in SPORT_TYPE_MAPPINGS:
return dict(SPORT_TYPE_MAPPINGS[k])
elif isinstance(st, dict):
key = st.get("sportTypeKey")
sid = st.get("sportTypeId")
if key and str(key).strip().lower() in SPORT_TYPE_MAPPINGS:
base = dict(SPORT_TYPE_MAPPINGS[str(key).strip().lower()])
if sid is not None and sid != 0 and sid != base["sportTypeId"]:
raise ValueError(f"sportType mismatch: {key} vs {sid}")
return base
if sid is not None and sid in SPORT_ID_MAPPINGS:
return dict(SPORT_ID_MAPPINGS[sid])
return None
def _normalize_sport_type(workout_data: dict) -> None:
resolved = _resolve_sport_block(workout_data.get("sportType"))
segments = workout_data.get("workoutSegments") or []
if not resolved and segments:
for seg in segments:
resolved = _resolve_sport_block(seg.get("sportType"))
if resolved:
break
if not resolved:
raise ValueError("Missing or invalid sportType. Both sportTypeId and sportTypeKey are required.")
workout_data["sportType"] = resolved
for seg in segments:
st = _resolve_sport_block(seg.get("sportType"))
seg["sportType"] = st if st else dict(resolved)
'''
t = "def _normalize_workout_steps(workout_data: dict) -> None:"
if t in c:
with open(p, "w") as f:
f.write(c.replace(t, patch + t + "\n _normalize_sport_type(workout_data)", 1))
print("Successfully patched garmin_mcp workouts.py!")
+61
View File
@@ -0,0 +1,61 @@
import fs from "node:fs";
import path from "node:path";
import { globSync } from "node:fs";
const baseDir = "/home/node/.openclaw/extensions/perplexity/dist";
if (fs.existsSync(baseDir)) {
const files = fs.readdirSync(baseDir).filter(f => f.startsWith("perplexity-web-search-provider.runtime-") && f.endsWith(".js"));
for (const filename of files) {
const file = path.join(baseDir, filename);
let code = fs.readFileSync(file, "utf8");
if (!code.includes("/* PATCHED_CHAT_COMPLETIONS */")) {
const target = "const structured = runtime.transport === \"search_api\";";
const fastPath = `const structured = runtime.transport === "search_api"; /* PATCHED_CHAT_COMPLETIONS */
if (!structured) {
const cacheKey = buildSearchCacheKey([
"perplexity",
runtime.transport,
runtime.baseUrl,
runtime.model,
query,
freshness
]);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
const cached = readCachedSearchPayload(cacheKey, cacheTtlMs);
if (cached) return cached;
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const result = await runPerplexitySearch({
query,
apiKey: runtime.apiKey,
baseUrl: runtime.baseUrl,
model: runtime.model,
timeoutSeconds,
signal,
freshness
});
const payload = {
query,
provider: "perplexity",
model: runtime.model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true
},
content: wrapWebContent(result.content, "web_search"),
citations: result.citations
};
writeCachedSearchPayload(cacheKey, payload);
return payload;
}`;
if (code.includes(target)) {
code = code.replace(target, fastPath);
fs.writeFileSync(file, code, "utf8");
console.log("Patched perplexity runtime for chat completions:", file);
}
}
}
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
import glob, sqlite3, json, os, sys, time
t0 = time.time()
recovered = 0
interrupted_turns = []
def extract_prompt(msg):
if not msg: return ""
content = msg.get("content")
if isinstance(content, str): return content
if isinstance(content, list):
parts = []
for c in content:
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
elif isinstance(c, str): parts.append(c)
return "\n".join(filter(None, parts))
return str(content or "")
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
try:
conn = sqlite3.connect(db, timeout=10.0)
cursor = conn.cursor()
while True:
cursor.execute("PRAGMA foreign_key_check;")
violations = cursor.fetchall()
if not violations: break
for table, rowid, parent, fkid in violations:
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
cursor.execute("SELECT session_key, entry_json, current_session_id FROM session_nodes WHERE status = \"running\"")
rows = cursor.fetchall()
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
for sk, ej, csid in rows:
try:
p = json.loads(ej) if ej else {}
sid = csid or p.get("sessionId")
if sid:
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
if ev:
ev_seq, ev_raw = ev
try:
ev_data = json.loads(ev_raw)
msg = ev_data.get("message") or ev_data
if msg.get("role") == "user":
prompt_text = extract_prompt(msg)
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
interrupted_turns.append({
"agent": agent_id,
"session_key": sk,
"session_id": sid,
"context": ctx,
"prompt": prompt_text
})
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
except Exception as ex:
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
p["status"] = "done"
p["activeWriterRunId"] = None
p["activeClaimRunId"] = None
p["restartRecoveryDeliveryRunId"] = None
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
recovered += 1
except Exception: pass
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status = \"running\"")
conn.commit()
conn.close()
except Exception as e:
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
if interrupted_turns:
try:
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
json.dump(interrupted_turns, f, indent=2)
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
except Exception as e:
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import json, os, subprocess, sys, time, urllib.request
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
if not os.path.exists(INTERRUPTED_FILE):
sys.exit(0)
try:
with open(INTERRUPTED_FILE, "r") as f:
turns = json.load(f)
except Exception as e:
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
sys.exit(0)
if not turns:
sys.exit(0)
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
gateway_ready = False
for _ in range(60):
try:
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
if resp.status == 200:
gateway_ready = True
break
except Exception:
pass
time.sleep(2)
if not gateway_ready:
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
sys.exit(1)
# Allow 5s for Telegram ingress to bind
time.sleep(5)
for turn in turns:
sk = turn.get("session_key")
sid = turn.get("session_id")
agent_id = turn.get("agent")
ctx = turn.get("context") or {}
prompt = turn.get("prompt") or ""
channel = ctx.get("channel") or "telegram"
to_target = ctx.get("to") or ""
thread_id = ctx.get("threadId")
target = to_target
if target.startswith("telegram:"):
parts = target.split(":")
target = parts[1] if len(parts) > 1 else target
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
# 1. Send status notification to Telegram if target exists
if target:
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
notify_cmd = [
"openclaw", "message", "send",
"--channel", channel,
"--target", str(target),
"-m", notice
]
if thread_id is not None:
notify_cmd.extend(["--thread-id", str(thread_id)])
try:
subprocess.run(notify_cmd, check=False, timeout=30)
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
except Exception as e:
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
# 2. Replay agent turn
if sk and prompt:
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
agent_cmd = [
"openclaw", "agent",
"--session-key", sk,
"-m", prompt,
"--deliver"
]
try:
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
if res.returncode == 0:
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
else:
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
except Exception as e:
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
try:
os.remove(INTERRUPTED_FILE)
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
except Exception:
pass
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
sync_crons.py
Idempotently reconciles declarative OpenClaw automations defined in crons.yaml
with the running OpenClaw gateway state.
Can run directly inside the container or remotely via kubectl exec.
"""
import json
import os
import subprocess
import sys
import yaml
def find_crons_file():
candidates = [
os.environ.get("CRONS_FILE"),
"crons.yaml",
"k8s/openclaw/crons.yaml",
"/home/node/.openclaw/scripts/crons.yaml",
os.path.join(os.path.dirname(__file__), "../crons.yaml"),
os.path.join(os.path.dirname(__file__), "crons.yaml"),
]
for c in candidates:
if c and os.path.exists(c):
return os.path.abspath(c)
raise FileNotFoundError("Could not find crons.yaml. Pass via CRONS_FILE environment variable.")
def is_inside_container():
return os.path.exists("/home/node/.openclaw") or os.environ.get("OPENCLAW_HOME") is not None
def run_openclaw_cmd(args):
if is_inside_container():
cmd = ["openclaw"] + args
else:
namespace = os.environ.get("NAMESPACE", "openclaw")
pod = os.environ.get("POD_NAME", "openclaw-0")
container = os.environ.get("CONTAINER_NAME", "openclaw")
cmd = ["kubectl", "exec", "-n", namespace, pod, "-c", container, "--", "openclaw"] + args
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(f"OpenClaw CLI command failed ({' '.join(cmd)}):\n{res.stderr}\n{res.stdout}")
return res.stdout
def list_existing_jobs():
raw = run_openclaw_cmd(["cron", "list", "--json"])
try:
data = json.loads(raw)
return data.get("jobs", [])
except Exception as e:
print(f"Warning: could not parse cron list json: {e}\nRaw output: {raw[:300]}", file=sys.stderr)
return []
def reconcile_jobs():
file_path = find_crons_file()
print(f"=== Syncing OpenClaw Crons-as-Code from {file_path} ===")
with open(file_path, "r", encoding="utf-8") as f:
spec = yaml.safe_load(f)
declarations = spec.get("jobs", [])
existing_jobs = list_existing_jobs()
print(f"Found {len(declarations)} declared jobs in YAML, {len(existing_jobs)} active jobs on gateway.")
for job in declarations:
name = job.get("name")
decl_key = job.get("declarationKey") or name
desc = job.get("description", "")
sched = job.get("schedule", {})
# Match existing job by declarationKey or exact name
match = next((j for j in existing_jobs if j.get("declarationKey") == decl_key or j.get("name") == name), None)
if match:
existing_id = match.get("id")
print(f"Removing existing job '{name}' (ID {existing_id}) for idempotent recreation...")
try:
run_openclaw_cmd(["cron", "rm", existing_id])
except Exception as e:
print(f"Warning removing {existing_id}: {e}")
# Build add command
cmd = [
"cron", "add",
"--name", name,
"--declaration-key", decl_key,
"--description", desc,
]
if sched.get("kind") == "every":
cmd.extend(["--every", sched.get("every", "15m")])
elif sched.get("kind") == "cron":
cmd.extend(["--cron", sched.get("expr", "* * * * *")])
if sched.get("tz"):
cmd.extend(["--tz", sched.get("tz")])
if job.get("session"):
cmd.extend(["--session", job.get("session")])
if job.get("delivery") == "none":
cmd.append("--no-deliver")
if job.get("command"):
cmd.extend(["--command", job.get("command")])
if job.get("agent"):
cmd.extend(["--agent", job.get("agent")])
if job.get("model"):
cmd.extend(["--model", job.get("model")])
if job.get("channel"):
cmd.extend(["--channel", job.get("channel")])
if job.get("to"):
cmd.extend(["--to", job.get("to")])
if job.get("threadId"):
cmd.extend(["--thread-id", str(job.get("threadId"))])
if job.get("announce"):
cmd.append("--announce")
if job.get("message"):
cmd.extend(["--message", job.get("message")])
print(f"Registering job '{name}'...")
run_openclaw_cmd(cmd)
print(f"Successfully registered '{name}'.")
print("=== OpenClaw Crons-as-Code reconciliation complete! ===")
if __name__ == "__main__":
reconcile_jobs()