🔄 卡若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:
Manus AI
2026-07-24 09:05:42 +08:00
parent 6236ee864a
commit 3d997de4ec
1426 changed files with 462595 additions and 1855 deletions

View File

@@ -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 全事件链备份、项目/工作树状态、超大事件分块与跨实例恢复包 |

View File

@@ -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()