🔄 卡若AI 同步 2026-07-24 09:05 | 更新:更新大文件排除规则;.restore-backups/F22_恢复前_20260723_210051 Skill规则更新;更新SKILL_REGISTRY.md;更新karuo_route.sh;更新SkillSpector扫描处置记录.md;更新对话019f7fde可执行模式复盘.md;更新对话019f7fde通过案例分析.md;更新工具候选与集成规则.md;更新生成业务续执行包.py;更新F22_复扫.json;更新F22_静态扫描.json;更新019f7fde-41d5-7641-9a25-561d9356c193.md;其余1172项见提交文件清单 | 排除 >1MB: 693 个
This commit is contained in:
@@ -6,8 +6,8 @@ triggers: Codex侧栏修复、Codex对话恢复、Codex项目分类、Codex旧
|
||||
owner: 金仓
|
||||
memory_palace_path: 卡若记忆宫殿/金殿/金仓厢/Codex对话状态修复
|
||||
memory_palace_slot: Codex 本地对话、项目侧栏和状态库修复入口
|
||||
version: "1.0"
|
||||
updated: "2026-07-05"
|
||||
version: "2.0"
|
||||
updated: "2026-07-19"
|
||||
---
|
||||
|
||||
# Codex对话状态修复
|
||||
@@ -100,6 +100,36 @@ cp /Users/karuo/.codex/process_manager/chat_processes.json "$backup/" 2>/dev/nul
|
||||
- 修复报告写入 `/Users/karuo/.codex/repair-backups/`
|
||||
- 若 Desktop 仍缓存旧状态,明确提示重启 Codex Desktop 再验
|
||||
|
||||
## Phase 5:公司 NAS Mongo 全链路备份与跨 Codex 恢复
|
||||
|
||||
使用:
|
||||
|
||||
```bash
|
||||
python3 scripts/codex_mongo_backup_restore.py backup
|
||||
python3 scripts/codex_mongo_backup_restore.py backup --recent-seconds 600 --no-snapshot
|
||||
python3 scripts/codex_mongo_backup_restore.py stats
|
||||
python3 scripts/codex_mongo_backup_restore.py restore --output /tmp/codex-restore
|
||||
python3 scripts/codex_mongo_backup_restore.py restore --project-root "/绝对路径/项目" --output /tmp/codex-restore
|
||||
```
|
||||
|
||||
公司 Mongo 中按以下层级保存:
|
||||
|
||||
| Collection | 内容 |
|
||||
|:---|:---|
|
||||
| `codex_threads` | thread ID、标题、cwd、项目/工作树、归档状态、模型、Git、rollout 路径 |
|
||||
| `codex_thread_events` | 完整 JSONL 事件链、顺序号、时间戳、事件类型、原始记录 |
|
||||
| `codex_event_chunks` | 超过 Mongo 单文档限制的截图/附件事件 gzip 分块 |
|
||||
| `codex_chat_messages` | 按项目和对话检索的用户/助手正文 |
|
||||
| `codex_projects` | 项目根、项目名、活跃/归档对话数量 |
|
||||
| `codex_state_snapshots` / `codex_backup_chunks` | Codex 状态文件可恢复快照 |
|
||||
| `codex_backup_runs` | 每次备份统计与结果 |
|
||||
|
||||
持续同步由 `~/Library/LaunchAgents/com.karuo.codex-mongo-sync.plist` 每 5 分钟执行;
|
||||
它只扫描最近 10 分钟有变化的线程。首次接入或换机前再执行一次无参数 `backup` 全量收口。
|
||||
|
||||
恢复采用两阶段:先导出到独立目录并核对 `manifest.json`,再备份目标 Codex 的
|
||||
`state_5.sqlite` / global state 后应用;不直接覆盖正在运行的 Codex 状态库。
|
||||
|
||||
## 常用证据入口
|
||||
|
||||
| 文件/目录 | 用途 |
|
||||
@@ -132,3 +162,4 @@ cp /Users/karuo/.codex/process_manager/chat_processes.json "$backup/" 2>/dev/nul
|
||||
| 日期 | 说明 |
|
||||
|:---|:---|
|
||||
| 2026-07-05 | 初版:从近30天 Codex Desktop 侧栏/rollout 修复高复发流程沉淀 |
|
||||
| 2026-07-19 | v2:增加公司 NAS Mongo 全事件链备份、项目/工作树状态、超大事件分块与跨实例恢复包 |
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Codex Desktop 全链路备份到公司 NAS MongoDB,并支持跨实例恢复。
|
||||
|
||||
备份对象:线程状态、项目/cwd、完整 rollout JSONL 事件、用户/助手消息、
|
||||
session_index/global/process-manager 状态快照。凭据只从 ~/.config/karuo-ai/mongo.env 读取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from pymongo import ASCENDING, DESCENDING, MongoClient, UpdateOne
|
||||
|
||||
HOME = Path.home()
|
||||
CODEX = HOME / ".codex"
|
||||
STATE_DB = CODEX / "state_5.sqlite"
|
||||
SESSION_INDEX = CODEX / "session_index.jsonl"
|
||||
GLOBAL_STATE = CODEX / ".codex-global-state.json"
|
||||
PROCESS_STATE = CODEX / "process_manager" / "chat_processes.json"
|
||||
ENV_FILE = HOME / ".config" / "karuo-ai" / "mongo.env"
|
||||
CHUNK_SIZE = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def load_env() -> None:
|
||||
if not ENV_FILE.exists():
|
||||
return
|
||||
for raw in ENV_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
|
||||
|
||||
|
||||
def company_db():
|
||||
load_env()
|
||||
uri = os.environ.get("KARUO_COMPANY_MONGO_URI", "").strip()
|
||||
if not uri:
|
||||
raise RuntimeError("KARUO_COMPANY_MONGO_URI 未配置")
|
||||
client = MongoClient(uri, serverSelectionTimeoutMS=7000, connectTimeoutMS=7000)
|
||||
client.admin.command("ping")
|
||||
return client, client[os.environ.get("KARUO_MONGO_DB", "karuo_site")]
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def read_threads() -> list[dict[str, Any]]:
|
||||
if not STATE_DB.exists():
|
||||
raise RuntimeError(f"状态库不存在: {STATE_DB}")
|
||||
con = sqlite3.connect(f"file:{STATE_DB}?mode=ro", uri=True)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
return [dict(row) for row in con.execute("SELECT * FROM threads ORDER BY updated_at DESC")]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def resolve_rollout(raw: str) -> Path | None:
|
||||
if not raw:
|
||||
return None
|
||||
path = Path(raw).expanduser()
|
||||
if path.exists():
|
||||
return path
|
||||
name = path.name
|
||||
for base in (CODEX / "sessions", CODEX / "archived_sessions"):
|
||||
hits = list(base.rglob(name)) if base.exists() else []
|
||||
if hits:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
def event_kind(record: dict[str, Any]) -> str:
|
||||
payload = record.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
return str(payload.get("type") or record.get("type") or "unknown")
|
||||
return str(record.get("type") or "unknown")
|
||||
|
||||
|
||||
def message_text(payload: dict[str, Any]) -> str:
|
||||
content = payload.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key in ("text", "input_text", "output_text"):
|
||||
if isinstance(item.get(key), str):
|
||||
parts.append(item[key])
|
||||
break
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def parse_rollout(path: Path) -> Iterable[tuple[int, str, dict[str, Any]]]:
|
||||
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
||||
for seq, raw in enumerate(fh):
|
||||
raw = raw.rstrip("\n")
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
record = {"type": "unparsed", "raw_text": raw}
|
||||
yield seq, raw, record
|
||||
|
||||
|
||||
def ensure_indexes(db) -> None:
|
||||
db.codex_threads.create_index([("project_root", ASCENDING), ("updated_at", DESCENDING)])
|
||||
db.codex_threads.create_index([("archived", ASCENDING), ("updated_at", DESCENDING)])
|
||||
db.codex_thread_events.create_index([("thread_id", ASCENDING), ("seq", ASCENDING)], unique=True)
|
||||
db.codex_chat_messages.create_index([("thread_id", ASCENDING), ("seq", ASCENDING)], unique=True)
|
||||
db.codex_projects.create_index("project_root", unique=True)
|
||||
db.codex_state_snapshots.create_index("created_at")
|
||||
db.codex_backup_chunks.create_index([("snapshot_id", ASCENDING), ("chunk", ASCENDING)], unique=True)
|
||||
db.codex_event_chunks.create_index(
|
||||
[("thread_id", ASCENDING), ("seq", ASCENDING), ("chunk", ASCENDING)], unique=True
|
||||
)
|
||||
db.codex_rollout_archives.create_index("thread_id", unique=True)
|
||||
db.codex_rollout_chunks.create_index(
|
||||
[("thread_id", ASCENDING), ("chunk", ASCENDING)], unique=True
|
||||
)
|
||||
|
||||
|
||||
def archive_rollout(db, thread_id: str, path: Path, now: datetime) -> dict[str, Any]:
|
||||
"""把整条 rollout 流式 gzip 后分块保存;比逐事件上传图片/base64 快很多。"""
|
||||
stat = path.stat()
|
||||
existing = db.codex_rollout_archives.find_one(
|
||||
{"thread_id": thread_id}, {"source_size": 1, "source_mtime_ns": 1})
|
||||
if existing and existing.get("source_size") == stat.st_size and \
|
||||
existing.get("source_mtime_ns") == stat.st_mtime_ns:
|
||||
return {"skipped": True, "source_size": stat.st_size,
|
||||
"packed_size": 0, "chunks": 0}
|
||||
h = hashlib.sha256()
|
||||
with tempfile.NamedTemporaryFile(prefix="codex-rollout-", suffix=".jsonl.gz") as tmp:
|
||||
with path.open("rb") as src, gzip.GzipFile(fileobj=tmp, mode="wb", compresslevel=6) as out:
|
||||
while True:
|
||||
block = src.read(4 * 1024 * 1024)
|
||||
if not block:
|
||||
break
|
||||
h.update(block)
|
||||
out.write(block)
|
||||
tmp.flush()
|
||||
packed_size = Path(tmp.name).stat().st_size
|
||||
total = (packed_size + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||
db.codex_rollout_chunks.delete_many({"thread_id": thread_id})
|
||||
tmp.seek(0)
|
||||
for i in range(total):
|
||||
db.codex_rollout_chunks.insert_one(
|
||||
{"thread_id": thread_id, "chunk": i, "total": total,
|
||||
"encoding": "gzip", "data": tmp.read(CHUNK_SIZE), "backup_at": now})
|
||||
db.codex_rollout_archives.update_one(
|
||||
{"thread_id": thread_id},
|
||||
{"$set": {"thread_id": thread_id, "source_path": str(path),
|
||||
"source_size": stat.st_size, "source_mtime_ns": stat.st_mtime_ns,
|
||||
"source_sha256": h.hexdigest(), "packed_size": packed_size,
|
||||
"chunks": total, "encoding": "gzip", "backup_at": now}}, upsert=True)
|
||||
return {"skipped": False, "source_size": stat.st_size,
|
||||
"packed_size": packed_size, "chunks": total}
|
||||
|
||||
|
||||
def make_state_snapshot(db, machine_id: str) -> str:
|
||||
files = [p for p in (STATE_DB, SESSION_INDEX, GLOBAL_STATE, PROCESS_STATE) if p.exists()]
|
||||
stamp = utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||||
snapshot_id = f"{machine_id}:{stamp}"
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
manifest = []
|
||||
for src in files:
|
||||
rel = src.relative_to(HOME)
|
||||
dst = root / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
manifest.append({"path": str(rel), "size": src.stat().st_size, "sha256": sha(src.read_bytes())})
|
||||
archive = root / "codex-state.json.gz"
|
||||
payload = json.dumps({
|
||||
"machine_id": machine_id,
|
||||
"created_at": stamp,
|
||||
"files": manifest,
|
||||
"data": {str(p.relative_to(HOME)): base64.b64encode(p.read_bytes()).decode("ascii") for p in files},
|
||||
}, ensure_ascii=False).encode("utf-8")
|
||||
packed = gzip.compress(payload, compresslevel=6)
|
||||
total = (len(packed) + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||
db.codex_backup_chunks.delete_many({"snapshot_id": snapshot_id})
|
||||
if packed:
|
||||
db.codex_backup_chunks.insert_many([
|
||||
{"snapshot_id": snapshot_id, "chunk": i, "total": total,
|
||||
"data": packed[i * CHUNK_SIZE:(i + 1) * CHUNK_SIZE], "created_at": utcnow()}
|
||||
for i in range(total)
|
||||
])
|
||||
db.codex_state_snapshots.update_one(
|
||||
{"_id": snapshot_id},
|
||||
{"$set": {"machine_id": machine_id, "created_at": utcnow(), "files": manifest,
|
||||
"chunks": total, "packed_size": len(packed), "format": "json+gzip+base64-v1"}},
|
||||
upsert=True,
|
||||
)
|
||||
return snapshot_id
|
||||
|
||||
|
||||
def backup(all_threads: bool = True, thread_id: str | None = None, no_snapshot: bool = False,
|
||||
recent_seconds: int | None = None, archive_full: bool = False) -> dict[str, Any]:
|
||||
machine_id = os.environ.get("KARUO_CODEX_MACHINE_ID", "karuo-macbook")
|
||||
client, db = company_db()
|
||||
ensure_indexes(db)
|
||||
now = utcnow()
|
||||
counts = Counter()
|
||||
projects: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
threads = read_threads()
|
||||
if thread_id:
|
||||
threads = [t for t in threads if t.get("id") == thread_id]
|
||||
if recent_seconds:
|
||||
cutoff = int(utcnow().timestamp()) - recent_seconds
|
||||
threads = [t for t in threads if int(t.get("updated_at") or 0) >= cutoff]
|
||||
event_ops: list[UpdateOne] = []
|
||||
msg_ops: list[UpdateOne] = []
|
||||
for thread in threads:
|
||||
tid = str(thread["id"])
|
||||
path = resolve_rollout(str(thread.get("rollout_path") or ""))
|
||||
root = str(thread.get("cwd") or "")
|
||||
project_name = Path(root).name if root else "未分类"
|
||||
thread_doc = dict(thread)
|
||||
thread_doc.update({
|
||||
"_id": tid, "thread_id": tid, "machine_id": machine_id,
|
||||
"project_root": root, "project_name": project_name,
|
||||
"rollout_original_path": str(thread.get("rollout_path") or ""),
|
||||
"rollout_found_path": str(path) if path else None,
|
||||
"rollout_exists": bool(path), "backup_at": now,
|
||||
"schema_version": "codex-thread-v2",
|
||||
})
|
||||
db.codex_threads.update_one({"_id": tid}, {"$set": thread_doc}, upsert=True)
|
||||
counts["threads"] += 1
|
||||
p = projects.setdefault(root, {"project_root": root, "project_name": project_name,
|
||||
"threads": 0, "active": 0, "archived": 0})
|
||||
p["threads"] += 1
|
||||
p["archived" if int(thread.get("archived") or 0) else "active"] += 1
|
||||
if not path:
|
||||
counts["missing_rollout"] += 1
|
||||
continue
|
||||
if archive_full:
|
||||
archived = archive_rollout(db, tid, path, now)
|
||||
counts["rollout_archives"] += 1
|
||||
counts["archive_source_bytes"] += int(archived["source_size"])
|
||||
counts["archive_packed_bytes"] += int(archived["packed_size"])
|
||||
for seq, raw, record in parse_rollout(path):
|
||||
raw_bytes = raw.encode("utf-8")
|
||||
payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
|
||||
kind = event_kind(record)
|
||||
event_doc = {"thread_id": tid, "seq": seq, "timestamp": record.get("timestamp"),
|
||||
"event_type": kind, "sha256": sha(raw_bytes),
|
||||
"raw_size": len(raw_bytes), "backup_at": now}
|
||||
# 截图/附件可能让单行 JSONL 超过 Mongo 16 MB。大事件单独 gzip 分块,
|
||||
# 主事件只保留检索元数据;恢复时无损拼回原始行。
|
||||
if archive_full:
|
||||
# 全量模式的原文已进入整卷 gzip;只保存可检索消息,不再逐事件重复上传。
|
||||
counts["events_archived"] += 1
|
||||
elif len(raw_bytes) > 8 * 1024 * 1024:
|
||||
packed = gzip.compress(raw_bytes, compresslevel=6)
|
||||
total_chunks = (len(packed) + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||
db.codex_event_chunks.delete_many({"thread_id": tid, "seq": seq})
|
||||
db.codex_event_chunks.insert_many([
|
||||
{"thread_id": tid, "seq": seq, "chunk": i, "total": total_chunks,
|
||||
"encoding": "gzip", "data": packed[i * CHUNK_SIZE:(i + 1) * CHUNK_SIZE],
|
||||
"sha256": sha(raw_bytes), "backup_at": now}
|
||||
for i in range(total_chunks)
|
||||
])
|
||||
event_doc.update({"oversized": True, "chunks": total_chunks,
|
||||
"record_preview": {"type": record.get("type"),
|
||||
"timestamp": record.get("timestamp"),
|
||||
"event_type": kind}})
|
||||
counts["oversized_events"] += 1
|
||||
elif not archive_full:
|
||||
event_doc.update({"oversized": False, "record": record, "raw": raw})
|
||||
if not archive_full:
|
||||
event_ops.append(UpdateOne(
|
||||
{"thread_id": tid, "seq": seq}, {"$set": event_doc}, upsert=True))
|
||||
counts["events"] += 1
|
||||
role = payload.get("role")
|
||||
text = message_text(payload)
|
||||
if kind == "message" and role in {"user", "assistant"} and text:
|
||||
msg_ops.append(UpdateOne(
|
||||
{"thread_id": tid, "seq": seq},
|
||||
{"$set": {"thread_id": tid, "seq": seq, "role": role, "content": text,
|
||||
"timestamp": record.get("timestamp"), "project_root": root,
|
||||
"project_name": project_name, "title": thread.get("title"),
|
||||
"archived": bool(thread.get("archived")), "backup_at": now}}, upsert=True))
|
||||
counts["messages"] += 1
|
||||
if len(event_ops) >= 1000:
|
||||
db.codex_thread_events.bulk_write(event_ops, ordered=False); event_ops.clear()
|
||||
if len(msg_ops) >= 1000:
|
||||
db.codex_chat_messages.bulk_write(msg_ops, ordered=False); msg_ops.clear()
|
||||
if event_ops:
|
||||
db.codex_thread_events.bulk_write(event_ops, ordered=False)
|
||||
if msg_ops:
|
||||
db.codex_chat_messages.bulk_write(msg_ops, ordered=False)
|
||||
for root, info in projects.items():
|
||||
db.codex_projects.update_one(
|
||||
{"project_root": root},
|
||||
{"$set": {**info, "machine_id": machine_id, "backup_at": now}}, upsert=True)
|
||||
snapshot_id = None if no_snapshot else make_state_snapshot(db, machine_id)
|
||||
result = {**counts, "projects": len(projects), "snapshot_id": snapshot_id,
|
||||
"machine_id": machine_id, "completed_at": now.isoformat()}
|
||||
db.codex_backup_runs.insert_one(result.copy())
|
||||
return result
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def stats() -> dict[str, Any]:
|
||||
client, db = company_db()
|
||||
try:
|
||||
return {
|
||||
"threads": db.codex_threads.count_documents({}),
|
||||
"events": db.codex_thread_events.count_documents({}),
|
||||
"messages": db.codex_chat_messages.count_documents({}),
|
||||
"projects": db.codex_projects.count_documents({}),
|
||||
"snapshots": db.codex_state_snapshots.count_documents({}),
|
||||
"latest_run": db.codex_backup_runs.find_one({}, {"_id": 0}, sort=[("completed_at", -1)]),
|
||||
}
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def restore(output: Path, thread_id: str | None = None, project_root: str | None = None) -> dict[str, Any]:
|
||||
"""先恢复为可审计导出包;应用到 Codex 状态库由 --apply 单独完成。"""
|
||||
client, db = company_db()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / "sessions").mkdir(parents=True, exist_ok=True)
|
||||
query: dict[str, Any] = {}
|
||||
if thread_id:
|
||||
query["_id"] = thread_id
|
||||
if project_root:
|
||||
query["project_root"] = project_root
|
||||
restored = 0
|
||||
manifest = []
|
||||
try:
|
||||
for thread in db.codex_threads.find(query):
|
||||
tid = thread["_id"]
|
||||
target = output / "sessions" / f"rollout-restored-{tid}.jsonl"
|
||||
archive = db.codex_rollout_archives.find_one({"thread_id": tid})
|
||||
count = 0
|
||||
if archive:
|
||||
chunks = db.codex_rollout_chunks.find({"thread_id": tid}).sort("chunk", 1)
|
||||
with tempfile.NamedTemporaryFile(prefix="codex-restore-", suffix=".gz") as packed:
|
||||
for chunk in chunks:
|
||||
packed.write(bytes(chunk["data"]))
|
||||
packed.flush(); packed.seek(0)
|
||||
with gzip.GzipFile(fileobj=packed, mode="rb") as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst, length=4 * 1024 * 1024)
|
||||
count = sum(1 for _ in target.open("rb"))
|
||||
else:
|
||||
rows = db.codex_thread_events.find({"thread_id": tid}).sort("seq", 1)
|
||||
with target.open("w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
if row.get("oversized"):
|
||||
chunks = db.codex_event_chunks.find(
|
||||
{"thread_id": tid, "seq": row["seq"]}).sort("chunk", 1)
|
||||
packed = b"".join(bytes(c["data"]) for c in chunks)
|
||||
raw_line = gzip.decompress(packed).decode("utf-8", errors="replace")
|
||||
else:
|
||||
raw_line = row.get("raw") or json.dumps(row.get("record", {}), ensure_ascii=False)
|
||||
fh.write(raw_line)
|
||||
fh.write("\n")
|
||||
count += 1
|
||||
meta = {k: v for k, v in thread.items() if k != "_id"}
|
||||
meta["thread_id"] = tid
|
||||
meta["restored_rollout"] = str(target)
|
||||
meta["event_count"] = count
|
||||
manifest.append(meta)
|
||||
restored += 1
|
||||
(output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, default=str, indent=2), encoding="utf-8")
|
||||
return {"threads": restored, "output": str(output), "manifest": str(output / "manifest.json")}
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Codex 全链路 Mongo 备份与跨实例恢复")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
bp = sub.add_parser("backup")
|
||||
bp.add_argument("--thread-id")
|
||||
bp.add_argument("--no-snapshot", action="store_true")
|
||||
bp.add_argument("--recent-seconds", type=int, help="只同步最近更新的线程")
|
||||
bp.add_argument("--archive-full", action="store_true", help="整卷压缩归档,适合首次全量")
|
||||
sub.add_parser("stats")
|
||||
rp = sub.add_parser("restore")
|
||||
rp.add_argument("--output", type=Path, required=True)
|
||||
rp.add_argument("--thread-id")
|
||||
rp.add_argument("--project-root")
|
||||
args = parser.parse_args()
|
||||
if args.command == "backup":
|
||||
result = backup(thread_id=args.thread_id, no_snapshot=args.no_snapshot,
|
||||
recent_seconds=args.recent_seconds, archive_full=args.archive_full)
|
||||
elif args.command == "stats":
|
||||
result = stats()
|
||||
else:
|
||||
result = restore(args.output, args.thread_id, args.project_root)
|
||||
print(json.dumps(result, ensure_ascii=False, default=str, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user