feat(openclaw): modularize scripts, implement crons-as-code and EaC governance
This commit is contained in:
@@ -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)
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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!")
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user