Powerman9x commited on
Commit
4153405
·
verified ·
1 Parent(s): e10b987

Upload sync_hf.py

Browse files
Files changed (1) hide show
  1. 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. Guard chat-history DBs BEFORE any step that can park/recreate
 
 
 
 
 
 
 
 
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