Spaces:
Runtime error
Runtime error
Upload sync_hf.py
Browse files- scripts/sync_hf.py +254 -1
scripts/sync_hf.py
CHANGED
|
@@ -2156,6 +2156,251 @@ class OpenClawFullSync:
|
|
| 2156 |
finally:
|
| 2157 |
conn.close()
|
| 2158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2159 |
def run_session_migration(self):
|
| 2160 |
"""Migrate legacy JSON session stores → SQLite.
|
| 2161 |
|
|
@@ -2760,7 +3005,15 @@ def main():
|
|
| 2760 |
# the duplicate when the merged live DB has fewer rows.
|
| 2761 |
sync._dedupe_canonical_session_rows()
|
| 2762 |
|
| 2763 |
-
# 1b.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2764 |
# them (purge, doctor --fix, 'agents add', startup-gate healing).
|
| 2765 |
sync._guard_agent_session_dbs()
|
| 2766 |
|
|
|
|
| 2156 |
finally:
|
| 2157 |
conn.close()
|
| 2158 |
|
| 2159 |
+
# ── Durable session pruning (default: archived sessions are deleted) ──
|
| 2160 |
+
#
|
| 2161 |
+
# Deleting a session from the Control UI never sticks: the next boot's
|
| 2162 |
+
# dataset restore copies the transcript JSONL back into the container and
|
| 2163 |
+
# the .session-guard row-count comparison re-injects the deleted sqlite
|
| 2164 |
+
# rows. The manifest records which sessions were requested so every boot
|
| 2165 |
+
# re-applies the deletion AFTER the restore and BEFORE the guard copy.
|
| 2166 |
+
# Defaults (no configuration needed):
|
| 2167 |
+
# (unset) → delete every archived session
|
| 2168 |
+
# PRUNE_SESSIONS=off → never delete anything
|
| 2169 |
+
# PRUNE_SESSIONS=<key,id,...> → delete specific session keys / window ids
|
| 2170 |
+
|
| 2171 |
+
def _prune_manifest_path(self) -> Path:
|
| 2172 |
+
return OPENCLAW_HOME / "agents" / ".session-prune-manifest.json"
|
| 2173 |
+
|
| 2174 |
+
def _load_prune_manifest(self):
|
| 2175 |
+
try:
|
| 2176 |
+
data = json.loads(
|
| 2177 |
+
self._prune_manifest_path().read_text(encoding="utf-8"))
|
| 2178 |
+
except Exception:
|
| 2179 |
+
return []
|
| 2180 |
+
entries = data.get("sessions") if isinstance(data, dict) else None
|
| 2181 |
+
return [e for e in (entries or []) if isinstance(e, dict)]
|
| 2182 |
+
|
| 2183 |
+
def _save_prune_manifest(self, entries):
|
| 2184 |
+
p = self._prune_manifest_path()
|
| 2185 |
+
try:
|
| 2186 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 2187 |
+
p.write_text(json.dumps({"sessions": entries}, indent=1),
|
| 2188 |
+
encoding="utf-8")
|
| 2189 |
+
except OSError as e:
|
| 2190 |
+
print(f"[PRUNE] cannot write manifest: {str(e)[:140]}")
|
| 2191 |
+
|
| 2192 |
+
@staticmethod
|
| 2193 |
+
def _looks_like_session_id(value: str) -> bool:
|
| 2194 |
+
# A bare window id is a UUID; session keys may *contain* a UUID
|
| 2195 |
+
# ('agent:main:recovered:<uuid>') but also have ':' — those are keys.
|
| 2196 |
+
v = value.lower()
|
| 2197 |
+
return (len(v) == 36 and v.count("-") == 4
|
| 2198 |
+
and all(c in "0123456789abcdef-" for c in v))
|
| 2199 |
+
|
| 2200 |
+
def _archived_session_targets(self):
|
| 2201 |
+
"""[(session_key, session_id|None)] for nodes with archived_at set."""
|
| 2202 |
+
out, seen = [], set()
|
| 2203 |
+
for fp in self._agent_session_dbs():
|
| 2204 |
+
if not fp.is_file():
|
| 2205 |
+
continue
|
| 2206 |
+
conn = sqlite3.connect(str(fp), timeout=10)
|
| 2207 |
+
try:
|
| 2208 |
+
conn.execute("PRAGMA busy_timeout=10000")
|
| 2209 |
+
cur = conn.cursor()
|
| 2210 |
+
try:
|
| 2211 |
+
rows = cur.execute(
|
| 2212 |
+
"SELECT w.session_key, w.session_id "
|
| 2213 |
+
"FROM session_windows w "
|
| 2214 |
+
"JOIN session_nodes n ON n.session_key = w.session_key "
|
| 2215 |
+
"WHERE n.archived_at IS NOT NULL").fetchall()
|
| 2216 |
+
except sqlite3.DatabaseError:
|
| 2217 |
+
rows = []
|
| 2218 |
+
for key, sid in rows:
|
| 2219 |
+
if (key, sid) not in seen:
|
| 2220 |
+
seen.add((key, sid))
|
| 2221 |
+
out.append((key, sid))
|
| 2222 |
+
try:
|
| 2223 |
+
keys = [r[0] for r in cur.execute(
|
| 2224 |
+
"SELECT session_key FROM session_nodes "
|
| 2225 |
+
"WHERE archived_at IS NOT NULL")]
|
| 2226 |
+
except sqlite3.DatabaseError:
|
| 2227 |
+
keys = []
|
| 2228 |
+
for key in keys:
|
| 2229 |
+
if not any(k == key for k, _ in out):
|
| 2230 |
+
out.append((key, None))
|
| 2231 |
+
seen.add((key, None))
|
| 2232 |
+
except sqlite3.Error:
|
| 2233 |
+
pass
|
| 2234 |
+
finally:
|
| 2235 |
+
conn.close()
|
| 2236 |
+
return out
|
| 2237 |
+
|
| 2238 |
+
def _prune_targets(self):
|
| 2239 |
+
"""(session_keys, window_ids) from manifest + PRUNE_SESSIONS env.
|
| 2240 |
+
|
| 2241 |
+
Unset → 'archived' (default-on). 'off' → nothing, ever.
|
| 2242 |
+
"""
|
| 2243 |
+
spec = (os.environ.get("PRUNE_SESSIONS") or "").strip()
|
| 2244 |
+
if spec.lower() in ("off", "0", "no", "false"):
|
| 2245 |
+
return set(), set()
|
| 2246 |
+
entries = self._load_prune_manifest()
|
| 2247 |
+
seen = {(e.get("key") or "", e.get("id") or "") for e in entries}
|
| 2248 |
+
if not spec:
|
| 2249 |
+
spec = "archived"
|
| 2250 |
+
added = []
|
| 2251 |
+
for raw in spec.split(","):
|
| 2252 |
+
w = raw.strip()
|
| 2253 |
+
if not w:
|
| 2254 |
+
continue
|
| 2255 |
+
if w.lower() in ("archived", "all-archived"):
|
| 2256 |
+
for key, sid in self._archived_session_targets():
|
| 2257 |
+
item = (key or "", sid or "")
|
| 2258 |
+
if item in seen or item == ("", ""):
|
| 2259 |
+
continue
|
| 2260 |
+
seen.add(item)
|
| 2261 |
+
added.append({"key": key, "id": sid,
|
| 2262 |
+
"at": int(time.time() * 1000)})
|
| 2263 |
+
elif self._looks_like_session_id(w):
|
| 2264 |
+
if ("", w) not in seen:
|
| 2265 |
+
seen.add(("", w))
|
| 2266 |
+
added.append({"id": w, "at": int(time.time() * 1000)})
|
| 2267 |
+
elif (w, "") not in seen:
|
| 2268 |
+
seen.add((w, ""))
|
| 2269 |
+
added.append({"key": w, "at": int(time.time() * 1000)})
|
| 2270 |
+
if added:
|
| 2271 |
+
entries.extend(added)
|
| 2272 |
+
self._save_prune_manifest(entries)
|
| 2273 |
+
print(f"[PRUNE] manifest seeded with {len(added)} "
|
| 2274 |
+
f"session target(s)")
|
| 2275 |
+
keys = {e["key"] for e in entries if e.get("key")}
|
| 2276 |
+
ids = {e["id"] for e in entries if e.get("id")}
|
| 2277 |
+
return keys, ids
|
| 2278 |
+
|
| 2279 |
+
def _prune_db(self, fp: Path, keys, ids):
|
| 2280 |
+
"""Delete rows for the target sessions; returns (rows, window_ids)."""
|
| 2281 |
+
if not fp.is_file():
|
| 2282 |
+
return 0, set()
|
| 2283 |
+
conn = sqlite3.connect(str(fp), timeout=10)
|
| 2284 |
+
try:
|
| 2285 |
+
conn.execute("PRAGMA busy_timeout=10000")
|
| 2286 |
+
try:
|
| 2287 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 2288 |
+
except sqlite3.DatabaseError:
|
| 2289 |
+
pass
|
| 2290 |
+
cur = conn.cursor()
|
| 2291 |
+
try:
|
| 2292 |
+
tables = [r[0] for r in cur.execute(
|
| 2293 |
+
"SELECT name FROM sqlite_master WHERE type='table' "
|
| 2294 |
+
"AND name NOT LIKE 'sqlite_%'")]
|
| 2295 |
+
except sqlite3.DatabaseError:
|
| 2296 |
+
return 0, set()
|
| 2297 |
+
win_ids = set(ids)
|
| 2298 |
+
if keys:
|
| 2299 |
+
ph = ",".join("?" * len(keys))
|
| 2300 |
+
try:
|
| 2301 |
+
for (sid,) in cur.execute(
|
| 2302 |
+
f"SELECT session_id FROM session_windows "
|
| 2303 |
+
f"WHERE session_key IN ({ph})", list(keys)):
|
| 2304 |
+
win_ids.add(sid)
|
| 2305 |
+
except sqlite3.DatabaseError:
|
| 2306 |
+
pass
|
| 2307 |
+
total = 0
|
| 2308 |
+
for t in tables:
|
| 2309 |
+
try:
|
| 2310 |
+
cols = [c[1] for c in
|
| 2311 |
+
cur.execute(f'PRAGMA table_info("{t}")')]
|
| 2312 |
+
except sqlite3.DatabaseError:
|
| 2313 |
+
continue
|
| 2314 |
+
if win_ids and "session_id" in cols:
|
| 2315 |
+
ph = ",".join("?" * len(win_ids))
|
| 2316 |
+
try:
|
| 2317 |
+
cur.execute(
|
| 2318 |
+
f'DELETE FROM "{t}" WHERE session_id IN ({ph})',
|
| 2319 |
+
sorted(win_ids))
|
| 2320 |
+
total += max(cur.rowcount, 0)
|
| 2321 |
+
except sqlite3.DatabaseError as e:
|
| 2322 |
+
print(f"[PRUNE] {fp.name}:{t} session_id delete "
|
| 2323 |
+
f"failed: {str(e)[:80]}")
|
| 2324 |
+
# session_nodes has no session_id column but points at the
|
| 2325 |
+
# window it owns — prune the node when only the id was given.
|
| 2326 |
+
if win_ids and t == "session_nodes" and \
|
| 2327 |
+
"current_session_id" in cols:
|
| 2328 |
+
ph = ",".join("?" * len(win_ids))
|
| 2329 |
+
try:
|
| 2330 |
+
cur.execute(
|
| 2331 |
+
f'DELETE FROM session_nodes '
|
| 2332 |
+
f'WHERE current_session_id IN ({ph})',
|
| 2333 |
+
sorted(win_ids))
|
| 2334 |
+
total += max(cur.rowcount, 0)
|
| 2335 |
+
except sqlite3.DatabaseError as e:
|
| 2336 |
+
print(f"[PRUNE] {fp.name}:session_nodes id delete "
|
| 2337 |
+
f"failed: {str(e)[:80]}")
|
| 2338 |
+
if keys and "session_key" in cols:
|
| 2339 |
+
ph = ",".join("?" * len(keys))
|
| 2340 |
+
try:
|
| 2341 |
+
cur.execute(
|
| 2342 |
+
f'DELETE FROM "{t}" WHERE session_key IN ({ph})',
|
| 2343 |
+
sorted(keys))
|
| 2344 |
+
total += max(cur.rowcount, 0)
|
| 2345 |
+
except sqlite3.DatabaseError as e:
|
| 2346 |
+
print(f"[PRUNE] {fp.name}:{t} session_key delete "
|
| 2347 |
+
f"failed: {str(e)[:80]}")
|
| 2348 |
+
conn.commit()
|
| 2349 |
+
try:
|
| 2350 |
+
mode = cur.execute("PRAGMA journal_mode").fetchone()[0]
|
| 2351 |
+
if str(mode).lower() == "wal":
|
| 2352 |
+
cur.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
| 2353 |
+
except sqlite3.DatabaseError:
|
| 2354 |
+
pass
|
| 2355 |
+
return total, win_ids
|
| 2356 |
+
finally:
|
| 2357 |
+
conn.close()
|
| 2358 |
+
|
| 2359 |
+
def _prune_session_files(self, ids) -> int:
|
| 2360 |
+
"""Remove transcript JSONL/trajectory files (and their import
|
| 2361 |
+
archives) for the pruned windows — otherwise the next boot's restore
|
| 2362 |
+
hands them straight back to the gateway, which re-imports the session.
|
| 2363 |
+
"""
|
| 2364 |
+
if not ids or not (OPENCLAW_HOME / "agents").is_dir():
|
| 2365 |
+
return 0
|
| 2366 |
+
removed = 0
|
| 2367 |
+
for dirpath, _dirs, files in os.walk(OPENCLAW_HOME / "agents"):
|
| 2368 |
+
if os.path.basename(dirpath) not in (
|
| 2369 |
+
"sessions", "session-sqlite-import-archive"):
|
| 2370 |
+
continue
|
| 2371 |
+
for fn in files:
|
| 2372 |
+
if ".sqlite" in fn or not any(i in fn for i in ids):
|
| 2373 |
+
continue
|
| 2374 |
+
try:
|
| 2375 |
+
os.remove(os.path.join(dirpath, fn))
|
| 2376 |
+
removed += 1
|
| 2377 |
+
except OSError:
|
| 2378 |
+
pass
|
| 2379 |
+
return removed
|
| 2380 |
+
|
| 2381 |
+
def _prune_sessions(self):
|
| 2382 |
+
keys, ids = self._prune_targets()
|
| 2383 |
+
if not keys and not ids:
|
| 2384 |
+
return 0
|
| 2385 |
+
targets = list(self._agent_session_dbs())
|
| 2386 |
+
targets += self._agent_session_dbs_from(OPENCLAW_HOME / ".session-guard")
|
| 2387 |
+
state_db = OPENCLAW_HOME / "state" / "openclaw.sqlite"
|
| 2388 |
+
if state_db.is_file():
|
| 2389 |
+
targets.append(state_db)
|
| 2390 |
+
total, found = 0, set()
|
| 2391 |
+
for fp in targets:
|
| 2392 |
+
try:
|
| 2393 |
+
rows, fids = self._prune_db(fp, keys, ids)
|
| 2394 |
+
total += rows
|
| 2395 |
+
found |= fids
|
| 2396 |
+
except Exception as e:
|
| 2397 |
+
print(f"[PRUNE] {fp} failed: {str(e)[:140]}")
|
| 2398 |
+
files = self._prune_session_files(found)
|
| 2399 |
+
if total or files:
|
| 2400 |
+
print(f"[PRUNE] deleted {total} row(s), {files} transcript file(s) "
|
| 2401 |
+
f"({len(keys)} session key(s), {len(found)} window id(s))")
|
| 2402 |
+
return total
|
| 2403 |
+
|
| 2404 |
def run_session_migration(self):
|
| 2405 |
"""Migrate legacy JSON session stores → SQLite.
|
| 2406 |
|
|
|
|
| 3005 |
# the duplicate when the merged live DB has fewer rows.
|
| 3006 |
sync._dedupe_canonical_session_rows()
|
| 3007 |
|
| 3008 |
+
# 1b. Durable session deletions — archived sessions by default
|
| 3009 |
+
# (PRUNE_SESSIONS=off disables). Must run AFTER the restore
|
| 3010 |
+
# (which re-creates the transcript JSONL) and BEFORE the guard
|
| 3011 |
+
# copy, or the guard re-injects the deleted rows next boot.
|
| 3012 |
+
pruned = sync._prune_sessions()
|
| 3013 |
+
if pruned:
|
| 3014 |
+
sync._session_db_report("after-prune")
|
| 3015 |
+
|
| 3016 |
+
# 1c. Guard chat-history DBs BEFORE any step that can park/recreate
|
| 3017 |
# them (purge, doctor --fix, 'agents add', startup-gate healing).
|
| 3018 |
sync._guard_agent_session_dbs()
|
| 3019 |
|